본문 바로가기

안드로이드/Kotlin

[Kotlin] 코틀린 공식 문서 - Cancellation and Timeout

개요

해당 게시글은 Cancellation and timeouts | Kotlin (kotlinlang.org)을 번역한 게시글입니다.

 

Cancelling coroutines 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.

 

job.cancel을 빨리 실행할 수록 코루틴에 의한 아웃풋을 거의 보지 못할 것이다. 그리고 Job을 확장한 함수인 cancelAndJoin을 통해 cancel과 join을 동시에 처리할 수 있다.

 

 

Cancellation is cooperative

코루틴 취소는 협력적이다. 하나의 코루틴 코드는 협력하여 취소할 수 있어야 한다. Suspending function도 취소가 가능하다. 코루틴의 취소를 확인하고 취소를 할 경우 CancellationException이 발생한다. 그러나 코루틴이 계속 연산 작업에 있고 취소를 확인하지 않을 경우 다음 예제와 같이 취소할 수 없다. 

 

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

위를 실행시키면 "I'm sleeping"이 5번의 반복 후에 작업이 자동으로 완료 될 때까지 취소된 후에도 계속해서 출력되는 것을 확인할 수 있다.

 

결과:

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.

 

Making Computation Code Cancellable

연산 중인 코드를 취소시키키 위해 두 가지 방법이 있다. 첫번째는 주기적으로 취소를 확인하는 suspend function을 수행하는 것이다. 이를 위해 yield 함수를 사용하는게 좋은 선택지이다. 다른 하나는 명시적으로 취소 상태를 확인하는 것이다. 먼저 후자의 접근 방식을 시도해본다.

 

while(i<5)를 while(isActive)로 교체하고 다시 실행시키면

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

보다시피 이제는 루프가 취소되었다. isActive는 확장 프로퍼티로 코루틴 내부에서 CoroutineScope 객체를 통해 사용할 수 있다.

 

Closing Resources with Finally

취소 가능한 suspending function은 취소 시 CancellationException을 발생시키며 일반적인 방법으로 처리할 수 있다. 예를 들어, try, finally 표현식을 활용하여 취소 시 수행되는 액션을 처리할 수 있다.

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

 

Run Non-Cancellale Block

이전 예제의 finally 블럭 내부에서 suspending function을 사용하려하면 코드를 실행하는 코루틴이 취소되기 때문에 CancellationException이 발생한다. 정상적으로 동작하는 모든 닫기 작업(파일 닫기/Job 취소)은 보통 non-blocking으로 처리되고 suspending function이 수행되지 않기 때문에 문제가 되지 않는다. 그러나 매우 드물게 취소 된 코루틴에서 일시 중지(suspend)해야 할 경우 다음 예제 처럼 withContext(NonCancellable)를 활용하여 처리한다.

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

finally에서 withContext(NonCacellable)을 통해 delay(1000L)가 수행가능했고, 이를 완전히 기다린 후 "job: And I've just ..."가 출력된 뒤 최종적으로 완료되었다.

 

Timeout

코루틴의 연산을 취소하기 위한 가장 명확하고 실질적인 이유는 실행 시간이 timeout 되었기 때문이다. 이는 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

withTimeout에 의해 발생된 TimeoutCancellationException은 CancellationException의 서브클래스이다. 이전 예제에서는 콘솔에 stack trace가 되지 않았다. 그 이유는 코루틴 내부의 CancellationException은 코루틴 완료의 정상적인 이유로 간주되기 때문이다. 그러나 이 예제에서는 withTimeout을 사용했다.

 

취소는 exception일 뿐이므로 모든 리소스는 일반적인 방식으로 닫히게 된다. 시간 제한에 대한 작업을 수행해야 할 경우 t try{...} catch{e:TimeoutCancellationException){...} 를 활용하거나 withTimeoutOrNull을 활용해라. withTimeoutOrNull은 withTimeout과 유사하지만 exception을 발생시키는 대신 시간 초과 시 null을 반환한다.

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

하지만 결과는 "Done"이 아닌 null이 반환되었다.

 

Asynchronous Timout and Resources

withTimeout에서 Timout 이벤트는 해당 블록에서 실행중인 코드가 비동기적이며 timeout 블록 내부에서 반환되기 직전에 언제든 발생이 가능하다. Timeout 블록 외부에서 닫거나 해제해야하는 블록 내부의 자원을 획득해야 하는 경우 이를 염두하여 코딩해야 한다.

 

예를 들어, 여기서는 닫을 수 있는 자원을 본 든 Resource 클래스를 통해 간단하게 얼마나 많이 acquired counter가 증가하고 clos()를 통해 감소하는 지 확인해보자. Repeat를 통해 많은 코루틴을 적은 timeout으로 실행하여 withTimeout 블록 내부에서 약간의 delay 이후 해당 리소스를 획득하고 외부에서 해제해보자.

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
}

만약 위 코드를 실행하면 항상 0을 출력하는 것이 아님을 확인할 수 있을 것이다. 

 

이 문제를 해결하려면 withTimeout 블록에서 Resource를 반환하는 것과 반대로 리소스에 대한 참조를 변수에 저장하여 반환할 수 있다. Timeout이 발생하여 finally 블록이 실행될 때 자원이 할당된 경우에만 close()가 수행된다. 자원이 참조로 할당되었다면 항상 닫히게 되고, 너무 많은 연산으로 인해 60ms-50ms=10ms 만큼의 간격 안에 자원이 할당되지 못한 경우 참조 변수가 null 이기 때문에 close()도 발생하지 않는다.

fun main() {
    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을 출력하며. 자원이 누수되지 않는다.