LoginSignup
0
0

More than 1 year has passed since last update.

コルーチンとチャネル入門#6 構造化された並列性

Last updated at Posted at 2022-06-16

ソース記事はこちら
コルーチンスコープは、異なるコルーチンの間の構造と親子関係の責務を担っている。新しいコルーチンは常にスコープの内側で開始する。コルーチンコンテキストには、コルーチンのカスタム名や、コルーチンがスケジュールされるスレッドを指定するディスパッチャーのような、与えられたコルーチンを実行するために使う追加の技術情報が格納されている。
launchasyncrunBlockingが新しいコルーチンを開始するために使われると、対応するスコープが作られる。これらすべての関数は、引数としてレシーバーを持つラムダを取り、暗黙的なレシーバ型はCoroutineScopeである。

launch { /* this: CoroutineScope */
}

新しいコルーチンは、スコープの内側でのみ開始することができる。launchasyncCoroutineScopeに対する拡張として宣言されており、そのため呼び出すときは、暗黙あるいは明示的なレシーバーが常に渡されるはずである。runBlockingによって開始されたコルーチンは例外で、runBlockingはトップレベル関数として定義されている。しかしそれは現在のスレッドをブロックするため、主にmain関数や、テストの中で、仲立ちをする関数として使われることを目的としている。
runBlockinglaunchasyncの内側で新しいコルーチンを開始するとき、それはスコープの内側で開始する。

import kotlinx.coroutines.*

fun main() = runBlocking { /* this: CoroutineScope */
    launch { /* ... */ }
    // これと同じ    
    this.launch { /* ... */ }
}

runBlockingの内側でlaunchを呼ぶと、CoroutineScope型の暗黙のレシーバーに対する拡張として呼び出している。あるいは明示的にthis.launchと書くことができるだろう。
ネストされたコルーチン(この例ではlaunchで始まる)は、外側のコルーチン(runBlockingで始まる)の子であるといえる。親子の関係はスコープを通して動作する。つまり子のコルーチンは親のコルーチンに対応するスコープから開始する。
新しいコルーチンを開始することなく、新しいスコープを作ることは可能である。coroutineScope関数はこれを行う。suspend関数の内側、例えばloadContributorsConcurrentの内側で、外側のスコープにアクセスすることなく、構造化された方法で新しいコルーチンを開始する必要があるとき、自動的にこのsuspend関数が呼び出される元の外側のスコープの子となる、新しいコルーチンスコープを作ることができる。
GlobalScope.asyncGlobalScope.launchを使うことで、グローバルスコープから新しいコルーチンを開始することも可能である。これはトップレベルの独立したコルーチンを作成する。
コルーチンの構造を提供するメカニズムは「構造化された並列性」と呼ばれる。構造化された並列性が持つグローバルスコープを超えるメリットを見ていこう。

  • スコープは、一般的に子のコルーチンに対して責任があり、子の一生はスコープの一生に所属している。
  • スコープは何かうまくいかなかったり、ユーザーが考えを変えて操作を取り消すことを決めた場合に、スコープは自動的に子のコルーチンをキャンセルできる。
  • スコープは自動的に子のコルーチンの完了を待つ。したがってもしスコープがコルーチンと対応するなら、親のコルーチンはそのスコープで起動したすべてのコルーチンが完了するまで完了しない。

GlobalScope.asyncを使うとき、いくつかのコルーチンをより小さなスコープに結びつける構造は存在しない。グローバルスコープから開始したコルーチンは、すべて独立している。つまりその一生はアプリケーション全体の一生によってのみ、限定されている。グローバルスコープから開始したコルーチンへの参照を保存し、その完了を待つか、明示的にキャンセルすることは可能だが、構造化されたコルーチンのように、自動的に発生されることはできない。

コントリビューターのロードのキャンセル

loadContributorsConcurrentの2つのバージョンを比較しよう。ひとつはすべての子のコルーチンを開始するcoroutineScopeを使うものと、GloablScopeを使うもの。両方のバージョンで、親のコルーチンをキャンセルするときにどのようにふるまうかを比較しよう。
要求を送るすべてのコルーチンがに、3秒のディレイを追加する。そうすればコルーチンが開始したが、要求を送る前にロード処理をキャンセルする十分な時間ができる。

suspend fun loadContributorsConcurrent(service: GitHubService, req: RequestData): List<User> = coroutineScope {
    // ... 
    async {
        log("starting loading for ${repo.name}")
        delay(3000)
        // repoのコントリビューターをロードする
    }
    // ...
    result // ※実際はresultではなくdeferreds.awaitAll().flatten().aggregate()
}

loadContributorsConcurrentの実装を(Request5NotCancellable.kt内の)loadContributorsNotCancellableにコピーし、新しいcoroutineScopeの生成を削除する。async呼び出しが解決に失敗するため、それらをGlobalScope.async経由で開始する必要がある。

suspend fun loadContributorsNotCancellable(service: GitHubService, req: RequestData): List<User> {   // #1
    // ... 
    GlobalScope.async {   // #2
        log("starting loading for ${repo.name}")
        delay(3000)
        //  repoのコントリビューターをロードする
    }
    // ...
    return result  // #3  ※実際はresultではなくdeferreds.awaitAll().flatten().aggregate()
}

