Multithreading in Kotlin Multiplatform Apps
Apr 16, 2020 · 9 minute read · Commentscode
androidkotlin
update - i did a small presentation about this as part of TouchlabShare - you can watch the video here.
In the past few days, I began looking at multithreading in Kotlin Multiplatform more carefully when I started a new iOS/Android project that I wanted to share business logic for. I realized that, despite understanding the rules of multithreading in Kotlin Native, I didn’t fully grasp the implications thereof. I thought to write this blog post to share what I learned (and as a reference for my future self).
Note - the excellent Practical Kotlin Native Concurrency series from Kevin goes through a lot of these concepts in a lot more detail - I highly recommend reading the series. I hope that this post adds some value to that series by presenting some additional examples, especially viewed from the perspective of someone trying to use shared Kotlin multiplatform code in an iOS app.
The Rules and Concepts
As a review, the rules are that an object is either:
- immutable (and therefore can be shared across multiple threads), or
- mutable (and is confined to a single thread)
One important concept that applies heavily here:
freeze
- freezes an object to be shared between threads - a frozen object is immutable (for the most part) - more on this later.
So let’s take some examples and see how things play out.
The Immutable Case
Singleton Objects
Let’s say that we have written the following multiplatform code:
object MathUtil {
fun square(input: Int) = input * input
}
From Swift’s main
thread, we can do:
// note - MathUtil() doesn't make a new instance, it's just
// Swift syntax for getting the instance of the object.
print(NSString(format: "number^2 is: %d", MathUtil().square(4)))
We can also do:
DispatchQueue.global(qos: .background).async {
print(NSString(format: "number^2 from bg is: %d", MathUtil().square(4)))
}
This second example, while obvious in retrospect, is one I never realized before - you can actually call Kotlin multiplatform code from multiple threads on iOS if the code is immutable. This is a super useful building block that can be used for more complicated examples.
Note that this also works if MathUtil
has some immutable val
properties in there, because Kotlin Native will freeze
those properties by default. This is an important point that will come into play later.
Class Instances
Let’s change things around a bit and consider a normal class (not an object
/singleton).
// common/src/commonMain/kotlin/net/cafesalam/test/NumericOperations.kt
class NumericOperations(private val amount: Int) {
fun square() = amount * amount
}
In iOS, we can use a fresh instance from this class in any thread:
let ops = NumericOperations(amount: 42)
print(NSString(format: "42^2 from main: %d", ops.square()))
DispatchQueue.global(qos: .background).async {
let bgOps = NumericOperations(amount: 10)
print(NSString(format: "10^2 from background: %d", bgOps.square()))
}
Now what if we replaced bgOps.square()
in the body of the async lambda in the snippet above with ops.square()
? We’d get a crash:
Uncaught Kotlin exception: kotlin.native.IncorrectDereferenceException: illegal attempt to access non-shared net.cafesalam.test.common.NumericOperations@337f9a8 from other thread
at 0 SharedCode 0x0000000105ca0777 kfun:kotlin.Throwable.<init>(kotlin.String?)kotlin.Throwable + 87 (/Users/teamcity1/teamcity_work/4d622a065c544371/runtime/src/main/kotlin/kotlin/Throwable.kt:22:37)
at 1 SharedCode 0x0000000105c93545
This is saying that we cannot use the instance of NumericOperations
that we made on one thread on another, because it’s a non-shared instance.
But why? Doesn’t the first rule say that immutable objects (which our class instance clearly is), can be shared across multiple threads? To quote the Stranger Threads post:
As far as the KN runtime is concerned, all non-frozen state is possibly mutable, and restricted to one thread.
So let’s suppose (for whatever reason) that we actually wanted to share the same instance across multiple threads - we can do this by freezing the instance. To do this, we can do something like this:
// common/src/iosMain/kotlin/net/cafesalam/test/Freezer.kt
object Freezer {
fun frozenNumericOperations(amount: Int) = NumericOperations(amount).freeze()
}
Using this class, we can now share the instance across two threads:
// main thread
let frozenOps = Freezer().frozenNumericOperations(amount: 42)
print(frozenOps.square())
DispatchQueue.global(qos: .background).async {
print(frozenOps.square())
}
The Mutable Case
So far, all the examples have only dealt with immutable data and data that could easily be frozen - let’s try adding some mutable state into the mix.
Singleton Objects
Let’s consider this example -
object Counter {
var count: Int = 0
}
If we try to use this from Swift from the main thread:
let counter = Counter()
counter.count = 42
print(counter.count)
When we try to modify count
, we get a crash -
Uncaught Kotlin exception: kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen net.cafesalam.test.common.SecondTestClass@a6abe8
at 0 SharedCode 0x000000010f42a777 kfun:kotlin.Throwable.<init>(kotlin.String?)kotlin.Throwable + 87 (/Users/teamcity1/teamcity_work/4d622a065c544371/runtime/src/main/kotlin/kotlin/Throwable.kt:22:37)
This is because the properties of an object
are all frozen by default. We can override this behavior by using the @ThreadLocal
annotation on the object
. @ThreadLocal
says that each thread gets its own copy of this object.
Changing the code to instead look like this fixes the issue:
@ThreadLocal
object Counter {
var count: Int = 0
}
Now on iOS, we can do something like:
let counter = Counter()
counter.count = 42
DispatchQueue.global(qos: .background).async {
let secondCounter = Counter()
secondCounter.count = 43
// original counter is still at 42 because of ThreadLocal
}
Note that if we were to try to share an instance between the ui thread and a background thread, we’d get an exception (due to the rules - if it’s not immutable, it must be confined to a single thread).
Class Instances
For normal classes with mutable variables, things are pretty straightforward - these instances are confined to the thread they were made on per the rules.
What if we want to have something be mutable but also be usable from multiple threads? How can we make that work?
Remember back to the point about freeze
, when I mentioned that “a frozen object is immutable (for the most part).” The for the most part piece is because Kotlin Native has several interesting types - the atomic types. To quote the documentation:
Atomic values and freezing: atomics AtomicInt, AtomicLong, AtomicNativePtr and AtomicReference are unique types with regard to freezing. Namely, they provide mutating operations, while can participate in frozen subgraphs. So shared frozen objects can have fields of atomic types.
These types can exist within a frozen type (therefore being frozen) and can still be modified. So for example, we can have some shared code that looks like this:
// common/src/iosMain/kotlin/net/cafesalam/test/AtomicCounter.kt
object AtomicCounter {
private val count = AtomicInt(0)
fun get(): Int = count.value
fun increase(): Int = count.addAndGet(1)
}
We can then use this on iOS from multiple threads:
// main thread
let atomic = AtomicCounter()
// first increment
atomic.increase()
DispatchQueue.global(qos: .background).async {
// object is singleton, so this is the same instance as if we were
// to just use atomic here directly.
let counter = AtomicCounter()
print(counter.get()) // prints 1
counter.increase()
print(counter.get()) // prints 2
}
Using these, especially AtomicReference
, makes life a lot easier. Note that, like freeze
, these types are only available in native code (i.e. not on jvm).
Global State
Top level values are (by default) declared on the main thread, preventing them from being used on other threads. Consider:
// common/src/commonMain/kotlin/net/cafesalam/test/Sample.kt
private val EMPTY_DATA = emptyArray<Any?>()
class TestClass() {
private var data = EMPTY_DATA
fun isEmpty() = data.isEmpty()
}
We can make an instance of TestClass
on the main thread and use it, but we cannot make a fresh instance on a background thread. If we try to, we’d get an exception:
Uncaught Kotlin exception: kotlin.native.IncorrectDereferenceException: Trying to access top level value not marked as @ThreadLocal or @SharedImmutable from non-main thread
at 0 SharedCode 0x00000001045f15d7 kfun:kotlin.Throwable.<init>(kotlin.String?)kotlin.Throwable + 87 (/Users/teamcity1/teamcity_work/4d622a065c544371/runtime/src/main/kotlin/kotlin/Throwable.kt:22:37)
We can use the @ThreadLocal
or @SharedImmutable
annotations on the top level variable to fix this issue (depending on the behavior we want). @SharedImmutable
says that this variable is immutable and therefore can be shared across threads (essentially making it frozen).
Other Points
Note that, up until now, there has been no mention of the Worker
class, nor of coroutines - this is pretty neat because, even without these, the existing building blocks allow us to run code in multiple threads on the target system itself.
It would be great to write shared code that does multithreading for us as well directly in commonMain
. Both the Worker
class and coroutines can be used to do this.
Here’s a small example using coroutines:
object BackgroundCalculator {
fun doSomeWork(param: Param, callback: ((Result) -> Unit)) {
GlobalScope.launch {
val result = withContext(Dispatchers.Default) {
// heavy operation here that returns a Result
}
withContext(Dispatchers.Main) {
lambda(result)
}
}
}
}
Using the org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.5-native-mt
artifact (or something newer), Dispatchers.Default
is now backed by a single background thread, whereas Dispatchers.Main
will point to the correct main thread on iOS / Android.
In this example, calling BackgroundCalculator.doSomeWork
will return right away. Some time later, once the heavy calculation is done, it will call the callback that is passed in from the main thread. A sample usage could look like this:
BackgroundCalculator().doSomeWork(param: parameter) { (result: [Result]) in
// this will be called on the main thread
print(result)
}
Note that objects will be frozen when they are transferred between threads using withContext
. This also shows how the basic rules that we mentioned at the very start of the article apply, even with context of coroutines and workers.
Tips
Q: Why couldn’t the cows moo at the same time? A: Because they had a mootex.
isFrozen / ensureNeverFrozen
isFrozen
and ensureNeverFrozen
are your friends for debugging things. These are available in native code only, but using Stately’s common artifact, you can use them in common code only. (Note - Stately
exposes these in common by implementing them as expect
, where the actual
implementation on native calls to the actual isFrozen
/ ensureNeverFrozen
, and otherwise just returns false
or does nothing).
i
expect
-ed this to befun
, but I didn’t realize that it wouldactual
ly befun
.
Unit tests
Unit tests can help find many issues - for example:
class SampleTests {
object MutableObject { var count = 0 }
@Test
fun testMutableObject() {
MutableObject.count = 42
assertEquals(MutableObject.count, 42)
}
}
This innocent looking test will fail with InvalidMutabilityException
if you run it on iOS (i.e. ./gradlew :common:iosTest
). We fix this by adding @ThreadLocal
above the object
declaration as mentioned above.
I learned this (and some other tricks - like how to test and catch the issue of global state without annotations not being accessible off the ui thread) - from this post from Jake Wharton. I really recommend reading it.
Special thanks to Kevin Galligan for reviewing this post.