visible true

技術的なメモを書く

Kotlin Coroutine 1.0.0までに夏から変わったところ

日進月歩。夏に話した頃とは大きく変わってしまったKotlinコルーチン。

Kotlin Fest 2018でコルーチンの話をしてきた - visible true

↑はコルーチンの概念と実現の仕組みを中心に置いたので、その辺はまぁ変わってないんで問題ないんですが、後半の実際にコルーチンを使う場面に関してはすでにdeprecatedっていうか、そのまま書いても動かないくらい変化してしまいました。

ということでざっくりメモ

0.25.xから1.0.0までの主要な変化

基本的にはリリースノートと関連するissueの議論を読めば大体わかります。 https://github.com/Kotlin/kotlinx.coroutines/releases

[0.26.0] CoroutineScopeを導入し既存の書き方がdeprecatedに

9/12にリリース。kotlin festから2週間やん。

CoroutineScopeという概念が追加された。既存のコルーチンはすべてGlobalScopeとして扱うことになった。そのほか構造的並行性(Structured concurrency)が基本的な考え方となり、スコープの階層を適切に構築しようなみたいな話になったぽい。

もともとは全部グローバルコルーチンだったので単純だけどリークの可能性もあった(永遠に生きるコルーチンとかのケアがあんまりなかった)が、構造的並行性によってrootスコープから末端まで親子関係を適切に構築することでライフサイクルの伝搬をいい感じにしていこうなという感じ。

parent jobで親子関係をやりくりしていたのを、CoroutineScopeでデフォルトで必ず親子になるようにしたため、書きやすくなったと言えるし設計もしやすくなったなと思う。大変だけど。

ちょっと追記

CoroutineScopeによってコルーチンのライフサイクルを構築しやすくなった。これによってAndroidとの親和性が上がったと言える。ActivityのライフサイクルやFragmentのライフサイクルなどに合わせてそこにぶら下がるコルーチン全体を停止できるようになるから。なのでコルーチンをガシガシ書ける空間(Activity, Fragment, ViewModelなどのライフサイクルに合わせて動くCoroutineScopeを実装できる)を提供できて、アプリに導入するハードルが下がる。実際CoroutineScope導入後にJetpackコンポーネントでどんどんコルーチンサポートが入ってきている。この点はすごく良かったのでは〜って思う。

[0.26.0] Dispatchersを導入

CoroutineScopeの動きとは別でDispatcherの名前がバラバラ(CommonPool, UI, JavaFx...)なのでなんとかしない?みたいな議論のなかでDispatchersオブジェクトが導入され、そこに生やす形になった。

  • CommonPool -> Dispatchers.Default
  • UI -> Dispatchers.Main
  • _ -> Dispatchers.IO

など。まぁこれは名前変わったね程度の理解で大体よさそう。CommonPoolはいずれなくなるらしいけどまぁよしなにやってくれるみたいなので頼むみたいな感想。

[0.30.0] 例外伝搬の仕様が変わった

9/29リリース。矢継ぎ早〜

コルーチンが例外を投げたときにどこまでどう伝搬するかみたいな話。たとえば次のコード

suspend fun foo() = coroutineScope<Unit> {
  val one = async {
    delay(Int.MAX_VALUE) // suspend until cancellation
  }

  val two = async { throw MyException() }

  one.await()
}

oneとtwoが同時に動きだすけどtwoはすぐに例外を起こす。しかしDeferredはawait()しないと例外が飛ばないので、oneが長いとtwoが死んでるのにずっと待っちゃうみたいな動きは問題だ、ってことで、即時に親をキャンセルする形になった。

これが怖いのは次ようなコードは書いちゃ駄目になったということ

val job = Job()
val scope = CoroutineScope(Dispatchers.Main + job)
scope.launch {
  try {
    val a = async { // launchの中でasync起動したらあかん
      delay(1000)
      throw Exception()
    }.await()
  } catch (e: java.lang.Exception) {
    e.printStackTrace()
  }
}

launchとasyncは親子関係を持っているがasyncは親をcancelにするのでlaunchもcanceledになり、さらにその上まで例外を伝搬する。アプリでこういう呼び出しをしていると例外補足できずにクラッシュしてしまう。

明確な理由はわからないが、スコープビルダー(coroutineScope{})は親をキャンセルしないことになっていて、次のように書けば解決する。

val job = Job()
val scope = CoroutineScope(Dispatchers.Main + job)
scope.launch {
  try {
    // ※クラッシュしないけどこのケースでは非推奨なのでこれは使わないでください
    coroutineScope { 
      async {
        delay(1000)
        throw Exception()
      }
    }.await()
  } catch (e: java.lang.Exception) {
    e.printStackTrace()
  }
}

ただほとんどのワンショット継続はwithContext(Dispatchers.IO)でまかなえるので次に書くようにしたほうがよさそう

val job = Job()
val scope = CoroutineScope(Dispatchers.Main + job)
scope.launch {
  try {
    withContext(Dispatchers.IO) {
        delay(1000)
        throw Exception()
    }
  } catch (e: java.lang.Exception) {
    e.printStackTrace()
  }
}

coroutineScope{}の使い所はスコープを切り換える時、つまりそのスコープの中で別のコルーチンを起動する時。async複数呼び出して並行で実行しまとめて待ち合わせる場合はcoroutineScope{}を使う必要がありそう。

この辺ややこしいし言語化するの大変なので大幅に省くけど、さらにSupervisorという考え方も導入されて混乱に拍車がかかっている。Supervisorはスコープ内のコルーチンがエラーになっても親がキャンセルされずに、他の子コルーチンを継続するやつ。実際使う機会はAndroidではほとんどないよな〜というのが個人的な見解。並行で複数リクエストするけど投機的で相互の失敗に依存しないケースとか。

[1.0.0] experimental packageを廃止 🎊

10/30 🎊

https://github.com/Kotlin/kotlinx.coroutines/releases/tag/1.0.0

experimentalパッケージからはずれてkotlinx.coroutinesになった!めでたい。 マイグレーションも1.3マイグレーションの中でやってくれるので移行も楽。楽しい。

おわりに

リリースノートはしっかり読んだほうがよいですね 😵