⛓️

Kotlin Coroutinesの制御・キャンセル・例外処理を支える技術 ー Structured Concurrencyの内部実装

に公開

本記事は、Kotlin Coroutinesの基礎をすでに理解している中級者以上の開発者を対象とし、さらに理解を深めることを目的としたシリーズの一部です。
今回は、Kotlin Coroutinesの制御・キャンセル・例外処理を支える重要な概念である、Structured Concurrencyについて解説します。

Structured Concurrencyの歴史と設計思想

まずは、Structured Concurrencyが、どのような背景のもと、どのような課題を解決するために生まれたのか、を説明します。
これは、なぜKotlin Coroutinesの仕様を理解する上で、Structured Concurrencyの設計思想を理解することが不可欠であるためです。

Martin Sústrikによる創出 (2016年)

"Structured Concurrency"という用語は、2016年にMartin Sústrik[3]が生み出したとされています[4]。Martin氏は、C言語でStructured Concurrencyを実現するためのライブラリであるlibdill[5]を公開しました。

libdillのドキュメント上には、Martin氏の考えるStructured Concurrencyのコンセプトが説明されています[6]

このドキュメントでは、親コルーチンが子コルーチンを起動した場合、子コルーチンが完了するまで親コルーチンが完了しないことがStructured Concurrencyの本質とされています。


Structured Concurrencyに従った子コルーチンの起動

一方で、親コルーチンの完了後に、子コルーチンが残存するような場合は、Structured Concurrencyを違反します。例えば、Javaでスレッドをforkした後、joinせずに起動元のスコープを抜けた場合には、このような状況になり得ます。


Structured Concurrencyに反する子コルーチンの起動

Structured Concurrencyにおいては、親コルーチンの完了時には、子コルーチンも必ず完了していることから、リソースのリークを防止できるという大きなメリットがあります。

Nathaniel J. Smithによる普及 (2018年)

その後、2018にNathaniel J. Smith[7]は、PythonでStructured Concurrencyを実現するためのライブラリであるTrio[8]を作成するとともに、Structured Concurrencyの有益性を主張するブログ記事 "Notes on structured concurrency, or: Go statement considered harmful" [9]を公開しました。彼のブログ記事は、Structured Concurrencyの設計思想を広めることに貢献しました。

Nathanielは、構造化プログラミング (Structured Programming) とのアナロジーで、Structured Concurrencyを導入すべき理由を説明しています。

1960年台に遡ると、Edsger W. Dijkstraは構造化プログラミングを提唱し、goto構文を否定する有名な論文 "Go To statement considered harmful" を公表しました[10]
構造化プログラミングとは、「順次 (順番に処理を進める)」「選択 (分岐)」「反復 (ループ)」の3つの制御構造のみからプログラムを構成する手法です[11]

構造化プログラミングは今やスタンダードな考え方となっており、現代的なプログラミング言語でgoto構文がサポートされる自体が稀でしょう。
しかしながら、並行処理に目を向けると、goto構文に相当するような、制御フローを壊しうる設計パターンが今もなお広く使われている、というのがNathaniel氏の主張です。このような危険なパターンの代表例として、Go言語のgo文が挙げられています。
結論として、Structured Concurrencyが普及することで、構造化プログラミングによってプログラムの明確さ・安全性が得られたのと同様のパラダイムシフトとなるとされています。

Kotlin Coroutinesへの実装 (2018年)

Kotlinは、メジャーなプログラミング言語としては、Structured Concurrencyを初めて公式の標準ライブラリでサポートした言語と言えます。
Kotlin Coroutinesにおいては、CoroutineScopeという概念によって、Structured Concurrencyを実現しています。
CoroutineScope内でコルーチンが起動されると、そのスコープ内で起動された全ての子コルーチンが完了するまで、CoroutineScope自体 (= 親コルーチン) は完了しません

▶️ Kotlin Playgroundで試す

import kotlinx.coroutines.*

suspend fun main() {
    coroutineScope { // CoroutineScopeを作成
        launch { // CoroutineScope内で、コルーチンを起動
            delay(100)
            println("Delay finished.")
        }
    }
    println("All finished.")
}

出力:

Delay finished.
All finished.

また、コルーチンを起動するための関数 (= Coroutine Builder) は、CoroutineScopeをレシーバとする拡張関数として定義されています。すなわち、すべてのコルーチンはCoroutineScopeを起点として起動する必要があります。
この制約によって、Kotlin Coroutinesの利用者は、Structured Concurrencyの本質である「子コルーチンが全て完了するまで、親コルーチンが完了しない」という仕様に、半ば強制的に従うこととなります。


以上、Structured Concurrencyの設計思想と、その背景を振り返りました。

Structured Concurrencyは、Kotlin Coroutinesにおいて、並行処理を明瞭かつ安全に記述することに貢献しています。
一方で、十分に理解しきれていない人にとっては、むしろ複雑あるいは分かりにくいと感じさせる挙動も生み出しています。例えば、キャンセル処理・例外処理の流れを難しいと感じている方は少なくないのではないでしょうか。

そこで以降の章では、Kotlin Coroutinesにおいて、Structured Concurrency ー すなわち子コルーチンの完了を待つという仕様 ーが、どのように実現されているかを解明していきます。

子コルーチンの完了を待つ仕組み

Kotlin Coroutinesが、「子コルーチンが全て完了するまで、親コルーチンが完了しない」というStrucured Concurrencyの本質をどのように実現しているかを、内部実装から解明していきます。

この仕様を実現してする上で重要なのが「CoroutineScope」です。
CoroutineScopeは、内部で起動された子コルーチンの完了を待機してから、CoroutineScope自体のスコープを抜けます。

