Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cancellation and Exceptions in Coroutines (Part 2) : Cancellation in coroutines 아티클 정리 #10

Open
ChanJun-Park opened this issue Sep 14, 2022 · 0 comments

Comments

@ChanJun-Park
Copy link
Owner

이 글은 Cancellation in coroutines Cancellation and Exceptions in Coroutines (Part 2) 아티클을 정리한 내용입니다. 틀린 내용이 있을 수 있습니다.

더이상 작업을 할 필요가 없는 코루틴은 취소할 필요가 있는데, 이런 코루틴 취소를 이 글에서 소개하고 있다.

Calling cancel

여러개의 coroutine 을 생성한 경우에 이들 모두를 관리하거나 취소하는 일은 번거롭고 어려울 수 있다. 이럴때 이들 Coroutine 을 생성한 부모 CoroutineScope 의 cancel 함수를 이용해서 모든 자식 coroutine 을 cancel 해줄 수 있다.

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

때로는 하나의 coroutine 만을 취소할 필요도 있는데 이럴때는 코루틴 빌더함수를 통해 반환받은 Job 객체를 이용해서 cancel 함수를 호출해주면 된다. 이때 이 코루틴의 취소는 Task hierarchy 상의 다른 형제 코루틴에는 전혀 영향을 주지 않는다(형제 코루틴은 취소되지 않음).

// 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()

Coroutine 의 취소는 CancellationException 이라는 예외를 통해서 특별히 처리되는데, 만약 어떤 이유로 코루틴이 취소되는지 명시하고 싶다면 cancel 함수에 인자로 직접 CancellationException 객체를 생성해서 전달해주면 된다.

fun cancel(cause: CancellationException? = null)

만약 cancel 함수에 CancellationException 를 전달하지 않는다면 내부적으로 기본 CancellationException 객체를 생성해서 사용한다.

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

CancellationException 예외가 발생하는 방법으로 Coroutine Cancellation 이 처리되는데 다음과 같은 과정이 수행된다.

자식 Coroutine은 CancellationException 를 발생시켜서 부모에게 자식 Coroutine 의 취소가 일어났음을 알려준다. 코루틴의 부모는 이 예외의 cause 를 확인하여 추가적인 작업이 필요한지 판단하는데 CancellationException 의 경우에는 더이상 추가적인 작업이 필요 없다.

만약 CoroutineScope 에 cancel 을 호출하여 작업을 취소한 경우에는 이 scope 를 통해서는 더이상 신규 코루틴을 생성할 수 없다.

만약 AndroidX KTX 라이브러리를 사용하는 경우에는 viewModelScope, lifecycleScope 등을 사용할 수 있다. 이들 scope 는 적절한 시점에 알아서 취소되기 때문에 프로그래머가 취소해 대해서 신경써줄 부분은 없다.

Why isn’t my coroutine work stopping?

단순히 cancel 을 호출한다고 해서 코루틴의 작업이 취소되는 것은 아니다. 다음 예제 코드를 보자. 1초에 2번씩 "Hello" 를 프린트하도록 별도의 코루틴을 생성해서 실행한다. 이 코루틴이 1초쯤 실행되게 delay 한 다음 cancel 하면 어떤 실행결과를 나타낼까?

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

실행결과

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

코루틴을 취소하고 "Done!" 을 프린트한 시점 뒤에서도 코루틴의 작업이 취소가 되지 않고, "Hello 3", "Hello 4" 가 출력된 것을 확인할 수 있다. 이 경우 cancel 을 호출한 시점에 "Hello" 를 출력하는 코루틴은 Cancelling 상태가 되지만 모든 작업을 끝마치고 나서야 Cancelled 상태로 변한다.

이를 통해 코루틴을 cancel 을 통해 취소한다고 해서 곧바로 취소가 되지 않음을 확인할 수 있다. 곧바로 코루틴이 취소가 되게 하기 위해서는 주기적으로 이 코루틴이 isActive 상태에 있는지를 체크할 필요가 있다.

코루틴의 취소는 협력적이어야 한다. (Cancellation of coroutine code needs to be cooperative!)

Making your coroutine work cancellable

우리가 생성한 코루틴이 취소에 협력적으로 작동하게 하기 위해서는 주기적으로 코루틴의 State 를 확인하거나 Long running 작업을 수행하기 전에 코루틴이 취소되었는지를 확인해야 한다. 예를 들어 여러개의 파일을 읽어들여서 작업을 수행하는 코루틴을 취소에 협력적으로 작성하려면 각각의 파일을 읽기 전에 코루틴이 취소되었는지를 확인하는 것이 좋다.

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

kotlinx.coroutines 라이브러리에 존재하는 delaywithContext 와 같은 suspend 함수들은 모두 코루틴 취소에 협력적으로 작성되어 있기 때문에 코루틴 취소를 위해 CancellactionException 을 발생시킨다던지 하는 추가적으로 해줘야 하는 작업이 없다. 그러나 이런 함수들을 하용하지 않는 코루틴을 취소에 협력적으로 작성하기 위해서는 다음과 같은 옵션들이 있을 수 있다.

  • 주기적으로 isActive, ensureActive() 를 이용해서 코루틴이 취소되었는지 체크한다.
  • 실행시간이 긴 작업을 수행하기 전에 yield 메소드를 호출하여 다른 작업이 실행될 수 있게 해준다.

Checking for job's acfive state

다음과 같이 무한 while 루프의 조건문에 isActive 를 추가하여 현재 코루틴이 취소되었는지를 체크할 수 있다.

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

현재 코루틴이 while 루프 밖에 있다는 것은 코루틴이 취소되었음을 의미한다. 덧붙여서 !isActive 를 값을 확인하여 while 루프 밖에서 코루틴이 취소되었음을 확인하고 logging 을 한다던지 추가적인 작업을 처리해줄 수 있게 된다.

