Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

디지안의 개발일지

[TIL] Kotlin Coroutine Cancellation 본문

Kotlin

[TIL] Kotlin Coroutine Cancellation

안덕기 2022. 2. 15. 23:09

코틀린 코루틴 취소

이 문서는 Kt.Academy를 보고 배운 내용을 작성하였습니다. 코틀린 코루틴을 취소하는 방법에 대해서 설명합니다.

 

코틀린 코루틴을 취소하는 것은 코루틴에서 매우 중요한 기능입니다. 훌륭한 취소 메커니즘은 그만큼 가치가 있습니다. 단지 스레드를 죽이는 것은 좋지 않은 해결법입니다. 스레드를 죽이기 전에 스레드에서 활용 중이던 어떤 연결이 리소스를 해제 해줘야 합니다.

그러면 코루틴은 어떻게 취소하는지 살펴보도록 하겠습니다.

기본 취소 방법

Job 인터페이스는 취소를 할 수 있는 cancel 함수가 존재합니다. 코루틴의 취소는 다음과 같은 특징을 가지고 있습니다.

  • 취소 시점으로부터 첫 번째 중단 지점으로부터 취소가 이루어집니다.
  • 하위 Job이 있는 경우 모두 취소합니다.
  • 한번 Job을 취소하면 해당 Job은 취소 상태가 되기 때문에 하위 Job을 만들 수 없습니다.

취소는 다음 예제와 같이 간단하게 이루어집니다.

suspend fun main(): Unit = coroutineScope {
   val job = launch {
       repeat(1_000) { i ->
           delay(200)
           println("Printing $i")
       }
   }

   delay(1100)
   job.cancel()
   job.join()
   println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

코루틴은 기본적으로 CancellationException 에 의해서 취소될 수 있습니다. 만약 다른 예외로 취소를 한다면 반드시 CancellationException 의 하위 타입이어야 합니다.

취소 이후에는 취소를 기다리기 위해서 join 함수를 자주 붙입니다. 아래와 같이 Join 없이 취소를 하면 중간에 취소가 되었다는 메시지가 발생할 수도 있습니다.

suspend fun main() = coroutineScope {
   val job = launch {
       repeat(1_000) { i ->
           delay(100)
           Thread.sleep(100) // We simulate long operation
           println("Printing $i")
       }
   }

   delay(1000)
   job.cancel()
   println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Cancelled successfully
// Printing 4

좀 더 쉽게 취소를 하고 싶으면 cancelAndJoin 함수를 활용하면 됩니다.

// The most explicit function name I've ever seen
public suspend fun Job.cancelAndJoin() {
    cancel()
    return join()
}

팩토리 기능을 활용하여 만든 Job()도 아래와 같이 쉽게 취소할 수 있습니다. 이는 한꺼번에 Job에 대한 코루틴을 취소할 수 있는 중요한 포인트입니다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            delay(200)
            println("Printing $i")
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

어떻게 취소되는가?

Job이 취소되면 그것의 상태는 취소되는 중으로 변경됩니다. 그 때 첫번째 지연되는 포인트에서 CancellationException 이 발생합니다. 이 예외는 try-catch를 통해서 예외를 잡을 수 있고 끝에 다시 예외를 발생시키는 것이 좋습니다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            repeat(1_000) { i ->
                delay(200)
                println("Printing $i")
            }
        } catch (e: CancellationException) {
            println(e)
            throw e
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// JobCancellationException...
// Cancelled successfully

취소된 코루틴은 그냥 중지되는 것이 아니라 예외를 사용하여 내부적으로 취소됩니다. 주의할 점은 finally block을 활용하여 사용 중인 리소스를 회수해야합니다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            delay(Random.nextLong(2000))
            println("Done")
        } finally {
            print("Will always be printed")
        }
    }
    delay(1000)
    job.cancelAndJoin()
}
// Will always be printed
// (or)
// Done
// Will always be printed

Just one more call

CancellationException 을 Catch하고 좀 더 많은 연산을 포착하고 호출할 수 있기 때문에 한계가 어디까지인지 궁금할 수 있습니다. 코루틴은 남은 리소스를 모두 정리하는 동안은 해당 코루틴을 계속 실행합니다. 만약, Job이 이미 Cancelling 상태라면, 지연 또는 다른 코루틴을 시작하는 것은 불가능합니다. Cancelling 상태에서 다른 코루틴을 시작하려고 하면 무시됩니다. 그리고 지연을 시도하면 CancellationException 이 발생합니다.

import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            delay(2000)
            println("Job is done")
        } finally {
            println("Finally")
            launch { // will be ignored
                println("Will not be printed")
            }
            delay(1000) // here exception is thrown
            println("Will not be printed")
        }
    }
    delay(1000)
    job.cancelAndJoin()
    println("Cancel done")
}
// (1 sec)
// Finally
// Cancel done

때로는 이미 취소된 코루틴에 대해서 지연하는 것이 필요합니다. 예를 들어, 데이터베이스를 롤백하는 경우를 의미합니다. 그 때 선호되는 방법은 withContext(NonCancellable) 을 사용하는 것입니다. withContext 는 해당 block의 내용을 다른 context에서 이루어지게 합니다. NonCancellable object는 내부에서 코루틴을 취소할 수 없는 context입니다. 그래서, 코루틴이 취소되더라도 block 안에 내용은 실행할 수 있습니다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        try {
            delay(200)
            println("Coroutine finished")
        } finally {
            println("Finally")
            withContext(NonCancellable) {
                delay(1000L)
                println("Cleanup done")
            }
        }
    }
    delay(100)
    job.cancelAndJoin()
    println("Done")
}
// Finally
// Cleanup done
// Done

invokeOnCompletion

