Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

디지안의 개발일지

[TIL] Kotlin Coroutines v1.5.30 Document - 3.Cancellation and timeouts 본문

Kotlin

[TIL] Kotlin Coroutines v1.5.30 Document - 3.Cancellation and timeouts

안덕기 2021. 9. 6. 23:30

들어가기 전에

이 글에서는 코루틴의 취소와 시간초과에 대해서 다룬다.

 

Cancelling coroutine execution

긴 시간 운영하는 애플리케이션에서는 백그라운드 코루틴을 세밀하게 제어하는 것이 필요하다. 예를 들어, 유저가 페이지를 닫았을 때 실행 중이던 코루틴과 그 결과가 더 이상 필요하지 않으면 취소되어야 한다. launch 함수는 Job을 반환하는데 Job을 통해서 취소 할 수 있다.

fun main() = runBlocking {
    val job = launch {
        repeat(1_000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1_300L)
    println("main: I'm tried of waiting!")
    job.cancel()
    job.join()
    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.

 

Cancellation is cooperative

코루틴 취소는 협조적이다. 코루틴 코드는 취소할 수 있도록 협조적으로 구성되어야 한다. kotlinx.coroutine의 모든 지연된 함수는 취소할 수 있다. 그 함수들은 코드가 취소될 때, 코루틴의 취소를 확인하고 CancellationException을 throw 한다. 그러나 만약 코루틴이 연속적으로 실행되고 취소를 확인하지 않는다면 코루틴을 취소할 수 없다. 다음 예제를 보자.

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1_300L)
    println("main: I'm tried of waiting!")
    job.cancelAndJoin()
    println("main: Now I can quit.")
}

위 예제는 "I'm sleeping"이 job을 취소한 이후에도 5번이 나와야만 멈추는 것을 확인할 수 있다.

 

Making computation code cancellable

코루틴이 취소될 수 있게 하는 방법은 두가지가 있다. 첫 번째 방법은 취소를 위해서 주기적으로 지연되는 함수를 실행하는 것이다. 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) {
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1_300L)
    println("main: I'm tried of waiting!")
    job.cancelAndJoin()
    println("main: Now I can quit.")
}

실행 결과 원하는 시점에서 loop를 취소할 수 있다. isActive는 CoroutineScope 객체를 통해서 사용할 수 있는 확장 프로퍼티다.

 

Closing resources with finally

취소할 수 있는 지연되는 함수들은 일반적인 방법으로 처리할 수 있는 취소 시 CancellationException을 발생 시킨다. 예를 들어, try { ... } finally { ... } 표현과 kotlin user 함수는 코루틴이 취소되었을 때 마지막 행동을 실행한다.

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1_000) { i ->
                println("job: I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            println("job: I'm running finally")
        }
    }
    delay(1_300L)
    println("main: I'm tried of waiting!")
    job.cancelAndJoin()
    println("main: Now I can quit.")
}

join과 cancelAndJoin 둘 다 모든 마지막 행동들이 끝날 때까지 기다리고 아래와 같은 결과를 출력한다.

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

이전 예제의 finally 블록에서 일시 중단 함수를 사용하려고 하면 이 코드를 실행하는 코루틴이 취소되기 때문에 CancellationException이 발생한다. 주로, 정상적으로 작동하는 모든 닫기 작업(파일 닫기, 작업 취소 또는 모든 종류의 통신 채널 닫기)은 주로 논블럭킹 작업이고 어떤 suspending 함수를 포함하지 않는다. 그러나 드물게 취소된 코루틴에서 suspend가 필요할 수 있다. 그 때는 withContext(NonCacellable) { ... } 로 래핑할 수 있다. withContext 함수와 NonCancellable context의 예제는 다음과 같다.

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

 

Timout

코루틴의 실행을 취소하는 가장 분명한 실제 이유는 실행 시간이 일부 제한 시간을 초과했기 때문이다. 이 전의 예제들처럼 suspending 작업들을 추적하여 수동으로 취소해야하는 타이밍을 잡을 수도 있지만 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
	at kotlinx.coroutines.TimeoutKt.TimeoutCancellationException(Timeout.kt:186)
	at kotlinx.coroutines.TimeoutCoroutine.run(Timeout.kt:156)
	at kotlinx.coroutines.EventLoopImplBase$DelayedRunnableTask.run(EventLoop.common.kt:497)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:274)
	at kotlinx.coroutines.DefaultExecutor.run(DefaultExecutor.kt:69)
	at java.base/java.lang.Thread.run(Thread.java:830)

TimeoutCancellationException은 withTimeout에 의해 발생한다. withTimeout은 CancellationException의 하위 클래스다. 지금까지는 이전 콘솔에 대해서 stacktrace를 본 적이 없다. 그 이유는 CancellationException이 원인인 코루틴 취소는 코루틴 완료로 처리되기 때문이다.

 

예외는 단지 취소일 뿐이고, 모든 리소스는 일반적인 방법으로 닫힌다. 어떤 종류의 시간 초과에 대해 특별히 추가 작업을 수행해야 하는 경우 try {...} catch(e: TimeoutCancellationException) {...} 블록에서 시간 초과로 코드를 래핑할 수 있다. 그리고 withTimeout과 유사하지만 예외를 throw하는 대신에 시간 초과 시 null을 반환하는 withTimeoutOrNull 함수를 사용할 수도 있다.

fun main(): Unit = runBlocking {
    val result = withTimeoutOrNull(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
        "Done"
    }
    println("Result is $result")
}

코드는 더 이상 예외를 발생시키지 않고 다음과 같이 출력한다.

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

 

Asynchronous timeout and resources

withTimeout의 타임아웃 이벤트는 해당 블록에서 실행 중인 코드와 관련하여 비동기적이다. 그리고 타임아웃 블록 내부에서 반환되기 직전에도 언제든지 발생할 수 있다. 블록 내부에서 리소스를 열거나 얻어야한다면 리소스를 외부에서 닫거나 release 해야 될 수 있다.

 

예를 들어, 닫기 가능해야하는 리소스를 모방한 Resource 클래스가 있다. Resource는 획득한 카운터를 증가시키고 close() function에서 이 카운터를 감소시켜 생성된 횟수를 단순히 추적한다. 짧은 시간 안에 많은 코루틴을 실행하고 약간의 시간 지연 이후에 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
}

위의 코드를 실행하면 컴퓨터의 타이밍에 따라 다를 수 있지만 항상 0을 인쇄하지는 않는다는 것을 알 수 있다. 만약 0이 아닌 값을 볼 수 없다면 이 예제에서 시간 초과를 조정해야 할 수도 있다. 결과적으로 0이 아닌 값이 나오는 이유는 리소스 해제를 잘 하지 못했다는 것이다. 이 문제를 해결하려면 withTimeout 블록에서 리소스에 대한 참조를 반환하는 대신 변수에 리소스에 대한 참조를 저장하면 된다.

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
}

 

원문