ミュータブルや複数スレッドで起きがちな問題

Sato Shun氏:『Kotlin Nativeのfrozenと並行処理について』というテーマで発表します。Sato Shunと言います。よろしくお願いします。まず予備知識なんですが、AndroidやiOSなどをそこまで知らなくても聞ける内容で、Kotlin Nativeのことを多少知っていればたぶんわかると思います。

目次です。一般的に並行処理の難しいところをお話しして、その次にKotlin Nativeでどのように課題を解決したかを説明、最後に並行処理について説明したいと思います。

そもそも一般的な並行処理の何が難しいかというと、例えばイミュータブルや、単一スレッドは特に問題はないと思います。

単一スレッドだったら、1つのインスタンスがあったときに同時に修正や編集がされることがないので問題にはなりません。そもそもイミュータブル、つまり変異不可能なインスタンスであれば並列処理の問題は起きないので、この2つの状態であれば問題はありません。

一方で、例えばvarみたいな感じで定義されていて、値がコロコロと変わるようなインスタンスや、複数スレッドであるときは問題が起きがちです。

例えばAというインスタンスがあって、それに同時にアクセスして、同時に編集している途中で値を呼び出してしまうと問題が起きてしまいます。

それを解決するためには、例えばJavaだとsynchronizedブロックや、mutexみたいな排他ロックを使います。ほかにも、例えばAtomic IntやAtomicなんとかみたいなAPIを使うソリューションもあります。

ただこれは選択肢も多いです。「Twitter」でもたまに「この並列処理の書き方ってどうなんですかね?」みたいな議論が起こっているくらいなので、けっこう難しい話なのかなと思っています。適切に書くのはとても難しいのかなと思っています。

いろいろなAPIはあるし、ノウハウもあるんだけど、とはいえ、どう書くの? みたいな感じかなと思っていて、例えばバグが起きたときに、それが並列処理周りっぽいなと思ったとしても再現ができないので、原因を特定するがとても困難という問題があるのかなと思っています。

これらがあるので並行処理はけっこう難しくて、エンジニア泣かせなのかなと思います。

「frozen」状態でインスタンスを変異不可にする

これをKotlin Nativeではどのように解決したかお話しします。そもそも、ミュータブルな状態のインスタンスをシェアすることで問題になったので、ミュータブルな状態のインスタンスをシェアしなければいいんじゃない? というところで、この並行処理の課題の解決を目指しました。

具体的には、Kotlin Nativeでshared XOR mutable制約という制約を導入しました。先ほど言ったとおり、複数スレッドかつ変異可能なときに並行処理の問題が起きるので、そもそもこれを防げばいいという発想です。

shared XOR mutable制約はKotlin Nativeだけではなくて、例えばRust言語でも使われている制約です。実装はいろいろとあって、Rustの場合はランタイム時ではなくてコンパイル時にチェックしているんですが、Kotlin Nativeの場合は言語を大きくいじることになるので、コンパイル時のチェックはゆるめにしておいて、ランタイム時にshared XOR mutable制約を満たす実装をしました。

具体的な実装をしたうえで、Kotlin Nativeではfrozen状態を導入しました。frozen状態は何かというと、インスタンスがfrozenしているのでなんとなく想像はつくと思うんですが、変異不可能であることを保証するものです。

frozenにするためにどうしたらいいかというと、Kotlin NativeはこのようなAPIを提供しています。拡張関数で.freezeと呼び出すと、それがイミュータブルになります。例えばuser.freezeと呼び出すと、そのuserインスタンスがフリーズしてイミュータブルな状態になります。

イミュータブルになるとどうなるかというと、変異が不可能になります。例えばさっき言ったuserインスタンスがフリーズしたとして、変更しようとするとクラッシュします。

先ほどランタイム時の話をちらっとしたんですが、ランタイム時にクラッシュします。コンパイル時ではないのでちょっと遠いんですが、実行したら必ずクラッシュします。並列処理でたまにクラッシュするのではなくて、100パーセントクラッシュする状態を作れます。

イミュータブルだと、先ほど言ったXOR mutable制約を守れるので安全にほかのスレッドと共有することが可能になります。

frozenした状態かどうかをチェックするisFrozenという拡張プロパティも用意されていて、これを呼び出すことでそのインスタンスがfrozen状態かどうかを確認できます。

また、1回freezeしたものはunfreezeできなくて、1度イミュータブルにしたらずっとイミュータブルな状態です。

