부모 코루틴과 자식 코루틴 : 상속되지 않는 Job

홍성덕·2024년 9월 20일
0

Coroutines

목록 보기
8/14

상속되지 않는 Job

두 개의 코루틴이 부모-자식 관계일 때 부모의 CoroutineContext 자식에게 상속된다. 그래서 부모의 CoroutineName, CoroutineDispatcher, CoroutineExceptionHandler는 자식에게 상속된다. 하지만 CoroutineContext 주요 Element 중 하나인 Job은 상속되지 않는다.

fun main() = runBlocking<Unit> { // 부모 코루틴 생성
    val runBlockingJob = coroutineContext[Job] // 부모 코루틴의 CoroutineContext로부터 부모 코루틴의 Job 추출
    launch { // 자식 코루틴 생성
        val launchJob = coroutineContext[Job] // 자식 코루틴의 CoroutineContext로부터 자식 코루틴의 Job 추출
        println(runBlockingJob === launchJob) // 출력 : false
    }
}

위의 코드를 실행하면 동일성 비교에서 false가 출력된다. 부모 코루틴의 Job이 자식 코루틴에게 상속되지 않는다는 것을 이 코드를 통해 확인 가능하다.

코루틴 제어에 Job 객체가 필요한데 Job 객체를 부모 코루틴으로부터 상속받게 되면 개별 코루틴의 제어가 어려워진다. 그래서 launch, async 함수 같은 코루틴 빌더 함수는 호출할 때 Job 객체를 새로 생성한다.


구조화에 사용되는 Job

부모 코루틴과 자식 코루틴은 각각 독립적인 Job 객체를 갖지만, 서로 아무런 관계가 없는 것이 아니다. Job 객체는 코루틴을 구조화하는 데 사용된다.

public interface Job : CoroutineContext.Element {
    // ...
    @ExperimentalCoroutinesApi
    public val parent: Job?
    // ...
    public val children: Sequence<Job>
    // ...
}

Job에는 parentchildren 프로퍼티가 존재한다. 이 프로퍼티를 통해서 자신의 부모와 자식을 참조한다. 부모 코루틴의 Job 객체는 children 프로퍼티를 통해 자식 코루틴의 Job 객체를 참조하고, 자식 코루틴 Job 객체는 parent 프로퍼티를 통해 부모 코루틴의 Job 객체를 참조한다. 부모와 자식이 서로 참조하는 양방향 참조이다.

fun main() = runBlocking<Unit> { // 부모 코루틴
    val parentJob = coroutineContext[Job]
    launch { // 자식 코루틴
        val childJob = coroutineContext[Job]
        println("${childJob?.parent === parentJob}") // true
        println("${parentJob?.children?.contains(childJob)}") // true
    }

    async { // 자식 코루틴
        val childJob = coroutineContext[Job]
        println("${childJob?.parent === parentJob}") // true
        println("${parentJob?.children?.contains(childJob)}") // true
    }
}

코드를 통해 확인해보면 부모 자식 간에 서로 참조하고 있다는 것을 알 수 있다.
코드에서 runBlocking은 최상위에 정의된 루트 코루틴이다. 루트 코루틴은 부모 코루틴이 없. 이런 경우 때문에 parent 프로퍼티가 Job?타입(nullable)으로 선언되어 있는 것이다.
또한 runBlocking은 두 개의 자식 코루틴을 가진다. 부모 코루틴은 하나만 가질 수 있지만 자식 코루틴은 여러 자식을 가질 수 있기 때문에 children 프로퍼티가 Sequence<Job> 타입으로 선언된 것이다.


부모-자식 관계를 정의하는 내부 코드

내부 코드를 통해 부모-자식 관계를 정의하는 코드를 찾아보자.

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 함수에서 StandaloneCoroutine 함수를 따라가보자. 부모 CoroutineContext와 자식 CoroutineContext를 합친 새로운 CoroutineContext인 newContext를 인자로 전달한다. 하지만 Job 객체는 상속되지 않으므로 newContext의 Job 객체는 부모 코루틴의 Job 객체이다.

private open class StandaloneCoroutine(
    parentContext: CoroutineContext,
    active: Boolean
) : AbstractCoroutine<Unit>(parentContext, initParentJob = true, active = active) { ... }

parentContext 파라미터로 들어오는 인자가 아까 launch()의 newContext이다. StandaloneCorotine은 AbstractCoroutine 추상 클래스를 상속받는 클래스로 parentContext를 인자로 전달하고 initParentJob을 true로 전달한다.

@InternalCoroutinesApi
public abstract class AbstractCoroutine<in T>(
    parentContext: CoroutineContext,
    initParentJob: Boolean,
    active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

    init {
        if (initParentJob) initParentJob(parentContext[Job])
    }
    
    // ...
}

그리고 AbstractCoroutine 클래스의 init 블록에 initParentJob이 true이면 initParentJob() 함수를 호출한다. 아까 StandaloneCoroutine 클래스에서 initParentJob을 true로 전달했으므로 initParentJob() 함수가 호출될 것이다. 그리고 parentContext[Job]을 전달하는데 이는 부모 CoroutineContext의 Job 객체이다.

// JobSupport.kt
    protected fun initParentJob(parent: Job?) {
        // ...
        val handle = parent.attachChild(this)
        // ...
    }

그리고 initParentJob()가 정의된 곳을 보면 전달된 부모의 Job에 attachChild(this)를 통해 this를 attach하는 것을 확인할 수 있다.

근데 여기서 this는 무엇일까? this를 Cmd + B를 통해 정의된 곳으로 이동하면 JobSupport 클래스를 가리킨다. 근데 현재 이 상황에서 this는 JobSupport 클래스를 상속한 AbstractCoroutine 클래스를 상속한 StandaloneCoroutine 객체를 가리킨다.
상속관계를 화살표로 나타내면 JobSupport -> AbstractCoroutine -> StandaloneCoroutine 이렇게 나타낼 수 있다. attachChild()를 통해 부모 코루틴의 자식으로 StandaloneCoroutine 객체를 attach 한 것이다.

다시 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
}

정리하자면, launch()를 호출하면 StandaloneCoroutine 객체를 생성함과 동시에 내부적으로 attachChild() 함수를 통해 부모-자식 관계를 정의하고, 해당 StandaloneCoroutine 객체를 Job 객체로 업캐스팅하여 리턴한다.

참고로 start가 Lazy일 때는 LazyStandaloneCoroutine 객체를 생성하지만 LazyStandaloneCoroutine 클래스는 StandaloneCoroutine 클래스의 하위 클래스이기 때문에 사실상 내부적인 동작은 같다.
그리고 async()를 호출했을 때도 내부적으로 부모-관계를 정의하는 것은 launch()를 호출했을 때와 같다.


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글

관련 채용 정보