Kotlin ‐ Coroutines 기초 - dnwls16071/Backend_Summary GitHub Wiki

📚 코루틴(Coroutine)

fun main(): Unit = runBlocking {  // runBlocking : 일반루틴 세계와 코루틴 세계를 연결, 함수 자체로 새로운 코루틴을 만든다.
    println("START")
    launch {                      // launch : 반환값이 없는 코루틴을 만든다.
        newRoutine()
    }
    yield()
    println("END")
}

suspend fun newRoutine() {        // suspend fun : 다른 suspend fun을 호출할 수 있다.
    val num1 = 1
    val num2 = 2
    yield()                       // yield : 지금 코루틴을 중단하고 다른 코루틴이 실행되도록 한다.(쓰레드 양보)
    println("${num1 + num2}")
}
  • 실행 결과(왜 이렇게 나오는지에 대해 이해가 중요)
START
END
3
  • launch라는 새로운 코루틴 함수를 호출하는 순간 만들어진 새로운 코루틴을 바로 실행시키지 않는다.
  • 루틴과 코루틴의 가장 큰 차이는 중단과 재개이다.
  • 루틴은 한 번 시작하면 종료될 때까지 멈추지 않지만, 코루틴은 상황에 따라 잠시 중단이 되었다가 다시 시작되기도 한다.
  • 때문에 완전히 종료되기 전까지 newRoutine 함수 안에 있는 num1, num2 변수는 메모리에서 제거되지 않는다.

📚 쓰레드와 코루틴

프로세스 - 쓰레드 관계

  • 프로세스(process)는 컴퓨터에서 실행되고 있는 프로그램을 의미한다.
  • 쓰레드(thread)는 프로세스보다 작은 개념으로 프로세스에 소속되어 여러 코드를 동시에 실행할 수 있도록 해준다.

쓰레드 - 코루틴 관계

  • 프로세스는 각각 독립된 메모리 영역을 가지기 때문에 1번 프로세스에서 2번 프로세스로 컨택스트 스위칭이 발생하면 힙 영역과 스택 영역이 모두 교체되기 때문에 비용이 제일 크다.
  • 쓰레드는 개별적인 스택 영역을 가지나 힙 영역을 공유하기 때문에 실행이 변경되면 스택 영역만 교체된다. 따라서 프로세스보다는 컨텍스트 스위칭 비용이 적다.
  • 코루틴은 1번 코루틴과 2번 코루틴이 같은 쓰레드에서 실행될 수 있다. 때문에 동일한 쓰레드에서 코루틴이 실행되면 메모리 전부를 공유하기 때문에 쓰레드보다 컨텍스트 스위칭 비용이 적다.

📚 코루틴 빌더(Coroutine Builder)와 Job

runBlocking

  • runBlocking : 새로운 코루틴을 만들고 루틴 세계 - 코루틴 세계를 연결해주는 역할을 한다.
  • 이렇게 코루틴을 만드는 함수를 코루틴 빌더라고 한다.
fun main(): Unit = runBlocking {

}
  • runBlocking 함수는 runBlocking으로 인해 만들어진 코루틴과 그 안에 있는 코루틴이 모두 완료될 때까지 쓰레드를 블락시킨다.⭐
  • 쓰레드가 블락되면 그 쓰레드는 블락이 풀릴 때까지 다른 코드를 실행시킬 수 없게 된다. → 무한 대기 상태에 빠질 위험이 높아진다.⭐
  • 그렇기에 runBlocking 함수를 계속해서 사용해선 안되고 프로그램에 진입하는 최초 메인 함수나 테스트 코드를 시작할 때만 사용하는 것을 권장한다.⭐

launch

  • launch : 코루틴 빌더의 한 종류이고 반환값이 없는 코드를 실행할 때 사용한다.
  • launch는 runBlocking과 다르게 만들어진 코루틴을 결과로 반환하고 이 객체를 이용해 코루틴을 제어할 수 있다. 이 객체 타입은 Job으로 코루틴을 나타낸다. 즉, Job을 하나 받으면 코루틴을 하나 만드는 것이다.
  • launch로 시작된 코루틴은 결과를 반환하지 않고, 호출한 스레드를 차단하지 않는다는 중요한 특징이 있다.
    • runBlocking : 내부 코루틴이 완료될 때까지 현재 스레드를 블로킹한다.⭐
    • launch : 현재 스레드를 차단하지 않고 새 코루틴을 시작한다.⭐
    • delay : 현재 코루틴을 일시 중단하지만, 스레드는 차단하지 않는다.⭐

cancel

fun main(): Unit = runBlocking {

    println("Hello World")

    val job = launch {
        (1..10).forEach {
            println("Hello $it")
            delay(500)
        }
    }

    delay(1000)
    job.cancel()
    println("Bye World")
}
  • 실행 결과(왜 이렇게 나오는지에 대해 이해가 중요)
Hello World
Hello 1
Hello 2
Bye World
  • cancel() 함수는 우리가 만든 코루틴을 취소하는 기능이다.⭐

join