invokeOnCompletionJob 으로부터 자원을 해제하는 다른 메커니즘입니다. 해당 함수는 Job이 터미널 상태(Completed , Cancelled )에 도달할 때 호출될 핸들러를 설정하는데 사용됩니다.

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        delay(1000)
    }
    job.invokeOnCompletion { exception: Throwable? ->
        println("Finished")
    }
    delay(400)
    job.cancelAndJoin()
}
// Finished

이 핸들러의 parameter는 다음과 같은 특징을 가지고 있는 하나의 예외를 갖습니다.

  • Job 이 예외 없이 끝나면 null 을 반환합니다.
  • 코루틴이 취소되면 CancellationException 이 됩니다.
  • 예외는 코루틴을 완료합니다.

만약 Job이 invokeOnCompletion 을 호출하기 전에 완료되면 완료된 이후에 즉시 invokeOnCompletion 을 호출합니다. Parameters인 onCancellinginvokeImmediately 은 사용하면 추가적인 사용자 정의가 가능합니다.

suspend fun main(): Unit = coroutineScope {
    val job = launch {
        delay(Random.nextLong(2400))
        println("Finished")
    }
    delay(800)
    job.invokeOnCompletion { exception: Throwable? ->
        println("Will always be printed")
        println("The exception was: $exception")
    }
    delay(800)
    job.cancelAndJoin()
}
// Will always be printed
// The exception was: 
// kotlinx.coroutines.JobCancellationException
// (or)
// Finished
// Will always be printed
// The exception was null

invokeOnCompletion 는 취소되는 동안 동기적으로 호출되며 실행될 스레드를 제어하지 않습니다.

Stopping the unstoppable

코루틴을 취소할 수 없는 상황에서 취소할 수 있도록 하는 방법에 대해서 설명합니다.

코루틴의 취소는 지연 지점에서 발생합니다. 그래서, 지연 지점이 없으면 취소되지 않습니다. 지연 지점이 없어서 인위적으로 지연 지점을 만드려면 yield() 을 사용하면 됩니다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        repeat(1_000) { i ->
            Thread.sleep(200)
            yield()
            println("Printing $i")
        }
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
    delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// Printing 3
// Printing 4
// Cancelled successfully

또 다른 옵션은 작업 상태를 추적하는 것입니다. 코루틴 빌더 내에서 thiscoroutineContext 속성을 참조할 수 있습니다. isActive 는 코루틴이 활성화 상태인지를 확인할 수 있는 속성인데 이를 이용해서 다음과 같이 코드를 구성할 수 있습니다.

suspend fun main(): Unit = coroutineScope {
    val job = Job()
    launch(job) {
        do {
            Thread.sleep(200)
            println("Printing")
        } while (isActive)
    }
    delay(1100)
    job.cancelAndJoin()
    println("Cancelled successfully")
}
// Printing
// Printing
// Printing
// Printing
// Printing
// Printing
// Cancelled successfully

또는 다음과 같이 ensureActive() 를 사용할 수도 있습니다. 해당 함수는 Job 의 상태가 active 가 아니면 CancellationException 을 발생시킵니다.

ensureActive()yield()는 결과는 비슷해보이지면 원리는 다릅니다. ensureActive() 는 취소를 위해 호출하는 것이기 때문에 일반적으로 더 가볍습니다. 그래서 취소를 위한 이유라면 ensureActive() 사용을 더 선호해야합니다. yield() 함수는 최상위 서스펜션 기능으로 일반적인 일시 중단 기능에서 사용할 수 있습니다. 일시 중단 및 재개를 수행하기 때문에 스레드 풀이 있는 디스패처를 사용하는 경우 스레드 변경과 같은 다른 효과가 발생할 수 있습니다.

suspendCancellableCoroutine

suspendCancellableCoroutine() 함수는 suspendCoroutine() 함수와 비슷한 느낌의 함수입니다. 중요한 포인트는 코루틴이 취소될 때 어떤 일이 일어나야 하는지 설정하는데 사용됩니다. 대부분 라이브러리에서 프로세스를 취소하거나 일부 리소스를 해제하는데 사용됩니다.

내부적으로는 어떻게 코루틴이 취소될까?

CancellableContinuationImplcancel() 함수는 다음과 같이 정의되어 있습니다.

public override fun cancel(cause: Throwable?): Boolean {
        _state.loop { state ->
            if (state !is NotCompleted) return false // false if already complete or cancelling
            // Active -- update to final state
            val update = CancelledContinuation(this, cause, handled = state is CancelHandler)
            if (!_state.compareAndSet(state, update)) return@loop // retry on cas failure
            // Invoke cancel handler if it was present
            (state as? CancelHandler)?.let { callCancelHandler(it, cause) }
            // Complete state update
            detachChildIfNonResuable()
            dispatchResume(resumeMode) // no need for additional cancellation checks
            return true
        }
    } 

코루틴을 취소할 때, 발생된 예외를 전달하면 CancelledContinuation 을 생성합니다. 이 때 전달되는 예외는 nullable한데 null인 경우 CancellationException 을 생성하도록 구성이 되어있습니다.

internal class CancelledContinuation(
    continuation: Continuation<*>,
    cause: Throwable?,
    handled: Boolean
) : CompletedExceptionally(cause ?: CancellationException("Continuation $continuation was cancelled normally"), handled) {
    private val _resumed = atomic(false)
    fun makeResumed(): Boolean = _resumed.compareAndSet(false, true)
}

그래서 만들어진 CancelledContinuation_state.compareAndSet() 함수로 전달을 합니다. 여기서부터는 추측이긴 하지만 CancellationException 인 경우 상태를 취소 바꾸고 그 이후부터 정상적으로 코루틴이 종료되도록 구현이 되어 있을 것으로 보입니다.