以下のサンプルコードを実行して確かめてみましょう。
ここで使用されているcoroutineScopeはCoroutineScopeを作成するためのメソッドです。また、launchはCoroutineScope内で子コルーチンを起動するためのメソッドです。
コードを実行すると、外側のcoroutineScopeのスコープが、内部でlaunchされた子コルーチンの完了を待つため、"All finished."というメッセージが最後にprintされます。

▶️ Kotlin Playgroundで試す

import kotlinx.coroutines.*

suspend fun main() {
    coroutineScope { // CoroutineScopeを作成
        launch { // CoroutineScope内で、コルーチンを起動
            delay(100)
            println("Delay finished.")
        }
    }
    println("All finished.")
}

出力:

Delay finished.
All finished.

coroutineScopeが子コルーチンの完了を待つ仕組み

では、CoroutineScopeを作成しているcoroutineScopeメソッドの内部実装を追うことで、「子コルーチンの完了を待つ」仕組みがどのように実現されているのかを解明していきましょう。

coroutineScopeのソースコードを以下に抜粋します[13]
なお、今回の説明に関係しないコードは、省略しています。

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

coroutineScope内では、以下の3つのステップで処理を行っています。

  1. suspendCoroutineUninterceptedOrReturnで、呼び出し元のContinuationを取得する。
  2. CoroutineScopeに紐づくコルーチンである、ScopeCoroutineを作成する。
  3. ScopeCoroutinestartUndispatchedOrReturnで起動し、CoroutineScope内の処理を実行する。

1.のステップで登場するContinuationsuspendCoroutineUninterceptedOrReturnについて、初めて聞いた読者も多いかもしれません。

これらの用語を簡単に説明します。

  • Continuationとは、コルーチン (suspend関数) の、中断・再開の状態を管理するインスタンスContinuationに対してresumeWithメソッドを呼び出すことで、呼び出し元の処理を再開する (今回の場合はcoroutineScopeのスコープを抜ける)。
  • suspendCoroutineUninterceptedOrReturnとは、"呼び出し元" (今回の場合coroutineScopeの呼び出し元) のContinuationを取得するためのメソッド。

suspend関数がbytecodeへとコンパイルされると、その引数には呼び出し元のContinuationが追加されます。つまり、コンパイル後は、以下のようなbytecodeとなります。

public suspend fun <R> coroutineScope(
    block: suspend CoroutineScope.() -> R,
    continuation: Continuation<*>): R {
    /* 省略 */
}

このコンパイル後に追加されるContinuationを、コンパイル前のソースコード上で参照するために用いられるのが、suspendCoroutineUninterceptedOrReturnメソッドです。

https://zenn.dev/kaseken/articles/a50fd3f5e6e2ba


coroutineScopeメソッドの説明に戻ります。
coroutineScope内では、suspendCoroutineUninterceptedOrReturnによって呼び出し元のContinuationを取得し、そのContinuationが持つ呼び出し元の情報 ( CoroutineContext) を使って、ScopeCoroutineを作成・起動しています。

    // 呼び出し元の`Continuation`を取得
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        // 呼び出し元の`CoroutineContext`と`Continuation`から、`ScopeCoroutine`を作成
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        // `ScopeCoroutine`を起動し、`block`を実行
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }

ここまでの流れを、図にまとめます。


coroutineScopeメソッドの処理

では、ここで作成されているScopeCoroutineとは、一体何でしょうか?

ScopeCoroutineとは

ScopeCoroutineは、CoroutineScopeに紐づくコルーチンです。
一般的には、Kotlin Coroutinesにおいて、コルーチンはlaunchasyncrunBlockingのようなCoroutine Builderから起動されると言われます。ただ、内部的には、CoroutineScopeもそれ自体のコルーチンを作ります


続いて、ScopeCoroutineの初期化時の引数として渡されている、CoroutineContextContinuationという2つのパラメータの役割を確認します。
これら2つは、「子コルーチンの完了を待つ」というStructured Concurrencyの定義を実現する上で不可欠な役割を果たしています。

ScopeCoroutineに渡されたCoroutineContextの役割

まず、CoroutineContextの役割を見ていきます。結論から述べると、これは「コルーチンの親子構造の形成」という重要な役割を持ちます。

ScopeCoroutineのソースコードを以下に抜粋します[15]

/**
 * This is a coroutine instance that is created by [coroutineScope] builder.
 */
internal open class ScopeCoroutine<in T>(
    context: CoroutineContext,
    @JvmField val uCont: Continuation<T>
) : AbstractCoroutine<T>(context, true, true), CoroutineStackFrame {
  /* 実装は省略 */
}

ScopeCoroutineの初期化時に渡されたcontextパラメータは、基底クラスであるAbstractCoroutineの初期化パラメータとして使用されています。

AbstractCoroutineのソースコードから、特に関係する部分を抜粋します[16]

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

    init {
        /*
         * Setup parent-child relationship between the parent in the context and the current coroutine.
         * It may cause this coroutine to become _cancelling_ if the parent is already cancelled.
         * It is dangerous to install parent-child relationship here if the coroutine class
         * operates its state from within onCancelled or onCancelling
         * (with exceptions for rx integrations that can't have any parent)
         */
        if (initParentJob) initParentJob(parentContext[Job])
    }

init内で、JobSupport (基底クラス) のinitParentJobメソッドが呼ばれています。
initParentJob内で、呼び出し元 (親コルーチン) と、新たに作られたScopeCoroutineとの間に、親子構造が形成されます。

Jobの親子構造の形成

ここで登場するJobとは、コルーチンのライフサイクルやコルーチン間の親子構造を管理するものです。Jobは、Structured Concurrencyを実現する上で、非常に重要なパーツです。