fun main(): Unit = runBlocking {
    val job1 = launch {
        delay(1000)
        println("Job 1")
    }
    val job2 = launch {
        delay(1000)
        println("Job 2")
    }
}
  • 각각의 코루틴에서 delay가 1초씩 걸려 있으나 실제로는 2초 미만으로 실행된다.
  • 그 이유는 job1에서 1초를 기다리는 동안 job2도 실행되면서 함께 1초를 기다리기 때문이다.
fun main(): Unit = runBlocking {
    val job1 = launch {
        delay(1000)
        println("Job 1")
    }
    job1.join()
    val job2 = launch {
        delay(1000)
        println("Job 2")
    }
}
  • 첫 번째 코드와 비교하면 전체 코드가 수행되는데 약 2초 이상이 소요된다.
  • job1 코루틴에 대해 join()을 호출하며 job1 코루틴이 끝날 때까지 완전히 기다렸다가 job2 코루틴이 실행되기 때문이다.

async

  • launch는 결과를 반환할 수 없으나 async는 결과를 반환할 수 있다.⭐
  • 여러 API를 동시 호출하여 소요시간을 최소화할 수 있다.
  • async를 사용할 때 주의할 점으로 만약 CoroutineStart.LAZY 옵션을 사용하게 되면 await()를 호출했을 때 계산 결과를 계속 기다리게 된다.
fun main(): Unit = runBlocking {
    val job = async {
        3 + 5
    }

    val result = job.await() // await : async 결과를 가져오는 함수
    println(result)
}

📚 코루틴의 취소

  • 필요하지 않은 코루틴은 적절히 취소해 리소스를 아껴야 한다.⭐
  • 코루틴의 취소를 위해서 Job 객체의 cancel() 함수를 사용할 수 있다. 다만, 취소 대상인 코루틴 역시 취소에 협조를 해주어야 한다.
  • 이런 취소에 협조하는 방법들은 여러가지가 있다.

suspend⭐

fun main(): Unit = runBlocking {
    val job = launch {
        var i = 1
        var nextPrintTime = System.currentTimeMillis()
        while (i <= 5) {
            if (nextPrintTime <= System.currentTimeMillis()) {
                println("${i++} 번째 출력!")
                nextPrintTime += 1000L // 1초 후에 다시 출력되도록 한다.
            }
        }
    }
    delay(100L)
    job.cancel()
}
  • 실행 결과(왜 이렇게 나오는지가 중요)
1 번째 출력!
2 번째 출력!
3 번째 출력!
4 번째 출력!
5 번째 출력!
  • cancel()함수를 호출해 코루틴을 취소했지만 5번 반복문을 모두 출력할 때까지 취소가 되지 않았다.

CancellationException⭐

  • 코루틴이 취소에 협력하는 두 번째 방법은 코루틴 스스로가 본인 상태를 체크해 취소 요청을 받았다면 CancellationException 예외를 던지는 방법이다.
    • isActive⭐
      • 코틀린을 만들 때 사용한 함수 블록 안에서는 isActive 프로퍼티에 접근할 수 있다.
      • 이 프로퍼티는 현재 코루틴이 활성화되었는지 아니면 취소 신호를 받았는지를 구분할 수 있다.
    • Dispatchers.Default⭐
      • 취소 신호를 정상적으로 전달하려면, 우리가 만든 코루틴이 다른 쓰레드에서 동작해야 한다.
      • Dispatchers.Default를 launch() 함수에 전달하면 우리 코루틴을 다른 쓰레드에서 동작시킬 수 있다.
fun main(): Unit = runBlocking {
    val job = launch(Dispatchers.Default) { // 다른 쓰레드에서 동작하도록 지정하는 설정값
        var i = 1
        var nextPrintTime = System.currentTimeMillis()
        while (i <= 5) {
            if (nextPrintTime <= System.currentTimeMillis()) {
                println("${i++} 번째 출력!")
                nextPrintTime += 1000L
            }
            if (!isActive) {
                throw CancellationException()
            }
        }
    }
    delay(100L)
    println("취소 시작")
    job.cancel()
}

❗컴퓨팅 자원을 적절하게 관리하기 위해선 코루틴을 적절히 취소할 수 있어야 한다.

  • kotlinx.coroutines 패키지의 suspend 함수를 호출하거나
  • isActive 로 직접 상태를 확인해 CancellationException을 던지거나

📚 코루틴 예외 처리와 Job 상태 변화

fun main(): Unit = runBlocking {
    val job1 = launch {
        delay(1000L)
        println("Job 1")
    }
    val job2 = launch {
        delay(1000L)
        println("Job 2")
    }
}
  • 위의 코드에서 코루틴은 총 3개이다.
    • runBlocking에 의해 실행되는 코루틴 1
    • job1 코루틴 2
    • job2 코루틴 3
  • runBlocking으로 만들어진 코루틴은 최상위 코루틴, 즉 root 코루틴(=부모 코루틴)이 된다.
  • launch로 만들어진 코루틴은 자식 코루틴이 된다.
  • 새로운 root 코루틴을 만들고자 한다면 launch로 코루틴을 만들 때 새로운 영역에 만들면 된다.
