どうもこんばんわ。
あまいろショコラータ1・2・3 コンプリートパックを買ってやっています。買ったのは3の発売のときだったので積んでたことになりますね、、
まずは、あまいろショコラータ攻略しました。
みくりちゃん!!!ところどころにあるやりとりがおもしろかった
↑じとめすち
むくれてるのかわい~
英語分からん分かる、英語が第一言語だとやっぱドキュメントもエラーメッセージもそのまま分かるのでしょうか。
直でドキュメント読めるのずるいとおもう(?)
><
それはそうと服が似合ってていい
あとあんまり関係ないけど無印版のエンディング曲がシリーズの中で一番好きかもしれません、
あ!!!!!これ
このブログの土日祝日のアクセス数のこと言ってますか!??!?!?!
技術(?)ブログ、休みの日はあんまりお客さん来てない。
CloudFront
の転送量をCloudWatch
で見てみたけど、明らかに休みの日だけ折れ線グラフがガタ落ちしてる。面白い。
本題
Jetpack Compose
がぶいぶい言わせている今日、Jetpack Compose
ではありとあらゆるところでKotlin Coroutines
のサスペンド関数や、Flow
が多用されています。
(Android
はかなり)コールバック地獄だったので、他の言語にあるような同期スタイルで記述でき、とても嬉しいわけですが、、、、
その割にはAndroid
本家のKotlin Coroutines
の紹介がおざなりというか、分かる人向けにしか書いていません!(別にAndroid
側が紹介する義理なんて無いけど)
Jetpack Compose
であれだけ多用しているのに!?!?(いや関係ないだろ)
https://developer.android.com/kotlin/coroutines
かくいう私もあんまり理解できてない、、、なんとなくで書いている。
数年前からふいんき(なぜか変換できない)で書いているので真面目にやります。
https://takusan.negitoro.dev/posts/android_sqlite_to_room/#なんでコルーチン
というわけで今回はKotlin Coroutines
のドキュメントを読んでみようの会です。ぜひ夏休みの読書感想文にどうぞ。(てか世間は夏休みなのか)
https://kotlinlang.org/docs/coroutines-guide.html
Kotlin Coroutines
の話をするのに、Android
である必要はないんですが、私がサンプルコード書くのが楽というだけです。
特別なことがなければAndroid
じゃない環境、Kotlin/JVM
とかでも転用できます。
いちおうドキュメントの流れに沿って読んでいこうかなと思うのですが、
スレッドと違い、なぜコルーチンは欲しいときに欲しいだけ作っていいのかとかの話をしないと興味がわかないかなと思いそこだけ先にします。
多分合ってると思う、間違ってたらごめん。
環境
まあ分かりやすいと思うのでAndroid
で、あとはJetpack Compose
を使います。
もしサンプルコードを動かすなら↓
サンプルとしてインターネット通信が挙げられると思うので、OkHttp
というHTTP クライアントライブラリ
も入れます。
インターネット通信のサンプルがあるため、インターネット権限を付与したプロジェクト(アプリ)を作っておいてください。
はじめに
https://kotlinlang.org/docs/coroutines-guide.html
Future
とかPromise
とかasync / await
を知っていればもっと分かりやすいかもしれませんが知らなくてもいいです。なんなら忘れても良いです。
というのもFuture
やPromise
には無い安全設計が存在したり、Kotlin Coroutines は他の言語にある async/await
という回答は大体あっているくらいしかないです。
また、Kotlin Coroutines
ではasync/await
は並列実行のために使われており、他の言語にあるasync function
に当たるものはsuspend fun
になります。
あらずし
一応言っておくと別にスレッド
とかFuture
とかPromise
の悪口が言いたいわけじゃないです。
ライブラリの解説
これはコルーチンとは関係ないのですが、OkHttp
というライブラリのコードでサンプルを書くので、
最低限の使い方をば。
これが非同期モード。現在のスレッドはブロックせず、代わりに通信結果はonResponse()
受け取る。
ブロックしないためメインスレッド(UI
スレッド)からも呼び出せます。
これが同期モード。execute()
を呼び出すと、レスポンスが返ってくるまで現在のスレッドをブロックします。
コールバック
Android
でコルーチンが来る前、コールバックをよく使っていました、てかこれからも使うと思います。Java
で書く人たちはKotlin Coroutines
使えないので。
人によってはRxなんとか
を使ってたりしたそうですが、Android
ライブラリのほとんどはコールバックだったと思います。
かくいうAndroid
チームが作るライブラリAndroid Jetpack(androidx.hogehoge みたいなやつ)
も、もっぱらそう。
コールバックは、せっかく覚えたプログラミングのいろはを全部台無しにしていきました。
というわけでまずはコールバックの振り返り。OkHttp
ってライブラリを入れてサンプルを書いていますが、Kotlin Coroutines
あんまり関係ないのでここは真似しなくてもいいと思います。
例えばプログラミングでは、上から処理が実行されるという話がされるはず、でも意地悪してこれだとどうなるかというと。
Logcat
の表示だとこれ。全然上から順番に処理されてない。
for
も使える、、あれ?コールバックだと期待通りじゃない?
Logcat
が途中までは良かったのに、順番がぐちゃぐちゃになってしまった。
順番を守るためには、コールバックの中に処理を書かないといけないわけですが、見通しが悪すぎる。
俗に言うコールバック地獄
。Android
はこんなのばっか。Camera2 API
みてるか~?
例外処理のtry-catch
を覚えたって?コールバックの前では役立たずです。
成功時に呼ばれる関数、失敗時に呼ばれる関数に分離。finally
が欲しい?関数作って両方で呼び出せばいいんじゃない?
それ以前に、コールバックがなければ直接スレッドを作って使うしか無いのですが、これは多分あんまりないと思います。
そもそもAndroid
だとコールバックのAPI
しか無いとかで。
なぜコールバック
それでもなおコールバックを使っていたのには、わけがちゃんとあります。
画面が固まってしまうんですよね。
最初からあるスレッド、UIスレッド
やメインスレッド
とも呼ばれていますが、これは特別で、UI
の更新や入力を受け付けるものになります。UI
の処理はこれ以外の他のスレッドでは出来ません。
このスレッドで軽い処理なら問題ないでしょうが、インターネット通信(しかも通信制限のユーザーが居るかも知れない!)をやってしまったら、しばらく画面が固まってしまいます。押しても反応しないアプリが誕生します。
やがて行き着く先はこのダイアログ、「アプリ名」は応答していません
。これはメインスレッドで行われている処理に時間かかった際に出ます。
Android 4.x
あたりまではよく見た記憶がある。今でもメインスレッドを止めればいつでもこのダイアログに会えます。
これを回避するためにスレッドを使ったり、コールバックを使い、時間がかかる処理をメインスレッドで行わないようにしていたわけです。
まあそれ以前に Android ではメインスレッドでインターネット通信できない(例外が投げられる)ので、そもそもやりたくても出来ません。
これで、アプリの安定性が上がった。 アプリがフリーズしないように対策出来た。代償としてコードが地獄になったわけ。つらい。
アプリの安定性
からアプリがフリーズしないように対策
とわざわざ言い直したわけですが、コールバックやスレッドだって使えば安定するかと言われると、そう簡単には安定しません。
ちゃんと使わないと別のエラーで落ちます。こっちは落ちます。フリーズじゃなくて。これが厄介!
Android 11
で非推奨になったAsyncTask
で痛い目を見た。なんて懐かしいですね。なんなら全然うまく動かなくてトラウマになってる人もいそう。私ももう見たくない。
画面回転したら落ちるとか、アプリから離れると落ちるとか、、、
流石にそれはしんどいのでMVVM
的な考え方にのっとり、UIを担当する処理
とそれ以外が分離されました。
画面回転ごときで落ちるのは、UI
の部分で通信処理を書いているのが原因。ViewModel
というUI
に提供するデータを用意する場所で書けばいい。こいつは画面回転を超えて生き残る。
コールバックが来ようと、同期的な処理になろうとずいぶんマシになったはず。
話がちょっとそれちゃったけど、これが今日のAndroid
で、まあ後半はKotlin Coroutines
あんまり関係ないんですが、非同期とかコールバックがしんどいってのがわかれば。
コールバックの代替案 Future と Promise
Rxなんとか
←これは使ったこと無いので触れないです。すいません。
Future
も名前知ってるレベルであんまり知らないです。Promise
がちょっとだけ分かります。
Promise
(ゲッダンの方ではない)とか
Future
(Feature
じゃなくFuture
)というのがあります。
コールバックの代替案で、Promise
はJavaScript
ではasync/await
とともに使われています。
Android
の話なので、JavaScript
の話をしてもあれですが、一応ね。こんな感じのJavaScript
です。
Promise
はコールバックのように非同期で処理されます。そのためどこかで待つ必要があります。then()
とcatch()
ですね。
コールバックはライブラリによってコールバック関数の名前が違ったりしますが(onSuccess
/ onResponse
/ onError
/ onFailure
とか?)、
Promise
で書かれていればthen() / catch()
と言った感じで一貫しています。
(ってPromise 本
に書いてありました。→ https://azu.github.io/promises-book/#what-is-promise )
then()
では、配列操作のmap { }
のように値を変換して返すことが出来ます。
ここにPromise
を返すことが出来て、このあとのthen()
で受け取ることが出来ます。Promise
で出来ていれば一貫していることになるので、処理を繋げることが出来ます。
これをPromise チェーン
とか言うそうです。
Promise チェーン
が無いとコールバック地獄になってしまいますからね。
↓のコードは↑のコードと大体同じですが、明らかに↑の、メソッドチェーンで呼び出していくほうがまだマシでしょう。
Kotlin Coroutines
の話をするのであんまり触れませんが、あとは複数のPromise
を待ち合わせたりも出来ます。
コールバックだと全部のコールバックが呼ばれたかの処理が冗長になりそうですからね。
ただ、よく見るとコールバックが少し減ったくらいしか差が無いと言うか(申し訳ない)、
Promise
を繋げたり、全部待つ必要がないならあんまり旨味がないのでは?と。then()
で受け取るのとコールバックで受け取るのはあんまり差がない?。
then() / catch()
のせいでtry-catch
は結局使えないじゃんって?。
ついに来た async / await や Kotlin Coroutines
上記のPromise
ではまだコールバック風な文化が残っていました。しかしついに今までの同期風に書けるような機能が来ました。async/await
です。
async/await
はそれぞれえいしんく/えいうぇいと
と読むらしい、あしんく/あうぇいと
ってのは多分違う。
ついにtry-catch
が使えるようになりました。
同期風にかけるようになったため、Promise
を繋げる→ああPromise チェーン
かとか考えなくても、await
を付けてあとは同期的に書けば良くなりました。
ループだって怖くない。ただしJavaScript
はforEach() / map()
でasync function
呼び出せないので、純粋なループにする必要あり。
ちゃんとfor
の順番通りでました!もうコールバックやだ!
Kotlin Coroutines
はこれのKotlin
版です。async function
はsuspend fun
になります。
というわけで長い長いあらずしも終わり。いよいよ本題にいきましょう。
最初のコルーチン
https://kotlinlang.org/docs/coroutines-basics.html#your-first-coroutine
兎にも角にもなにか書いてみましょう。というわけでこちら。
"Hello"
を出力して、その1秒後
に"World"
を出力するコルーチンです。
出力結果はこう。何の面白みもないですが。
launch { }
関数を使い新しいコルーチンを起動しています。delay()
は指定時間コルーチンの処理を一時停止してくれます。
lifecycleScope
というのは、Android
のActivity
と連携したコルーチンスコープ
です。
コルーチンスコープ
というのは後で説明しますが、とにかく新しくコルーチンを起動するときにはスコープが必要だということがわかれば。
ちなみに、ドキュメントではGlobalScope
や、runBlocking { }
がコード例として出てきますが、Android
アプリ開発ではまず使いません。
Android
でコルーチンを使う場合は、lifecycleScope
とかrememberCoroutineScope()
とかの用意されたコルーチンスコープを使い起動します。コルーチンスコープを自分で作ることも出来ますがあんまりないと思います。
GlobalScope
とrunBlocking { }
はコルーチンのサンプルコードを書く場合には便利なのですが、実際のコードの場合は少なくともAndroid
では出番がありません。
delay()
はサスペンド関数の1つで、fun start()
をsuspend fun start()
のように書き直せば、サスペンド関数を自分で作ることも出来ます。
これらはサスペンド関数の中で呼び出すか、launch { }
、async { }
などの中じゃないと呼び出せません。
自分でsuspend fun
を作った場合、上記のコードはこんな感じになります。
ザックリ説明したところで、
コルーチンの話をしていく前に、先に言った通り、なぜスレッドと違って大量にコルーチンを作れる
のかという話を。
大量にコルーチンを起動できる理由 その1
Kotlin Coroutines
は今あるスレッドを有効活用します。スレッドを退屈させない(遊ばせない)仕組みがあります。
例えば以下のコード。3
秒後と5
秒後にprintln
するわけですが、これを処理するのにスレッドが 2 個必要でしょうか?
例えばその下の3秒後に println
とか5秒間
待ってる間に処理できそうじゃないですか?
Kotlin Coroutines
はこんな感じに、待ち時間が発生すれば、他のコルーチンの処理をするためスレッドを譲るようにします。
これにより、launch { }
した回数よりも遥かに少ないスレッドで処理できちゃうわけ。
一時停止と再開という単語がでてきますがおそらくこれです。
付録 譲るところを見てみる
実際に譲っているか見てみましょう。println
を追加して、どのスレッドで処理しているかを出力するように書き換えました。
Dispatchers.Default
というまだ習ってないものを使ってますが、とりあえずは別のスレッドを使う場合はこれをつければいいんだって思ってくれれば。Dispatchers
で詳しく話します。
結果です。実行する度に若干変化するかと思いますが、私の手元ではこんな感じでした。
launch { }
直後はそれぞれ別のスレッドが使われてますが、delay
後は同じスレッドを使っている結果になりました。
ちゃんとdelay
で待っている間、他のコルーチンにスレッドを譲っているのが確認できました。
また、この結果を見るに、delay()
する前と、した後では違うスレッドが使われる場合がある。 ということも分かりましたね。
メインスレッド
の場合は 1 つしか無いので有りえませんが、このサンプルではDispatchers.Default
を指定したためにこうなりました。
詳しくはDispatchers
のところで話しますが、スレッドこそ違うスレッドが割り当てられますが、Dispatchers.Default
は複数のスレッドを雇っているので、Default
の中で手が空いているスレッドが代わりに対応しただけです。
(また、これはdelay()
に限らないんですがまだ習ってないので・・・)
付録 Thread.sleep と delay
この2つ、どちらも処理を指定時間止めてくれるものですが、大きな違いがあります。
Thread.sleep
はスレッド自体を止めてしまいます。コルーチンを実際に処理していくスレッド自体が止まってしまいます。
一方delay
は指定時間コルーチンの処理が一時停止するだけで、スレッドは止まらない。止まらないので、待っている間、スレッドは他のコルーチンの利用に割り当てることができます。
delay
はスレッドが止まるわけじゃないので、例えばメインスレッドで処理されるコルーチンを作ったところで、
ANR
のダイアログは出ません。コルーチンが一時停止するだけで、メインスレッド自体は動き続けていますから。
たとえ無限にdelay
したとしても、上記の理由によりびくともしないと思います。
大量にコルーチンを起動できる理由 その2
ただ、上記の説明は、既存のスレッドを効率良く使う説明であって、
大量に作ってもいい理由にはあんまりなっていない気がします。
https://stackoverflow.com/questions/63719766/
Java
のスレッドは、OS
のスレッドを使って作られています。Java
のスレッドとOS
のスレッドは1:1
の関係になりますね。
スレッドを新たに作るのはメモリを消費したりと、コストがかかる処理のようです。
また、搭載しているCPU
のコア数以上にスレッドを生成されると処理しきれなくなるため、各スレッド均等に処理できるよう(よく知らない)
コンテキストスイッチ
と呼ばれる切り替えるための仕組みがあるのですが、これも結構重い処理らしい。
どうでも良いですが、コンテキストスイッチがあるので、1コア CPU
だとしても複数のスレッドを動かすことが出来ます。同時に処理できるとは言ってませんが。(パラレルとコンカレントの話は後でします)
話を戻して、Kotlin Coroutines
はlaunch { }
でコルーチンを作成しても、スレッドは作成されません。
もちろん、 コルーチンの中身を処理していくスレッドが必要なのですが、Kotlin Coroutines
側ですでにスレッドを確保しているので(詳しくはDispatchers
で)、それらが使われます。
そのためコルーチンと、確保しているスレッドの関係は多:多
の関係になります。
どれかのスレッドで処理されるのは確かにそうですが、スレッドとコルーチンが1:1
で紐付けられるわけではありません。大量にコルーチンを起動出来るもう一つの理由ですね。
コンテキストスイッチに関してもOS
のスレッドだとOS
がやるので重たい処理になる(らしい)のですが、
コルーチンだとKotlin
側が持ってるスレッド上でコルーチンを切り替えるだけなので軽いらしい。
付録 本当にメモリ使用量が少ないのか
そうは言ってもよく分からないと思うので、thread { }
が本当に重たいのか見ていきたいと思います。
それぞれ1000個(!?)作ってみます。Pixel 8 Pro / Android 15 Beta
で試しました。デバッグビルドなのであんまりあてにならないかも。
開発中のアプリであれば、Android Studio
のProfiler
でメモリ使用量を見ることが出来ます。
スレッドでテスト
ボタンを押したらスレッドを作ってThread.sleep(60秒)
するコードを書きました。
結果がコレです。
ボタンを押したら赤い丸ポチが付くわけですが、まあ確かに増えてますね。
コルーチンでテスト
ボタンを押したら、コルーチンを起動(launch { }
)して、delay(60秒)
するコードを書きました。みなさんも試してみてください。
結果がコレで、赤い丸ポチが出ているときがボタンを押したときです。
最初ちょっと増えましたが、2回目以降は押しても得には、目に見えるレベルで増えたりはしてなさそう。
実際にJava
のスレッドを作っているわけじゃないだけあって、いっぱい押しても特に起きない
構造化された並行性
https://kotlinlang.org/docs/coroutines-basics.html#structured-concurrency
コルーチンのドキュメントをいい加減なぞっていこうかと思ったのですが、
もう一個、これはスレッド
、Future
、Promise
から来た人たちが困惑しないように先に言及することにしました。これら3つにはない考え方です。
英語だとstructured concurrency
って言うそうです。かっこいい。
launch が使えない問題
もしこれを見る前にすでにKotlin Coroutines
を書いたことがある場合、まず壁にぶち当たるのがこれ。
launch { }
を使いたくても、赤くエラーになるんだけど。って。しかも謎なことに、書く場所によってはエラーにならない。一体なぜ!?
エラーになるので仕方なくGlobalScope
と呼ばれる、どこでも使えるコルーチンスコープ
を使って強行突破を試みますが、
そもそもGlobalScope
を使うことが滅多にないとLint
に警告されます。なんでよ!?
反省してGlobalScope
のドキュメントを見に行きましょう。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-global-scope/
読んでみると、どうやらcoroutineScope { }
を使えば、suspend fun
の中でもlaunch { }
出来るっぽいですよ!?
試してみると、確かにcoroutineScope { }
ではエラーが消えています。
これは何故かと言うと、launch { }
がcoroutineScope
の拡張関数になっているからですね。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html
また、launch { }
の中でlaunch { }
出来たのは、launch
の引数block
が、this
でCoroutineScope
を提供していたからなんですね。
以下のコードが分かりやすいかな?
ちなみに、launch
がCoroutineScope
の拡張関数になっているという答えにたどり着けた場合、自分が作る関数もCoroutineScope
を取る拡張関数にすればいいのでは・・・!という答えになるかもしれません。
もしその発想にたどり着けた暁にはもうゴールは目前で、最後の一押しとしてLint
がcoroutineScope { }
に置き換えるよう教えてくれます。かしこい!!!
ただ、引数にコルーチンスコープ
を取る場合は教えてくれないので注意。
ところで、なんでコルーチンを起動するのにコルーチンスコープが必要なんでしょうか?
スレッドや Future 、Promise はどこでも作れるじゃないですか、なんでこんな仕様なの?めんどくせ~~
と思うかもしれませんが、これはスレッド
やFuture
、Promise
にはない安全設計のため、この様になっています。
この安全設計が構造化された並行性
と呼ばれるものです。
親は子を見守る
Kotlin Coroutines
に構造化された並行性を導入した方の、こちらの記事もどうぞ
https://elizarov.medium.com/structured-concurrency-722d765aa952
↑の方の記事で引用されているこちらも。
コールバックはgoto
文と何ら変わらないという話
https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
ちなD.C.5
いや、上記の画像はあんまり関係ないのですが、
二人三脚という競技は、例えば解けてしまった場合は結び直して再出発する必要があります。
いきなり相方がどっか走り出したらルール違反になります。
おおむね、プログラミングの世界でも、起動した並列処理がどこか行方不明にならないよう待ち合わせしたり、
時にはエラーになったら他の並列処理を終了に倒したいときがあります。分割ダウンロードを作るとか。
JavaScript
のPromise
では、Promise.all
を使うことで全てのPromise
が終わるまで待つ事ができます。
しかし、特に待ち合わせとか何もしない場合は独立してPromise
が動きます。これはスレッド
やFuture
にも言えますが。
例えば以下のコード、main5
関数の方は明示的に並列処理を待ち合わせしていますが、main6
の方は非同期処理を開始するだけ開始してそのままにしています。
main6
の呼び出し後は面倒を見ていません。別にPromise
に関係なく、コールバックだろうとそのまま待たずに呼び出し元へ戻ったら同じことが言えます。
構造化された並行性ではこれを問題視しています。
例えば、非同期処理を待たないので、コードの理解が困難になります。
冒頭で行った通り、非同期処理、コールバックたちはプログラミングのいろはを全て破壊していったので、
for
は順番を守らない、try-catch-finally
だとcatch
に来ない、finally
が非同期処理よりも先に呼ばれるしで、理解が困難になります。
(finally
が先に呼ばれるせいでAutoCloseable#use { }
、他の言語のusing { }
が使えない等)
それではKotlin Coroutines
を見ていきましょう。賢い仕組みがあります。そして困惑するかもしれません。
以下のコードを見てください。
上でいった通り、suspend fun
の中ではlaunch { }
出来ないため、coroutineScope { }
を使いました。
これの実行結果ですが、予想できますか?
先に おしまい! が来るのかと思いきや、coroutineScope { }
で起動した3・5・10 秒待つコルーチンを並列で起動
を全て待ってからおしまい
に進んでいます。
最後に10000 ミリ秒待ったよ!
が出ると思ったそこのキミ。多分スレッド
やFuture
、Promise
から来ましたね?
これが構造化された並行性
と呼ばれるもので、並列で起動した子コルーチンが全て終わるまで、親のコルーチンが終わらないという特徴があります。
明示的に待つ必要はなく、暗黙のうちに全ての子の終了を待つようになっています。(もちろん明示的に待つ事もできます。join()
)
この子が終わっているかの追跡に、コルーチンスコープを使っているんですね。新しいコルーチンの起動にコルーチンスコープが必要なのもなんとなく分かる気がする。
スレッド
やFuture
、Promise
の場合、この構造化された並行性
が無いため、
非同期処理を開始するだけ開始して、成功したかまでは確認しない。すぐ戻って来るよろしく無い関数が作れてしまいます。
一方Kotlin Coroutines
では基本的に並列処理が終わる前に戻ってくるような関数は作れません。
(コルーチンスコープ
の使い方を間違えていなければ)
エラーが伝搬する
こっちのが分かりやすいかな。
Kotlin Coroutines
では、並列実行した処理でどれか1つが失敗したら他も失敗するのがデフォルトです。生き残っている並列処理をそのまま続行したいことのほうが稀なはずなので、これは嬉しいはず。
どれか1つのPromise / Future / スレッド
で失敗したら他も失敗にするという処理、なかなか面倒な気がします。
また、キャンセルさせたいと思っても全てのPromise / Future / スレッド
に失敗を伝搬する何かを自前で実装する必要があります。
Kotlin Coroutines
のデフォルトでは、子が失敗した場合その他の子も全てを失敗にします。
どれか1つが失敗したら、親が検知して、子供に失敗、もといキャンセルを命令します。ちなみにキャンセルは例外の仕組み動いているのですが、後述します。
以下のコードで試してみましょう。
まだ習っていないCancellationException
とかが出てきますがすいません。
logcat
がこうです。
delayTask
で10
秒待っている間に、errorTask()
が例外を投げました。
すると、エラーを伝搬するため、他のdelayTask()
へCancellationException
例外が投げられます。
子を失敗させたら、最後に呼び出し元へエラーを伝搬させます。呼び出し元のcatch
で例外をキャッチできるようになります。
CancellationException
の話はまだしてないのであれですが、キャンセルが要求されるとこの例外がスローされます。
ルールとしてCancellationException
はcatch
したら再 throw
する必要があるのですが、これも後述します。
呼び出し元はRuntimeException
をキャッチしておけば例外で落ちることはないですね。
もちろん、すべての子の待ち合わせしつつ、子へキャンセルを伝搬させない方法ももちろんあります。
並列で処理するけど、他の並列処理に依存していない場合に使えると思います。
ちなみに
コルーチンスコープを頼りにしているので、コルーチンスコープを適切に使わない場合は普通に破綻します。思わぬエラーです。
例えば以下はすべての子を待ち合わせしません。スコープが違うので他人の子です。我が子以外には興味がないサスペンド関数さん。
これだって、以下のように書くとエラーが伝搬しません。それから呼び出し元で例外をキャッチできないので絶対やめましょう。
我が子以外には説教をしないサスペンド関数です。
キャッチしきれなくて普通にクラッシュします。
以上!!!!
構造化された並行性!!多分クソわかりにくかったと思う。
キャンセル
コルーチンのドキュメント通りに歩くと、次はこれです。
Cancellation and timeouts
https://kotlinlang.org/docs/cancellation-and-timeouts.html
これも大事で、これを守らないとKotlin Coroutines
の売り文句とは裏腹に、思った通りには動かないコードが出来てしまいます。
というわけでキャンセル、読んでいきましょう。
コルーチンのキャンセル方法
いくつかありますがlaunch { }
したときの返り値のJob
を使う場合。Job#cancel
が生えているので呼べばキャンセルできます。
例えば以下のJetpack Compose
で出来たカウントアップするやつでは、
開始ボタンを押したらループがコルーチンで開始、終了ボタンを押したらコルーチンをキャンセルさせて、カウントアップを止めます。
コルーチンスコープを利用したキャンセル
他にもCoroutineScope#cancel
やCoroutineContext#cancelChildren
を使って、子のコルーチンを全てキャンセルさせる事もできます。
cancel()
だとこれ以降コルーチンを作ることが出来ないので、それが困る場合はcancelChildren()
を使うといいと思います。
Android のコルーチンスコープのキャンセル
ただ、Android
だとコルーチンスコープのキャンセルはあんまりしないと思います。
というのも、既にAndroid
が用意しているコルーチンスコープ、lifecycleScope / viewModelScope / rememberCoroutineScope() / LaunchedEffect
たちは、それぞれのライフサイクルに合わせて自動でキャンセルする機能を持っています。
lifecycleScope
ではonDestroy
(確か)、rememberCoroutineScope() / LaunchedEffect
ではコンポーズ関数が表示されている間。
なので、例えば以下のコードでは、条件分岐でコンポーズ関数が表示されなくなったらLaunchedEffect
を勝手にキャンセルしてくれます。コンポーズ関数が画面から消えたのにカウントアップだけ残り続けるなんてことは起きません。
自分でコルーチンスコープを作る場合、MainScope()
やCoroutineScope()
を呼び出せば作れるのですが、それはコルーチンコンテキストとディスパッチャの章
で!
#コルーチンスコープ
コルーチンが終わるまで待つ
cancel()
したあとにjoin()
することで終わったことを確認できます。別にキャンセルしなくても終わるまで待ちたければjoin()
すれば良いです。
cancel()
はあくまでもキャンセルを命令するだけで、キャンセルの完了を待つ場合はjoin()
が必要です。
後述しますが、キャンセルをハンドリングして後始末をする事ができるため、その後始末を待つ場合はjoin()
が役立つかも。
また、cancelAndJoin()
とかいう、名前通り2つを合体させた関数があります。
キャンセルの仕組み
まずはキャンセルの仕組みをば。
キャンセルの仕組みですが、キャンセル用の例外CancellationException
をスローすることで実現されています。
キャンセルが要求された場合は上記の例外をスローするわけですが、誰がスローするのかと言うと、一時停止中のサスペンド関数ですね。以下の例だとdelay()
さんです。
例えば先程のカウントアップのプログラムでは、delay()
がキーパーソンになります。
delay()
のドキュメントを確認しますが、指定時間待っている間にコルーチンがキャンセルされた場合は関数自身がキャンセル例外をスローすると書いています。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html
delay()
はキャンセルに協力的に作られているので良いのですが、自分でサスペンド関数を書いた場合はちゃんとキャンセルに協力的になる必要があります。
この話を次でします。
try-catch / runCatching には注意
サスペンド関数を try-catch / runCatching で囲った場合、注意点があります。
CancellationException
例外をスローすることでキャンセルが実現しているわけですが、try-catch
やrunCatching
でCancellationException
をキャッチした場合はどうなるでしょうか?
結果はこれです。
キャンセル後もキャンセルされずに処理が続行されています。これはキャンセル例外をキャッチしてしまったのが理由です。
修正方法としては、
try-catch
では最低限の例外だけをキャッチする
CancellationException
をキャッチしたら再スローする
- キャンセルされているか明示的に確認する
のどれかが必要です。まずはtry-catch
から。
もしくは、Exception を網羅的にキャッチするが、CancellationException だけは再スローする。
同じことがrunCatching { }
にも言えます。
これはKotlin Coroutines
がcencellableRunCatching { }
とか、suspendRunCatching { }
を作ってくれないのが悪い気もする。
議論されてるけど、、、まーだ時間かかりそうですかねー?:https://github.com/Kotlin/kotlinx.coroutines/issues/1814
対策としてはrunCatching { }
のキャンセル対応版を作るか
参考にしました、ありがとうございます:https://nashcft.hatenablog.com/entry/2023/06/16/094916
もしくは、Result
クラスにgetOrCancel()
みたいな拡張機能を作って、Result
を返すけど、もしキャンセル例外で失敗していればスローするとか。
try-catch
もrunCatching { }
もどっちも厳しい場合は、
try-catch
やrunCatching { }
の後にensureActive()
を呼び出す手もあります。
ensureActive()
はCoroutineScope
の拡張関数なのでコルーチンスコープが必要で、coroutineScope { }
で囲ったり、launch { }
の中で使わないといけないのが玉に瑕。
出力結果はこうです。
キャンセル後は余計な処理がされていないことを確認できました!
キャンセルは協力的
さて、ここからが大事です。(ここまでも大事ですが)
さっき書いたカウントアップするだけのコードはキャンセル出来ましたが、これはdelay()
やisActive
がキャンセルに協力しているからキャンセルできただけです。
自分でサスペンド関数を書く場合は、キャンセルに協力的になる必要があります。
例えば以下の、コルーチンの中で、OkHttp
でGET
リクエストを同期的に呼び出すコードを動かしてみます。
読者さんのインターネットの速度によっては、以下のように再現できないかも。なのであれなのですが。(開発者向けオプションのネットワーク速度を変更する、使わなければ128k
のpovo 2.0
を契約する等)
しかし、さっきのカウントアップのときとは違い、cancel()
を呼んだのにもかかわらず、GET
リクエストが続行されています。
これはキャンセルに協力的ではありませんね。 キャンセルしたらインターネット通信を始めないでほしいです。ギガが減るんでね。
ギガが減るのも良くないけど、キャンセルが適切に行われないとクラッシュを巻き起こす可能性もあります。
画面回転や、Fragment の破棄後に非同期処理が終わり、破棄されているのにUI
更新しようとして落ちるパターン。ViewModel
が来る前まではみんな引っかかってたはず。
getActivity() != null
とか、Fragment#isAdded() == true
とかで分岐してなんとかしのいでた。
例に漏れずコルーチンでも、UI
破棄のタイミングでキャンセルを要求したのは良いものの、キャンセル対応のサスペンド関数を書いていないと、破棄後にUI
更新する羽目になりやっぱり同じエラーに鳴ってしまいます。
まあUI
関係ないならViewModel
に書けよという話ではあるんですが。
キャンセル可能な処理の作り方
いくつか、キャンセルに関連する関数、フラグがあります。
yield()
CoroutineScope#isActive
CoroutineScope#ensureActive()
try-catch / runCatching
のときにも触れましたが、isActive
とensureActive()
はコルーチンスコープ
の拡張関数になっていて、
サスペンド関数の中をcoroutineScope { }
で囲ってあげるか、launch { }
の直下で使う必要があります。
まあyield()
もよく見るとcoroutineScope { }
を使っているので適当にcoroutineScope { }
で囲っとけばいいんじゃない(適当)
今回なら、インターネット通信を始める前にキャンセルされているか確認すれば良さそうですね。
直前にensureActive()
を呼ぶか、isActive
で確認を入れれば良さそうですね。
こんな感じにキャンセル後もリクエストが継続されているような挙動じゃなくなりました。
自分で作ったサスペンド関数がキャンセル可能になりました!!!キャンセル後も、キャンセルする前のリクエストが残ってるせいで微妙に分かりにくい。
もしwhile
ループをしている場合も同様で、isActive
でループを抜けられるようにするか、ensureActive()
で例外を投げる必要があります。
付録 どこに ensureActive() / isActive を入れるの
キャンセルされているか確認しろと言われても、1行毎に入れていったら洒落にならないでしょう。
これの答えは、インターネット通信とか、ファイル読み書きとか、CPU
を大量に消費する処理(フィボナッチ数列を計算する)とかの、
重たい、時間がかかる処理を始める前に確認すればいいんじゃないかなと思います。
https://developer.android.com/kotlin/coroutines/coroutines-best-practices?hl=ja#coroutine-cancellable
それから、delay()
やそのほかkotlinx.coroutines
パッケージ傘下にあるサスペンド関数(最初から用意されているサスペンド関数)は、基本的にキャンセルに対応しているため、ensureActive()
とかで確認せずともキャンセルされたら例外を投げてくれるはずです。
逆を言えば最初から用意されていない、自分でサスペンド関数を書く場合はキャンセル出来るよう心がける必要があります。
delay
withContext
(後述)
join
/ cancelAndJoin
付録 ensureActive() と isActive 2つもあって迷っちゃうな~
https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629
確かに2パターンあります。
微々たる差ではあるのですが、isActive
の方は例外を投げないので、while { }
の下でなにか処理をしたい場合にちょっと楽かもしれません。
ただ、ensureActive()
でもtry-finally
であとしまつ出来るので、どっちでもいい気がします。
そういえば、ensureActive()
と違い、isActive
の方は例外を投げないから、isActive
でキャンセルを実装したらキャンセル後も処理が続行してしまうのでは、、、と思う方がいるかも知れません。例外の場合は投げれば後続の処理は実行されませんからね。
たしかにそれはそうです。ただ、これはtry-finally
すればensureActive()
でも出来るやつなのでそういうものだと思います。
じゃあisActive
を使った実装方法だと自作サスペンド関数がキャンセルに対応できないのかというと、そんなことはなくて、今回使ったcoroutineScope { }
がキャンセルに対応しています。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html
coroutineScope { }
のブロック内(波括弧の中身)が終了して、呼び出し元へ戻る際にキャンセルチェックが入ります。
戻った際にキャンセルが要求されていることが分かったらcoroutineScope { }
自身が例外を投げます。
よって、今回書いてきたコードではどちらを使ってもcoroutineScope { }
かensureActive()
が例外を投げてくれるので、キャンセルに対応することになります。
付録 yield() の説明もしろ
なんて読むのか調べたらいーるど
って読むらしい。
記事書き終わった後に良い例を思い出したので書いてみる。
ドキュメントではスレッドを譲るって書いてあるけど、なんか難しくて避けてた。
これはスレッドを専有するような処理を書く時に使うと良いみたい。
まだ習ってない物を使いますが、limitedParallelism()
を使い、1スレッド
で処理されるコルーチンを2つ起動します。
1スレッド
しかないため、どちらかのコルーチンがスレッドを専有、ずっと終わらない処理を書いた場合はもう片方のコルーチンは処理されないことになります。例を書きます。
これでlogcat
を見てみると、[launch 1]
しかログが出ていません。
なぜならシングルスレッドしか無い上に、スレッドをブロックする無限ループを書いて専有してしまっているためです。
ここでyield()
が役に立ちます。説明どおりならスレッドを譲ってくれるはずです。
ループ毎にyield()
を呼び出してみましょう。
これで、logcat
を見てみると、[launch 2]
の出力がされるようになりました。
スレッドを専有するような処理ではyield()
を入れておくと良いかもですね!
try-finally が動く
キャンセルできるサスペンド関数は、キャンセル時はキャンセル例外を投げるため、try-finally
が完全に動作します。
処理が完了するか、はたまたキャンセルされるかわかりませんが、finally
に書いておけばどちらにも対応できます。プログラミングのいろはがようやく戻ってきました。
こんな感じにキャンセルされる、されないに関係なくfinally
が実行できています。やったやった!
finally でコルーチンが起動できない解決策
コルーチンがキャンセルした後、新しくコルーチンを起動することが出来ません。
あんまりないかもしれませんが、どうしてもfinally
でサスペンド関数を呼び出したいときの話です。
キャンセル済みの場合、finally { }
の中ではコルーチンを起動しても、キャンセル済みなのでensureActive()
を呼び出すと例外をスローするし、isActive
はfalse
になります。
サスペンド関数は動かなくなります。
どうしてもfinally { }
でサスペンド関数を呼び出したい場合は、withContext(NonCancellable) { }
で処理をくくると、サスペンド関数も一応動くようになります。
withContext
は後述します。が、NonCancellable
を引数に渡すと、キャンセル不可の処理を実行できるようになります。
出力結果はこうです、ensureActive()
がキャンセル例外をスローしなくなりました。
一方、isActive
とかも、NonCancellable
が付いている場合はtrue
になるので注意です。キャンセルされたかの判定が壊れます。
あくまでリソース開放とかの最小限にとどめましょうね。
ちなみに、NonCancellable
、よく見るとlaunch { }
にも渡せるんですが、withContext { }
以外で使ってはいけません。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-non-cancellable/
タイムアウト
タイムアウトもできます。
withTimeout { }
を使うと指定時間以内にコルーチンが終了しなかった場合に、
withTimeout { }
の中の処理はキャンセルし、また関数自身もTimeoutCancellationException
をスローします。
先述の通り、タイムアウトしたらwithTimeout
は例外を投げるので、投げられた場合後続する処理が動きません。
それが困る場合は、代わりにnull
を返すwithTimeoutOrNull { }
を使うと良いです。
付録 質問:キャンセル例外を投げればキャンセルしたことになりますか
これは元ネタがあって、私はただパクっただけです、興味があれば先に元ネタを読んでください。
https://medium.com/better-programming/the-silent-killer-thats-crashing-your-coroutines-9171d1e8f79b
回答: なりません
ensureActive()
の説明では以下のコードと大体同じことをやっていると書いています。
これだけ見ると、例外を投げればキャンセルできるのかと思ってしまいます。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html
それでは自分で投げてみましょう!キャンセル時の挙動は、先述の通り、
isActive
がfalse
であるはずで、
ensureActive()
も例外を投げるはずで、
withContext { }
も使えないはず、
なので、それも出力して見てみることにします。
で、logcat
に出てきたのがこれです。
どうやらダメみたいですね。
キャンセル例外を投げるだけではキャンセル扱いにはならないみたいです。ちゃんと正規ルートでキャンセルしましょう。
これはほとんど無いと思いたい。。。!
ただ、CancellationException
がKotlin Coroutines
で追加された例外ではなく、Java
のエイリアスになっているので,
Kotlin Coroutines
のことを一切考慮していないJava / Kotlin
コードからその例外を投げられる可能性は、、、可能性だけならありますね。
サスペンド関数
https://kotlinlang.org/docs/composing-suspending-functions.html
つぎはこれ、サスペンド関数の話です。ついに並列処理の話ができます。
直列処理
今まで通り、そのまま書けば直列処理です。
普通に書くだけで順番通りに処理されるとか、コールバックのときにはあり得なかったことですね。
結果はこう。直列処理なので、1つ目が終わるまで2つ目は呼ばれません。
並列処理
次に並列処理です。1番目の処理を待たずに並列で走らせることが出来ます。
async { }
とawait()
を使います。
async { }
の使い方はlaunch { }
のそれと同じなのですが、launch { }
と違って返り値を返せます!
await()
で返り値を取得できます。
それ以外は大体同じなのでコルーチンスコープ
が必要なのも同様です。
直列のときよりも大体半分の時間で終わっています。
数が多い場合は配列に入れてawaitAll()
すると良いかもしれません。
見た目が良くてすき
並列処理の開始を制御する
async { }
は何もしない場合はすぐに実行されますが、明示的に開始するように修正することが出来ます。
async { }
の引数にstart = CoroutineStart.LAZY
をつけると、start()
を呼び出すまで動かなくなります。
出力はこうです。
ちゃんとstart()
した後にrequestInternet()
が呼ばれてそうですね。
構造化された並行性
は最初の方で話したので、まずはこちらを読んでください。Promise / Future
との違いの話です。
#構造化された並行性
で、上記では触れなかったasync { }
の使い方をば。
他の言語から来た場合、async
キーワードは関数宣言時に使うので、このように書きたくなるかなと思います。
しかし、構造化された並行性があるKotlin Coroutines
ではasync { }
は呼び出し側で使うのが良いです。
関数の返り値にasync { }
を使う、ではなくサスペンド関数を作ってasync { }
の中で呼び出すのが良いです。
というのも、これだと子コルーチンのどれかが失敗した際に、他の子コルーチンをキャンセルする動作が動かないんですよね。
出力はこうです
構造化された並行性では、子のどれか1つが失敗したら他の子コルーチンにもキャンセルが伝搬するのですが、結果はこうです。
launch { suspendDelay(tag = "launch") }
、async { suspendDelay(tag = "async") }
はちゃんとキャンセルされているのですが、asyncDelay()
だけは生き残っています。
これはなぜかと言うと、asyncDelay()
だけはコルーチンスコープが違うため、キャンセル命令が行き届いてないのです。
launch { }
とasync { }
はsuspendCoroutine { }
のコルーチンスコープを使っていますが、asyncDelay()
はGlobalScope
のコルーチンスコープを使っています。
そのため、修正するとしたら、
コルーチンスコープをasyncDelay()
の引数に渡すよりは、asyncDelay()
関数をサスペンド関数
にし、async { }
を呼び出す責務を呼び出し側に移動させるのが正解です。
async { suspendDelay(tag = "async") }
の使い方が正解ですね。
他の言語にあるPromise
とかは、返り値を返すこのスタイルが使われているので注意です。
付録 launch と async
async { }
だと値が返せるんだし、全部async { }
でいいのではと。
値を返さない場合はlaunch { }
、値を返す必要があればasync { }
でいいと思います。launch { }
のほうが考えることが少なくてよいです。
というのも、詳しくは例外の章
で話すのですが、例外を投げる部分が違ってきます。ほんと微々たる違いなのですが、例外なのでシビアにいきましょう。
launch { }
は、親のコルーチンまで例外を伝達させます。親まで伝達するため他の子コルーチンもキャンセルになります。
ので、例外をキャッチするにはcoroutineScope { }
(親のスコープ)でtry-catch
する必要があります。
async { }
は例外を返せます。launch { }
と違い、async { }
はawait()
を使い値を返せるといいましたが、await()
で例外も受け取ることが出来ます。
ついでに親のコルーチンスコープまで例外を伝達させます。 親まで伝達するため他の子コルーチンもキャンセルになります。
なので、await()
の部分ではなく、try-catch
はcoroutineScope { }
や親のコルーチンでやる必要があります。ちなみにawait()
でtry-catch
してもキャッチできます。ただ親にも伝達します。
逆に期待通り、await()
で例外をキャッチできるようにする方法もあります。親に伝搬しない方法。
SupervisorJob()
やsupervisorScope { }
を使うことでawait()
で例外をキャッチして、かつ親にも伝搬しないようになります。が、後述します。
出力がこう
コルーチンコンテキストとディスパッチャ
https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html
launch { }
やasync { }
には引数を渡すことが出来ます。この引数のことをCoroutineContext
(コルーチンコンテキスト)といいます。
コルーチンコンテキストには後述するDispatchers
などを設定できるのですが、この章ではその話です。
スレッドとディスパッチャ
コルーチンは大量に作れますが、結局はどれかのスレッドで処理されるといいました。
launch { }
とasync { }
では、引数にDispatchers
を指定することが出来ます。このDispatchers
が実際に処理されるスレッドを指定するものです。
よく使うのが以下の3つです。
| 解説 | スレッド数 |
---|
Dispatchers.Default | メインスレッド以外のスレッドで、CPU を消費する処理向けです。 | 2 個以上CPU のコア数 以下 |
Dispatchers.IO | メインスレッド以外のスレッドで、インターネット通信や、ファイル読み書き向けです。 | 少なくとも 64 個、足りなければ増える。 |
Dispatchers.Main | メインスレッドです。 | 1 個 |
Default
とIO
は、Dispatchers
が複数のスレッドを持っている(雇っている)形になります。
その時空いているスレッドが使われる感じです。
Dispatchers.Main
に関して、Kotlin
のドキュメントには出てこないので不思議に思ったかもしれません。なぜかというとAndroid用
(というかGUI
向け)に拡張して作られたからです
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html
実際に指定してみます。以下のコードを試してみましょう。
出力がこうです。
出力される順番が前後するかもしれませんが気にせず。
無指定の場合はmain
になっていますが、これは呼び出し元、親のDispatchers
を引き継ぐためです。
しかしprintThread()
を呼び出しているlaunch { }
でも無指定です。特に親のlaunch
でも指定がない場合はコルーチンスコープに設定されているDispatchers
が使われます。
ちなみに、Dispatchers.Main
以外は、複数のスレッドが処理を担当するため、もしスレッドに依存した処理を書く場合は注意してください。
Android
開発においてはメインスレッドとそれ以外のスレッドという認識(雑)なので特に問題はないはずです。問題がある場合はnewSingleThreadContext
の説明も読んでください。
例えば以下のコード、どのスレッドで処理されるかはKotlin Coroutines
のみが知っています。
予測は出来ません。続きを読めば対策方法があります。
Android が用意しているスコープはメインスレッド
Android
のlifecycleScope
とJetpack Compose
のrememberCoroutineScope()
、LaunchedEffect
はデフォルトでMain
が指定されています。
が、心配になってきたので一応試しましょう。coroutineContext[CoroutineDispatcher]
でDispatchers
を取り出せるようです。
結果は認識通り、Main
が使われていました。よかった~
付録 Dispatchers.Main.immediate の immediate って何
lifecycleScope
やrememberCoroutineScope
、LaunchedEffect
のコルーチンスコープ
はDispatchers.Main.immediate
だということが判明しました。
しかし、Dispatchers.Main
とDispatchers.Main.immediate
の違いはなんなのでしょうか?
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-coroutine-dispatcher/immediate.html
・・・??
というわけでドキュメントを見てみましたが、いまいちよく分からなかったので、一緒に書いてあるサンプルコードのコメントを元に実際に動かしてみる。
サンプルコードのコメントを見るに、すでにメインスレッドで呼び出されている場合は、即時実行される。とのこと。試します。
まずはDispatchers.Main
で。
これは予想通り、launch { }
で囲った1/2/3
よりも先に4
がでますね。
一方、Dispatchers.Main.immediate
をつけると・・・?
おお!すでにメインスレッドで呼ばれている場合はlaunch { }
が後回しにされずに即時実行されていますね。
newSingleThreadContext
ごく、極稀に、自分で作ったスレッドでコルーチンを処理させたい時があります。
あんまり、というか本当に無いと思うのですが、唯一あったのがOpenGL ES
ですね。
話がそれてしまうので手短に話すと、OpenGL ES
は自分を認識するのにスレッドを使っています。
メインスレッド以外でUI
を操作できないのと同じように、OpenGL ES
もOpenGL ES
のセットアップ時に使われたスレッドを自分と結びつけます。
そのため、OpenGL ES
の描画を行う際は、スレッドを気にする必要があります。OpgnGL ES
にはマルチスレッドは多分ありません。
話を戻して、どうしても自分で作ったスレッドでしか処理できない場合があります。
その場合はnewSingleThreadContext()
を使うことで、新しく Java のスレッドを作り、その中で処理されるDispatchers
を返してくれます。
はい。Java のスレッド
を作ることになります。これを多用した場合はKotlin Coroutines
の売り文句の 1 つ、スレッドより軽量
を失うことになります。
そのため、シングルトンにしてアプリケーション全体で使い回すか、使わなくなったら破棄する必要があります。
(親切なことにAutoCloseable
インターフェースを実装しているので、use { }
拡張関数が使えます!)
出力がこうです。ちゃんと新しく作ったスレッドで処理されていますね。
Unconfined
私もこの記事を書くために初めて使う、し、いまいちどこで使えばいいかよくわからないので多分使わない。
これは特別で、呼び出したサスペンド関数がスレッドを切り替えたら、サスペンド関数を抜けた後もそのスレッドを使うというやつです。
よく分からないと思うので例を書くと。
と、その前にMain
の例。Main
で起動したコルーチンでDispatchers
を切り替える。
withContext
はまだ習っていないのですが、CoroutineContext(Dispatchers)
を切り替える際に使います。後述します。
出力がこうです。
ちゃんとspecialTask()
を呼び出し終わった後はメインスレッド
で処理されていますね。
つぎにDefault
に書き換えてみます。
実行するたびに若干異なる場合がありますが、 手元ではこうでした。
specialTask()
が終わった後はDispatchers.Default
のスレッドが使われてます。が、なぜか違うスレッドが使われてますね。これはDefault
はMain
と違って複数のスレッドでコルーチンを処理しているためです。
はじめの方で話した通り、サスペンド関数を抜けた後、(メインスレッドのようなスレッドが 1 つしかない場合を除いて)違うスレッドが使われる可能性があるといいました。その影響です。
DefaultDispatcher
はDispatchers.Default
が持っているスレッドなので(要検証)、スレッドこそ違うものが割り当てられましたが、Dispatchers
は元に戻ってきていますね。
最後にUnconfined
です。よく見ててください。
出力がこうです。
specialTask()
を抜けた後もspecialTask()
が使っていたスレッド(Dispatchers
)を使っています。が、あんまり使う機会はないと思います。
知っていることを自慢できるかもしれないけど、そんなもん自慢したら深堀りされて詰む。
コンテキストを切り替える withContext
withContext { }
、これまでも習ってないのにちょくちょくでてきてましたが。ついに触れます。
これを使うと好きなところでスレッド、正しくはDispatchers
を切り替えることが出来ます。
出力がこうです。
こんな感じにスレッドを行ったり来たり出来ます。newSingleThreadContext()
も渡せます。
ブロック内は指定したスレッドで処理されます。ブロックを抜けると元のスレッドに戻るため、スレッドを切り替えているのにコールバックのようにネストせずに書けるのが感動ポイント。
delay()
のそれと同じように、この手の関数は、戻ってきた際に同じスレッドが使われるとは限らないので注意です。
先述の通りスレッド、ではなくDispatchers
が元に戻るだけ、Dispatchers.Default
は複数のスレッドを雇っているのでその時空いているスレッドが割り当てられます。
これも実行するたびに変化するかもしれませんが、手元ではこんな感じに、Dispatchers
こそ同じものの、スレッドは違うものが割り当てられてそうです。
また、キャンセル後にサスペンド関数が呼び出せないのと同じように、withContext { }
も呼び出せません。
ただし、キャンセルの章で話した通り、NonCancellable
をつければ一応は使えます。乱用しないように。
Job()
構造化された並行性の章にて、親コルーチンは子コルーチンを追跡して、全部終わったことを確認した後に親が終わることを話しました。
しかし、コルーチンスコープを使うことでこの親子関係を切ることが出来ることも話しました。
この他にコルーチンコンテキスト
のJob()
をオーバーライドすることでもこの関係を切ることが出来ます。
こんな感じに。
親子関係が切れてしまったので、親がキャンセルされても生き残っていますね。使い道があるのかは知らない。
デバッグ用に命名
本家の説明で十二分だとおもう
https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html#naming-coroutines-for-debugging
同時に Dispatchers NonCancellable Job を指定したい
コルーチンコンテキストは+ 演算子
で繋げることが出来ます。
コルーチンスコープ
もし、自分でコルーチンスコープを作って管理する場合は、ちゃんと使わなくなった際にコルーチンスコープを破棄するようにする必要があります。
例えばAndroid
のサービスでコルーチンが使いたい場合、(Activity
、Fragment
、Jetpack Compose
とは違い)Android
では用意されていないため自分で用意する必要があります。
作る分にはCoroutineScope()
やMainScope()
を呼べば作れますが、ちゃんと破棄のタイミングでキャンセルしてあげる必要があります。
という話がMainScope()
のドキュメントに書いてあるので読んで。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-scope.html
スレッドローカルデータ
すいませんこれは使ったこと無いので分かりません。;;
https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html#thread-local-data
付録 Android の Handler と Dispatchers
Android
ではHandler
を引数に渡す関数が結構あります。
この手の関数は引数にコールバックと、そのコールバックを呼び出すスレッドを指定するためのHandler
を取ります。
ちなみにnull
のときはメインスレッド
、コードだとHandler(Looper.getMainLooper())
をAndroid
側で渡しているそうです。
まあこの手の関数はHandler
をnull
に出来て、しかも大抵はnull
で問題なかったりするのですが。(小声)
例えばCamera2 API
の写真撮影メソッド。null
でメインスレッド?
例えばMediaCodec
のsetCallback()
。null
でメインスレッド
例えばMediaProjection
のregisterCallback()
。null
でメインスレッド
ただ、コールバックを別スレッドで呼び出されるようHandler()
とHandlerThread()
を作ることもあるでしょう。MediaCodec
の非同期モードは多分そう。
概要としては、HandlerThread()
と呼ばれるスレッドを作り、Handler#getLooper()
をHandler()
へ渡して、出来たHandler
を引数に渡せば完成なのですが、もう一歩、このHandler()
をKotlin Coroutines
のDispatchers
としても使うことが出来ます。
(ということを最近知りました、ありがとうございます)
https://qiita.com/sdkei/items/b066817fb7f7c34d5760
こんな感じですね。
HandlerThread()
をKotlin Coroutines
でも転用したい場合はどうぞ!
付録 Dispatchers.Default はなぜ最低 2 個で、Dispatchers.IO は最低 64 個もあるの
これの答えをKotlin Coroutines
の中で探したんですが、それっぽい記述を見つけることは出来ず。
というわけで手詰まりになりました。ここから先の話は憶測です。ドキュメントには書いていない話なので。
Kotlin Coroutines
関係なく、CPU
を駆使する処理(CPU バウンドなタスク
)だと、起動するスレッド数は搭載されているCPU
のコア数と同じか半分が良いという風の噂があるらしい。
あんまり理解できてないけど、なんとなくこういうこと?かなり理論上な気はする。
CPU
を駆使する処理はCPU
にしか依存していないので(遅くなる原因のファイル読み書き等の邪魔するものがない場合)1スレッドだけで使用率を100%
に出来る?。
1スレッドだけで100%
になるからコア数以上に増やしてもそれ以上、100%
を超えられないので意味がないってことらしい?
一方ファイル読み書きやインターネット通信(IO バウンド
)はCPU
がどれだけ早くても読み書き、通信速度がボトルネックで100%
にできない?
から並列化させてCPU
を遊ばせない(暇させない)ほうがいいってことなのかな
Flow と Channel
https://kotlinlang.org/docs/flow.html
↓書きました↓
https://takusan.negitoro.dev/posts/amairo_kotlin_coroutines_flow/
省略します!!!
やる気があればFlow
の話をします。
というのもFlow
はサスペンド関数のそれ同じくらい内容があってそれだけで記事一本出来てしまうので。。。
いやだって目次が多すぎる。。。
例外
例外の話です。地味に新しい事実がある
違う親の子コルーチンの例外
違う親の子コルーチンというか、自分が親のコルーチンのときにも言える話ですね。lifecycleScope.launch { }
のこと。
まあ落ちます。自分が親のコルーチンにも言えることでしょうが。
ちなみにasync { }
は受け取れますが、そもそも違う親で起動しない気がして、だから何みたいな。。。。
キャッチされなかった例外を観測
キャッチされなかった例外を観測することが出来ます。キャッチされなかったというわけで、もうアプリは回復できません(落ちてしまう)
スタックトレース収集とかに使えるかも?
試したことがあるかもしれませんが、launch { }
をtry-catch
で囲っても意味がありません。普通に落ちます。
軽量スレッドとはいえスレッドなんですから。
じゃあどうすればいいのかというと、CoroutineExceptionHandler { }
を使うと出来ます。
先述の通りキャッチされなかった例外が来るので、回復目的には使えません。出来るとしたらスタックトレースと回収とかアプリ全体を再起動とかでしょうか。
ここでキャッチ出来るようになるので、アプリが落ちることはなくなります。
例えば以下のようにスタックトレースの回収が出来ます。
logcat
はこんな感じです。
ちなみに親以外に付けても動きませんので!
キャンセルと大元の例外
子コルーチンの中で例外が投げられた場合、親を通じて他の子もキャンセルされるわけですが、子に来るのはキャンセル例外です。
キャンセルの原因となった子コルーチンの例外は親のコルーチンで受け取れます。
子コルーチンがRuntimeException
でコケたら他の子コルーチンにはRuntimeException
が来るわけではなく、キャンセルの例外が来るよという話です。親でRuntimeException
を受け取れます。
複数の例外の報告
はドキュメントにゆずります。得にはないかな、、、
https://kotlinlang.org/docs/exception-handling.html#exceptions-aggregation
スーパーパイザー
ドキュメントにあるサンプルコードパクってもAndroid
だと動かないな、、、
ここまで出てきたコードでは子のコルーチンが例外投げられたら、親に伝搬し、他の子に対してはキャンセルを投げるというものでした。
しかし、他の子コルーチンは生かしておきたい場合があります。その場合、子コルーチン内の処理をtry-catch
して例外を投げないようにする、でも良いですが、もう一つの方法があります。SupervisorJob()
とsupervisorScope { }
です。
試してみましょう。
指定時間後に成功するタスクsuccessTask()
と、指定時間後に例外を投げて失敗するfailTask()
をそれぞれ並列で呼び出します。
async { }
はawait()
で値もしくは例外を受け取ることが出来ます。って書いてあります。
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-supervisor-job.html
結果はこんな感じで、supervisorScope { }
は子と子が持っている子にのみキャンセルが伝搬します。親には伝搬しません。
ので、親に対して明示的にキャンセルしない場合は、子コルーチンはそのまま生き続けます。
もちろん子がすべて終わるまで親が終わらないルールはsupervisorScope { }
でも引き継がれています。なので最後にcomplete !!!
が出る形にあります。
さっきはasync { }
の例を出しましたが、launch { }
でも動きます。async { }
だとawait()
でキャッチできますが、
launch { }
の場合はというと、、、、代わりにコルーチンスコープに設定されたCoroutineExceptionHandler
で例外をキャッチできます。コルーチンスコープにそれがない場合は落ちます。
これでもsuccessTask()
がちゃんと生き残っています。
並行処理と可変変数
見出しは適当につけました。
最後の章。かな。
https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html
マルチスレッド起因の問題はコルーチンでも起きる
マルチスレッドで変数を同時に書き換えると正しい値にならないというのは有名だと思います。
logcat
はこうです。実行するタイミングによっては10000
が表示されるかもしれませんが、それはたまたま動いただけです。
これはマルチスレッドで同時に変数にアクセスしているから(まあ目には見えない速さなんですが)、タイミング悪く増える前の変数に+1
したとかでおかしくなってるんでしょう。
Dispatchers
の章で話した通り、Dispatchers.Main
やシングルスレッドのDispatchers
を使えば、他スレッドから参照されたり変更されたりしないので、ちゃんと期待通りになります。
また、変数に@Volatile
を付けても期待通りにならないそうです(これがなんなのかいまいちわからないのでスルーします)
ループと withContext
ちなみにrepeat { }
の中でwithContext { }
よりもwithContext { }
の中でrepeat { }
のほうが良いです。
いくら軽いとはいえ、繰り返し文のたびにwithContext { }
を呼び出すのはコストがかかるそうです。
スレッドセーフ
いくつかあります。
まずはAtomicInteger
、これはInt
ですが他にもBoolean
とかもあるはず。何回実行しても10000
になります。
あとはsynchronized
のコルーチン版Mutex()
を使うことでも同時アクセスを防ぐためにロックできます。変数の操作はもちろん、処理自体を同時実行されないようにしたい場合にこちら。ブロック内はスレッドセーフになります。
なおsynchronized
はKotlin Coroutines
では使えません。
以上!
(Flow
とChannel
以外は)一通り読み終えました。
長かった。。。。つかれた。
ファンディスク
ここからはドキュメントに書いてないけど、実戦投入する際によく使うやつを書いていきます。
コールバックの関数をサスペンド関数に変換する
ちなみに、これが使えるのは一度だけ値を返す場合のみです。複数回コールバック関数が呼ばれる場合はFlow
の勉強が必要です。。。
コールバックの関数をサスペンド関数に変換できます。Kotlin Coroutines
を使ってやりたいことの1位か2位くらいに居座っていそう、コールバックの置き換え。達成感あるんだよなこれ。
suspendCoroutine { }
とsuspendCancellableCoroutine { }
の2つがあります。差はキャンセル不可能か可能かです。後者のキャンセル対応版を使うのが良いです。
suspendCoroutine { }
の場合はこんな感じです。
suspendCoroutine
でContinuation
がもらえるので、コールバックの中でresume
とかを呼び出せば良い。
キャンセル出来ないので、キャンセルを要求したとしても、suspendCoroutine
自身はキャンセル例外を投げません。
後続でキャンセルチェックとかを入れるなどする必要があります。
ensureActive()
がキャンセル判定をし、今回だとキャンセルしているので例外を投げます。logcat
にはCancellationException !!!
が出ます。
ensureActive()
をコメントアウトすると、そのままprintln()
へ進んでしまいます。これはおそらく意図していない操作だと思います。
suspendCancellableCoroutine
だとこんな感じです。
こちらはキャンセル機能を持ちます。Continuation
がCancellableContinuation
に変化します。キャンセルが要求されたときに呼び出されるコールバックを提供します。
極力こちらのsuspendCancellableCoroutine { }
でキャンセルに協力的なサスペンド関数を作っていくのがよろしいかと思います。
使い方はキャンセルに対応したコールバックが追加されたくらいで、ほぼ同じです。
先述の通り、キャンセルに対応しているので、キャンセル要求がされた場合はsuspendCancellableCoroutine { }
自身が例外を投げます。
そのためキャンセルチェックは不要です。
ちゃんとlogcat
にはensureActive()
無しでCancellationException !!!
が出ています。
並列と並行(パラレルとコンカレント)
https://stackoverflow.com/questions/1050222/
同時に処理を実行する際に出てくるキーワードに、 並列(parallel / パラレル) と 並行(concurrent / コンカレント) という単語が出てきます。
機械翻訳にぶち込むと同じ単語が出てきて困惑するのですが、 少なくとも Kotlin Coroutines
では別に扱ってそうです。
平行(コンカレント)
これは同時に起動は出来るというだけ。同時に処理はできていない。
CPU
が1コア
しかなければ1つのことしか出来ない。。。。ですが、実はコンテキストスイッチ
と呼ばれるものがあって、
同時に起動している処理を細かく区切って、それぞれ均等にCPU
に与えて処理させているので、同時に処理が出来ているように見えている。
1コア
しか無いので、同じ時間には1つしか処理できないことになります。
ワンオペで店を回しているようなものでしょうか。
並列(パラレル)
これは同時に処理が出来ます。マルチコア CPU
とかIntel のハイパースレッディング
のそれを使うやつです。
同時、同じ時間に複数の処理が出来る違いがあります。もちろんコア数を超えるスレッドがあれば同じくコンテキストスイッチが頑張ります。
複数人雇っていることになりますね。これなら同じ時間に違う仕事をさせることが出来ます。
だから何?
並列の実行数を制限したいときに、どっちを制限したいのかによって使い分ける必要があります。
| 平行(同時に起動する) | 並列(同時に処理する。使うスレッド数を制限する) |
---|
1つだけ | Mutex() | limitedParallelism(1) |
上限付き | Semaphore(上限の数) | limitedParallelism(上限の数) |
Mutex
例えば以下の関数はwithLock { }
の中からのみ呼び出されているため、successTask()
を3つ並列にしても、1つずつ処理されることになります。
なので、同時にHello world
が出力されることはありません。
logcat
Semaphore
Mutex()
は1つずつですが、2個までは許容して3個目以降は待ち状態にしたい場合があると思います。
例えばMinecraft
のマインカートは一人乗りなのでMutex()
で事足りますが、ボートは二人まで乗れますのでMutex()
は使えないですね。
それがSemaphore()
です。見ていきましょう。
引数に同時に利用できる上限数を入れます。
実行結果ですが、2つずつ処理されるので、logcat
の出力も2個ずつ出てくるんじゃないかなと思います。
limitedParallelism
これは同時に利用するスレッド数を制限するものです。
Dispatchers.IO
やDispatchers.Default
は複数のスレッドを持っている(雇っている)ので、それらの制限をするのに使うそう。
ややこしいのが、これはスレッド数を制限するものであって、launch { }
やasync { }
の同時起動数を制限するものではないということです。
Kotlin Coroutines
はスレッドを有効活用するため、delay()
等の待ち時間が出れば他のコルーチンの処理に当てるのでコルーチンの起動数制限には使えません。
もしlaunch { }
やasync { }
の同時起動数を制限したい場合はさっきのSemaphore()
が解決策です。
例えばlimitedParallelism(1)
にした場合はスレッドが1つだけになるので、このようにループでインクリメントさせても正しい値になります。
ちなみにシングルスレッドが約束されるだけであって同じスレッドが使われるとは言っていません。
うーん、どこで使うのかな。多分賢い使い方があるんだと思うんだけど。
forEach / map でサスペンド関数呼べます
JavaScript
から来た方向け。Kotlin
のforEach
やmap
等でサスペンド関数を呼び出せるよという話です。JavaScript
ではforEach
内でawait
出来ないのは有名な話ですが、Kotlin
では出来るよという話、それだけです。
Kotlin
にはインライン関数と呼ばれる、関数呼び出しではなく、関数の中身のコードを呼び出し元に展開する機能があります。(他の言語にあるマクロみたいなやつ)
forEach
やmap
はインライン関数なので、これらは純粋な繰り返し文に置き換わります。なのでサスペンド関数を呼び出すことが出来る感じです。
例えば以下のKotlin
コードは
Java
ではこのようになるそうです。
ちゃんと純粋なループに置き換わっていますね。
おわりに
いやーーーーーーKotlin Coroutines
、頭が良い!よく考えられてるなと思いました。