본문 바로가기

Kotlin

코루틴 취소와 타임아웃

1. Cancelling coroutine execution

 

오래 동작하는 애플리케이션에서는, 백그라운드에서 동작하는 코루틴들에 정제한 제어가 필요합니다.

 

예를 들어서, 유저가 페이지를 닫을 수 있을것이고, launched된 코루틴은 이제 더이상 필요가 없어졌고, 연산은 취소되어야 할것입니다.

 

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.

 

1.3초 기다린 다음 job.cancel이 호출되었고, 한번 출력한 다음 delay가 0.5초기 때문에 3번 출력되고 종료되었습니다.

 

cancel과 join을 합친 cancelAndJoin 함수가 있습니다.

 

2. Cancellation is cooperative

 

코루틴 취소는 협력적입니다. 코루틴 코드는 취소가능해지기 위해서 협력해야 합니다.

 

모든 kotlinx.coroutines에 있는 모든 suspending 함수는 취소 가능합니다. 해당 함수들은 코루틴의 취소를 체크한 뒤에 CancellatiojnException을 throw 합니다.

 

하지만, 코루틴이 연산중이고, 취소를 체크하지 못한다면, 취소되지 않습니다.

 

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.

 

결과를 보면 취소된 이후에도 연산이 끝날 때까지 계속 sleeping ~~ 를 출력하는걸 볼 수 있다.

 

3. Making computation code cancellable

 

연산중인 코드를 취소가능하게 만드는 두 가지 접근방법이 있습니다.

 

첫 번째는 주기적으로 취소 여부를 확인하는 일시정지 기능을 호출하는 것입니다. 해당 목적에 적합한 yield 함수가 있습니다.

 

다른 방법은 명시적으로 취소 상태를 호출하는 것입니다. 후자의 접근방법을 알아보겠습니다.

 

while (i < 5) 를 while (isActive)로 변경해서 다시 돌려보았습니다.

 

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.")

 

결과

 

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.

 

변경한 결과 loop가 이제 취소되었습니다. isActive는 CoroutineScope 객체를 통해서 코루틴 내부에서 사용가능한 확장 프로퍼티입니다.

 

4. Closing resources with finally

 

취소 시 suspending 함수들은 CancellationException을 날리고, 일반적인 경우 이를 제어 할 수 있습니다.

 

예를 들어서, try { ... } finally { ... } 표현과 코틀린의 use 함수 실행을 통해서 코루틴 취소시 finalization 동작을 실행할 수 있습니다.

 

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.

 

join cancelAndJoin 둘 다 모든 finalization 동작이 끝나야 완료됩니다.

 

5. Run non-cancellable block

 

앞 예제의 finally block에서 suspending 함수를 사용하려고 시도하면 CancellationException이 발생합니다.

 

왜냐하면, 해당 코드를 동작시키는 코루틴이 취소되었기 때문입니다. 보통 이건 문제가 안되는게, (ex) 파일 닫기, job 취소, communication 채널 닫기)잘 동작하는 닫기 연산들은 보통 non-blocking이고, suspending 함수들을 포함하고 있지 않기 때문입니다.

 

하지만, 아주 가끔 코루틴을 취소할 때 일시정지가 필요하다면 withContext(NonCancellable) { ... } 와 NonCancellable context로 감싸면 됩니다.

 

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.

 

6. Timeout

 

실행중인 코루틴을 취소하는 가장 명백하고 실용적인 이유는 timeout이 초과되었을 때 일것입니다.

 

수동으로 상호작용하는 Job을 추적하고, 코루틴을 분리해서 launch 한 다음에 일시정지 이후에 추적되는 코루틴을 취소하는 것 대신에, 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
 at (Coroutine boundary. (:-1) 
 at FileKt$main$1$1.invokeSuspend (File.kt:-1) 
 at FileKt$main$1.invokeSuspend (File.kt:-1) 
Caused by: kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms
at kotlinx.coroutines.TimeoutKt .TimeoutCancellationException(Timeout.kt:184)
at kotlinx.coroutines.TimeoutCoroutine .run(Timeout.kt:154)
at kotlinx.coroutines.EventLoopImplBase$DelayedRunnableTask .run(EventLoop.common.kt:502)

 

설명

 

withTimeout 에서 발생하는 TimeoutCancellationException은 CancellationException의 서브클래스입니다.

 

보통 일반적인 경우는 CancellationException이 발생하지만, withTimeout을 사용했기 때문에 TimeoutCancellationException이 발생했습니다.

 

취소는 단지 exception이기 때문에, 모든 리소스의 close 연산은 정산적으로 진행됩니다.

 

부가적인 연산을 사용하고 싶다면 try { ... } catch { ... }를 사용하거나, 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

 

7. Asynchronous timeout and resources

 

withTimeout으로부터의 타임아웃 이벤트는 블록 내부의 코드와 비동기적으로 동작하며, 언제든지 발생할 수 있습니다.

 

이 사실을 기억하고, 만약 리소스를 open 하거나 얻었으면 block 바깥에서 이를 close나 release 해주어야 합니다.

 

예를 들어서, closable과 유사한 Resource 클래스가 있고, 얼마나 많이 생성되었는지를 나타내는 acquired 카운터가 있고, Resource가 생성될 때 +1 됩니다. 그리고, close 함수가 실행될 때 acquired는 -1 됩니다.

 

그리고, withTimeout 블록 내부에서 잠깐의 타임아웃 및 딜레이와 함께 Resource 클래스를 생성해보았고, 블록 바깥에서 이를 close 해보았습니다.

 

var acquired = 0

class Resource {
    init { acquired++ } // Acquire the resource
    fun close() { acquired-- } // Release the resource
}

fun main() {
    runBlocking {
        repeat(50_000) { // Launch 100K coroutines
            launch { 
                val resource = withTimeout(60) { // Timeout of 60 ms
                    delay(50) // Delay for 50 ms
                    Resource() // Acquire a resource and return it from withTimeout block     
                }
                resource.close() // Release the resource
            }
        }
    }
    // Outside of runBlocking all coroutines have completed
    println(acquired) // Print the number of resources still acquired
}

 

만약 위의 코드를 실행시키면 0으로 출력되지 않습니다. 타이밍에 따라서 resource.close()가 실행되기 전에 취소될 수 있기 때문입니다.

 

위의 코드를 해결하기 위해서는 Resource를 저장하여 finally 블록에서 이를 명시적으로 close 해주어야 합니다.

 

( withTimeout 의 시간을 늘리는것으로도 해결되지만 100%는 아닙니다. )

 

runBlocking {
    repeat(100_000) { // Launch 100K coroutines
        launch { 
            var resource: Resource? = null // Not acquired yet
            try {
                withTimeout(60) { // Timeout of 60 ms
                    delay(50) // Delay for 50 ms
                    resource = Resource() // Store a resource to the variable if acquired      
                }
                // We can do something else with the resource here
            } finally {  
                resource?.close() // Release the resource if it was acquired
            }
        }
    }
}
// Outside of runBlocking all coroutines have completed
println(acquired) // Print the number of resources still acquired

 

결과

 

0

 

출처

 

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

'Kotlin' 카테고리의 다른 글

코루틴 suspending 함수 합성하기  (0) 2022.06.01
코루틴 basic  (0) 2022.06.01
코틀린 코루틴 개념  (0) 2022.05.29
코틀린 1.4, 1.5 버전에서의 변경사항  (0) 2021.08.15
(번역글) kotlin 1.5.20 릴리즈 노트  (0) 2021.08.15