Coroutine - Cancellation과 Timeout

조갱·2022년 5월 1일
0

Coroutine

목록 보기
3/9

번역본

마지막 수정일 : 2021-09-13

이 섹션은 Coroutine - Cancellation과 Timeout에 대해 다룹니다.

Coroutine 의 실행 중단하기

장기간 실행되는 어플리케이션에서, 당신의 background coroutine에서의 세밀한 조작이 필요할 수도 있다. 예를 들어, 유저는 coroutine이 실행된 페이지를 닫아야 할 수도 있다. 그리고 이것의 결과는 더이상 필요하지 않고, 이것의 동작은 취소되어야 한다. Launch 함수는 Job object를 리턴하는데, 그것은 실행중인 코루틴을 취소할 수 있다.

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

실행 결과는 아래와 같다.

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.

Main 스레드가 job.cancel을 실행하자 마자, 우리는 다른 coroutine으로부터의 결과를 보지 않는데, job이 취소되었기 때문이다. 또한, Job 객체는 CancelAndJoin이라는 확장 함수를 가지는데, 이는 cancel과 join 의 실행을 결합한 것이다.

취소는 협조적이다.

Coroutine의 취소는 협조적이다. Coroutine 코드는 취소가능하기 위해 협조해야만 한다. kotlinx.coroutines 패키지에 존재하는 모든 suspending 함수는 취소가능하다. 그들은 작업이 취소되었을 때 coroutine의 cancellation을 체크하고, CancellationException을 발생시킨다, 그러나, 만약 코루틴이 계산 작업중에 있면서 cancellation을 확인하지 않는다면, 취소될 수 없을 것이다, 아래 코드처럼.

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

위 코드를 실행하면, 코루틴이 cancellation 이후에도 작업이 완료될 때 까지 "I'm sleeping"을 출력하는 것을 볼 수 있다.이것 스스로 5번 반복한 이후에도.

계산 코드를 취소가능하게 만들기

계산 코드를 취소가능하게 만드는 방법은 두가지의 접근법이 있다. 첫 번째는, 취소 여부를 체크하기 위해 주기적으로 suspending 함수를 실행하는 것이다. 해당 기능을 위해서는 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.")

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

당신도 볼 수 있겠지만, 이제 이 반복문은 취소된다. isActiveCoroutineScope 객체를 통해 coroutine 안에서 사용할 수 있는 확장 프로퍼티이다.

finally 키워드를 통한 자원 해제

취소가능한 suspending 함수는 취소시에 CancellationException을 던진다. 이것은 일반적인 방법으로 다뤄질 수 있다. 예를 들어, 코틀린은 일반적으로 coroutine이 취소될 때 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.")

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

joincancelAndJoin은 모두 finalization action의 완료를 위해 기다리기 때문에, 위 예시는 아래의 결과를 만들어낸다.

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.

non-cancellable 블록 실행하기

이전 예제에서, finally 블록에서 suspending 함수를 사용할 때, CancellationException이 발생하는데, 이 코드를 실행하는 coroutine이 취소되었기 때문이다. 일반적으로, 이것은 문제가 되지 않는데, 잘 동작하는 모든 닫기 동작 (file 닫기, job취소, 다른 통신 채널 닫기)는 일반적으로 non-blocking 이며, 어떠한 suspending 함수를 포함하지 않기 때문이다. 그러나, 당신이 취소된 coroutine을 취소해야만 하는 것과 같이 드문 경우에, withContext(NonCancellable) {...} 내부의 corresponding 코드를 withContext 함수와 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.")

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

Timeout

Coroutine의 실행을 중단하는 가장 명백하고 분명한 이유는, 이것의 실행 시간이 초과된 경우이다. 당신은 해당 Job 객체에 대한 참조를 수동으로 추적하고, 잠시 뒤에 다른 coroutine을 실행하여 추적된 Job 객체를 취소할 수도 있지만, withTimeout 함수를 통해 해당 작업을 할 수 있도록 준비되어있다. 아래 예시를 참고해보자.

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

이것은 아래 결과를 출력한다.

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에 의해 발생한 TimeoutCancellationException 예외는, CancellationException의 서브클래스이다. 우리는 이전까진 콘솔에 출력되는 stack trace를 보지 못해왔다. 그 이유는, 취소된 coroutine 내부의 CancellationException은 coroutine completion을 위한 일반적인 이유로 고려되어왔기 때문이다. 그러나, 이 예시에서 우리는 withTimeoutmain함수 내부에 직접적으로 사용했다.

cancellation은 단지 예외이기 때문에, 모든 자원들은 일반적인 방법으로 반환된다. 당신은 코드를 timeout과 함께 try {...} catch (e: TimeoutCancellationException) {...} 블럭에 감쌀 수 있다. 만약 당신이 추가적인 action을 해야한다면, 어떠한 종류의 timeout에 특화된. 또는, withTimeoutOrNull 함수를 사용할 수도 있다. 이것은 withTimeout과 유사하지만, timeout이 발생할 때 exception을 발생시키지 않고 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")

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

위 예시 코드를 실행할 때에, 예외가 발생하지 않는 모습을 볼 수 있다.

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

비동기적인 timeout과 자원들

withTimeout 의 시간 초과 이벤트는 해당 블록에서 실행 중인 코드에 대해 비동기적이며 시간 초과 블록 내부에서 반환되기 직전에 언제든지 발생할 수 있다. 블록 내에서 닫거나 블록 외부에서 해제해야 하는 리소스를 열거나 획득하는 경우 이 점에 유의해야 한다.

