오늘도 더 나은 코드를 작성하였습니까?

Cancellation and timeouts 본문

Coroutine/coroutineBasic

Cancellation and timeouts

hik14 2022. 7. 5. 17:15

Cancelling coroutine execution (코루틴 실행 취소)

오랜시간 실행되는 애플리케이션에서는 백그라운드 코루틴에 대한 세밀한 제어가 필요하다.

예를 들어 사용자가 코루틴을 시작한 페이지를 닫았을 수 있으며 이제 그 결과가 더 이상 필요하지 않다면, job을 취소할수 있어야 한다.

launch{  }는 실행 중인 코루틴을 취소하는 데 사용할 수 있는 job을 반환한다.

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이 job.cancel을 호출하자마자 취소되었기 때문에 다른 코루틴의 출력을 볼 수 없습니다.

취소 및 조인 호출을 결합하는 jon extention function인 cancelAndJoin도 있습니다.

 

Cancellation is cooperative (협력적 취소)

- 코루틴 취소는 cooperative이다.

- 코루틴 코드는 취소 가능하도록 협력적이다.

- kotlinx.coroutines의 모든 suspend Fun은 취소 가능합니다.

- 코루틴 취소를 확인하고 취소되면 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.

 

 

실행하여 5회 반복 후에 job이 자체적으로 완료될 때까지 canellation을  후에도 "I'm sleep"을 계속 인쇄하는지 확인

CancellationException에러를 catch { } 확인하고 다시 외부로 던지지 않으면 동일한 문제가 관찰될 수 있습니다.

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}@4e21445c

job: I'm sleeping 3 ...

 

kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@4e21445c

job: I'm sleeping 4 ...

 

kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@4e21445c

main: Now I can quit.

 

try{ ... } catch { ... }는 안티 패턴(일반적이고 흔하게 사용되는)이지만

CancellationException을 다시 발생시키지 않는 runCatching 함수를 사용할 때와 같이 더  다른 방식으로 보여질 수 있다

 

Making computation code cancellable

computation code를 취소 가능하게 만드는 방법에는 두 가지가 있습니다.

 

첫 번째는 cancellation 를 확인하는 suspend fun 을 주기적으로 호출하는 것입니다. 그 목적에 좋은 선택인 yield() 함수가 있습니다.

 

* yield()

- suspend fun

- 현재 코루틴을 실행하고 있는 Thread 및 Thread pool 을  다른 coroutine 에게 양보한다. 

 

두 번째는 cancellation 상태를 명시적으로 확인하는 것입니다.

 

명시적 확인  -->  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.")

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 객체를 통해 코루틴 내부에서 사용할 수 있는 extend attribute

 

CoroutineScope.isActive -> coroutineContext.isActive  -> Job.isActive

Job.isActive. -> job이 시작되고 완료되지 않은 상태이며 sub job이 실행이 완료되지 않으면, active 상태이다

 

Closing resources with finally

취소 가능한 suspend fun 은 취소 시 CancellationException을 발생시키며 일반적인 방식으로 처리할 수 있습니다.

 

*Kotlin의 use 함수는 코루틴이 취소될 때 정상적으로 종료 작업을 실행합니다.

 

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: I'm tired of waiting! job: I'm running finally main: Now I can quit.

 

Run non-cancellable block

 finally {...} 블록에서 suspend fun 를 사용하려고 하면,  실행하는 코루틴이 취소되기 때문에 CancellationException이 발생합니다.

대부분 모든 close 작업(File close, cancelling a job, or closing any kind of a communication channel)은 일반적으로 non-blocking이고, suspend fun 아니기 때문에  일반적으로 별 문제가 없다. 즉, 일반 함수는 finally{ ... } 안에서 잘 실행됨.

 

그러나 드물게 취소된 코루틴에서 suspend fun 사용해야한다면,

다음 예제와 같이 withContext 함수와 NonCancellable  context를 사용하여 해당 코드를 withContext(NonCancellable) {...}안에 작성한다.

 

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

Timeout

코루틴의 실행을 취소하는 가장 명백한 실제 이유는 실행 시간이 제한 시간을 초과했기 때문이다.

 

job에 대한 참조를 가지고 있다가 일정 시간이 지난뒤 job을 취소하기 위한 별도의 코루틴을 시작할 수 있지만,

이를 수행하는 withTimeout 함수를 이용하면된다.

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

withTimeout에 의해 throw되는 TimeoutCancellationException은 CancellationException의 하위 클래스이다.

 

cancellation는 예외이기 때문에, 모든 리소스는 일반적인 방식으로 닫힙니다.

try {...} catch(e: TimeoutCancellationException) {...} 블록에서 시간 초과로 코드를 래핑할 수 있습니다.

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

 

시간 초과에 대해 구체적으로 몇 가지 추가 작업을 수행해야 하거나 withTimeout과 유사하지만 시간 초과 시 예외를 throw하는 대신 null을 반환하는 withTimeoutOrNull 함수를 사용 하는 경우

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

Asynchronous timeout and resources (비동기식 시간 초과 및 리소스

 

withTimeout( ){ ... }의 타임아웃 이벤트는

해당 블록에서 실행 중인 코드와 관련하여 비동기적이며,  타임아웃 블록 내부에서 반환 직전에도 언제든지 발생할 수 있습니다.

주의 해야될점은 블록 내부에서 자원을 열어서 참조하게 되면, 자원을 블록 외부에서 닫고, 해제 할 수 있어야한다.   

 

acquired의 count 를 증가시키고,  close()에서 이 카운터를 감소시켜 생성된 횟수를 단순히 추적하는 Resource 클래스를 사용하여 closable resource 만든다.

짧은 시간 초과를 걸고 많은 코루틴을 실행한다.

약간의 지연 후에 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이 아닌 값을 보려면 이 예제에서 시간 초과를 조정해야 할 수도 있다.

 

이 문제를 해결하려면 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