티스토리 뷰
그런데 궁금하다.
어떻게 코루틴을 정지시키는가?
정확히는 어떻게 원리로 코루틴이 되어 있기에 상태를 감지하고 정지하는걸까?
이제 그 해답을 찾아보고자 한다.
코루틴 스코프?
코루틴스코프를 직접 원문을 가져오면 이러하다.
/**
* 주어진 코루틴 [context]를 감싸는 [CoroutineScope]를 생성합니다.
*
* 만약 주어진 [context]에 [Job] 요소가 포함되지 않았다면, 기본적으로 `Job()`이 생성됩니다.
* 이렇게 하면, 이 스코프 내의 어떤 자식 코루틴이 실패하거나 스코프 자체가 [취소][CoroutineScope.cancel]되었을 때,
* [coroutineScope] 블록 내부에서와 마찬가지로 스코프의 모든 자식이 함께 취소됩니다.
*/
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
오호... 코루틴을 시작할 때, scope를 먼저 시작하는데, 이는 스코프 (= 범위)부터 context를 기반으로 관리하기 위함임을 주석에서 유추해볼 수 있다.
그렇다면... 우리의 의문이었던 어떻게 취소를 가능하게 하는가에 대해서는,
자식 코루틴의 컨택스트의 상태를 계속해서 추적하고 있음이 틀림없다.
그렇다면 코루틴의 context는 어디서 생성될까...
launch?
우리가 코루틴을 사용하기 위해서 사용했던 launch 함수를 살펴보자
/**
* 현재 스레드를 차단하지 않고 새로운 코루틴을 실행하며, 실행된 코루틴을 나타내는 [Job]을 반환합니다.
* 생성된 코루틴은 해당 `Job`이 [취소][Job.cancel]될 때 함께 취소됩니다.
*
* 코루틴의 컨텍스트는 [CoroutineScope]로부터 상속됩니다.
* 추가적인 컨텍스트 요소는 [context] 매개변수를 통해 지정할 수 있습니다.
* 만약 컨텍스트에 디스패처 또는 [ContinuationInterceptor]가 포함되지 않았다면, 기본적으로 [Dispatchers.Default]가 사용됩니다.
* 또한 부모 `Job`은 기본적으로 [CoroutineScope]에서 상속되지만, [context] 요소를 통해 직접 재정의할 수도 있습니다.
*
* 기본적으로, 코루틴은 즉시 실행되도록 예약됩니다.
* 다른 시작 옵션은 `start` 매개변수를 통해 지정할 수 있으며, 자세한 내용은 [CoroutineStart]를 참고하세요.
* 예를 들어, `start` 매개변수를 [CoroutineStart.LAZY]로 설정하면 코루틴이 _지연(lazy)_ 상태로 생성됩니다.
* 이 경우, 생성된 코루틴의 [Job]은 _새로운(new)_ 상태로 존재하며,
* [start][Job.start] 함수를 호출하거나 [join][Job.join]이 최초로 호출될 때 자동으로 실행됩니다.
*
* 기본적으로, 이 코루틴에서 발생한 처리되지 않은 예외는 컨텍스트의 부모 `Job`을 취소합니다.
* ([CoroutineExceptionHandler]가 명시적으로 지정되지 않은 경우)
* 따라서, `launch`가 다른 코루틴의 컨텍스트 내에서 사용되었을 때, 처리되지 않은 예외가 발생하면 부모 코루틴도 함께 취소됩니다.
*
* 새로 생성된 코루틴에서 사용할 수 있는 디버깅 기능에 대한 설명은 [newCoroutineContext]를 참고하세요.
*
* @param context [CoroutineScope.coroutineContext]에 추가할 코루틴 컨텍스트.
* @param start 코루틴 시작 옵션. 기본값은 [CoroutineStart.DEFAULT].
* @param block 제공된 스코프의 컨텍스트에서 실행될 코루틴 코드.
*/
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
오호.. 코드와 주석을 살펴보면,
launch 함수에서 생성자를 통해 코루틴의 context가 부모 코루틴의 context를 받아, 생성됨을 알 수 있다.
(데코레이터 패턴같네)
그렇게 탄생된 코루틴은 생성된 컨택스트와 코드블럭, 시작 방법을 갖고 start함수를 통해 실행된다.
그렇다면 컨택스트로는 뭘 갖고 있는걸까?
CoroutineContext 인터페이스를 살펴보자
@SinceKotlin("1.3")
public interface CoroutineContext {
public operator fun <E : Element> get(key: Key<E>): E?
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
context.fold(this) { acc, element ->
val removed = acc.minusKey(element.key)
if (removed === EmptyCoroutineContext) element else {
// make sure interceptor is always last in the context (and thus is fast to get when present)
val interceptor = removed[ContinuationInterceptor]
if (interceptor == null) CombinedContext(removed, element) else {
val left = removed.minusKey(ContinuationInterceptor)
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}
public fun minusKey(key: Key<*>): CoroutineContext
public interface Key<E : Element>
public interface Element : CoroutineContext {
public val key: Key<*>
public override operator fun <E : Element> get(key: Key<E>): E? =
@Suppress("UNCHECKED_CAST")
if (this.key == key) this as E else null
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
public override fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}
}
CoroutineContext는 코루틴 실행에 필요한 다양한 요소
(예: 디스패처, Job, 로깅 등)를 저장하고 관리하는 컨텍스트 역할을 하는 것으로 보인다.
흠.. 더 거창한 동작을 하는가 싶었지만 그렇지는 않고..
해당 컨택스트를 가진 코루틴에서 필요한 요소와 부모 job을 관리하는 정도로 이해하면 좋을 것 같다.
cancel은 그럼 어떻게 이뤄질까?
cancel 함수를 찾아가보자.
/**
* 이 작업은 선택적인 취소 [원인]과 함께 이 작업을 취소합니다.
* 원인은 오류 메시지를 지정하거나 디버깅 목적으로 취소 이유에 대한 추가 정보를 제공하는 데 사용될 수 있습니다.
* 취소 메커니즘에 대한 자세한 설명은 [Job] 문서를 참조하세요.
*/
public fun cancel(cause: CancellationException? = null)
오호...cancel 함수를 들어가보면, CoroutineContext.Element 를 상속한 job을 다시 살펴보고 cancel의 메커니즘을 알아보라고한다. 그럼 다시 job 을 찾아, 설명을 보자.
백그라운드 작업 (Job)
개념적으로, Job은 완료로 끝나는 생명 주기를 가진 취소 가능한 작업입니다.
작업은 부모-자식 계층으로 구성될 수 있으며, 부모 작업이 취소되면 모든 자식 작업이 재귀적으로 즉시 취소됩니다. 자식 작업이 CancellationException 외의 예외로 실패하면 부모 작업과 그 자식 작업들도 즉시 취소됩니다. 이 동작은 SupervisorJob을 사용하여 사용자 정의할 수 있습니다.
Job의 기본 예시 생성:
- Coroutine Job은 launch 코루틴 빌더로 생성됩니다. 지정된 코드 블록을 실행하고 해당 블록의 실행이 완료되면 작업도 완료됩니다.
- CompletableJob은 Job() 팩토리 함수로 생성됩니다. CompletableJob.complete를 호출하여 완료됩니다.
개념적으로, Job의 실행은 결과 값을 생성하지 않습니다. 작업은 사이드 이펙트를 위해서만 실행됩니다. 결과 값을 생성하는 Job은 Deferred 인터페이스를 사용합니다.
Job 상태
Job은 다음과 같은 상태를 가집니다:
New | false | false | false |
Active | true | false | false |
Completing | true | false | false |
Cancelling | false | false | true |
Cancelled | false | true | true |
Completed | false | true | false |
- 일반적으로 Job은 Active 상태로 생성됩니다 (생성되고 실행됨).
- 그러나 CoroutineStart.LAZY로 설정하면, 해당 Job은 New 상태로 생성됩니다. 이러한 Job은 start나 join을 호출하여 활성화될 수 있습니다.(우리가 아까 위에서 봤던 start)
Job은 코루틴이 실행 중이거나 CompletableJob이 완료될 때까지 활성 상태입니다.
활성 Job이 예외를 발생시키면!!!! 해당 Job은 Cancelling 상태로 전환됩니다. cancel 함수를 호출하면 즉시 취소 상태로 전환됩니다. Job은 작업을 완료한 후 자식 작업들이 완료될 때까지 대기하며 Completed 상태로 전환됩니다. Completing 상태는 내부적으로만 존재하며, 외부에서는 여전히 활성 상태로 보입니다.
Job 상태 흐름
- New → Active → Completing → Completed
- Active 상태에서 cancel이나 fail이 발생하면 Cancelling → Cancelled 상태로 변환됩니다.
취소 원인 (Cancellation Cause)
- 코루틴 Job이 예외적으로 완료되면, 해당 코루틴의 본문에서 예외가 발생하거나 CompletableJob.completeExceptionally가 호출됩니다. 예외적으로 완료된 Job은 취소되며, 이 예외가 Job의 취소 원인이 됩니다.
- Job의 정상 취소는 CancellationException이 발생할 때 구분되며, 이 경우 부모 Job의 취소는 발생하지 않습니다. 반면, 다른 예외가 발생하면 해당 Job은 실패로 간주되고, 부모 Job이 해당 예외를 받아 취소됩니다.
cancel 함수는 오직 CancellationException만을 취소 원인으로 허용하므로, 부모 Job은 자식들을 취소할 수 있지만 자신은 취소되지 않습니다. 이를 통해 부모는 자식들을 재귀적으로 취소할 수 있습니다.
동시성 및 동기화
이 인터페이스와 그로부터 파생된 모든 인터페이스의 함수는 스레드 안전하며, 외부 동기화 없이 동시 실행되는 코루틴에서 안전하게 호출될 수 있습니다.
상속 불안정성
Job 인터페이스와 그 파생 인터페이스는 3rd-party 라이브러리에서 상속하기에 불안정합니다. 향후 새로운 메서드가 추가될 수 있기 때문입니다. 하지만 해당 인터페이스는 사용에는 안정적입니다.
오호... 위 내용에 따르면 이전 장에서 우리가 살펴본 CancellationException이 나온 것을 알 수 있다.
즉, cancel 함수는 CancellationException을 이용하여 감지 후, 작업을 취소하며, Job의 상태를 Cancelling으로 전환시킨다.
이때 부모-자식 관계에서 부모 작업이 취소될 때 자식 작업들이 함께 취소되도록 하는 동작이 중요한 부분이다.
cancel 함수는 CancellationException만을 허용하는데,
이는 부모 작업이 자식 작업들을 취소할 수는 있지만 자신이 취소되지 않도록 하기 위함으로 보인다.
이와 같은 취소 메커니즘은 코루틴의 계층적 구조에서 비롯된 것으로,
만약 부모 작업이 취소되면 자식 작업이 재귀적으로 취소되는 방식을 통해 여러 작업들을 효율적으로 관리하고, 필요할 때 모든 작업을 취소한다.
반대로, 자식 작업에서 예외가 발생하면 그 예외가 부모 작업으로 전파되어 부모 작업도 취소되도록 한다.
더 활용해서 취소의 전파를 살펴보면...아래처럼 동작하는 것으로 정리할 수 있다.
1. 자식 코루틴이 예외를 던지거나 cancel()을 호출, 부모 코루틴이 이를 감지하고 자식 코루틴을 취소
2. 부모 코루틴은 자신이 멈추게 되면, 최상위 부모에게까지 취소가 전파되어 재귀적으로 취소
3. 기존에 첫 코루틴의 자식 코루틴들은 부모 코루틴의 취소 상태를 자동으로 따라가므로, 별도의 메서드 호출 없이 취소
참고) ctrl 키와 인터페이스 설명
'Kotlin' 카테고리의 다른 글
코틀린 코루틴? 원문을 보고 말지 (5) + Dispatcher (0) | 2025.03.16 |
---|---|
코틀린 코루틴? 원문을 보고 말지 (4) + 비동기 (0) | 2025.03.15 |
코틀린 코루틴? 원문을 보고 말지 (2) + cancel (0) | 2025.03.15 |
코틀린 코루틴? 원문을 보고 말지 (1) + runBlocking, launch (0) | 2025.03.10 |
코틀린 코루틴? 원문을 보고 말지 (preview) (1) | 2025.03.10 |