Multithreading in Kotlin Multiplatform Apps

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:

One important concept that applies heavily here:

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 be fun, but I didn’t realize that it would actually be fun.

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.

comments powered by Disqus