この記事はKotlin Advent Calendar 2021 の22日目の記事です。
Kotlin Advent Calendarに参加するのは今年で7年連続7回目になりました🎉🐦
毎年22日近辺を書いています。マイルストーンの時から書いている記事もあるので情報が古くなっているものもありますが、
過去にはこんな記事を書いていました。
2018年 22日: Kotlin Multiplatform環境でKotlin SerializationとAndroid ExtensionsのParcelize Annotationを使う
2019年 20日(代打): オレの考えた最強のKotlin Multiplatform Projectアーキテクチャ2020
今年は先日リリースされた Kotlin1.6から導入されたKotlin/Nativeの新しいメモリマネジメントによってfreezeを使わなくても、オブジェクトがスレッド間でアクセスが可能になったのを確認したいと思います。
遠回しに言いましたが、要するにKotlin/Nativeでもついに簡単にバックグラウンドスレッドが扱えるようになりました 🎉
どう変わったか
2018年にKotlin Multiplatform Projectに本格的に触れてから、ずっとこの日を待ちわびていました。
知らない方のために説明するとKotlin/Nativeでは今までオブジェクトをスレッドで間で共有ができませんでした。
そのため、スレッドを跨ぐ際には値をfreezeという方法でimmutableにしてから共有する必要がありました。
[参照: Kotlin Native Concurrency
]
この問題はKotlin/Nativeがリリースされた当初からIssueとして取り上げられており、こちら
で議論され続けています。
今回は、Kotlin Hands-on にあるこちら のコードを使わせていただきます。
以前の挙動
freeze
freezeという方法でimmutableにすると説明しましたが、コードで示すとこのようになります。
data class SomeMutableData(var i:Int)
val smd = SomeMutableData(3)
smd.freeze()
SomeMutableDataのインスタンスを作って、オブジェクトに生えてる freeze()
メソッドを呼び出します。
ここでは詳しい説明はしませんがfreezeはGenericsの拡張関数として定義されているため、Kotlin/Nativeでは全てのオブジェクトで利用できます。
freeze()
はオブジェクトをimmutableにするため、以下のコードはインクリメントするところで落ちてしまします。
val smd = SomeMutableData(3)
smd.i++
println("smd: $smd") // 4
smd.freeze()
smd.i++ // crash
オブジェクトがfreezeされているかどうかは isFrozen
で判定できます。freeze()
はそのオブジェクト自体だけではなくそのオブジェクトが参照する全てのプロパティもfreezeします。
data class SomeData(val s:String, val i:Int)
data class DataWithReference(val child:SomeData)
val dataWithReference = DataWithReference(SomeData("Hello 🐶", 22))
dataWithReference.freeze()
println("Am I frozen? ${dataWithReference.child.isFrozen}") // Am I frozen? true
ThreadLocal
また1.6より前ではGlobalに定義された値も変更を加えることができませんでした。
object DefaultGlobalState {
var i = 5
}
println("i ${DefaultGlobalState.i}") // i 5
DefaultGlobalState.i++ // crash
解決策として @ThreadLocal
annotationをつければ通常通り利用可能でした。
@ThreadLocal
object ThreadLocalGlobalState {
var i = 5
}
println("i ${ThreadLocalGlobalState.i}") // i 5
ThreadLocalGlobalState.i++
println("i ${ThreadLocalGlobalState.i}") // i 6
@ThreadLocal
は各Thread毎にLocal変数として扱う宣言をしています。
そのため、MainThread以外(他のThrad)からアクセスをしても別のものとして扱われてしまいMutable状態でThread間の共有はできません。
println("main thread: i ${ThreadLocalGlobalState.i}") // main thread: 5
ThreadLocalGlobalState.i++
println("main thread: i ${ThreadLocalGlobalState.i}") // main thread: 6
background {
println("other thread: i ${ThreadLocalGlobalState.i}") // other thread: 5
}
上記の background
ブロックの処理はここでは解説しないのでこちら
を参照してください。
一部のみ解説するとworkerを使って他のスレッドでブロック内を実行していますが、freeze()
で必ずimmutableにしています。
private val worker = Worker.start()
private val collectFutures = mutableListOf<Future<*>>()
fun background(block: () -> Unit) {
val future = worker.execute(TransferMode.SAFE, { block.freeze() }) {
it()
}
collectFutures.add(future)
}
SharedImmutable
この挙動はdata classに限らずGlobalにあるプロパティも同じです。
var globalCounterData = SomeMutableData(33)
background {
globalCounterData.i++ // crash
}
こちらの場合は @SharedImmutable
annotationをつけて予めimmutableにさせます。
@SharedImmutable
val globalCounterDataShared = SomeMutableData(33)
background {
println(globalCounterDataShared) // SomeMutableData(i=33)
}
globalCounterDataShared.i++ // crash
1.6からの挙動
Kotlin1.6からはこれらの問題が解決されてスレッド間でのオブジェクトの扱いが楽になっています。
とはいえ、まだexperimentalなため有効にするには gradle.properties
に設定を書く必要があります。
kotlin.native.binary.memoryModel=experimental
freeze
まずはfreezeから確認していきます。
当然ですが、1.6でmemoryModelをexperimentalにしてもfreeze()
した場合はエラーが起きます。
freezeの挙動自体は変わっていません。
val smd = SomeMutableData(3)
smd.i++
println("smd: $smd") // 4
smd.freeze()
smd.i++ // crash
ThreadLocal
準備として、blockの freeze()
は不要なので外します。
fun background(block: () -> Unit) {
val future = worker.execute(TransferMode.SAFE, { block }) {
it()
}
collectFutures.add(future)
}
以前はincrementの箇所で落ちていましたが @ThreadLocal
をつけなくてもインクリメントできています。
object DefaultGlobalState {
var i = 5
}
println("i ${DefaultGlobalState.i}") // i 5
DefaultGlobalState.i++
println("i ${DefaultGlobalState.i}") // i 6
次はバックグラウンドスレッドからインクリメントしたオブジェクトにアクセスしてみます。
println("main thread: i ${DefaultGlobalState.i}") // main thread: i 5
DefaultGlobalState.i++
println("main thread: i ${DefaultGlobalState.i}") // main thread: i 6
background {
println("other thread: i ${DefaultGlobalState.i}") // main thread: i 6
}
スレッドを跨いでも値が共有されています👏
ThreadLocalもannotationをつけた際の挙動は変わっていないので注意しましょう。
@ThreadLocal
object ThreadLocalGlobalState {
var i = 5
}
println("main thread: i ${ThreadLocalGlobalState.i}") // main thread: 5
ThreadLocalGlobalState.i++
println("main thread: i ${ThreadLocalGlobalState.i}") // main thread: 6
background {
println("other thread: i ${ThreadLocalGlobalState.i}") // other thread: 5
}
SharedImmutable
Globalのプロパティも同様に @SharedImmutable
annotation不要でスレッド間で値を共有できています 👏
var globalCounterData = SomeMutableData(33)
println("main thread: i ${globalCounterData.i}") // main thread: i 33
globalCounterData.i++
background {
println("other thread: i ${globalCounterData.i}") // other thread: i 34
}
まとめ
今まではiOS等のKotlin/Nativeに対応する際はスレッドを気にして実装していく必要があったため、慣れていないとハマりポイントがいくつかありました。
しかし、ついにKotlin/Nativeにおける最大の弱点であったConcurrency問題にも待望の機能が実装されKotlin Multiplatform Projectが本格的に実用段階になったのではないでしょうか。
2022年もKotlinのさらなる進化に期待しましょう!
Have a nice Kotlin!