具体的なサンプル構造で説明します。例えばSampleというインスタンスがあって、2行目でsample.i++と中身を書き換えます。この場合は特にエラーは起きず、3行目で1回freezeして、4行目でi++とやると、ここでクラッシュします。

invalidMutabilityExceptionというクラッシュログを吐いてクラッシュします。これはfreezeしてイミュータブルにしたのに、値を書き換えようとしたのでクラッシュしています。このようにして、Kotlin Nativeでは並行処理の課題を解決しました。

iOSとAndroidで挙動が異なるので注意が必要

ではfrozenですべて解決したのかと言うと、そうとも言えません。この機能は、界隈ではけっこう賛否両論があるのかなと思っています。並行処理が安全に書けるならいいじゃんと思うかもしれませんが、JVM(Android)ではそもそもfrozenという状態がありません。

iOS、Androidでけっこう挙動が異なるんですよ。Kotlin Nativeではfrozenがあってそれを意識しなくてはいけないのに、Androidでは特に意識しなくてもいいんです。なのでこの機能だと、Androidでは動くけれどiOSではクラッシュするみたいなことが起こりがちなのかなと思っています。

クラッシュするのが早いので、多少安全にはコーディングできますが、JVM(Android)とKotlin Nativeではけっこう挙動が異なるので、その点で否の意見がけっこうあるのかなと思います。

また、先ほどは.freezeと明示的に呼び出したんですが、暗黙的にfreezeしてしまうパターンもあります。

objectだとcompanion objectみたいなシングルトンなインスタンスを定義できると思うんですが、シングルトンは自動でfreezeする仕様なので、例えばDefaultGlobalStateというオブジェクトを作って、iという変異可能なフィールドをもったときにcountupをコールすると、JVM(Android)では特に特にクラッシュはしないんですが、Kotlin NativeではinvalidMutabilityExceptionとクラッシュしてしまいます。Kotlin Nativeの場合は、基本的にオブジェクトの中ではvarみたいな変異可能なものはもてません。

どうしてももちたいときは、例えばThreadLocalを使うと一応回避はできます。ただ、ThreadLocalというアノテーションから推測するに、例えばThread1と2という2つのものがあったときに、別々のインスタンスを見に行ってしまうので、クラッシュはしないんですが、たぶん挙動が異なって、シングルトンではなくなってしまいます。

このThreadLocalを付けて仕様が満たせるのであれば、ThreadLocalアノテーションを付けてもいいと思います。Atomic系のライブラリも提供しているので、例えばAtomic Intを使うというアプローチもあります。

このように、JVM(Android)とKotlin Nativeではこのあたりの挙動が微妙に異なることがあるので注意が必要です。

frozenのまとめです。shared XOR mutableを満たすためにfrozenが導入されました。これにより、並列処理を安全に扱えるようになりました。

ですが、先ほどのobjectのように「iOSだけ動かないぞ」みたいなこともたまに起こります。このあたりは慣れの話なのかもしれませんが、挙動が違うというところはけっこう注意が必要なのかなと思います。

複数スレッドではインスタンスをイミュータブルにする

frozenはここまでにして、次にKotlin Nativeの並行処理について話していきます。Kotlin Nativeで並行処理なコードが書きたいとき、一番低いレベルのものとしてWorkerクラスが用意されています。これはJavaで言うところのThreadクラスみたいなものです。

先ほどのshared XOR mutable制約のとおり、基本的に複数スレッドの場合はミュータブルなものを共有できません。スレッド間をまたぐときは複数スレッドなのでイミュータブルにする必要があり、つまりfrozenする必要が常にあります。

基本的に、WorkerAPIを使うときはインスタンスを全部freezeする必要があります。それを踏まえて具体的なコードを見ていきます。workerにはWorker.startというAPIが提供されていて、Worker.startと呼び出すとworkerのインスタンスを生成してくれます。

それを実行するために、executeAPIをコールします。第1引数、第2引数、第3引数にいろいろと指定してあげます。

シグニチャーはこのように定義されています。まず、TransferModeでSAFEかUNSAFEかを指定します。先ほど言ったとおり、frozenじゃないものをworker内で使おうとするとクラッシュするんですが、UNSAFEにすると、その制約がクラッシュさせません。

ただ、それだとUNSAFEという名前のとおりSAFEではないので、どうしてもこれはSAFEにできないぞというときにUNSAFEにするくらいで、基本的にはSAFEにして起動します。

