Android

5. Kotlin Coroutine - Cancellation

Sara.H 2024. 5. 22. 23:33

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

 

어떤 작업이 더이상 실행되지 않아도 될 때는, 메모리를 낭비하지 않기 위해 취소해주어야 한다. 

 

코루틴은 CancellationException 이라는 예외를 던짊으로써 cancellation 을 처리한다. 

fun cancel(cause: CancellationException? = null)

// 객체 안넘겨주면 기본값 사용함 
public override fun cancel(cause: CancellationException?) {
cancelInternal(cause ?: defaultCancellationException())
}

cancel 함수에 인자값으로 사실은 CancellationException 이라는 녀석이 숨어있다. 

이 예외에 대한 구체적인 이유를 적어주고 싶다면 cause 에 예외를 넘겨주면 된다. 

만약 넘겨주지 않는다면 default를 만들어서 내부적으로 캔슬을 한다. 

 

parent - child job 이 있다고 할때, child 는 CancellationException 을 부모에게 던지고, 

부모는 이를 까본 후 자신이 처리해야 하는 예외인지 판단하게 된다. 

부모는 CancellationException 으로 인해 끝나버린 자식의 경우 추가적인 액션을 취하지 않는다. 

만약 ktx 라이브러리를 통해서 생성된 라이프사이클과 연관된 스코프라면 개발자가 직접 cancel 해주어야 하는 경우는 없다. 

viewModelScope, lifecycleScope 모두 각자 알아서 끝나야 될 때를 알고있다. 

 

그렇다면 다시, cancel 한다는 건 구체적으로 어떤 것을 의미하나 ? 

coroutine 을 cancel 했다고 해서 그 작업이 끝난것은 아니다. 

cancel() 을 호출하면 코루틴은 Cancelling 상태로의 전이가 일어날 뿐이다. 

자신의 작업이 다 끝난 경우에 비로소 Cancelled 상태로의 전이가 일어난다. 

 

즉, 코루틴은 어떠한 협력 없이는 작업을 중단할 수 없으며, 이로써 코루틴의 취소는 "협력적(Cooperative)" 하다는 표현이 등장한 것이다. 취소를 위한 어떠한 협력 매커니즘이 반드시 존재해야 한다. 

 

kotlinx.coroutine 에서 제공하는 모든 suspend 함수들은 사실 Cancellable 하다. 

즉, 내부적으로 active 상태인지를 주기적으로 확인하는 로직이 있어 cancel() 을 했을 때, 실제로 작업이 중단될 수 있는 것이다. 

가령 withContext, delay 같은 애들이 있다. 

 

만약 당신이 Cancellable 한 suspend 함수를 만들어야 한다면 다음의 두 가지 방법으로 Cancellable 하게 만들 수 있다. 

1. Job.isActive / ensureActive() 

2. yield() 를 통해 다른 작업이 일어나도록 함 

 

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

 

이런식으로 내부적으로 isActive 를 체크하는 코드다. 

ensureActive 를 사용하면 직접적으로 프라퍼티에 접근하는 코드에 대한 보일러플레이트를 줄일 수 있다는 장점이 있기는 한데, 

뭔가 구체적으로 로깅을 하고 싶다거나 하는 경우 살짝 애매해질수도. 

 

그러면 yield 는 뭐냐? 

당신이 하려는 작업이 

1. CPU 를 많이 사용한다거나 

2. 스레드풀을 소진시킬 가능성이 있다거나 

3. 스레드풀에 스레드를 추가하지 않은 채로 스레드가 다른 작업을 하기를 원한다거나 

할때 사용해라. -> 무슨말이냐 ? 

ensureActive 를 처음에 호출한 것 처럼 yield 도 함수 시작할때 호출하면 된다. 

package kotlinx.coroutines

import kotlinx.coroutines.internal.*
import kotlin.coroutines.intrinsics.*

/**
 * Yields the thread (or thread pool) of the current coroutine dispatcher
 * to other coroutines on the same dispatcher to run if possible.
 *
 * This suspending function is cancellable: if the [Job] of the current coroutine is cancelled while
 * [yield] is invoked or while waiting for dispatch, it immediately resumes with [CancellationException].
 * There is a **prompt cancellation guarantee**: even if this function is ready to return the result, but was cancelled
 * while suspended, [CancellationException] will be thrown. See [suspendCancellableCoroutine] for low-level details.
 *
 * **Note**: This function always [checks for cancellation][ensureActive] even when it does not suspend.
 *
 * ### Implementation details
 *
 * If the coroutine dispatcher is [Unconfined][Dispatchers.Unconfined], this
 * functions suspends only when there are other unconfined coroutines working and forming an event-loop.
 * For other dispatchers, this function calls [CoroutineDispatcher.dispatch] and
 * always suspends to be resumed later regardless of the result of [CoroutineDispatcher.isDispatchNeeded].
 * If there is no [CoroutineDispatcher] in the context, it does not suspend.
 */
