loading...
Touchlab

Practical Kotlin Native Concurrency Part 2

kpgalligan profile image Kevin Galligan ・6 min read

Practical Kotlin Native Concurrency (3 Part Series)

1) Practical Kotlin Native Concurrency 2) Practical Kotlin Native Concurrency Part 2 3) Practical Kotlin Native Concurrency Part 3

Back in part 1, you learned a bit about KN's state rules, installed and ran Intellij, and ran some sample code. In part 2 we'll dig a little deeper.

First, update the sample! There may have been repo changes. Undo anything local and run git pull.

git checkout .
git pull

3) Global State

Before we get into actual concurrent code, lets chat a bit about global state. Some things in Kotlin can be referenced globally, from any thread. As a result, KN treats them with special rules.

These are kind of weird. However, there are only 2 types of global state, so just remember the weirdness.

object is frozen

Any global object is frozen, but that instance can be shared and referenced from any thread.

Find 3) Global State in SampleMacos.kt and uncomment cantChangeThis().

fun cantChangeThis(){
    println("i ${DefaultGlobalState.i}")
    DefaultGlobalState.i++
    println("i ${DefaultGlobalState.i}") //We won't get here
}

object DefaultGlobalState{
    var i = 5
}

This will compile fine, but all global object instances are frozen by default, so you'll see your old friend InvalidMutabilityException.

If you need that state to be mutable you do have some options. You can use atomics, which we'll talk about later, but for now you can make that object thread local. That means each thread has it's own copy, and according to rule #1, mutable state == 1 thread.

Run canChangeThreadLocal().

fun canChangeThreadLocal(){
    println("i ${ThreadLocalGlobalState.i}")
    ThreadLocalGlobalState.i++
    println("i ${ThreadLocalGlobalState.i}")
}

@ThreadLocal
object ThreadLocalGlobalState{
    var i = 5
}

Notice the annotation @ThreadLocal. That tells the runtime that each thread has an instance defined. Running canChangeThreadLocal() will print

i 5
i 6

Keep in mind that if you were accessing ThreadLocalGlobalState from another thread, the value of i would be different.

Just an FYI. These rules apply to companion objects as well.

properties are main thread only

This is the "weird" rule. Global properties are neither thread local nor frozen. They are only visible to the main thread 1. This will seem odd, but just keep it in mind.

To be clear, global functions are fine. We're just talking about global properties.

Run globalCounting()

fun globalCounting(){
    globalCounterData.i++
    println("count ${globalCounterData.i}")
    globalCounterData.i++
    println("count ${globalCounterData.i}")
}

var globalCounterData = SomeMutableData(33)

You'll see

count 34
count 35

We haven't explained how to leave the main thread yet, but as a quick preview to demonstrate that properties are main thread only, run globalCountingFail(). I won't show the code, but you'll get an exception:

kotlin.native.IncorrectDereferenceException: Trying to access top level value not marked as @ThreadLocal or @SharedImmutable from non-main thread
        at 0   KNConcurrencySamples.kexe   (blah blah blah)
        at 1   KNConcurrencySamples.kexe   (blah blah blah)
(etc)

Trying to access the top-level state fails2.

You can annotate top-level state with either @ThreadLocal, which will give each thread a copy, or with @SharedImmutable, which will make that state frozen and shared. We'll skip the code for that, though, and move on.

Actual Concurrency

We're going to leave the main thread now. In other explanations, I start by going deep on Worker. Worker is KN's primary concurrency primitive. It's a job queue that has it's own private thread.

We won't be going over Worker. Assuming you are using a library to manage concurrency, you won't interact with Worker much, if at all. For a deeper understanding of what's happening, again I'd point you at my previous posts and talks, and if you're really curious, the JetBrains docs and videos go way into it.

Emerging Model

You can technically attempt to pass mutable state to other threads. In practice, KN concurrency libraries will freeze state when it goes between threads. This is true of the libraries we've been using internally, and the soon-to-be-released kotlinx.coroutines implementation.

In this post we'll create a simple background thread processor. In a later post, we'll look at the kotlix.coroutines preview.

4) Background

We've created a simple background task processor function to demonstrate crossing threads. The signature is this:

fun background(block: () -> Unit)

We're not going to walk through that code because it uses worker and requires understanding a bit more complexity than we need.

The important parts:

1) The argument is a function, which will be executed in another thread
2) That function argument itself will be frozen

Here is a basic example. Run basicBackground()

fun basicBackground(){
    println("Is main thread $isMainThread")
    background {
        println("Is main thread $isMainThread")
    }
}

We've also isMainThread, which will require you're doing this on a Mac unless we get a PR that enables other platform. Anyway, the function prints the following.

Is main thread true
Is main thread false

That gets us onto another thread to do work, but obviously doesn't do much. This next example will pass some state into the other thread.

This is very important to understand. If you were skimming the post, now is a good time to s l o w d o w n.

Run captureState():

fun captureState(){
    val sd = SomeData("Hello 🥶", 67)
    println("Am I frozen? ${sd.isFrozen}")
    background {
        println("Am I frozen now? ${sd.isFrozen}")
    }
}

This will print:

