코루틴에서는 비동기 작업인 코루틴을 부모-자식 관계로 구조화함으로써 코루틴이 보다 안전하게 관리되고 제어될 수 있도록 한다. 이처럼 비동기 작업을 구조화함으로써 비동기 프로그래밍을 보다 안정적이고 예측 가능하게 만드는 원칙이 구조화된 동시성(Structured Concurrency) 원칙이다.
부모-자식 관계로 구조화하는 방법은 간단하다. 부모 코루틴 블록 내에서 새로운 코루틴 빌더 함수를 호출하여 코루틴을 생성하면 된다.
fun main() = runBlocking<Unit> { // 1번 코루틴
launch { // 2번 코루틴
launch { // 3번 코루틴
}
}
}
위의 예시에서 가장 안쪽 launch로 생성되는 3번 코루틴은 바깥쪽 launch로 생성되는 2번 코루틴의 자식이 된다. 2번 코루틴은 runBlocking으로 생성되는 1번 코루틴의 자식이 된다. 이렇게 블록 내부에 새로운 코루틴 빌더 함수를 호출함으로써 부모-자식 관계가 성립된다.
이렇게 부모-자식 관계로 구조화된 코루틴은 여러 특징을 갖는데, 이번 글에서는 "부모 코루틴의 실행 환경이 자식 코루틴에게 상속된다"는 특징에 대해 설명해보겠다.
부모 코루틴의 실행 환경이 자식 코루틴에게 상속된다는 말은 코루틴의 실행 환경 정보를 담은 객체인 CoroutineContext가 상속된다는 의미이다. 즉 자식 코루틴에게 CoroutineContext의 주요 요소인 CoroutineName
, CoroutineDispatcher
, CoroutineExceptionHandler
가 상속된다. 단, Job은 상속되지 않는다.
우선 Job을 제외한 나머지 요소의 상속을 살펴보자.
@OptIn(ExperimentalStdlibApi::class)
fun main() = runBlocking<Unit> {
val predefinedContext = Dispatchers.IO + CoroutineName("CoroutineA") + CoroutineExceptionHandler { _, _ -> }
println("CoroutineName : " + predefinedContext[CoroutineName])
println("CoroutineDispatcher : " + predefinedContext[CoroutineDispatcher])
println("CoroutineExceptionHandler : " + predefinedContext[CoroutineExceptionHandler])
println()
launch(predefinedContext) { // 부모 코루틴 생성
launch { // 자식 코루틴 생성
println("CoroutineName : " + this.coroutineContext[CoroutineName])
println("CoroutineDispatcher : " + this.coroutineContext[CoroutineDispatcher])
println("CoroutineExceptionHandler : " + this.coroutineContext[CoroutineExceptionHandler])
}
}
}
// 출력
// CoroutineName : CoroutineName(CoroutineA)
// CoroutineDispatcher : Dispatchers.IO
// CoroutineExceptionHandler : com.hongstudio.wtdcomposehandson.Test2Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6e0e048a
// CoroutineName : CoroutineName(CoroutineA)
// CoroutineDispatcher : Dispatchers.IO
// CoroutineExceptionHandler : com.hongstudio.wtdcomposehandson.Test2Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6e0e048a
predefinedContext
라는 CoroutineContext를 먼저 생성하고 부모 코루틴의 CoroutineContext로 지정하였다. 그리고 자식 코루틴을 생성하여 자식 코루틴의 CoroutineContext 요소를 출력해서 부모 코루틴의 CoroutineContext와 비교해보았다.
부모 코루틴과 자식 코루틴의 CoroutineName
, CoroutineDispatcher
, CoroutineExceptionHandler
이 모두 같다는 것을 알 수 있다.
@OptIn(ExperimentalStdlibApi::class)
fun main() = runBlocking<Unit> {
val predefinedContext = Dispatchers.IO + CoroutineName("CoroutineA") + CoroutineExceptionHandler { _, _ -> }
launch(predefinedContext) { // 부모 코루틴 생성
launch { // 자식 코루틴 생성
println(predefinedContext[CoroutineName] === this.coroutineContext[CoroutineName])
println(predefinedContext[CoroutineDispatcher] === this.coroutineContext[CoroutineDispatcher])
println(predefinedContext[CoroutineExceptionHandler] === this.coroutineContext[CoroutineExceptionHandler])
}
}
}
// 출력
// true
// true
// true
혹시 동등성만 성립하고 동일성은 성립하지 않는 것인지 궁금하여 추가적으로 동일성 확인도 해보았다. 동일성 비교를 했을 때 모두 true가 출력되는 것으로 봐서 실제로 동일한 객체를 참조하고 있는 것이다.
하지만 부모 코루틴의 모든 실행 환경 정보가 항상 자식 코루틴에게 상속되지는 않는다. 만약 자식 코루틴을 생성하는 코루틴 빌더 함수로 새로운 CoroutineContext 객체가 전달되면, 부모 코루틴에게서 전달받은 CoroutineContext 구성 요소들은 새로운 CoroutineContext 객체의 구성 요소들로 덮어씌워진다.
@OptIn(ExperimentalStdlibApi::class)
fun main() = runBlocking<Unit> {
val predefinedContext = Dispatchers.IO + CoroutineName("CoroutineA") + CoroutineExceptionHandler { _, _ -> }
println("CoroutineName : " + predefinedContext[CoroutineName])
println("CoroutineDispatcher : " + predefinedContext[CoroutineDispatcher])
println("CoroutineExceptionHandler : " + predefinedContext[CoroutineExceptionHandler])
println()
launch(predefinedContext) { // 부모 코루틴 생성
launch(CoroutineName("CoroutineB") + Dispatchers.Default) { // 자식 코루틴 생성
println("CoroutineName : " + this.coroutineContext[CoroutineName])
println("CoroutineDispatcher : " + this.coroutineContext[CoroutineDispatcher])
println("CoroutineExceptionHandler : " + this.coroutineContext[CoroutineExceptionHandler])
println()
println(predefinedContext[CoroutineName] === this.coroutineContext[CoroutineName])
println(predefinedContext[CoroutineDispatcher] === this.coroutineContext[CoroutineDispatcher])
println(predefinedContext[CoroutineExceptionHandler] === this.coroutineContext[CoroutineExceptionHandler])
}
}
}
// 출력
// CoroutineName : CoroutineName(CoroutineA)
// CoroutineDispatcher : Dispatchers.IO
// CoroutineExceptionHandler : com.hongstudio.wtdcomposehandson.Test2Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6e0e048a
// CoroutineName : CoroutineName(CoroutineB)
// CoroutineDispatcher : Dispatchers.Default
// CoroutineExceptionHandler : com.hongstudio.wtdcomposehandson.Test2Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6e0e048a
// false
// false
// true
예시에서 자식 코루틴에 새로운 CoroutineContext 객체인 CoroutineName("CoroutineB") + Dispatchers.Default
를 전달하였다. 출력해보면 CoroutineExceptionHandler는 동일하지만 새로 전달된 CoroutineName과 CoroutineDisaptcher는 부모 코루틴과 자식 코루틴이 다르다는 것을 확인할 수 있다.
부모 코루틴의 CoroutineContext가 자식 코루틴에게 어떻게 상속되고, 새로운 CoroutineContext가 어떻게 덮어씌워지는 것인지 알기 위해 launch()
함수의 내부를 살펴보자.
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
}
여기서 newCoroutineContext(context)
를 따라가보자.
@ExperimentalCoroutinesApi
public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
val combined = foldCopies(coroutineContext, context, true)
val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
debug + Dispatchers.Default else debug
}
여기서 foldCopies()
가 정의된 곳으로 가보자.
private fun foldCopies(originalContext: CoroutineContext, appendContext: CoroutineContext, isNewCoroutine: Boolean): CoroutineContext {
// Do we have something to copy left-hand side?
val hasElementsLeft = originalContext.hasCopyableElements()
val hasElementsRight = appendContext.hasCopyableElements()
// Nothing to fold, so just return the sum of contexts
if (!hasElementsLeft && !hasElementsRight) {
return originalContext + appendContext
}
// ...
}
foldCopies()
를 보면 originalContext
와 appendContext
를 plus하는 코드가 존재한다. 즉 launch()를 통해 자식 코루틴을 생성하면 부모의 CoroutineContext인 originalContext
에 자식의 CoroutineContext인 appendContext
를 plus하는 것이다. plus()
함수에 대한 설명은 이 글의 CoroutineContext 구성요소 조합에서 설명하였으니 참고하자.
plus()
가 이루어졌기 때문에 부모의 CoroutineContext 요소를 자식이 상속받을 수 있는 것이고 자식의 CoroutineContext 요소로 덮어씌울 수 있는 것이다.
그리고 foldCopies()
하단에 코드를 생략하였는데 생략된 부분은 너무 복잡해서 구체적인 코드 흐름을 파악하기 힘들었다. 하지만 return originalContext + appendContext
에 breakpoint를 지정하고 테스트를 여러 번 진행해봤을 때, 하단의 생략된 부분으로 코드가 진행된 적이 없었다. 아마도 특별한 경우가 아니고서는 originalContext + appendContext
가 return되는 것으로 끝나는 것 같다.
한 가지 더, launch()
내부에서 호출되는 newCoroutineContext()
함수는 다른 코루틴 빌더 함수인 async, runBlocking 내에서도 사용되는 함수이다. 하지만 테스트 결과 async를 통해 자식 코루틴을 생성했을 때는 실행 환경이 상속되지만, runBlocking을 통해 자식 코루틴을 생성했을 때는 실행 환경이 상속되지 않는다.