엄코딩의 개발 일지

Cancelling coroutine execution

 

장기적으로 실행되는 어플리케이션에서 백그라운드 코루틴 제어권을 얻는 것이 필요합니다.

예를 들어서 유저가 코루틴을 시작한 페이지를 닫은 경우, 결과가 더이상 필요하지 않은 경우, 해당 작업을 취소할 수 있습니다.

launch 함수는 코루틴 실행을 취소할 수 있는 job 을 반환합니다.

 

val job = launch {
    repeat(1000) { i ->
            println("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.")

 

출력 결과는 다음과 같습니다.

 

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

 

메인 호출 job.cancle 이 발생하면, 코루틴이 취소되었기 때문에 어떠한 출력도 볼 수 없습니다.

job extension 함수로  canceljoin을 결합한 cancelAndJoin 있습니다.

 

cancelAndJoin

suspend fun Job.cancelAndJoin(): Unit (source)

Cancels the job and suspends invoking coroutine until the cancelled job is complete.

This suspending function is cancellable and always checks for the cancellation of invoking coroutine’s Job. If the Job of the invoking coroutine is cancelled or completed when this suspending function is invoked or while it is suspended, this function throws CancellationException.

In particular, it means that a parent coroutine invoking cancelAndJoin on a child coroutine that was started using launch(coroutineContext) { ... } builder throws CancellationException if the child had crashed, unless a non-standard CoroutineExceptionHandler is installed in the context.

This is a shortcut for the invocation of cancel followed by join.

 

Cancellation is cooperative

 

코루틴 취소는 협조적입니다. 코루틴 코드는 취소가능하도록 협조되어야 합니다. kotlinx.coroutines의 모든 suspending function은 취소가능합니다. 코루틴의 취소를 확인하고, 취소되었을 때 CancelliationException 을 발생시킵니다.

하지만, 코루틴이 복잡한 환경에서 작동하고, 취소를 확인하지 않으면 다음 예제처럼 취소할 수 없습니다.

 

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

 

출력 결과

 

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

 

Making computation code cancellable

 

계산 코드를 취소할 수 있게 만드는 방법은 두가지가 있습니다. 첫번째 방법은, 주기적으로 취소를 체크하는 suspending function 를 발생시키는 것입니다. yield()  함수는 목적을 위한 좋은 선택입니다.  다른 한가지 방법은 명시적으로 취소 상태를 체크하는 것입니다.

아래 코드는 후자의 접근 방식으로 접근한 것입니다.

 

fun main() = runBlocking {
    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("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 ( i < 5 ) 를 while ( isActive ) 로 변경한 것입니다.

안드로이드에서 isActive 설명은 아래와 같습니다.

 

* This property is a shortcut for `coroutineContext.isActive` in the scope when
 * [CoroutineScope] is available.
 * See [coroutineContext][kotlin.coroutines.coroutineContext],
 * [isActive][kotlinx.coroutines.isActive] and [Job.isActive].
 */
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
public val CoroutineScope.isActive: Boolean
    get() = coroutineContext[Job]?.isActive ?: true

 

출력 결과

 

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

 

while ( i < 5 ) 를 사용했을 때와는 다르게 1300L 만큼 delay 된 후에 main이 실행된 것을 볼 수 있습니다.

 

Closing resources with finally

 

취소 가능한 suspending functions 는 일반적인 방법으로 처리할 수 있는 CancellationException을 throw 합니다. 

예를 들어서 try { ... } finally { .. } 표현과 Kotlin use function 은 코루틴이 취소되었을 때 종료 작업을 실행합니다.

 

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                    println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            println("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.")    
}

 

출력 결과

 

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

 

Run non-cancellable block

 

finally 블록에서 suspending function을 호출하려고 시도하면 실행되고 있는 코루틴이 취소되므로 CancellationException 이 발생합니다.

일반적으로 모든 문제를 해결하는 closing 연산들 ( 파일 닫기, job 취소, 또는 다른 종류의 통신 채널 닫기 등 ) 은 일반적으로 non-blocking 이고 suspending functions를 포함하지 않기 때문에 문제가 되지 않습니다. 그러나, 취소된 코루틴에서 delay 또는 중단해야 하는 경우 충돌하는 코드를 withContext 함수와 NonCancellable context를 사용한 withContext(NonCancellable) { ... } 를 사용하여 해당 코드를 감쌀 수 있습니다.

 

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                    println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            withContext(NonCancellable) {
                println("I'm running finally")
                delay(1000L)
                println("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.")    
}

 

출력 결과

 

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

 

Timeout

 

실행중인 코루틴을 취소해야하는 가장 명백한 이유는 실행시간이 초과한 경우입니다. 해당 코루틴에 대한 참조를 수동적으로 추적하고, delay 또는 일시 정지된 후에 참조를 통해 추적하여 취소할 수 있지만 whilteTimeout 기능을 사용할 수도 있습니다.

 

fun main() = runBlocking {
    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 kotlinx.coroutines.TimeoutKt.TimeoutCancellationException (Timeout.kt:122) 
 at kotlinx.coroutines.TimeoutCoroutine.run (Timeout.kt:88) 
 at kotlinx.coroutines.EventLoopBase$DelayedRunnableTask.run (EventLoop.kt:316) 
 at kotlinx.coroutines.EventLoopBase.processNextEvent (EventLoop.kt:123) 
 at kotlinx.coroutines.DefaultExecutor.run (DefaultExecutor.kt:61) 
 at java.lang.Thread.run (Thread.java:745) 

 

withTimeout에 의해 발생되는 TimeoutCancellationException은 CancellationException의 subclass 입니다.

이전 콘솔에서는 보지 못했는데, 그 이유는 취소된 코루틴 내부 CancellationException은 코루틴 완성에 정상적인 이유로 고려되었기 때문입니다. 그러나 예제에서는 withTimeout를 main 내부에서 사용했습니다.

 

취소는 단지 예외이기 때문에, 모든 리소스들은 정상적으로 닫힙니다. 만약 구체적으로 모든 종류의 타임아웃 또는 withTimeout 과 유사한 withTimeoutOrNull을 사용하여,

null 또는 timeout return해야 하는 추가적인 action이 필요한 경우에 try { ... } catch (e: TimeoutCancellationException  { ... } 을 사용하여 코드를 감쌀 수 있습니다.

 

fun main() = runBlocking {
    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