fun main(): Unit = runBlocking {
    val job1 = CoroutineScope(Dispatchers.Default).launch {
        delay(1000L)
        println("Job 1")
    }
    val job2 = CoroutineScope(Dispatchers.Default).launch {
        delay(1000L)
        println("Job 2")
    }
}
fun main(): Unit = runBlocking {
    val job = CoroutineScope(Dispatchers.Default).launch {
        throw IllegalArgumentException()
    }
    delay(1000L)
}
  • 실행 결과(왜 이렇게 나오는지 이해하는 것이 중요)
Exception in thread "DefaultDispatcher-worker-1" java.lang.IllegalArgumentException
	at com.example.demo.CoroutineEx1Kt$main$1$job$1.invokeSuspend(CoroutineEx1.kt:29)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:811)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:715)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:702)
	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@1fa13a0e, Dispatchers.Default]
  • launch의 경우 예외가 발생하면 예외를 출력하고 코루틴이 종료된다.
  • 반면 async 함수는 예외가 발생하더라도 예외를 출력하지 않는다.
fun main(): Unit = runBlocking {
    val job = CoroutineScope(Dispatchers.Default).async {
        throw IllegalArgumentException()
    }
    delay(1000L)
}
  • async 함수에서 발생한 예외를 확인하고 싶다면 await 함수를 사용해야 한다.
  • async 함수는 launch와 다르게 값을 반환하는 코루틴을 사용하기 때문에 예외 역시 값을 반환할 때 처리할 수 있도록 설계되었다.
  • 그럼 만약 위의 launch에서 설정한 Dispatchers.Default 즉, 별도의 쓰레드에서 코루틴을 실행하는 것이 아니라면 예외 전파는 어떻게 될까?
fun main(): Unit = runBlocking {
    val job = launch { // async로 변경해도 동일
        throw IllegalArgumentException()
    }
    delay(1000L)
}
  • 실행 결과(왜 이렇게 나오는지 이해하는 것이 중요)
Exception in thread "main" java.lang.IllegalArgumentException
	at com.example.demo.CoroutineEx1Kt$main$1$job$1.invokeSuspend(CoroutineEx1.kt:29)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:277)
	at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:95)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:69)
	at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:48)
	at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
	at com.example.demo.CoroutineEx1Kt.main(CoroutineEx1.kt:27)
	at com.example.demo.CoroutineEx1Kt.main(CoroutineEx1.kt)
  • 코루틴 안에서 발생한 예외가 부모 코루틴으로 전파되기 때문에 main 쓰레드에서 예외가 발생한 것이다.
  • async도 마찬가지로 async 코루틴에서 예외가 발생하면 그 예외가 부모 코루틴으로 전파되고 부모 코루틴 역시 취소 절차에 들어가게 된다. runBlocking의 경우 예외가 발생하면 해당 예외를 출력하기에 async 예외를 받아 즉시 처리하게 되는 것이다.(await 함수를 사용하지 않더라도)
  • 부모 코루틴에게 예외를 전파하지 않으면서 격리된 방식으로 처리하려면 SupervisorJob()을 사용하면 된다.
fun main(): Unit = runBlocking {
    val job = async(SupervisorJob()) {
        throw IllegalArgumentException()
    }
    delay(1000L)
}
  • 실행 결과(왜 이렇게 나오는지가 중요)

  • async 함수에 SupervisorJob()을 넣어주면 async 자식 코루틴에서 예외가 발생하면 부모 코루틴으로 전파되지 않고, await를 사용해야 예외가 반환되는 원래 패턴을 지키게 된다.
  • launch 함수와 같이 반환 타입이 없는 경우에 대해서 코틀린 예외 핸들링을 하고 싶다면 여러 방법이 존재한다.

try ~ catch ~ finally로 잡기

fun main(): Unit = runBlocking {
    val job = launch() { // async로 변경해도 동일
        try {
            throw IllegalArgumentException()
        } catch (e: IllegalArgumentException) {
            println("정상 종료")
        }
    }
}
  • try~catch문을 활용하면 발생한 예외를 잡아 코루틴이 취소되지 않게 만들 수 있고 적절한 처리를 한 이후에 다시 예외를 던져줄 수도 있다.

예외가 발생한 이후 에러 로깅이나 에러 메시지를 보내는 등의 공통 로직을 처리해야한다면? → CoroutineExceptionHandler

fun main(): Unit = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, _ ->
        println("예외")
    }

    val job = CoroutineScope(Dispatchers.Default).launch(exceptionHandler) {
        throw IllegalArgumentException()
    }
    delay(1000L)
}
  • 실행 결과(왜 이렇게 나오는지 이해하는 것이 중요)
예외
  • 발생한 예외가 CancellationException인 경우 → 취소로 간주하고 부모 코루틴에게 전파되지 않는다.
  • 다른 예외가 발생한 경우 → 실패로 간주하고 부모 코루틴에게 전파된다.