ソースコード上は、Jobはinterfaceであり、具体的な実装はJobSupportクラスに存在します。
Structured Concurrencyに関わる多くの処理 (状態管理・キャンセル処理・例外処理など) が、JobSupportクラス内で実装されているため、このクラスは以降頻出します。

では、Jobの親子構造の形成を担っている、JobSupportクラスのinitParentJobの内部実装を見ていきます。

JobSupportinitParentJobメソッドのソースコードを以下に示します[17]

    protected fun initParentJob(parent: Job?) {
        assert { parentHandle == null }
        if (parent == null) {
            parentHandle = NonDisposableHandle
            return
        }
        parent.start() // make sure the parent is started
        // ここでJobの親子関係を形成している。
        val handle = parent.attachChild(this)
        parentHandle = handle
        if (isCompleted) {
            handle.dispose()
            parentHandle = NonDisposableHandle // release it just in case, to aid GC
        }
    }

着目して頂きたいのは、parent.attachChild(this)の部分です。
ここで、親のJobに対して、このコルーチンに紐づくJobをattachする (取り付ける) ことで、親子構造を形成しています。

また、attachChildメソッドからはChildHandle[18]というオブジェクトが返されます。このオブジェクトは、子JobparentHandleというフィールドに保持されます。
このChildHandleとは、子コルーチンのJobから、親コルーチンのJobを参照するためのハンドラです。
後のセクションで触れますが、子コルーチンからキャンセル処理・例外処理を親コルーチンに伝える際には、このChildHandleが使用されます。

:::messages
initParentJobによって親子構造を形成する処理は、あらゆるコルーチンの基底クラスであるAbstractCoroutineから呼ばれています。そのため、ScopeCoroutineに限らず、あらゆるコルーチンの初期化時に、同様のことが行われます。

例えば、後ほどlaunchによってコルーチンが起動される際の内部実装も説明しますが、その際にも「起動元のCoroutineScopeのコルーチン」と「launchで起動された子コルーチン」の間の親子関係の形成するために、ここで説明したJobSupportinitParentJobが使われています。
:::


ここまでで、ScopeCoroutineに渡されたCoroutineContextの役割を見てきました。
その重要な役割の一つに、呼び出し元の親コルーチンと、新しく作られたJobの親子構造を形成することがあると分かりました。


Jobの親子構造の形成


続いて、もう一方の、ScopeCoroutineに渡されたContinuationの役割を見ていきましょう。

結論から述べると、CoroutineContextは「親子構造の形成」を担ったのに対し、Continuationその親子構造に依存して「子コルーチンの実行を待つ」という役割を担います。

ScopeCoroutineに渡されたContinuationの役割

ScopeCoroutineの初期化時に渡されたContinuationは、uContフィールドとして、ScopeCoroutine内に保持されます。
ScopeCoroutineのソースコード[19]から、Continuationに関わる部分を抜粋します。

internal open class ScopeCoroutine<in T>(
    context: CoroutineContext,
    @JvmField val uCont: Continuation<T> // unintercepted continuation
) : AbstractCoroutine<T>(context, true, true), CoroutineStackFrame {

    override fun afterCompletion(state: Any?) {
        uCont.intercepted().resumeCancellableWith(recoverResult(state, uCont))
    }

    override fun afterResume(state: Any?) {
        uCont.resumeWith(recoverResult(state, uCont))
    }
}

渡されたContinuationは、afterCompletionafterResumeの2箇所で使用されており、それぞれresumeCancellableWithあるいはresumeWithが呼ばれています。
これらのメソッドは、挙動に違いはあるものの、呼び出し元のContinuationに制御を戻す、すなわちcoroutineScope自体のスコープを抜けるタイミングで呼ばれる、という点では共通しています。

つまり、これらのメソッドが呼ばれるまでの経路を知ることで、Structured Concurrencyの本質である「子コルーチンの完了を待つ」ことの仕組みを理解することに繋がります。

ScopeCoroutineの起動

先述したafterCompletionあるいはafterResumeは、ScopeCoroutineの実行の最終段階で呼ばれるものと考えられます。
そこで、CoroutineScopeに紐づくScopeCoroutineの起動から完了までの流れを追うことで、どのような経路でafterCompletionあるいはafterResumeの呼び出しに繋がるのかを解明しましょう。

coroutineScopeのソースコードを、以下に再掲します。

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        // `ScopeCoroutine`の起動
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

CoroutineScopeに紐づくScopeCoroutineは、startUndispatchedOrReturnメソッドによって起動されます。

startUndispatchedOrReturnならびにstartUndispatchedのソースコードを以下に示します[20]

internal fun <T, R> ScopeCoroutine<T>.startUndispatchedOrReturn(
    receiver: R, block: suspend R.() -> T
): Any? = startUndispatched(alwaysRethrow = true, receiver, block)

