(2020年9月22日) この記事は Zenn でも公開しています。
Kotlin 標準の並行プログラミング API である coroutine を理解1したのでまとめました。
本家のガイド は包括的で(上から読めば)丁寧に書いてあるのですが、実際に自分が読んだ際には理解に結構苦労したので、少し別のアプローチでの入門になります。
(はじめにお断り: Coroutine と Promise, async/await, Rx, etc…)
並行処理の API といえば、ここ数年だと「Promise」や「Rx」のような計算結果を表すオブジェクトを then()
や map()
などでつなげていくスタイルか、「async/await」などと呼ばれるような計算結果オブジェクトを馴染みやすい形で書くスタイルが多いと思います。
coroutine の基本を学習する際には、一旦これらは忘れてもらった方が良いです。 なんとなく async/await っぽい位置づけと考えている方もいるかもしれません(筆者もそう思っていた一人です)。馴染みやすい書き方という意味では似ているのですが、背後の考えは結構違います。
Coroutine と suspend 関数
簡単な例
以下、簡単な使用例として通信処理を伴う処理を考えます。同期的に呼び出すとこうなると思います。
// メインスレッドで実行
fun runMain() {
val data = Weathers.tomorrow()
println("明日の天気: ${data}")
}
fun Weathers.tomorrow(): String {
Thread.sleep(2000) // 長い処理を表すための仮実装
return "晴れ"
}
これを実行すると、2秒後に 明日の天気: 晴れ
が出力されます。一方 coroutine を使うと以下のように書く事ができます2。
// メインスレッドで実行
fun runMain(): Job = viewModelScope.launch {
val data = Weathers.tomorrow()
println("明日の天気: ${data}")
}
suspend fun Weathers.tomorrow(): String {
delay(2000) // 長い処理を表すための仮実装
return "晴れ"
}
viewModelScope.launch { … }
を呼び出す事で、非同期で処理が開始されます。suspend
, delay
などの見慣れないキーワードが一部追加されていますが、主な記述内容は同期的な呼び出しと同じです。
最初の同期的な書き方では2秒間スレッドがブロックされてしまうという問題があります。この間ユーザーの操作がフリーズしてしまうのでアプリケーションでこのコードを書くのは現実的ではありません。一方 couroutine を使うと結果が出るまでの2秒間ユーザーは普通に操作ができます。**便利!!**🥳🥳
処理の中断(suspend)
上のような差ができる理由は coroutine ではスレッドをブロックする代わりに処理を「中断」するからです。
最初の例では Thread.sleep()
により2秒間メインスレッドを占有(ブロック)していて、その間他の処理がしたくてもメインスレッドが使えません。一方 coroutine の例で使った delay()
関数は2秒間ブロックするのではなく、メインスレッドを2秒間解放してからメインスレッドを再取得して処理を続行します。開放中は他の処理にスレッドを活用できます。これを「中断(suspend)」と呼びます。
フレームワークやライブラリの機能を使って同じような事を実現する事ためにはコールバック関数を用意する必要があるため、coroutine と比べると周りくどい書き方になりがちです。以下は Android による例です。
// メインスレッドで実行
fun runMain() {
// 結果をコールバックを渡して呼び出し
Weathers.tomorrow { data ->
println("明日の天気: ${data}")
}
}
fun Weathers.tomorrow(callback: (String) -> Unit): Unit {
// 2秒後に処理を呼び出すようタイマーを設定する。
// callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
val handler = Handler()
handler.postDelayed({ callback("晴れ") }, 2000)
}
delay()
のように中断が起こる関数を「suspend 関数」と呼びます。通常の関数は中断しない(できない)ので、suspend 関数を呼び出す事ができません。 suspend 関数を呼びたい場合は launch
の中で呼び出すか、関数に suspend
キーワードを付けて suspend 関数にする必要があります。
CoroutineScope
coroutine を使った例では viewModelScope
に対して launch { … }
を呼び出す事で coroutine を起動していました。coroutine を起動する機能を持つオブジェクトは「CoroutineScope」と呼ばれます。
CoroutineScope は coroutine の起動だけではなく起動した coroutine を適切に終了する役割を担っています。例えば Android の KTX で提供されている3 viewModelScope
は、画面を閉じた時に coroutine を自動でキャンセルしてくれます。 Android でユーザー操作による処理を実行する場合は viewModelScope
から起動すると良いでしょう。
一方で Kotlin の標準で用意されている GlobalScope
もあります。こちらは処理が自動でキャンセルされません。一般的には自動でキャンセルを行ってくれる scope を使った方が良いとされます4。
coroutine を手動でキャンセルする事も可能です。 launch { … }
メソッドの結果に対し cancel()
を呼び出すとキャンセルする事ができます。
fun runMain(): Job = viewModelScope.launch {
val data = Weathers.tomorrow()
println("明日の天気: ${data}")
}
val job = runMain()
// 個別にジョブをキャンセルする。特に必要な場合のみ使う。
job.cancel()
runMain()
runMain()
// キャンセルすれば viewModelScope から起動したジョブを全てキャンセルできる。
viewModelScope.cancel() // しかし実際はフレームワーク側でやってくれるので不要
Coroutine は難しくない
このように、coroutine の基本は「launch()
で非同期処理を開始し、中断する関数には suspend
をつける」というとてもシンプルなものです。並行プログラミングをする上で見落としがちなキャンセル処理も、CoroutineScope が適切にやってくれるようになっています。
他のプラットフォームにおける Promise のような概念もあるのですが、特に必要ない限りは suspend 関数で済ませるのが簡単で問題も少ないです。
とはいえ suspend 関数が充実していない段階では、suspend 関数だけで済ませるのは中々難しいと思います。Kotlin では従来の処理を suspend 関数にする方法をいくつか用意しているので、それを紹介します。
suspend 関数への変換
ブロック処理 → suspend 関数
一定期間待つような単純な処理であれば delay()
という組み込みの suspend 関数を使えば実現できました。しかしそう言った suspend 関数がない場合は従来のブロックする処理から suspend 関数を作る必要があります。
一番最初の例にあったブロック版の関数を考えてみましょう。
fun Weathers.tomorrow(): String {
Thread.sleep(2000) // 長い処理を表すための仮実装
return "晴れ"
}
以下のように別のスレッドに切り替える事で、ブロック処理を suspend 関数に変換する事ができます。
suspend fun Weathers.tomorrow(): String = withContext(Dispatchers.IO) {
Thread.sleep(2000) // 長い処理を表すための仮実装
"晴れ"
}
withContext(Dispatchers.IO) { … }
で別のスレッドを使って実行し、withContext()
の処理が終わるまでの間メインスレッドを開放する事ができます。
標準では Dispatches.IO
(IO処理用) の他に Dispatchers.Main
(メインスレッド5)や Dispatchers.Default
(計算用)があります。また、独自でスレッドプールを作る事もできます。
スレッドの切り替えについての詳細は、公式ドキュメントの Coroutine Context and Dispatchers に書かれてあります。
コールバック関数 → suspend 関数
すでに非同期処理用にコールバック形式の関数がある場合は、どうでしょう。先ほどの Handler
を使った例を改善してみます。
fun Weathers.tomorrow(callback: (String) -> Unit): Unit {
// 2秒後に処理を呼び出すようタイマーを設定する。
// callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
val handler = Handler()
handler.postDelayed({ callback("晴れ") }, 2000)
}
suspendCoroutine { … }
を使う事で suspend 関数に変換する事ができます。
suspend fun Weathers.tomorrow(): String = suspendCoroutine { c ->
// 2秒後に処理を呼び出すようタイマーを設定する。
// callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
val handler = Handler()
handler.postDelayed({ c.resume("晴れ") }, 2000)
}
ただし、suspendCancellableCoroutine
を使った方が望ましいです。
suspend fun Weathers.tomorrow(): String = suspendCancellableCoroutine { c ->
// キャンセル対応用コード1: コールバック処理を一旦変数で持つ
val callback = Runnable {
// キャンセル対応用コード2: キャンセルされている場合は何もしない
if (c.isActive) c.resume("晴れ")
}
val handler = Handler()
// キャンセル対応用コード3: coroutine のキャンセルが起きた時に Handler 側もキャンセルする
c.invokeOnCancellation { handler.removeCallbacks(callback) }
// 2秒後に処理を呼び出すようタイマーを設定する。
// callback 処理はメインスレッドで行われるが、それまでの間スレッドは解放される
handler.postDelayed(callback, 2000)
}
ちょっと長くなってしまいましたが、これは主にキャンセル対応のためのコードがあるからです。coroutine は任意のタイミングでキャンセルする事ができるので、キャンセルされた時に元の非同期関数の処理もキャンセルする事が望ましいです。また、処理の要所要所でキャンセルされていないか(isActive
が true か)チェックする必要もあります。
キャンセルと協調的マルチタスキング
「キャンセル対応」というのが出てきました。 suspend 関数へ変換する処理を適切に作るためには、coroutine のキャンセルの仕組みを理解した方が良いでしょう。
実は、CoroutineScope 等に対してキャンセルを呼び出してもいきなり実行中の処理が打ち切られるわけではありません。実際に打ち切られるポイントは限られており、またキャンセルに従わないで処理を続行する事も可能です。
キャンセルされた処理が実際に打ち切られるポイントは、主に「suspend 関数を呼んだタイミング」です。キャンセル対応された suspend 関数6はキャンセル対応しています)を呼び出した場合、CancellationException
が呼ばれます。投げられた CancellationException
をキャッチせずに coroutine を終了すれば、キャンセル処理が完了します。
そのため、suspend 関数が CancellationException
を投げるかもしれない事を想定するだけでキャンセル対応できます。一方で、ブロック処理を suspend 関数を作るようなケースだと、isActive
や yield
でキャンセルが要求されているか確認したりするなどして、明示的にキャンセル対応しないと最後まで処理を続けてしまいます。
キャンセルについての詳細は、公式ドキュメントの Cancellation and Timeouts に書かれてあります。
まとめ
ここまで Kotlin の coroutine の基本、特に起動と中断について解説しました。「時間のかかる処理をスレッドをブロックせずに非同期で行いたい」といった用途であれば、今回の内容で大体事足りるのではないかと思います。
次回は並列処理を行う際の話や、Kotlin の coroutine で特徴的な structured concurrency について解説したいと思います。
-
入門読んで Hello, world! ができるようになった程度という意味。Flow も分かってないし…。 ↩
-
Android と KTX を使った場合の書き方。 ↩
-
Android の場合は
viewModelScope
の他にlifecycleScope
が用意されています(2020/03月時点)。 ↩ -
Kotlinの標準ライブラリ開発のリーダーである Roman Elizarov さんによるThe reason to avoid GlobalScope に GlobalScope を避けるべき理由が書かれてあります。 ↩
-
Dispatchers.Main
の実装はプラットフォーム側が用意するもので、素の Kotlin では使用できません。 ↩ -
標準の suspend 関数(
delay()
など)は全てキャンセル対応しています。 ↩