第2引数のproducerは、worker内で使うインスタンスを渡します。最後worker内の実装、実態を書きます。

例えばSampleというインスタンスがあって、これをworker内で使いたいというときには、このような感じでworker.executeとコールして、TransferModeを指定して、このworker内で使う値を第2引数から渡してて、第3引数で適当に実装します。

これで動くかというと、動きません。このコードだと、IllegalStateExceptionと出てしまいます。

先ほど、複数スレッドの場合はfreezeしないと送れないという話がありましたが、このSampleはまだfreezeしていないので、このようなコードを書くとSamlpeのstateがおかしいぞみたいなExceptionが吐かれます。sample.freezeとすると正しく動いてくれます。

Kotlin Nativeは連鎖的にインスタンスをfreezeする

次に、sampleとsample2みたいに2つのインスタンスをworker内で使いたいときについて考えていきます。

先ほども言ったとおり、第2引数は基本的には1つしか送れないので、sample.freezeみたいな感じで1つは送れるんですが、2つは送れません。

これをどうすればいいかというと、いろいろな解決方法はあるのですが、今回はbackgroundというメソッドを作って、自分で実装して解決してみたいと思います。

このbackgroundは自分で実装して、第1引数に関数型を受け取り、Lambdaを受け取って、そのLambdaをfreezeして、それをコールします。it()なので、そのファンクションをコールするというシンプルなメソッドです。

これをどう使うかというと、backgroundという関数をLambdaで呼び出して、その中でsampleとsample2を呼び出します。このように書くことで、sampleとsample2の2つの状態を別スレッドの中で使うことができます。

ここで「あれ?」と思った人がもしかしたらいるかもしれません。Lambdaはfreezeしているけれど、sampleとsample2はfreezeしていないからExceptionが出るんじゃない? と感じるかもしれませんが、これは正しく動きます。

sampleとsample2を明示的にfreezeしていないのになぜ正しく動くかというと、Kotlin Nativeの実装になっているからです。Kotlin Nativeは、とあるインスタンスをfreezeしたときに、そのインスタンスが参照している変数も連鎖的にfreezeされるという挙動をします。

今回の場合はLambdaをfreezeしました。そのLambdaはsampleとsample2を参照しているので、sampleとsample2も自動的にfreezeするという挙動をします。なので、sampleとsample2を別に明示的にfreezeする必要はありません。このようなコードでも正しく動きます。

並行処理のまとめです。Kotlin NativeにはWorkerという低レベルなAPIが用意されていて、スレッド間をまたぐ場合は明示的にfreezeする必要が基本的にあります。あるインスタンスをfreezeすると、参照しているインスタンスも連鎖的にfreezeします。

3つ目の「連鎖的にfreezeする」なんですが、僕もこんな挙動するんだって思ったので、けっこう注意が必要な仕様かなと思います。例えばuserというクラスがあって、userがAとBというインスタンスを参照しているときにuserをfreezeすると、AとBもfreezeするんですよね。

なので、AとBの値を変えると当然クラッシュします。そういうときに意図しない動作をするので、注意が必要なのかなと思います。

高レベルなAPI Coroutineを使うときの注意

最後にちょっとおまけです。Workerは低レベルなので、使うことはないのかなと思っていて、実際の開発ではCoroutineみたいな高レベルなものをたぶん使うのかなと思います。

CoroutineはKotlin Nativeでは、現状単一スレッドバージョンと複数スレッドバージョンの2つのバージョンがあります。それはプロジェクトによって適切なほうを選べばいいと思うんですが、さっきのfrozenの視点からするとけっこう挙動が違います。

例えばこのようなコードがあったとします。sampleというインスタンスがあって、sample.isFrozenを呼び出して、withContextを使うときにisFrozenの値がどう変わるかというと、2行目のisFrozenは当然freezeしないのでfalseになります。

では5行目の、2個目のisFrozenがどうなるかというと、単一スレッドの場合はfalseになります。なぜかというと、単一スレッドは特にfreezeしなくてもいいのでfalseになるんです。複数スレッドバージョンの場合は、isFrozenはtrueになります。なので自動的にsampleがfreezeします。

これは単純にCoroutine側の実装がそうなので、そういうふうになります。このように、ライブラリ側で気を利かせてfreezeしてくれたりするので、そこはけっこう注意が必要なのかなと思います。

Android側の感覚だと、「ここでクラッシュするんだ!?」みたいになると思うので、ここはけっこう注意が必要だと思います。

以上です。ご清聴ありがとうございました。