본문 바로가기
Android/Coroutine

[Kotlin/Coroutine] 2. Cancellation and timeouts

by 겸 2023. 10. 22.

Cancelling coroutine execution

코루틴의 실행 취소

 

오랜시간동안 실행되는 애플리케이션에서 백그라운드 코루틴관리를 어떻게 해야할까?

사용자가 코루틴을 시작했던 페이지를 닫고, 더 이상 결과가 필요하지 않다면 해당 작업을 취소해야 할 것이다.

launch함수를 사용하면 Job을 리턴하고, 이것으로 실행 중인 코루틴을 취소할 수 있다.

 

코드

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

main이 호출되고 1.3초후 job.cancel이 실행되어 job코루틴이 취소되었기에 job의 완료를 기다린 후 “main: Now I can quit.”가 출력되었다.


Cancellation is cooperative

코루틴의 취소는 협력적이다.

 

코루틴 코드가 취소가능하기 위해서는 협력적이어야 한다.

모든 중단 함수는 취소 가능하다.

코루틴의 취소를 확인하고 취소될 때 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.

위 코드는 취소했는데도 계속해서 I’m sleeping이 출력되고 있다.

즉, 코루틴이 계산 중인 경우 취소를 확인하지 않으면 계속 출력되는 것이다.

 

코드

val job = launch(Dispatchers.Default) {
    repeat(5) { i ->
        try {
            // print a message twice a second
            println("job: I'm sleeping $i ...")
            delay(500)
        } catch (e: Exception) {
            // log the exception
            println(e)
        }
    }
}
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!
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@16d5e4dd
job: I'm sleeping 3 ...
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@16d5e4dd
job: I'm sleeping 4 ...
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@16d5e4dd
main: Now I can quit.

try - catch문을 추가한 결과, 취소 시 CancellationException을 throw하고 있음을 볼 수 있다.


Making computation code cancellable

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

  1. 취소를 확인하는 중단 함수를 주기적으로 호출하기
    1. yield 함수를 호출하기
  2. 취소 상태를 명시적으로 확인하기

코드

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.

이전 코드에서 while (i < 5)를 대신해서 while (isActive)를 사용한 코드이다.

isActiveCoroutineScope객체를 통해 코루틴 내에서 사용할 수 있는 확장 프로퍼티이다.


Closing resources with finally

finally를 사용해 종료하기

 

중단 함수는 CancellationException을 throw한다.

이때 try { … } finally { … } 와 코틀린의 use 함수는 코루틴이 취소될 때 작업을 실행한다.

 

1. 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.

 

2. use

 

Closable을 상속받고 있는 객체에 use를 사용하면 try/catch/finally가 포함되어 있어 자동으로 close된다.


Timeout

시간초과

 

실행시간을 초과했을 때 대부분 코루틴 실행을 취소할 것이다.

직접 Job이나 launch를 추적해 취소하는 방법 이외에도 withTimeout을 사용할 수 있다.

 

코드

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

withTimeoutTimeoutCancellationException을 throw한다.

TimeoutCancellationExceptionCancellationException의 하위 클래스이다.

일반적인 예외처리와 동일하게 처리된다.

 

코드

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

이후 추가 동작을 실행하기 위해서는 예외를 throw하는 것 대신 withTimeoutOrNull을 사용해 null을 반환할 수 있다.


Asynchronous timeout and resources

비동기 시간초과와 리소스

 

withTimeout에서 타임아웃 이벤트는 블록 내에서 실행 중인 코드가와 비동기적으로 발생하거나 return직전에 일얼날 수 있다.

 

코드

var acquired = 0

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

fun main() {
    runBlocking {
        repeat(10_000) { // Launch 10K 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 또는 다른 값이 출력될 수 있다.

 

withTimeout{ … } 내부 코드가 모두 실행된 후에도 타임아웃이 발생해서 리소스 낭비가 일어날 수 있다.

이런 비동기 문제를 어떻게 해결할 수 있을까?

 

리소스를 withTimeout{ … } 에서 바로 return하지 않고, 변수를 따로 두면 리소스가 null이 아닌 경우에만 close가 동작한다.

 

코드

var acquired = 0

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

fun main() {
    runBlocking {
        repeat(10_000) { // Launch 10K 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이 출력된다.

반응형