본문 바로가기
Coroutine

[KotlinConf 2019 시리즈] Part 2 : Cancellation in coroutines

by DONXUX 2023. 3. 18.

이 글은 아래의 문서를 바탕으로 정리된 글임을 알려드립니다.

원문 : https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629

 

Cancellation in coroutines

Cancellation and Exceptions in Coroutines (Part 2)

medium.com

목차

개요

코루틴의 생명주기를 제어하고 더 이상 필요하지 않을 때 취소해야 한다는 것이 구조화된 동시성의 핵심입니다. 이 글에서는 코루틴 취소의 내부 동작에 대해 자세히 살펴보겠습니다.

Part 1을 읽고 오시는 것을 추천합니다.

취소 호출하기

코루틴이 시작된 스코프 전체를 취소하면 생성된 모든 하위 코루틴을 취소할 수 있습니다.

// assume we have a scope defined for this layer of the app
val job1 = scope.launch { … }
val job2 = scope.launch { … }
scope.cancel()

 

특정 코루틴만 취소하는 것도 가능합니다.

// assume we have a scope defined for this layer of the app
val job1 = scope.launch { … }
val job2 = scope.launch { … }
// First coroutine will be cancelled and the other one won’t be affected
job1.cancel()

CancellationException

 

코루틴은 CancellationException이라는 특수한 예외를 던져서 취소를 처리합니다. 취소 이유에 대해 더 자세한 정보를 제공하려면 .cancel을 호출할 때 CancellationException 인스턴스를 제공할 수 있습니다.

fun cancel(cause: CancellationException? = null)

 

만약 CancellationException 인스턴스를 제공하지 않으면, 기본 값이 들어가게 됩니다.

public override fun cancel(cause: CancellationException?) {
    cancelInternal(cause ?: defaultCancellationException())
}

 

코루틴이 취소(여기서는 cancel 호출이나 작업이 끝나서 정상적으로 취소된 경우 말고도, 예외가 생겨 취소되는 경우도 포함)되면 하위 작업은 예외를 통해 부모에게 취소 알림을 보냅니다. 부모는 취소의 원인을 확인하여 예외를 처리해야 하는지 여부를 결정합니다. 만약 CancellationException으로 인해 취소된 경우라면, 부모는 예외를 처리할 추가적인 작업을 하지 않습니다.

코루틴 작업이 바로 멈추지 않는 이유

cancel를 호출한다고 해서 코루틴 작업이 자동으로 중지되는 것은 아닙니다. 여러 파일을 읽는 등의 상대적으로 무거운 연산을 수행하고 있는 경우에는, 자동으로 중지할 수 있는 방법은 없습니다.

 

아래 코드를 통해, 코루틴을 사용하여 0.5초마다 "Hello"를 두번 출력해야 한다고 가정해보겠습니다. 코루틴을 1초 동안 실행한 다음 취소합니다.

import kotlinx.coroutines.*
 
fun main(args: Array<String>) = runBlocking<Unit> {
   val startTime = System.currentTimeMillis()
    val job = launch (Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("Hello ${i++}")
                nextPrintTime += 500L
            }
        }
    }
    delay(1000L)
    println("Cancel!")
    job.cancel()
    println("Done!")
}

launch를 호출하면 새로운 코루틴이 생성되고, 이 코루틴을 1000ms 동안 실행합니다. 하지만 출력 결과는 다음과 같습니다.

Hello 0
Hello 1
Hello 2
Cancel!
Done!
Hello 3
Hello 4

cancel를 호출하고 코루틴은 Cancelling 상태로 전환되었습니다. 하지만 Hello 3, Hello 4가 출력됩니다. 오로지 작업이 끝나야만 코루틴은 Cancelled 상태로 전환됩니다.

 

저희는 cancel을 호출한다고 해서 작업이 바로 중지되지 않는다는 것을 확인했습니다. 그럼 취소되었을 때, 그 이후 출력을 방지하려면 어떻게 해야할까요?

취소가능한 작업 만들기