예를 들어, 여기서는 Resource 클래스로 닫기 가능한 자원을 모방할건데, 간단하게acquired 카운터 변수를 증감함으로써 얼마나 생성됐는지를 추적한다. 작은 timeout으로 많은 코루틴을 실행하여 withTimeout 블록 내부에서 이 자원을 획득하고, 약간의 딜레이 후에 외부에서 이 자원을 해제해보자.

var acquired = 0

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

fun main() {
    runBlocking {
        repeat(100_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
}

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

당신이 위 코드를 실행하면, 항상 0이 출력되지 않는 것을 볼 수 있겠지만, 이것은 당신의 장비의 타이밍에 영향을 받기 때문에, 실제로 0이 출력되지 않는 것을 보기 위해서는 timeout을 조정해야 할 수도 있다.

여기서 acquired 카운터 변수를 10만회 증감하는 것은 완벽하게 안전한데, 이것은 항상 동일한 메인 스레드에서 발생하기 때문이다. 더 많은 것은 coroutine context 챕터에서 설명된다.

이 문제를 해결하기 위해, 당신은 withTimeout 블록에서 리소스를 반환하는 것 대신 리소스에 대한 참조를 변수에 저장할 수 있다.

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

이 예제는 리소스가 누수되지 않고 항상 0을 출력한다.

=================================================

원문

Last modified: 13 September 2021

This section covers coroutine cancellation and timeouts.

Cancelling coroutine execution

In a long-running application you might need fine-grained control on your background coroutines. For example, a user might have closed the page that launched a coroutine and now its result is no longer needed and its operation can be cancelled. The launch function returns a Job that can be used to cancel the running coroutine:

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

It produces the following output:

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.

As soon as main invokes job.cancel, we don't see any output from the other coroutine because it was cancelled. There is also a Job extension function cancelAndJoin that combines cancel and join invocations.

Cancellation is cooperative

Coroutine cancellation is cooperative. A coroutine code has to cooperate to be cancellable. All the suspending functions in kotlinx.coroutines are cancellable. They check for cancellation of coroutine and throw CancellationException when cancelled. However, if a coroutine is working in a computation and does not check for cancellation, then it cannot be cancelled, like the following example shows:

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

Run it to see that it continues to print "I'm sleeping" even after cancellation until the job completes by itself after five iterations.

Making computation code cancellable

There are two approaches to making computation code cancellable. The first one is to periodically invoke a suspending function that checks for cancellation. There is a yield function that is a good choice for that purpose. The other one is to explicitly check the cancellation status. Let us try the latter approach.

Replace while (i < 5) in the previous example with while (isActive) and rerun it.

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

As you can see, now this loop is cancelled. isActive is an extension property available inside the coroutine via the CoroutineScope object.

Closing resources with finally

Cancellable suspending functions throw CancellationException on cancellation which can be handled in the usual way. For example, try {...} finally {...} expression and Kotlin use function execute their finalization actions normally when a coroutine is cancelled:

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

Both join and cancelAndJoin wait for all finalization actions to complete, so the example above produces the following output:

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.

Run non-cancellable block

Any attempt to use a suspending function in the finally block of the previous example causes CancellationException, because the coroutine running this code is cancelled. Usually, this is not a problem, since all well-behaving closing operations (closing a file, cancelling a job, or closing any kind of a communication channel) are usually non-blocking and do not involve any suspending functions. However, in the rare case when you need to suspend in a cancelled coroutine you can wrap the corresponding code in withContext(NonCancellable) {...} using withContext function and NonCancellable context as the following example shows:

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

Timeout

The most obvious practical reason to cancel execution of a coroutine is because its execution time has exceeded some timeout. While you can manually track the reference to the corresponding Job and launch a separate coroutine to cancel the tracked one after delay, there is a ready to use withTimeout function that does it. Look at the following example:

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

It produces the following output:

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

The TimeoutCancellationException that is thrown by withTimeout is a subclass of CancellationException. We have not seen its stack trace printed on the console before. That is because inside a cancelled coroutine CancellationException is considered to be a normal reason for coroutine completion. However, in this example we have used withTimeout right inside the main function.

Since cancellation is just an exception, all resources are closed in the usual way. You can wrap the code with timeout in a try {...} catch (e: TimeoutCancellationException) {...} block if you need to do some additional action specifically on any kind of timeout or use the withTimeoutOrNull function that is similar to withTimeout but returns null on timeout instead of throwing an exception:

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

There is no longer an exception when running this code:

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

Asynchronous timeout and resources

The timeout event in withTimeout is asynchronous with respect to the code running in its block and may happen at any time, even right before the return from inside of the timeout block. Keep this in mind if you open or acquire some resource inside the block that needs closing or release outside of the block.

For example, here we imitate a closeable resource with the Resource class, that simply keeps track of how many times it was created by incrementing the acquired counter and decrementing this counter from its close function. Let us run a lot of coroutines with the small timeout try acquire this resource from inside of the withTimeout block after a bit of delay and release it from outside.

var acquired = 0

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

fun main() {
    runBlocking {
        repeat(100_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
}

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

If you run the above code you'll see that it does not always print zero, though it may depend on the timings of your machine you may need to tweak timeouts in this example to actually see non-zero values.

Note, that incrementing and decrementing acquired counter here from 100K coroutines is completely safe, since it always happens from the same main thread. More on that will be explained in the chapter on coroutine context.

To workaround this problem you can store a reference to the resource in the variable as opposed to returning it from the withTimeout block.

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

Open in Playground →

Target: JVMRunning on v.1.6.10

You can get the full code here.

This example always prints zero. Resources do not leak.

profile
A fast learner.

0개의 댓글