Am I frozen? false
Am I frozen now? true

It is very important to understand that function args can capture state, and when they are frozen, that state is also frozen.

As an alternate visualization, see below. When we call background, that function argument is frozen, and since it captures sd, which is a local val in the calling function, sd is also frozen.

Alt Text

Local values are easy to understand and deal with. Where this becomes more problematic, and triggers more controversy, is when unintended state is captured.

Run captureTooMuch()

We'll be using a more complex model class that has a number you can increment, and when you do, we push that value into a "database" in the background:

class CountingModel{
    var count = 0

    fun increment(){
        count++
        background {
            saveToDb(count)
        }
    }

    private fun saveToDb(arg:Int){
        //Do some db stuff
    }
}

Our function to use the model looks like this:

fun captureTooMuch(){
    val model = CountingModel()
    model.increment()
    println("I have ${model.count}")

    model.increment()
    println("I have ${model.count}") //We won't get here
}

What's the result? Our old friend, InvalidMutabilityException!

I have 1
Uncaught Kotlin exception: kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen sample.CountingModel@17e01008
        at 0   KNConcurrencySamples.kexe   (nah)
        at 1   KNConcurrencySamples.kexe   (meh)
(etc)

To help illustrate the issue here, consider captureTooMuchAgain()

fun captureTooMuchAgain(){
    val model = CountingModel()
    println("model is frozen ${model.isFrozen}")
    model.increment()
    println("model is frozen ${model.isFrozen}")
}

This prints

model is frozen false
model is frozen true

count is an instance var of CountingModel, so capturing count in that way will capture the whole model and freeze it.

To help avoid this, it is good practice to move thread-jumping code to another function, and only capture function arguments. Try captureArgs():

fun captureArgs(){
    val model = CountingModelSafer()
    model.increment()
    println("I have ${model.count}")

    model.increment()
    println("I have ${model.count}")
}

class CountingModelSafer{
    var count = 0

    fun increment(){
        count++
        saveToDb(count)
    }

    private fun saveToDb(arg:Int) = background {
        println("Doing db stuff with $arg, in main $isMainThread")
    }
}

Debugging

Going back to captureTooMuch(), we get the InvalidMutabilityException when we try to increment, but that's not super helpful. We want to know when/how our data got frozen in the first place. Obviously, in this example, it's not hard to track down, but in more complex examples it will be.

You can mark an object that should never be frozen with ensureNeverFrozen(). When freeze is happening, the runtime will throw an exception at the point of freezing. Run captureTooMuchSource()

fun captureTooMuchSource(){
    val model = CountingModel()
    model.ensureNeverFrozen()
    model.increment()
    println("I have ${model.count}") //We won't even get here
}

That will get us an exception that traces right back to the root issue:

Uncaught Kotlin exception: kotlin.native.concurrent.FreezingException: freezing of sample.CountingModel.$increment$lambda-0$FUNCTION_REFERENCE$2@7d601058 has failed, first blocker is sample.CountingModel@7d601008
        at 0   KNConcurrencySamples.kexe   (ugh)
        at 1   KNConcurrencySamples.kexe   (stuff)
(skip a few lines...)
        at 13  KNConcurrencySamples.kexe           0x00000001088c570d kfun:sample.background(kotlin.Function0<kotlin.Unit>) + 301 (/Users/kgalligan/temp/KNConcurrencySamples/src/macosMain/kotlin/sample/BackgroundSupport.kt:12:12)
        at 14  KNConcurrencySamples.kexe           0x00000001088c5102 kfun:sample.CountingModel.increment() + 258 (/Users/kgalligan/temp/KNConcurrencySamples/src/macosMain/kotlin/sample/Background.kt:35:9)
        at 15  KNConcurrencySamples.kexe           0x00000001088c547e kfun:sample.captureTooMuchSource() + 286 (/Users/kgalligan/temp/KNConcurrencySamples/src/macosMain/kotlin/sample/Background.kt:77:11)

The stack trace is a bit verbose, but you'll be able to walk back to the source issue.

End

That's a wrap for Part 2. In Part 3 we'll wrap up the primitives with atomics, then introduce various libraries you can use for concurrency in you applications.


  1. I don't know for sure why they made that decision. However, I suspect it's because you can get into trouble. Early on, global vals were thread-local. If you create a Worker as a global, it'll start a new thread. That thread gets it's own Worker, which starts a new thread. Etc. Good times. KN no longer does this. 

  2. Sort of? Apparently, simple integer data at least can be shared and mutable. Run globalCountingTil(). You'll need to add that method manually to main. It lets you edit a global Int. Not sure how why, but investigating. 

Practical Kotlin Native Concurrency (3 Part Series)

1) Practical Kotlin Native Concurrency 2) Practical Kotlin Native Concurrency Part 2 3) Practical Kotlin Native Concurrency Part 3

Posted on by:

kpgalligan profile

Kevin Galligan

@kpgalligan

#kotlin Multipatform and Native

Touchlab

We are the Kotlin Multiplatform experts. We partner with mobile engineering leaders to accelerate feature development, maximize efficiency, and future-proof teams.

Discussion

markdown guide