private fun <T, R> ScopeCoroutine<T>.startUndispatched(
    alwaysRethrow: Boolean,
    receiver: R, block: suspend R.() -> T
): Any? {
    val result = try {
        block.startCoroutineUninterceptedOrReturn(receiver, this)
    } catch (e: DispatchException) {
        dispatchExceptionAndMakeCompleting(e)
    } catch (e: Throwable) {
        CompletedExceptionally(e)
    }

    if (result === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
    val state = makeCompletingOnce(result)
    if (state === COMPLETING_WAITING_CHILDREN) return COROUTINE_SUSPENDED
    afterCompletionUndispatched()
    return if (state is CompletedExceptionally) {
        when {
            alwaysRethrow || notOwnTimeout(state.cause) -> throw recoverStackTrace(state.cause, uCont)
            result is CompletedExceptionally -> throw recoverStackTrace(result.cause, uCont)
            else -> result
        }
    } else {
        state.unboxState()
    }
}

まず、、引数として渡されたblock (これはcoroutineScopeの内部の処理) を、実行スレッド上でそのまま実行しています。その実行結果 (result変数) に応じて、異なるハンドリングを行っています。

blockの実行結果は、3パターンに分かれます。それぞれどのようにハンドリングされるかを見ていきます。

パターン1. coroutineScopeに渡されたblockが中断された場合

パターン1に相当するコードを示します。

coroutineScope {
    delay(100) // suspension point: ここで実行が一時中断される。
    launch { delay(1000) }
}

このようにcoroutineScope内にsuspension pointが存在する場合 (ここではsuspend関数であるdelayが存在する)、ここで処理が中断されます。

この場合、blockの実行は中断されているため、resultCOROUTINE_SUSPENDEDとなります。その結果、startUndispatchedでは、coroutineScopeblockを実行するのみで早期returnすることとなります。

    val result = try {
        block.startCoroutineUninterceptedOrReturn(receiver, this)
    } catch (e: DispatchException) {
        dispatchExceptionAndMakeCompleting(e)
    } catch (e: Throwable) {
        CompletedExceptionally(e)
    }

    if (result === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED

その後、coroutineScopeblockが完了したタイミングで、coroutineScopeに紐づくコルーチン (ScopeCoroutine) に対して、非同期的にresumeWithが呼ばれます。

resumeWithのソースコードを以下に示します[21]。これは、基底クラスのAbstractCoroutineに存在します。

    public final override fun resumeWith(result: Result<T>) {
        val state = makeCompletingOnce(result.toState())
        if (state === COMPLETING_WAITING_CHILDREN) return
        afterResume(state)
    }

ここで登場しているmakeCompletingOnceは、コルーチンの完了処理を進めるためのメソッドです。パターン2でもmakeCompletingOnceは利用されるため、後ほど詳細に解説します。

makeCompletingOnceの呼び出し後の流れは2パターンに分かれます。

coroutineScope内のblockが完了した際に、未完了の子コルーチンが残っている場合:
makeCompletingOnceでコルーチンの完了処理を進めた結果、まだ未完了の子コルーチンが存在する場合 (COMPLETING_WAITING_CHILDRENが返された場合) は、子コルーチンの完了を待機するために、何もせずreturnします。
後ほど説明しますが、こちらの場合には、全ての子コルーチンが完了した時点でafterCompletionが呼ばれます。

coroutineScope内のblockが完了した際に、未完了の子コルーチンがない場合:
makeCompletingOnceでコルーチンが完了した場合 (= 未完了の子コルーチンが存在しない場合) には、afterResumeを呼び出します
ScopeCoroutineafterResumeが呼ばれ、CoroutineScopeのスコープを抜けることとなります。

言葉だけでは難しいので、サンプルコードを図示して説明します。これはmakeCompletingOnceの後に、未完了のコルーチンが存在するパターンに相当します。

coroutineScope {
    delay(100) // suspension point: ここで実行が一時中断される。
    launch { delay(1000) }
}


パターン1の流れ: coroutineScopeblock終了時に、未完了の子コルーチンが存在するケース

一方、以下のようなサンプルコードは、coroutineScopeblock終了時に未完了の子コルーチンが存在しないパターンに相当します。

coroutineScope {
    launch { delay(100) }
    delay(1000) // delayの間にlaunchされた子コルーチンが完了する。
}


パターン1の流れ: coroutineScopeblock終了時に、未完了の子コルーチンが存在しないケース

いずれのケースにおいても、「子コルーチンが完了するまで、親コルーチンが完了しない」というStructured Concurrencyの原則が満たされていることが分かります。

パターン2. coroutineScopeに渡されたblockは完了するものの、未完了の子コルーチンが存在する場合

パターン2に相当するコードを、以下に示します。

coroutineScope {
    launch { delay(1000) } // `block`内の処理が終わった直後は未完了
}

launch自体はsuspend関数ではないのでblock内のコードは中断されません。
しかし、block内の処理が完了した時点では、launchによって起動された子コルーチンが未完了です。

以下にstartUndispatchedのコードを抜粋します。

    val result = try {
        block.startCoroutineUninterceptedOrReturn(receiver, this)
    } catch (e: DispatchException) {
        dispatchExceptionAndMakeCompleting(e)
    } catch (e: Throwable) {
        CompletedExceptionally(e)
    }

    if (result === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
    val state = makeCompletingOnce(result)
    if (state === COMPLETING_WAITING_CHILDREN) return COROUTINE_SUSPENDED

パターン2では、coroutineScope内のblock自体は、中断することなく完了します。
そのため、resultにはCOROUTINE_SUSPENDEDではなく実行結果が入り、一つ目のif文は通過します。そして、先ほども登場したmakeCompletingOnceが呼ばれます。

makeCompletingOnceはコルーチンの完了処理を進めるためのメソッドです。
パターン2は、makeCompletingOnceを実行した結果、COMPLETING_WAITING_CHILDRENが返される、すなわち未完了の子コルーチンが残っている場合です (一方で、残っていない場合は、後述するパターン3となります)。

makeCompletingOnceが実行されると、全ての子コルーチンが完了したタイミングで、ScopeCoroutineに対してafterCompletionが呼ばれます
これによって、coroutineScopeの呼び出し元のContinuationresumewWithが呼ばれ、coroutineScopeのスコープを抜けることとなります。

以下のコードを例に、パターン2における処理の流れを図示します。

coroutineScope {
    launch { delay(1000) } // `block`内の処理が終わった直後は未完了
}


パターン2の処理の流れ

パターン3. coroutineScopeに渡されたblockが完了し、未完了の子コルーチンも存在しない場合

パターン3に相当するコードを、以下に示します。

coroutineScope {
    println("Hello, World!")
}

このケースは、coroutineScopeに渡されたblockが中断せず、かつblockの完了時点で未完了の子コルーチンも存在しないケースです。実際の使い方で、このようなケースが生じることは稀かもしれません。

このパターンでは、blockが同期的に完了し、即座にcoroutineScopeのスコープを抜けることになります。


パターン3の処理の流れ

makeCompletingOnceの内部実装

パターン1・パターン2で使用されていた、makeCompletingOnceの内部実装を解説します。

makeCompletingOnceは、コルーチンの完了を進めるためのメソッドです。
makeCompletingOncetryMakeCompletingメソッドを呼び、子コルーチンが未完了な場合には、そこから更にtryMakeCompletingSlowPathメソッドが呼ばれます。

tryMakeCompletingSlowPathから、特に重要な部分を抜粋して以下に示します[22]

  private fun tryMakeCompletingSlowPath(state: Incomplete, proposedUpdate: Any?): Any? {
        // 子コルーチンの一覧を取得する。
        val list = getOrPromoteCancellingList(state) ?: return

        // 子コルーチンを1つ取得する。
        val child = list.nextChild()
        // 未完了のコルーチンが存在する場合には、`COMPLETING_WAITING_CHILDREN`を返す。
        if (child != null && tryWaitForChild(finishing, child, proposedUpdate))
            return COMPLETING_WAITING_CHILDREN

        return finalizeFinishingState(finishing, proposedUpdate)
    }

子コルーチンに対してtryWaitForChildメソッドを呼び出すことで、子コルーチンが完了するまで待機しています。

tryWaitForChildのソースコードを以下に示します[23]

    private tailrec fun tryWaitForChild(state: Finishing, child: ChildHandleNode, proposedUpdate: Any?): Boolean {
        val handle = child.childJob.invokeOnCompletion(
            invokeImmediately = false,
            handler = ChildCompletion(this, state, child, proposedUpdate)
        )
        // 子コルーチンが未完了であれば、returnして完了を待つ。
        if (handle !== NonDisposableHandle) return true
        // 他に子コルーチンがあれば、それに対して再帰的に`tryWaitForChild`を呼び出す。
        val nextChild = child.nextChild() ?: return false
        return tryWaitForChild(state, nextChild, proposedUpdate)
    }

子コルーチンのJobに対して、invokeOnCompletionメソッドが呼ばれています。
これはコールバック関数に等しく、子コルーチンの完了時に、ハンドラ (ChildCompletion) が呼ばれるようになります。

また、この子コルーチン自体はすでに完了している場合には、別の子コルーチンに対してtryWaitForChildを再帰的に呼び出しています。
これにより、未完了の子コルーチンが一つでも存在する場合には、tryWaitForChildメソッドはtrueを返すことになります。

続いて、子コルーチンに渡されているハンドラである、ChildCompletionのソースコードを以下に示します[24]

    private class ChildCompletion(
        private val parent: JobSupport,
        private val state: Finishing,
        private val child: ChildHandleNode,
        private val proposedUpdate: Any?
    ) : JobNode() {
        override val onCancelling get() = false
        override fun invoke(cause: Throwable?) {
            parent.continueCompleting(state, child, proposedUpdate)
        }
    }

子コルーチンの完了時に、continueCompletingが呼ばれることが分かります。
そこで、continueCompletingメソッドのソースコードから、特に重要な部分を抜粋して、以下に示します。[25]

    private fun continueCompleting(state: Finishing, lastChild: ChildHandleNode, proposedUpdate: Any?) {
        val waitChild = lastChild.nextChild()
        if (waitChild != null && tryWaitForChild(state, waitChild, proposedUpdate)) return

        // 全ての子コルーチンが完了している場合、`afterCompletion`を呼び出す。
        val finalState = finalizeFinishingState(state, proposedUpdate)
        afterCompletion(finalState)
    }

tryMakeCompletingSlowPathと同様に、tryWaitForChildが呼ばれています。
また、tryWaitForChildがfalseを返した場合、すなわち全ての子コルーチンが完了している場合には、afterCompletionが呼ばれます
ScopeCoroutineafterCompletionが呼ばれることで、coroutineScopeの呼び出し元のContinuationに対してresumeCancellableWithが呼ばれ、coroutineScopeのスコープから抜けることになります。

makeCompletingOnceが呼ばれてからの流れを、図に整理します。


makeCompletingOnceが呼ばれてからの流れ

全ての子コルーチンが完了するまで、「tryWaitForChild -> ハンドラを子コルーチンのJobに登録 -> 子コルーチンの完了時にcontinueCompletionが呼ばれる -> tryWaitForChild」というループが継続することが分かります。

以上のようにして、CoroutineScopeは「全ての子コルーチンの完了を待つ」というStructured Concurrencyの原則を実現しています。

launchCoroutineScopeとの親子構造を形成する仕組み

CoroutineScopeが、内部で起動された子コルーチンの完了を待つには、子コルーチンとの親子構造が形成される必要があります。
そこで、coroutineScopeの内部でコルーチンをlaunchした際に、「CoroutineScopeのコルーチン」と「launchされた子コルーチン」の間に親子構造が形成される仕組みを確認します。

launchのソースコードを以下に示します[26]

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
}

Jobの親子構造の形成には、最初の2つのステップが関わっています。

  1. newCoroutineContextの作成
  2. StandaloneCoroutineの作成

それぞれの実装を追っていきます。

newCoroutineContextの作成

newCoroutineContextはプラットフォームごとに実装が定義されています。
ここではJVM版のソースコードを示します[27]

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
}

重要なのは、1行目のfoldCopies(coroutineContext, context, true)です。
ここで、親であるCoroutineScopeの持つCoroutineContextと、launchのパラメータとして渡されたCoroutineContextをマージしています。
この結果として、newCoroutineContextが返すCoroutineContextには、(基本的には) 親のJobが含まれることになります。

StandaloneCoroutineの作成

続いて、launch内の以下の部分で、StandaloneCoroutineあるいはLazyStandaloneCoroutineが作られます。

    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)