public suspend fun yield(): Unit = suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
    val context = uCont.context
    context.ensureActive()
    val cont = uCont.intercepted() as? DispatchedContinuation<Unit> ?: return@sc Unit
    if (cont.dispatcher.isDispatchNeeded(context)) {
        // this is a regular dispatcher -- do simple dispatchYield
        cont.dispatchYield(context, Unit)
    } else {
        // This is either an "immediate" dispatcher or the Unconfined dispatcher
        // This code detects the Unconfined dispatcher even if it was wrapped into another dispatcher
        val yieldContext = YieldContext()
        cont.dispatchYield(context + yieldContext, Unit)
        // Special case for the unconfined dispatcher that can yield only in existing unconfined loop
        if (yieldContext.dispatcherWasUnconfined) {
            // Means that the Unconfined dispatcher got the call, but did not do anything.
            // See also code of "Unconfined.dispatch" function.
            return@sc if (cont.yieldUndispatched()) COROUTINE_SUSPENDED else Unit
        }
        // Otherwise, it was some other dispatcher that successfully dispatched the coroutine
    }
    COROUTINE_SUSPENDED
}

 

Job.join() 과 Deffered.await() 를 할때, 

cancel() 을 전/후로 호출하면 결과적으로 어떤 차이가 있을까? 

 

job.cancel() 

job.join() 

순서대로 호출을 하면 join() 은 작업이 끝날때까지 suspend 한다. -> cancellable 하다고 문서에는 나와있는데, 무슨 말이지? 

 

job.join() 

job.cancel() 

순서대로 호출하면 cancel() 은 아무런 효과가 없다. 이미 작업이 끝난 상태이기 때문이다. 

 

Deffered 는 Job 의 특정한 타입으로서, 마찬가지로 캔슬할 수 있다. 

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

Deferred 에 cancel() 이 호출된 이후에는 await() 를 호출하면 JobCancellationException 을 던진다. 

await() 의 역할이 결과가 계산되기까지 코루틴을 suspend 하는 것인데, 코루틴이 취소 되어서 결과가 계산될 수 없기 때문이다. 

따라서 await 를 cancel 이후에 호출하면 예외가 발생하는 것. 

 

Handling cancellation side effects 

코루틴이 취소된 이후에 특정 동작을 수행하고 싶다고 해보자. 

가령 어떤 자원을 닫는다거나, 취소를 로깅한다거나 ... 

이를 달성할 수 있는 몇 가지 방법 있음: 

- !isActive 확인해주기 

- try - catch - finally 

- suspendCancellableCoroutine / invokeOnCancellation 

 

각 방법에 대한 예시: 

1. 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!”)

isActive 하지 않거나, 작업이 정상적으로 끝난경우 complete 상태에 오게 되고, clean up 코드를 넣어준다. (while 문 밖에) 

 

2. try-catch-finally 

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

finally 블럭에서 clean up 을 수행하는 경우다. 

다만, 이 때 조심해야 할 것은 취소중인 (cancelling state) 코루틴은 더이상 suspend 연산이 불가능하다는 것이다. 

다시 말해서, 더이상 suspend 상태에서 어떠한 코드블럭을 실행할 능력을 상실해 버린다. 

따라서 finally 블럭에서 suspend 연산을 하고 싶다면, non-cancellable coroutine context 로 작업을 이관해야 한다. 

그럼으로써 우리는 cancelling 상태에 대한 suspend 를 할 수 있다. 

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!”)

 

3. suspendCancellableCoroutine / invokeOnCancellation 

만약 suspendCoroutine 을 이용해서 콜백을 코루틴으로 변환했다면, suspendCancellableCoroutine 을 사용하는 것을 고려해보자. 

그러면 캔슬 되는 상태에 대한 체크를 continuation.invokeOnCancellation 처럼 할 수 있음 

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

 

결론) 

구조화된 동시성 (Structured concurrency) 에 대한 이점을 가져가고 싶다면, 

그리고 불필요한 작업을 하고 싶지 않다면 항상 job 이 취소가능하도록 관리하라. 

 

어지간하면 당신이 직접 스코프 만들려 하지 말고, jetpack 에서 주는거 써라. 

- viewModelScope 

- lifecycleScope 

 

만약 꼭 만들어야만 하겠으면 반드시 cancellable 하게 만들라. 

 

The cancellation of coroutine code needs to be cooperative so make sure you

update your code to check for cancellation to be lazy and avoid doing more

work than necessary.