この関数は、現在はラムダの内側の最後の式としてではなく、結果を直接返却している(#1#3の行)。そして「コントリビューター」の全コルーチンはGlobalScopeの内側で開始している。(#2の行)
プログラムを実行し、CONCURRENTバージョン経由でコントリビューターのロードを選択することで開始することができ、「コントリビューター」の全コルーチンが開始するまで待つ必要があり、その後「キャンセル」をクリックする。ログを見ると、結果がログされていないので、すべて要求がまさにキャンセルされることがわかる。

2896 [AWT-EventQueue-0 @coroutine#1] INFO Contributors - kotlin: loaded 40 repos
2901 [DefaultDispatcher-worker-2 @coroutine#4] INFO Contributors - starting loading for kotlin-koans
...
2909 [DefaultDispatcher-worker-5 @coroutine#36] INFO Contributors - starting loading for mpp-example
/* キャンセルをクリック */
/* 要求は送られていない */

それでは、同じ手順を、今度はNOT_CANCELLABLEオプションを選んで繰り返そう。

2570 [AWT-EventQueue-0 @coroutine#1] INFO Contributors - kotlin: loaded 30 repos
2579 [DefaultDispatcher-worker-1 @coroutine#4] INFO Contributors - starting loading for kotlin-koans
...
2586 [DefaultDispatcher-worker-6 @coroutine#36] INFO Contributors - starting loading for mpp-example
/* キャンセルをクリック */
/* しかし、すべての要求はまだ送られている */
6402 [DefaultDispatcher-worker-5 @coroutine#4] INFO Contributors - kotlin-koans: loaded 45 contributors
...
9555 [DefaultDispatcher-worker-8 @coroutine#36] INFO Contributors - mpp-example: loaded 8 contributors

何も起こらない!どのコルーチンもキャンセルされず、すべての要求は送られている。
キャンセル処理が「コントリビューター」のプログラムでどのように実装されているか、見てみよう。cancelボタンがクリックされると、メインの「ローディング」コルーチンを明示的にキャンセルする必要がある。その後、自動的に子のコルーチンがキャンセルされる。
それがボタンのクリックで「ローディング」コルーチンをキャンセルできる方法である。

interface Contributors {

    fun loadContributors() {
        // ...
        when (getSelectedVariant()) {
            CONCURRENT -> {
                launch {
                    val users = loadContributorsConcurrent(req)
                    updateResults(users, startTime)
                }.setUpCancellation()      // #1
            }
        }
    }

    private fun Job.setUpCancellation() {
        val loadingJob = this              // #2

        // キャンセルボタンがクリックされた場合は、ローディングジョブをキャンセルする
        val listener = ActionListener {
            loadingJob.cancel()            // #3
            updateLoadingStatus(CANCELED)
        }
        addCancelListener(listener)

        // ローディングジョブが完了したら、ステータスを更新し、リスナーを削除する
    }
}

launch関数はJobインスタンスを返却する。Jobには「ローディングコルーチン」への参照が格納されており、それはすべてのデータをロードし、結果を更新する。そのsetUpCancellation()拡張関数を呼び出し(#1の行)、レシーバーとしてJobのインスタンスを渡している。これを表現する別の方法は、明示的に書くには次のようになる。

val job = launch { ... }
job.setUpCancellation()

読みやすさのために、setUpCancellation関数の内部で、loadingJob変数経由でレシーバーを参照している。(#2の行)その後、cancelボタンにリスナーを追加し、そのため、それがクリックされると、loadingJobがキャンセルされる。(#3の行)
構造化された並列性により、親のコルーチンをキャンセルするだけで、自動的にすべての子のコルーチンにキャンセルが伝播される。

外側のスコープのコンテキストの使用

与えられたスコープの内部で新しいコルーチンが開始されると、そのすべては同じコンテキストで動作することを確認するのがはるかに簡単になる。もし必要であれば、コンテキストを置き換えるのもはるかに簡単である。
それでは前のセクションの末尾の質問に戻ろう。「外側のスコープからディスパッチャーを使うこと」は、まさにどのように動作するのか?(あるいは、より具体的には、「外側のスコープのコンテキストからディスパッチャーを使うこと」である)
新しいスコープは、coroutineScopeかコルーチンビルダーで作成される、常に外側のスコープのコンテキストを継承する。このケースだと、外側のスコープはsuspend loadContributorsConcurrentの呼び出し元のスコープである。

launch(Dispatchers.Default) {  // outer scope
    val users = loadContributorsConcurrent(service, req)
    // ...
}

すべてのネストされたコルーチンは、自動的に継承されたコンテキストで開始し、ディスパッチャーはそのコンテキストの一部である。それが、asyncにより開始されたすべてのコルーチンが、デフォルトディスパッチャーのコンテキストで開始する理由である。

suspend fun loadContributorsConcurrent(
    service: GitHubService, req: RequestData
): List<User> = coroutineScope {
    // このスコープは、外側のスコープからのコンテキストを継承している 
    // ... 
    async {   // ネストされたコルーチンは、継承したコンテキストとともに開始する
        // ...
    }
    // ...
}

構造化された並列性により、トップレベルのコルーチンを作るときに一度(ディスパッチャーのような)主なコンテキスト要素を指定することができる。すべてのネストされたコルーチンはその後、コンテキストを継承し、必要な場合のみそれを修正する。
留意すべきは、例えばAndroidのようなUIアプリケーションのためにコルーチンを使ってコードを書くとき、トップのコルーチン用にデフォルトで、CoroutineDispatchers.Mainを使用し、その後、異なるスレッドで動作することが必要な時に、異なるディスパッチャーを明示的に配置するのが、一般的な方法だということである。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0