2024.12.19
システムの穴を運用でカバーしようとしてミス多発… バグが大量発生、決算が合わない状態から業務効率化を実現するまで
Kotlin Nativeのfrozenと並行処理について(全1記事)
リンクをコピー
記事をブックマーク
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はあるし、ノウハウもあるんだけど、とはいえ、どう書くの? みたいな感じかなと思っていて、例えばバグが起きたときに、それが並列処理周りっぽいなと思ったとしても再現ができないので、原因を特定するがとても困難という問題があるのかなと思っています。
これらがあるので並行処理はけっこう難しくて、エンジニア泣かせなのかなと思います。
これを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では並行処理の課題を解決しました。
では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とすると正しく動いてくれます。
次に、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の値を変えると当然クラッシュします。そういうときに意図しない動作をするので、注意が必要なのかなと思います。
最後にちょっとおまけです。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側の感覚だと、「ここでクラッシュするんだ!?」みたいになると思うので、ここはけっこう注意が必要だと思います。
以上です。ご清聴ありがとうございました。
2024.12.20
日本の約10倍がん患者が殺到し、病院はキャパオーバー ジャパンハートが描く医療の未来と、カンボジアに新病院を作る理由
2024.12.19
12万通りの「資格の組み合わせ」の中で厳選された60の項目 532の資格を持つ林雄次氏の新刊『資格のかけ算』の見所
2024.12.16
32歳で成績最下位から1年でトップ営業になれた理由 売るテクニックよりも大事な「あり方」
2023.03.21
民間宇宙開発で高まる「飛行機とロケットの衝突」の危機...どうやって回避する?
PR | 2024.12.20
モンスター化したExcelが、ある日突然崩壊 昭和のガス工事会社を生まれ変わらせた、起死回生のノーコード活用術
2024.12.12
会議で発言しやすくなる「心理的安全性」を高めるには ファシリテーションがうまい人の3つの条件
2024.12.18
「社長以外みんな儲かる給与設計」にした理由 経営者たちが語る、優秀な人材集め・会社を発展させるためのヒント
2024.12.17
面接で「後輩を指導できなさそう」と思われる人の伝え方 歳を重ねるほど重視される経験の「ノウハウ化」
2024.12.13
ファシリテーターは「しゃべらないほうがいい」理由 入山章栄氏が語る、心理的安全性の高い場を作るポイント
2024.12.10
メールのラリー回数でわかる「評価されない人」の特徴 職場での評価を下げる行動5選
Climbers Startup JAPAN EXPO 2024 - 秋 -
2024.11.20 - 2024.11.21
『主体的なキャリア形成』を考える~資格のかけ算について〜
2024.12.07 - 2024.12.07
Startup CTO of the year 2024
2024.11.19 - 2024.11.19
社員の力を引き出す経営戦略〜ひとり一人が自ら成長する組織づくり〜
2024.11.20 - 2024.11.20
「確率思考」で未来を見通す 事業を成功に導く意思決定 ~エビデンス・ベースド・マーケティング思考の調査分析で事業に有効な予測手法とは~
2024.11.05 - 2024.11.05