일정한 간격으로 또는 장기간 실행되는 작업을 시작하기 전에 취소 여부를 확인해야합니다. 예를 들어 디스크에서 여러 파일을 읽는 경우, 각 파일을 읽기 전에 코루틴이 취소되었는지 여부를 확인할 수도 있습니다. 이렇게 하면 필요하지 않을 때 CPU 자원을 낭비할 일은 없을 것입니다.

val job = launch {
    for(file in files) {
        // TODO check for cancellation
        readFile(file)
    }
}

kotlinx.coroutines의 모든 중단 함수(withContext, delay  등)는 취소 가능합니다. 따라서 그 중 하나를 사용하는 경우에는 취소 여부를 확인하고 실행을 중지하거나 CancellationException을 throw할 필요가 없습니다. 그러나 중단 함수를 사용하지 않는 경우라면, 코루틴 코드를 취소와 협력적으로 만드는 두 가지 방법이 있습니다.

  • job.isActive 나 ensureActive()로 활성 상태 체크
  • yield()를 사용하여 다른 작업을 실행

Job의 활성 상태 체크하기 (isActive, ensureActive())

"Hello"를 5번 출력하는 예제 코드를 다시보도록 합시다. 저희는 취소 이후에 "Hello" 출력을 원하지 않습니다. 그렇다면 while을 통해 반복될 때마다 활성상태를 체크해주면 어떨까요? 

// Since we're in the launch block, we have access to job.isActive
while (i < 5 && isActive)

cancel를 호출하면 isActive는 false가 될테니 반복은 종료될 것입니다.

 

활성 상태를 체크하는 또 다른 방법은 ensureActive()를 호출하는 것입니다. ensureActive()의 내부 구현은 다음과 같습니다.

fun Job.ensureActive(): Unit {
    if (!isActive) {
         throw getCancellationException()
    }
}

job이 active 상태가 아니라면 CancellationException을 던지도록 구현되어 있습니다. 즉, 코루틴이 활성 상태가 아니라면 바로 중지시켜버립니다.

while (i < 5) {
    ensureActive()
    …
}

ensureActive()를 사용하면 isActive로 필요한 if문을 구현할 필요 없이, 코드를 간결하게 작성할 수 있다는 장점이 있습니다. 하지만 작업이 즉시 중지되므로, 로깅과 같은 다른 작업을 수행할 수 있는 유연성은 잃게됩니다.

yield()를 사용하여 다른 작업 실행하기

작업을 취소할 수 있는 또 다른 방법은 yield()를 사용하는 것입니다. yield() 또한 ensureActive()와 동일하게 job이 active 상태가 아니라면 CancellationException을 던집니다. 차이점은 없어보입니다. 그럼 yield()라는 녀석은 왜 존재하는 걸까요?

 

yield()는 다음과 같은 조건을 만족하는 작업에 유용하게 쓰입니다.

  1. CPU 집약적인 작업을 수행합니다.
  2. 쓰레드 풀을 소모할 가능성이 있습니다.
  3. 쓰레드 풀에 더 많은 쓰레드를 추가하지 않고도, 다른 작업을 수행하길 원합니다.

왜 이러한 작업에 유용하게 쓰일까요?

yield()는 사실상 실행 중인 다른 작업이 처리될 수 있는 기회를 만들어주고 현재 코루틴을 일시중지 시킵니다. 즉, 다른 코루틴에게 자원 공유 기회를 주는 메소드입니다. 'yield'라는 단어에도 '양도하다'라는 뜻이 담겨있죠. yield()를 호출한 코루틴은 현재 위치를 기억하고 다른 코루틴에게 실행권을 넘기고 일시중지되었다가 다음 호출 때 그곳부터 다음을 실행할 수 있도록 합니다. 그래서 선점형 멀티태스킹을 구성할 수 있고, 덕분에 앞에서 언급한 조건에 해당하는 작업에 유용하게 쓰이게 되는 것입니다.

Job.join vs Deferred.await 취소