StandaloneCoroutineあるいはLazyStandaloneCoroutineのいずれになるかは、コルーチンの起動直後の状態を左右します。後者は、明示的にstartされるまでコルーチンが起動しません。
ただ、今回調査しているJobの親子構造の形成プロセスにおいては、両者の基底クラスであるAbstractCoroutineが関係しているため、いずれであっても違いはありません。

StandaloneCoroutineあるいはLazyStandaloneCoroutineの初期化パラメータとして、前ステップで作成されたnewContext (呼び出し元のCoroutineContextlaunchのパラメータのCoroutineContextをマージしたもの) が渡されています。
これにより、基底クラスのAbstractCoroutineへと、このCoroutineContextが渡されることになります。

AbstractCoroutineの初期化時の流れに関しては、既にScopeCoroutineのパートでも触れました。再びAbstractCoroutineのソースコードを抜粋します。

public abstract class AbstractCoroutine<in T>(
    parentContext: CoroutineContext,
    initParentJob: Boolean,
    active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
    init {
        if (initParentJob) initParentJob(parentContext[Job])
    }

init内のinitParentJobメソッドにより、「呼び出し元のコルーチンのJob」と「launchされたコルーチンのJob」の間に親子構造が形成されます。


ここまでは、子コルーチンが正常に完了することを想定して、どのように親のCoroutineScopeが子コルーチンの完了を待つか、を追ってきました。

一方で、子コルーチンが完了に至らない、すなわち途中でキャンセルされるケースも存在します。
そういったケースにおいても、「子コルーチンが全て完了するまで、親コルーチンが完了しない」というStructured Concurrencyの原則は守られます。そこで次のセクションでは、キャンセル処理に関して解説します。

キャンセル処理でStructured Concurrencyが守られる仕組み

コルーチンのキャンセルには、「正常なキャンセル」と「(CancellationException以外の) 例外発生による失敗」の2種類が存在します。
ただ、いずれのパターンでも、キャンセルの伝播に共通の機構を用いているため、併せて解説していきます。

キャンセル・例外処理の基本的な仕様

まずは簡単に、Kotlin Coroutinesにおけるキャンセル・例外処理の基本的な仕様をおさらいします。

1つ目の「正常なキャンセル」においては、あるコルーチンを正常にキャンセルすると、その子孫のコルーチンも正常にキャンセルされます。一方、親コルーチンへは、キャンセルが伝播されません。


正常なキャンセルの伝播

Jobに対してcancelメソッドが明示的に呼ばれた場合、あるいはコルーチン内でCancellationExceptionが投げられた場合などに、正常なキャンセルが開始されます。


一方で、「例外発生による失敗」においては、あるコルーチンが失敗すると、その子孫のコルーチンだけでなく、その親のコルーチン (そしてその子孫のコルーチン) もキャンセルされます。
コルーチン内でCancellationException以外の例外が投げられた場合には、失敗の伝播が開始されます。


例外発生による失敗の伝播


いずれのケースにおいても、子孫のコルーチンのキャンセルが完了してから、親コルーチンのキャンセルも完了します。
この仕様により、キャンセル時のコルーチンの生存期間も、Structured Concurrencyに従うこととなります。

例外が投げられた場合のキャンセル伝播の流れ

launch内で例外が発生すると、launchで起動されたコルーチンに紐づくContinuationに対して、resumeWith(Exception)が呼び出され、launchが終了することとなります。

resumeWithが呼ばれると、以下のような流れで、最終的にJobSupporttryMakeCompletingSlowPathメソッドが呼ばれます。

  1. resumeWithからJobSupport.makeCompletingOnceが呼ばれる。
  2. JobSupport.makeCompletingOnceからJobSupport.tryMakeCompletingが呼ばれる。
  3. JobSupport.tryMakeCompletingからJobSupport.tryMakeCompletingSlowPathが呼ばれる。

JobSupporttryMakeCompletingSlowPathから、例外処理に関連するコードを抜粋して、以下に示します[28]

    private fun tryMakeCompletingSlowPath(state: Incomplete, proposedUpdate: Any?): Any? {
        // `list`は子コルーチンの一覧
        val list = getOrPromoteCancellingList(state) ?: return 
        val finishing = state as? Finishing ?: Finishing(list, false, null)
        val notifyRootCause: Throwable?
        synchronized(finishing) {
            val wasCancelling = finishing.isCancelling
            (proposedUpdate as? CompletedExceptionally)?.let { finishing.addExceptionLocked(it.cause) }
            // まだキャンセル中でない場合、キャンセル処理を開始する。
            // 失敗の原因となった例外を`notifyRootCause`にセットする。
            notifyRootCause = finishing.rootCause.takeIf { !wasCancelling }
        }
        // 例外によって終了していた場合、子コルーチンにキャンセルを伝播する。
        notifyRootCause?.let { notifyCancelling(list, it) }
        // `tryWaitForChild`を呼び出し、全ての子コルーチンが実行完了あるいはキャンセル完了するまで待機する。
        val child = list.nextChild()
        if (child != null && tryWaitForChild(finishing, child, proposedUpdate))
            return COMPLETING_WAITING_CHILDREN
        return finalizeFinishingState(finishing, proposedUpdate)
    }

tryMakeCompletingSlowPath内で、以下のような流れで例外処理が行われます。

  1. 子コルーチンの一覧をlist変数にセットする。
  2. キャンセル・例外発生によって終了していた場合、その例外をnotifyRootCauseにセットする。
  3. キャンセル・例外発生によって終了していた場合、子コルーチンにキャンセルを伝播する。
    具体的には、listnotifyRootCauseを引数として、notifyCancellingが呼ばれる。
  4. tryWaitForChildメソッドにより、全ての子コルーチンが完了 (正常終了もしくはキャンセル完了) するまで待機する。

4つ目のステップで利用されているtryWaitForChildは、子コルーチンが正常終了する際にも使われていたものです。
例外処理においても、正常系と同様の仕組みで、子コルーチンの完了が待機されていることが分かります。


続いて、notifyCancellingメソッドのソースコード[29]を以下に示します。

    private fun notifyCancelling(list: NodeList, cause: Throwable) {
        // first cancel our own children
        onCancelling(cause)
        list.close(LIST_CANCELLATION_PERMISSION)
        notifyHandlers(list, cause) { it.onCancelling }
        // then cancel parent
        cancelParent(cause) // tentative cancellation -- does not matter if there is no parent
    }

notifyCancelling内で行われていることとして、以下の2つが特に重要です。

  1. notifyHandlersメソッドによって、子コルーチンにキャンセルを伝播する。
  2. cancelParentメソッドによって、親コルーチンにキャンセルを伝播する。

子コルーチンへのキャンセルの伝播

notifyHandlersメソッドのソースコード[30]を以下に示します。

    private inline fun notifyHandlers(list: NodeList, cause: Throwable?, predicate: (JobNode) -> Boolean) {
        var exception: Throwable? = null
        list.forEach { node ->
            if (node is JobNode && predicate(node)) {
                try {
                    node.invoke(cause)
                } catch (ex: Throwable) {
                    exception?.apply { addSuppressed(ex) } ?: run {
                        exception = CompletionHandlerException("Exception in completion handler $node for $this", ex)
                    }
                }
            }
        }
        exception?.let { handleOnCompletionException(it) }
    }

node.invoke(cause)の部分で、ChildHandleNodeinvokeメソッドが呼ばれています。
ChildHandleNodeのソースコード[31]を見ると、invokeメソッドでは、ChildJobに対してparentCancelledが呼ばれています

private class ChildHandleNode(
    @JvmField val childJob: ChildJob
) : JobNode(), ChildHandle {
    override val parent: Job get() = job
    override val onCancelling: Boolean get() = true
    override fun invoke(cause: Throwable?) = childJob.parentCancelled(job)
    override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}

JobSupportにあるparentCancelledメソッドの実装を、以下に示します[32]

    public final override fun parentCancelled(parentJob: ParentJob) {
        cancelImpl(parentJob)
    }

cancelImplメソッドが呼ばれています。詳細な解説は省略しますが、cancelImplメソッドを通じて、再び「そのコルーチンのキャンセル」と「親・子コルーチンへのキャンセル伝播」が行われます。
このようにして、子孫コルーチンへとキャンセルが伝播していきます。

親コルーチンへのキャンセルの伝播

続いて、notifyHandlers (子コルーチンへのキャンセル伝播) の後に呼ばれる、cancelParentのソースコード[33]を以下に示します。

    private fun cancelParent(cause: Throwable): Boolean {
        if (isScopedCoroutine) return true

        val isCancellation = cause is CancellationException
        val parent = parentHandle
        if (parent === null || parent === NonDisposableHandle) {
            return isCancellation
        }

        return parent.childCancelled(cause) || isCancellation
    }

メソッド名からも明らかなように、これは親コルーチンをキャンセルするためのメソッドです。
重要なのはparent.childCancelledの部分で、ここで親コルーチンに対してキャンセルが伝播されます。
JobSupport内にあるchildCancelledのソースコードを以下に示します[34]

    public open fun childCancelled(cause: Throwable): Boolean {
        if (cause is CancellationException) return true
        return cancelImpl(cause) && handlesException
    }

まず、原因がCancellationExceptionだった場合には、親のキャンセルがスキップされています。これによって「正常なキャンセルだった場合、子孫コルーチンのみに伝播する」という仕様が実現されています。

一方、原因がCancellationException以外の例外だった場合には、親コルーチンに対して、cancelImplが呼び出されます
cancelImplは子コルーチンへのキャンセル伝播の仕組みを追った際にも登場しました。
これは「そのコルーチンのキャンセル」と「親・子孫コルーチンへのキャンセル伝播」を行うメソッドです。
このようなメカニズムで、CancellationException以外の例外発生時には、親コルーチンもキャンセルされることになります。

supervisorScopeを使うとなぜキャンセルが伝播しないのか

coroutineScopeの代わりにsupervisorScopeを利用すると、CancellationException以外の例外が発生した場合でも、そのコルーチンの親・子孫に対してキャンセルが伝播しません


supervisorScope利用時の失敗の伝播

supervisorScopeを使ったコードの例を、以下に示します。

▶️ Kotlin Playgroundで試す

import kotlinx.coroutines.*

suspend fun main() {
    supervisorScope {
        launch { // Launch Coroutine A.
            throw Exception("Some error message.")
        }
        .invokeOnCompletion { cause -> println("Completed Child Coroutine A, cause: $cause") }
        launch { // Launch Coroutine B.
            delay(100)
        }
        .invokeOnCompletion { cause -> println("Completed Child Coroutine B, cause: $cause") }
    }
    println("supervisorScope completed.")
}

出力:

Exception in thread "DefaultDispatcher-worker-2 @coroutine#1"
Completed Child Coroutine A, cause: java.lang.Exception: Some error message.
Completed Child Coroutine B, cause: null
supervisorScope completed.

launchされたコルーチンで例外が発生しても、親であるsupervisorScopeや、同じsupervisorScope内でlaunchされた他のコルーチンはキャンセルされません (試しにcoroutineScopeに変えてみると、違いがよく分かるかと思います)。

supervisorScopeのソースコード[35]から、上記の挙動となる理由が分かります。

public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = SupervisorCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

coroutineScopeと異なり、ScopeCoroutineの代わりにSupervisorCoroutineが起動されています。
SupervisorCoroutineのソースコード[36]は以下の通りで、ScopeCoroutineを継承しています。

private class SupervisorCoroutine<in T>(
    context: CoroutineContext,
    uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

SupervisorCoroutineでは、childCancelledがオーバーライドされており、デフォルトの挙動と異なりキャンセル処理を行わない (cancelImplを呼ばない) ようになっています。
そのため、子コルーチンのcancelParentメソッドからchildCancelledが呼ばれても、supervisorScopeに紐づくコルーチンがキャンセルされません。


以上、キャンセルがどのように親・子孫コルーチンへと伝播されるのか、そしてどのように子コルーチンのキャンセル完了を待つのかを、ソースコードから明らかにしました。
なお、実際の例外処理のソースコードはもっと複雑で、説明しきれていない部分も多いことをご承知おきください。

まとめ

本記事では、Structured Concurrencyとは何か、そしてKotlin CoroutinesにおいてどのようにStructured Concurrencyが実現されているのかを解説しました。

私自身、CoroutineScopeが内部のコルーチンの完了を待つ仕組みなど、「なぜこう動くのか分からない」という部分が多くありました。
ただ、今回の調査を通じて、そういった「ブラックボックス」に光を当てたことで、より自信を持ってKotlin Coroutinesの実装やデバッグ、レビューができるようになったと思っています。

読者の皆様にも、同じような価値を提供できれば嬉しいです。また、同じくKotlin Coroutines自体のソースコードを読んでみたいという方への指針になればと思います。

脚注
  1. Coroutines: https://kotlinlang.org/docs/coroutines-overview.html ↩︎

  2. Kotlin Coroutinesの核心:Builder・CoroutineScope・Job・CoroutineContextの関係: https://zenn.dev/kaseken/articles/99d92a128cbc9a ↩︎

  3. sustrik: https://github.com/sustrik ↩︎

  4. JEP 505 Structured Concurrency: https://openjdk.org/jeps/505 ↩︎

  5. libdill: https://github.com/sustrik/libdill ↩︎

  6. Explanation of Structured Concurrency in libdill documentation: https://libdill.org/structured-concurrency.html ↩︎

  7. njsmith: https://github.com/njsmith ↩︎

  8. Trio: https://github.com/python-trio/trio ↩︎

  9. Notes on structured concurrency, or: Go statement considered harmful: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ ↩︎

  10. Edsger W. Dijkstra: https://en.wikipedia.org/wiki/Edsger_W._Dijkstra ↩︎

  11. 構造化プログラミング: https://ja.wikipedia.org/wiki/構造化プログラミング ↩︎

  12. The reason to avoid GlobalScope: https://elizarov.medium.com/the-reason-to-avoid-globalscope-835337445abc ↩︎

  13. coroutineScopeのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/CoroutineScope.kt#L280 ↩︎

  14. 内部実装から理解するKotlin Coroutines:suspend関数・Continuation編: https://zenn.dev/kaseken/articles/a50fd3f5e6e2ba ↩︎

  15. ScopeCoroutineのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/internal/Scopes.kt#L11 ↩︎

  16. AbstractCoroutineのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/AbstractCoroutine.kt#L36 ↩︎

  17. JobSupportinitParentJobメソッドのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L141 ↩︎

  18. ChildHandleのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/Job.kt#L461 ↩︎

  19. ScopeCoroutineのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/internal/Scopes.kt#L11 ↩︎

  20. startUndispatchedOrReturnのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/intrinsics/Undispatched.kt#L41 ↩︎

  21. AbstractCoroutineresumeWithのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/AbstractCoroutine.kt#L98 ↩︎

  22. tryMakeCompletingSlowPathのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L902 ↩︎

  23. tryWaitForChildのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L954 ↩︎

  24. ChildCompletionのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L1260 ↩︎

  25. continueCompletingメソッドのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L965 ↩︎

  26. launchのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/Builders.common.kt#L44 ↩︎

  27. newCoroutineContextのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/jvm/src/CoroutineContext.kt#L14 ↩︎

  28. tryMakeCompletingSlowPathのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L902 ↩︎

  29. notifyCancellingメソッドのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L320 ↩︎

  30. notifyHandlersメソッドのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L360 ↩︎

  31. ChildHandleNodeのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L1575 ↩︎

  32. parentCancelledのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L667 ↩︎

  33. cancelParentのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L336 ↩︎

  34. childCancelledのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/JobSupport.kt#L680 ↩︎

  35. supervisorScopeのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/Supervisor.kt#L50 ↩︎

  36. SupervisorCoroutineのソースコード: https://github.com/Kotlin/kotlinx.coroutines/blob/f4f519b36734238ec686dfaec1e174086691781e/kotlinx-coroutines-core/common/src/Supervisor.kt#L64 ↩︎

Discussion