Beeeam

코루틴 공식 문서 읽기 (Coroutine Cancellation and timeouts) 본문

Kotlin

코루틴 공식 문서 읽기 (Coroutine Cancellation and timeouts)

Beamjun 2023. 3. 5. 19:06

https://kotlinlang.org/docs/cancellation-and-timeouts.html

 

Cancellation and timeouts | Kotlin

 

kotlinlang.org

 

코루틴에서 실행되는 모든 중단 함수들은 취소 요청에 응답을 할 수 있어야 한다. 그러면 코드 실행중 취소 요청이 있는지를 반복적으로 확인해야 하는데 코루틴은 이러한 취소 요청에 대응할 수 있도록 구현이 되어 있다.

취소 요청에 대응하여 코루틴이 취소 되면 CancellationException을 발생 시키면서 종료를 한다.

 

Cancelling coroutine execution (코루틴 실행 취소)

애플리케이션이 장시간 동안 실행되고 있으면 백그라운드의 코루틴들을 관리해야 할 필요가 생긴다. 예를 들어 사용자가 실행된 코루틴 페이지를 닫기 하면 이 코루틴 실행 결과는 필요가 없어지고, 작동이 취소될 수 있다. launch 함수는 Job이라는 객체를 반환하는데 얘는 진행 중인 코루틴을 취소하는데 사용될 수 있다.

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion 
println("main: Now I can quit.")

위의 코드를 실행하면 다음과 같은 결과가 나온다.

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

위의 결과를 보면 코루틴이 실행되어 "job: I'm sleeping $i ..." 이라는 출력 결과가 나오다가 job.cancel() 함수가 호출되자 코루틴이 종료되는 것을 확인할 수 있다.

그리고 취소와 join을 동시에 할 수 있는 확장 함수인 job.cancelAndJoin()이 존재한다. 얘를 사용하면 위의 예시처럼 job.cancel() , job.join()을 따로 사용하지 않아도 된다.

 

Cancellation is cooperative

코루틴 코드들은 취소에 협조적이여야 한다. kotlinx.coroutines 안의 모든 suspending 함수들은 취소 될 수 있다. 얘네들은 취소 될 때 코루틴의 취소를 확인하고, CancellationException(취소 예외)이 발생한다. 하지만 만약에 코루틴이 계산 중 이고, 취소를 확인하지 않으면 취소될 수 없다.

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) { // computation loop, just wastes CPU
        // print a message twice a second
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

위의 코드를 실행하면 다음과 같은 결과가 나온다.

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting! job:
I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

job.cancelAndJoin()을 만났는데도 job: I'm sleeping … 이 나오는 것을 볼 수 있다. 이 작업은 코루틴 안의 while 반복문이 다 끝날 때 까지 실행이 되고, 종료가 되었다.

 

Making computation code cancellable(위의 문제 해결 방법)

계산 중인 코드를 취소하는 방법은 2가지가 있다.

  1. 취소를 확인하는 suspending 함수를 주기적으로 호출한다. (yield 함수가 이에 적합하다. )
  2. 명시적으로 취소 상황을 확인한다. (isActive 사용)
fun main() = runBlocking {
    val startTime = currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) { // computation loop, just wastes CPU
            // print a message twice a second
            **yield()**
            if (currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")
}

위의 코드는 첫 번째 방법이다. 이전 예시의 코드에서 달라진 점은 while 반복문을 시작할 때 yield() 함수를 호출하는 점이다. 이를 통해서 취소 요청을 확인 하기 때문에 우리가 원하는 타이밍에 코루틴을 취소 할 수 있다.

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    **while (isActive)** { // cancellable computation loop
        // print a message twice a second
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

위의 코드는 두 번째 방법이다. while 반복문에 isActive을 넣어서 취소 요청이 들어오지 않는 동안에만 반복문을 실행하도록 하였다.

위의 두 개의 코드를 실행하면 둘 다 동일하게 밑의 결과가 나온다.

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

 

Closing resources with finally

취소할 수 있는 suspending 함수들은 취소할 때 CancellationException(취소 예외)이 발생하는데 이 CancellationException을 이용하여 예외 처리를 할 수 있다.

예를 들면 try { … } finally { … }를 사용하여 코루틴이 취소 될 때 할 동작을 설정할 수 있다.

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        println("job: I'm running finally")
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

위의 코드를 실행하면 다음과 같은 결과가 나온다.

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.

코루틴 내의 try { } 안의 동작이 진행되다가 job.cancelAndJoin()을 만나면서 job: I'm sleeping ... 이 출력되지 않고, job: I'm running finally 이 출력 되는 것을 볼 수 있다.

 

Run non-cancellable block

바로 이전에 다뤘던 예제에서 finally 함수를 사용하면 코루틴이 취소 되면서 CancellationException 이 발생하였다. 파일을 닫거나, 작업을 취소하거나 어떠한 종류의 통신 채널을 닫는 정상적인 closing 동작은 보통 non-blocking 되지 않고, 어떠한 suspending 함수도 호출하지 않아서 문제가 되지 않는다. 하지만 밑의 예제와 같이 withContext(NonCancellable) { … } (withContext 함수, NonCancellable 컨텍스트) 를 사용하면 바로 종료가 되지 않는다.

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        withContext(NonCancellable) {
            println("job: I'm running finally")
            delay(1000L)
            println("job: And I've just delayed for 1 sec because I'm non-cancellable")
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

위의 코드의 실행 결과는 다음과 같다.

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
job: And I've just delayed for 1 sec because I'm non-cancellable
main: Now I can quit.

job.cancelAndJoin()을 만나서 job: I'm sleeping ... 의 실행이 끝났지만 finally 구문 안의 withContext(NonCancellable) 함수가 실행되는 것을 볼 수 있다.

 

Timeout

withTimeout(시간) 함수를 사용하면 실행 시간이 일정 시간을 초과하게 되면 실행이 취소가 된다.

withTimeout(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
}

위의 코드를 실행하면 다음과 같은 결과가 나온다.

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

withTimeout(시간) 함수 안에 인자로 1300L을 할당하였는데 I'm sleeping $i ... 가 3번 출력되고 4번째 실행이 될 때 실행 시간이 1300L을 초과하기 때문에 시간 초과 Exception이 발생한다.

Timeout Exception은 예외라서 예외를 발생하면서 강제로 함수를 종료한다. 근데 만약 특별한 시간 초과 예외에 대해서 처리해야 할 코드가 있으면 어떻게 해야 할까?

withTimeoutOrNull() 함수를 사용하면 된다. 이 함수는 시간 초과가 발생하면 예외를 발생하지 않고 null 값을 반환한다.

val result = withTimeoutOrNull(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
    "Done" // will get cancelled before it produces this result
}
println("Result is $result")

위의 코드를 실행하면 다음과 같은 결과가 나온다.

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

withTimeoutOrNull() 함수를 사용하자 예외가 발생하지 않는 것을 볼 수 있다. 그리고 변수 result 안에는 withTimeoutOrNull() 의 결과로 null 값이 할당 된 것도 확인 할 수 있다.