코루틴의 결과를 반환받는 방법은 두 가지 있습니다.

  • launch에서 반환된 job은 join을 호출할 수 있습니다.
  • async에서 반환된 Deferred(Job 유형 중 하나)은 await을 호출할 수 있습니다.

Job.join

join()을 호출하면 작업이 완료될 때까지 코루틴을 일시 중단합니다. job.cancel()과 함께 사용하면 이렇게 작동합니다.

  • job.cancel() 다음에 job.join()을 호출하면, 작업이 완료될 때까지 코루틴이 일시 중단됩니다.
  • job.join() 다음에 job.cancel()를 호출하면, 작업이 이미 완료된 상태이므로 아무런 영향을 미치지 않습니다.

Deferred.await

Deferred는 코루틴의 결과를 받고싶을 때 사용합니다. 이 결과는 Deferred.await가 코루틴의 완료할 때 반환합니다. Deferred는 Job의 일종이며 취소할 수 있습니다.

취소된 deferred에 대해 await를 호출하면 JobCancellationException이 throw 됩니다.

val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!

await()은 결과가 계산될 때까지 코루틴을 일시 중단합니다. 하지만 코루틴이 취소되었기 때문에 결과를 계산할 수 없습니다. 그래서 cancel후에 await를 호출하면 JobCancellationException 예외가 발생합니다.

 

반면에, await후에 deferred.cancel()를 호출한다면, 이미 코루틴이 완료되었기 때문에 아무 일도 일어나지 않을 것입니다.

취소 사이드 이펙트 처리

만약 코루틴이 취소될 때, 사용 중인 리소스를 닫거나, 취소 로그를 찍거나, 다른 정리가 필요한 경우처럼 특정 작업이 필요하다면 어떻게 해야할까요? 

!isActive 체크

단순하게 isActive를 체크하여 취소되었을 때 특정 작업을 수행할 수 있을 것입니다.

while (i < 5 && isActive) {
    // print a message twice a second
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
// the coroutine work is completed so we can cleanup
println(“Clean up!”)

Try catch finally

코루틴이 취소될 때 CancellationException이 throw 되므로 try/catch로 감싼 다음, fianlly 블록에서 특정 작업을 수행할 수도 있을 것입니다.

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      println(“Clean up!”)
    }
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

 

보통 리소스를 정리하는 작업 같은 경우는 Non-Blocking으로 구현되는 경우가 많긴하나, 만약 이 취소된 코루틴 안에서 동기적으로 중단 함수를 호출한다면 어떻게 될까요? 중단 함수 이후로부턴 코드가 작동하지 않습니다. 왜냐하면 코루틴은 Cancelling 상태일 경우 더이상 일시 중단이 불가능하기 때문입니다. 그럼 취소 시 동기적인 작업을 수행하려면 어떻게 해야할까요?

 

코루틴이 취소되었을 때 중단 함수를 호출하려면, 특정 작업을 NonCancellable CoroutineContext에서 수행해야 합니다. 이렇게 하면 코드가 일시 중단될 수 있고, 작업이 완료될 때까지 코루틴을 Cancelling 상태로 유지할 수 있습니다.

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // or some other suspend fun 
         println(“Cleanup done!”)
      }
    }
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

suspendCancellableCoroutine 와 invokeOnCancellation

만약 suspendCoroutine 메소드를 사용하여 콜백을 코루틴으로 변환했다면, suspendCancellableCoroutine을 사용하는 것이 좋습니다. 취소 시 수행해야 할 작업은 contination.invokeOnCancellation을 사용하여 구현할 수 있습니다.

suspend fun work() {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // do cleanup
       }
   // rest of the implementation
}

 

구조화된 동시성의 장점을 실현하고 불필요한 작업을 수행하지 않도록 하려면 코드를 취소할 수 있도록 만들어야 합니다. viewModelScope 또는 lifecycleScope와 같이 작업이 완료될 때 작업을 취소하는 CoroutineScope를 사용하세요. 직접 CoroutineScope를 만드는 경우 job에 연결하고 필요할 때 취소를 호출하도록 해야합니다.