ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 공식문서로 알아보는 Cancellation and timeouts
    Kotlin/코루틴 2023. 12. 4. 00:01

    개요

    Kotlin 공식문서를 보며 코루틴의 취소와 타임아웃에 대해 알아보고 실습도 진행해보고자 합니다.

     

    코루틴의 취소

    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.

    오랫동안 실행되는 애플리케이션에서는 백그라운드 코루틴을 세밀하게 제어할 수 있어야 합니다.

    예를 들어 사용자가 코루틴을 실행한 페이지를 닫았는데 이제 그 결과가 더 이상 필요하지 않으므로 작업을 취소할 수 있습니다.

    그렇지 않으면 해당 작업에 대한 리소스가 낭비될 것입니다. (메모리 누수 발생 등)

    launch 함수는 실행 중인 코루틴을 취소하는 데 사용할 수 있는 Job을 반환합니다.

     

    main에서 job.cancel이 호출되면 해당 job의 코루틴이 취소되었기 때문에 더 이상 호출되기 않습니다.

    job.cancleAndJoin()

    취소와 동시에 join을 호출하는 cancleAndJoin 확장함수도 존재합니다.

     

     

    suspend와 함께한다면 취소할 수 있다.

    코루틴에서 취소는 cooperative(협력적)입니다.

    이 말은 코루틴이 취소되기 위해서는 코루틴이 협력해야 한다는 의미있고, 모든 suspend 함수는 취소에 협력적입니다.

    코루틴이 취소되었는지 확인하고 취소되었다면 CancellationException 예외를 던집니다. 

    하지만 내부 job이 취소할 수 없다면 내부적으로 계속 리소스를 소모합니다.

     

    위의 예제에서는 delay()라는 suspend 함수를 사용했기 때문에 취소가 가능합니다.

     

    suspend와 함께하지 않으면 취소할 수 없다.

    suspend fun notCancelable(){
            coroutineScope {
                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.

    내부적으로 suspend 함수가 사용되지 않았기 때문에 cancelAndJoin() 메서드가 호출되더라도 5번의 연산이 모두 수행된 후 종료됩니다.

     

    만약 취소후에 예외를 잡아본다면

    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}@1f266e5d
    job: I'm sleeping 3 ...
    kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@1f266e5d
    job: I'm sleeping 4 ...
    kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@1f266e5d
    main: Now I can quit.

     

    JobCancellationException을 출력하는 모습을 볼 수 있습니다.

     

     

    suspend가 없더라도 코루틴의 active 한 상황을 체크하여 취소가능하게 만들기

    suspend fun cancelWithCheckingStatus(){
            coroutineScope {
                val startTime = System.currentTimeMillis()
                val job = launch(Dispatchers.Default) {
                    var nextPrintTime = startTime
                    var i = 0
                    println("current status is = $isActive")
                    while (isActive) { // cancellable computation loop
                        // print a message twice a second
                        if (System.currentTimeMillis() >= nextPrintTime) {
                            println("job: I'm sleeping ${i++} ...")
                            nextPrintTime += 500L
                        }
                    }
                    println("current status is = $isActive")
                }
                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.")
            }
        }
        
    //결과
    current status is = true
    job: I'm sleeping 0 ...
    job: I'm sleeping 1 ...
    job: I'm sleeping 2 ...
    main: I'm tired of waiting!
    current status is = false
    main: Now I can quit.

    취소 여부를 확인하는 함수를 주기적으로 호출하여 취소 상태를 명시적으로 확인할 수 있습니다.

    isActive 필드로 확인할 수 있습니다.

    isActive 필드는 코루틴 내부에서 CoroutinScope의 상태를 알려주는 확장 property입니다.

     

    finally로 리소스 닫기 혹은 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: Now I can quit.

    finally를 활용하여 코루틴이 취소되더라도 항상 실행되는 구문을 만들 수 있습니다.

     

     

     

    종료된 코루틴에서도 코루틴을 부를 수 있다 (취소가 불가능한 코루틴)

    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.

    이전 예제에서 코루틴을 시도하면 CancellationException이 발생합니다.

    드물게 취소된 코루틴에서 일시중단을 수행해야 한다면 위의 예시처럼 withContext 함수와 NonCancellable 콘텍스트를 활용할 수 있습니다.

     

    만약 withContext부분이 없다면 delay를 포함한 하위 부분은 수행되지 않습니다.

    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.

     

    타임아웃

    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이 발생하게 됩니다.

     

     

    타임아웃 발생 시 null이 반환

    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

    withTimeoutOrNull을 활용하게 되면 예외대신에 null이 반환됩니다.

     

    timeout시 리소스가 반환되지 않을 수 있음

    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
    }

    실제로 코드를 돌려보니 acquired가 33개 남아있습니다.

    즉, 33개의 자원이 반환되지 않았습니다.

     

    10K 코루틴에서 획득 카운터 증가 및 감소는 항상 동일한 스레드에서 발생하므로 완전하게 thread-safe 합니다.

     

    리소스 누수가 없는 코드

    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

    finally를 활용하여 리소스를 닫아줍니다.

    이제는 0이라는 결과가 잘 출력됩니다.

    댓글

Designed by Tistory.