ensureActive() 함수를 사용하게 되면 해당 함수 내부적으로 isActive 값을 확인하여 이 값이 false 인 경우 CancellationException 를 발생시켜 코루틴을 취소하게 만든다.

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

이제 while 루프 안쪽의 맨 첫번째 코드로 ensureActive() 를 호출하여 코루틴이 취소된 경우에 예외를 발생시키고 코루틴을 취소시킬 수 있다.

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

이렇게 함으로서 직접 isActive 를 체크하는 번거로움은 없앨 수 있지만, while 루프 밖에서 추가적으로 로깅 처리를 한다던지 같은 유연성은 떨어지게 된다.

Let other work happen using yield()

다음의 경우에는 코루틴 취소를 위해 yield() 함수 사용을 고려해볼 수 있다.

  1. 코루틴이 CPU instensive 한 작업을 수행하려고 한다.
  2. thread pool 을 소진 시킬 가능성이 있다.
  3. 추가적인 thread 를 thread pool 에 넣지 않고 thread 가 다른 작업을 처리해주기를 바란다.

yield() 내부에서는 먼저 현재 코루틴이 isActive 상태인지를 체크한 다음 그렇지 않다면 CancellationException 를 발생시키기 때문에 코루틴을 취소에 협력적으로 만들 수 있다.

Job.join vs Deferred.await cancellation

코루틴의 작업 결과를 기다릴 수 있는 2가지 방법이 존재한다. 하나는 launch 코루틴 빌더 함수를 통해 반환받은 Job 객체를 join 하는 방법, 다른 하나는 async 코루틴 빌더 함수를 통해 반환받은 Deffered 객체를 await 하는 방법이다.

Job.join 함수는 해당 Job 을 Context 하는 코루틴이 종료될때 까지 join 함수를 호출한 코루틴을 suspend 시킨다. Job 의 joincancel 은 호출 순서에 따라서 다음과 같은 동작을 수행한다.

  • join 함수 호출 후에 cancel 호출 : 이미 코루틴이 completed 된 이후에 cancel 을 호출했기 때문에 아무런 영향이 없다.
  • cancel 함수 호출 후에 join 호출 : 코루틴의 취소가 모두 완료되고 Completed 가 된 이후에 join 함수의 suspend 가 종료된다.

Deffered 객체는 async 코루틴 빌더를 통해 반환받을 수 있는 일종의 Job 객체이다. Deffered.await 함수는 코루틴이 값을 생성할때까지 해당 함수를 호출한 코루틴을 suspend 시키고, 생성한 값을 반환해주는 역할을 한다. Deffered 객체도 Job 을 구현한 클래스이기 때문에 cancel 을 호출할 수 있으며 awaitcancel 호출 순서에 따라서 다음과 같은 동작이 이루어진다.

  • await 함수 호출 후에 cancel 호출 : 이미 코루틴이 completed 된 이후에 cancel 을 호출했기 때문에 아무런 영향이 없다.
  • cancel 호출 후에 await 함수 호출 : JobCancellationException 예외가 발생한다. await 함수는 코루틴이 생성한 값을 반환받기 위해 사용하는데 코루틴을 취소하면 값을 생성할 수 없기 때문에 예외가 발생하도록 동작하는 것이 자연스럽다.
val deferred = async { … }
deferred.cancel()
val result = deferred.await() // throws JobCancellationException!

Handling cancellation side effects

만약 코루틴이 취소된 이후에 사용하고 있었던 리소스를 정리한다던가, 로깅 처리를 한다던지 특별한 동작이 수행되도록 하고 싶다면 다음과 같은 방법을 사용할 수 있다.

Check for !isActive

무한 while 루프를 실행하는 코루틴인 경우에 while 루프의 조건문에 isActive 를 추가한다. 이후 코루틴 취소로 인해 while 루프를 빠져나오게된 이후에 원하는 작업을 처리하도록 구현하면 된다.

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

코루틴이 취소된 시점에 취소에 협력적인 suspend 함수를 호출한 경우 CancellationException 이 발생하기 때문에 이 예외를 try catch 문으로 묶은 다음 finally 블록에서 원하는 작업을 처리해줄 수 있다.

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

그런데 finally 블록에서 또다른 suspend 함수 호출이 필요한 경우에는 문제가 될 수 있다. 이미 취소되어 Cancelling 상태에 있는 코루틴에서는 더이상 suspend 함수 호출이 불가능하기 때문이다. 만약 suspend 함수 호출이 필요하다면 NonCancellable Context 로 코루틴 Context 를 switch 하면 해당 Context 에서 suspend 함수 호출이 가능하다. NonCancellable Context 에서 수행하는 작업이 종료될때까지 코루틴은 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 and invokeOnCancellation

만약 suspendCoroutine 을 통해서 콜백 형태의 함수를 suspend 함수로 변환해주고 있다면 suspendCancellableCoroutine 와 invokeOnCancellation 를 통해서 해당 작업이 취소 가능하도록 구현하는 것이 좋다.

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

코루틴의 structured concurrency 의 장점을 누리고 불필요한 작업이 수행되는 것을 막기 위해서는 코루틴을 취소에 협력적으로 만들어줄 필요가 있다.

또한 viewModelScope, lifecycleScope 처럼 Jetpack 라이브러리에 있는 CoroutineScopes 를 사용하여 더이상 불필요한 작업이 자동으로 취소되도록 해주는 것이 좋다. 만약 직접 CoroutineScopes 를 만들어서 사용하는 경우에 적절한 시점에 이 CoroutineScopes 를 취소해주는 작업이 필요하다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant