이동식 저장소

[Kotlin] Coroutines - Cancellation and Timeouts 본문

Primary/Kotlin

[Kotlin] Coroutines - Cancellation and Timeouts

해스끼 2021. 1. 20. 10:56

코루틴 취소하기

코루틴을 취소해야 할 경우가 있다. 예를 들어 어떤 페이지가 열려 있을 때 작동하는 코루틴이 있는데, 사용자가 그 창을 닫았다고 해 보자. 창이 닫혔으므로 코루틴은 더 이상 작동할 필요가 없으며 (따라서) 중지하는 것이 바람직하다.

launch 메소드가 반환하는 Job 객체를 이용하여 코루틴을 제어할 수 있다.

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) 
println("main: I'm tired of waiting!")
job.cancel() // job 취소
job.join()   // job이 완전히 취소될 때까지 기다림
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.join 메소드를 이용하여 코루틴이 멈출 때까지 기다려야 한다. 아니면 간결하게 Job.cancelAndJoin 메소드를 사용할 수도 있다.

무작정 취소되는 것은 아니다

물론 모든 코루틴을 이렇게 취소할 수 있는 것은 아니며, 코루틴이 협조할 때만 취소할 수 있다. 예를 들어 kotlinx.coroutines의 모든 suspending 함수는 취소 가능하다. 현재 코루틴에 취소 명령이 내려졌는지 지속적으로 확인하기 때문이다. 그러나 코루틴이 계산하기 바빠서 자신에게 취소 명령이 내려졌는지 확인하지 않는다면, 코루틴은 취소되지 않는다.

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) {
        // 1초에 두 번씩 출력
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancel()과 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!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

위의 코드를 보면 job은 어떠한 suspending 함수도 사용하지 않기 때문에 자신에게 취소 명령이 내려졌는지 확인할 수 없고, 따라서 job.cancelAndJoin() 후에도 계속 작동하게 된다.

취소할 수 있게 하려면

첫 번째 방법으로는 suspending 함수를 주기적으로 호출하는 방법이 있다. 이러한 목적에 맞는 yield() 메소드가 있으니 필요하면 사용하도록 하자.

두 번째 방법으로는 코루틴 내부에서 자신이 취소 대상인지 확인하는 것이다. 여기서는 두 번째 방법을 사용하도록 하겠다.

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) { // this: CoroutineScope
    var nextPrintTime = startTime
    var i = 0
    while (isActive) { // 취소 대상인지 확인
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
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.

CoroutineScope 객체의 isActive 프로퍼티를 사용하여 코루틴이 계속 작동할 수 있는지 확인하였다.

finally 사용하기

취소될 수 있는 suspending 함수는 CancellationException을 던진다. 코루틴에서 파일 등의 리소스를 사용하는 경우 코루틴이 취소될 때 리소스를 해제할 필요가 있는데, try {...} finally {...} 또는 코틀린의 use 함수를 사용하면 좋다.

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L) // suspending function
        }
    } finally {
        println("job: I'm running finally")
    }
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
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.

joincancelAndJoin은 모두 finally 구문이 실행될 때까지 기다려 준다. 작업을 종료할 때 꼭 실행해야 하는 코드가 있다면 위처럼 해 보자.

finally로 안 되는 경우

finally 블럭에서 사용하는 코드는 대부분 파일을 닫고 job을 취소하는 등의 non-blocking 코드이다. 그러나 finally 안에서 suspending 함수를 호출해야 하는 경우가 있을 수 있다. 그런데 코루틴이 취소 대상인 경우에는 suspending 함수를 실행하면 CancellationException을 던진다고 했다. Exception을 처리하는 과정에서 똑같은 Exception이 발생하는 것이다.

이런 경우에는 suspending 함수를 호출하는 부분을 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) 
println("main: I'm tired of waiting!")
job.cancelAndJoin() 
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.

시간 초과

보통 코루틴을 취소하는 이유는 작업이 너무 오래 걸려서인 경우가 많다. 예를 들어 웹 페이지가 5초 안에 로드되지 않는다면 로드를 멈추고 사용자에게 에러 메시지를 띄워줘야 할 때가 있다. 이런 경우에는 withTimeout 메소드를 사용하여 간편하게 timeout을 처리할 수 있다.

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은 인자로 주어진 시간(ms) 안에 코루틴이 종료되지 않으면 TimeoutCancellationException을 던진다. 이 예외는 CancellationException을 상속받았기 때문에 위에서와 동일한 방법으로 핸들링할 수 있다. 당연히 try catch로 핸들링해도 된다.

fun main() = runBlocking {
    try {
        withTimeout(1300L) {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        }
    } catch (e: CancellationException) { // 또는 TimeoutCancellationException
        println("Cancel!")
    }
}
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Cancel!

또는 withTimeoutOrNull 메소드를 사용할 수도 있다. 이 메소드는 withTimeout과 유사하지만 TimeoutCancellationException이 발생하면 null을 반환한다.

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

timeout은 비동기적이다

withTimeout에서 timeout은 실행되는 코드와 비동기적으로 발생한다. 코드가 종료되기 직전에 timeout이 발생할 수도 있다는 뜻이다. 코루틴 내부에서 외부의 리소스를 사용하는 경우 이 점에 주의할 필요가 있다.

다음의 코드를 보자. 리소스를 흉내내는 Resource 클래스를 정의하였다. 이 클래스는 단순히 현재 몇 개의 객체가 만들어졌는지 세는 역할을 한다. 이제 Resource 객체를 만들고 닫는 코루틴을 10만 번 실행해 보자. 이때 withTimeout(60)delay(50)를 사용하여 단 10ms 안에 객체를 만들어 반환하도록 하였다.

var acquired = 0

class Resource {
    init { acquired++ } // 리소스 생성
    fun close() { acquired-- } // 리소스 해제
}

fun main() {
    runBlocking {
        repeat(100_000) { // 10만 번 실행
            launch { 
                val resource = withTimeout(60) { // timeout
                    delay(50)
                    Resource() // 리소스 생성하여 반환    
                }
                resource.close() // 반환된 리소스 해제
            }
        }
    }
    println(acquired) // 해제되지 않은 리소스의 개수 출력
}
17

CPU의 상황에 따라 객체를 만들었지만 반환하려는 찰나에 timeout이 발생할 수도 있다. 코드를 실행해 보면 매번 결과가 달라지는 것을 확인할 수 있다.

이 문제를 해결하려면 Resource 객체가 반환되지 않을 수도 있음을 인정해야 한다. 따라서 반환이 아니라 할당 형식을 사용해야 한다.

runBlocking {
    repeat(100_000) { 
        launch { 
            var resource: Resource? = null // 참조 변수
            try {
                withTimeout(60) {
                    delay(50)
                    resource = Resource() // 리소스를 만들어서 참조함     
                }
                // 리소스 작업...
            } finally {  
                resource?.close() // 리소스가 만들어진 경우에만 해제
            }
        }
    }
}
println(acquired) // 해제되지 않은 리소스의 개수 출력

이렇게 하면 항상 0이 출력된다. 메모리 누수를 해결하였다.

0

참고 문헌

Coroutine Cancellation and Timeouts - kotlinlang

'Primary > Kotlin' 카테고리의 다른 글

[Kotlin] Coroutines - Context and Dispatchers  (0) 2021.01.21
[Kotlin] Coroutines - Suspending 함수 활용하기  (0) 2021.01.20
[Kotlin] Coroutines - Basics  (0) 2021.01.19
[Kotlin] Sequence  (0) 2021.01.15
[Kotlin] Thread 생성 및 실행  (0) 2021.01.14
Comments