December 22, 2021

さよならfreeze! ついにきたKotlin/Native New Memory Management!!

この記事はKotlin Advent Calendar 2021 の22日目の記事です。

Kotlin Advent Calendarに参加するのは今年で7年連続7回目になりました🎉🐦
毎年22日近辺を書いています。マイルストーンの時から書いている記事もあるので情報が古くなっているものもありますが、 過去にはこんな記事を書いていました。

今年は先日リリースされた 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!

© AAkira 2021