· 10 min read Posted by Kevin Galligan
Kotlin/Native (Stranger) Threads
Chapter 1: Getting Started
This is going to be a multi-part series on threading in Kotlin Native. To build a reasonably functional mutltiplatform implementation, understanding threads is critical. The Kotlin/Native design and the way threads are intended to be used is quite different than what we’re used to in Java land. I’ll be learning this as we go, but as many have pointed out, if you really want to understand something, try explaining it to others.
Why Stranger Things theme? KN is opinionated about threading and state. To keep you out of trouble, it creates a world where different threads mostly can’t see each other’s data, and have very little interaction. However, underneath that world is a similar looking, but far more powerful (and potentially dangerous) world where you have access to everything. It’s the Upside Down, basically¹. We won’t be talking about that much right now, but we’ll definitely need to poke around and make some new structures to enable productive app development.
Tools
You’ll need some kind of test environment to run things in. For now I’d suggest downloading CLion. Read this on installing Kotlin/Native support. As mentioned in the beginning, I’m just getting started with this. The same is true of CLion. I hope we’ll be able to do KN in Intellij directly, but CLion appears to be the tool for the bulk of KN and C++ development. That means learning CLANG, at a minimum. Not right away, though. We can poke around for a while without understanding CLANG.
To verify everything is working, create a new KN project and use the HelloWorld sample.
CLion New Project Dialog
Hit the play button in the action panel and you should see “Hello, Native World!” printed to console.
Source
We’ll be trying out some stuff. To run the source, clone this:
Docs
This is probably a good time to step back and say if you don’t have a pretty decent understanding of how threads work in general, you can run through the samples, but you may be lost often. The intro stuff will probably be useful, but its safe to say we’ll also wind up in the weeds periodically. Will update with good intro resources if I come across them.
Anyway, back to KN. Read the CONCURRENCY doc in the KN repo. It explains a lot. Today we’ll go over the basics of Worker, how to pass “hot” data, and what data freezing is.
Worker
Workers are the core unit of concurrency provided for out of the box with KN. I’ll start by saying this:
Each thread, including the main thread and each worker, has it’s own copy of state.
I’ll repeat this a few times in this post, so if there’s an important takeaway, it’s that.
You create workers and pass them jobs to complete. I would imagine there are implementations where this doesn’t correspond to a thread, but right now, for iOS at least, that will boil down to a thread. A worker is a thread with a job queue. Also, remember, it’s own copy of state.
Simple Example
Let’s make a worker and have it do something.
Call ‘startWoker’ to start a worker. This will create the thread and make it available for jobs.
You create jobs with “schedule”. That method takes a TransferMode param, a producer lambda, and the actual job, also as a lambda.
Inside the job lambda is the work you actually do.
Scheduling returns a Future. You can use this to get the job result inside the calling thread. We call ‘consume’ simply to wait until the job is done.
If you’re manually creating workers and leaving that context, shut them down properly with ‘requestTermination’.
Let’s dig into each piece in more detail.
Worker Lifecycle
The worker is a managed resource that you create, and if you’re going to be forgetting about it, you probably want to shut down. That corresponds to two methods: startWorker() and requestTermination().
There’s not much to say about ‘startWorker’, other than it creates a worker queue. ‘requestTermination’ is a little more interesting. By default it’ll finish all jobs in its queue, but you can pass in a boolean ‘false’ to tell it to shut down asap.
If you’re creating workers, you should shut them down. If you’re creating a set of workers that are intended to stick around with your app at all times, don’t worry about it. If your process gets killed, so with your threads.
Consume
The schedule call returns a Future. On that you can check status and get results of the job. ‘consume’ will force your thread to wait for the job to finish, and pass the result of the job into the lambda. In our simple case, we return nothing, so the lambda accepts nothing.
Scheduling Work
Now that the simple stuff is out of the way, it’s time to look at scheduling work. This is where we need to get very precise.
The schedule method takes 3 parameters.
- TransferMode — Checked or Unchecked. The “safe” option is Checked.
- Producer lambda — This should return the data that will be passed into your job.
- Job lambda — This is the “work” that will be run on the different thread. It has a single input param, which is the output of the producer lambda. You can return a value, which can be read by your calling process.
The primary complication around all of these parameters is that your data needs to cross between threads. As mentioned, each thread has it’s own state, and isn’t allowed to see others’ state. How can that happen? To stick with the post theme, you’re sending something through the Upside Down and back again. To do that safely, there are some considerations.
The Lambdas
The goal of how workers interact is to protect state. KN will do some magic to check that the state you’re passing isn’t referenced anywhere else OR that your state is frozen. Frozen is a runtime immutable state that we’ll discuss later.
In other words, KN wants to make sure your state is safe. Either nobody else can see it, or nobody can change it (including you).
How does it check? That’s an Upside Down topic for a different chapter.
Producer Lambda
This lambda returns an instance of data that is passed into the job lambda. It’s run in the same thread that calls it.
Why a lambda you ask? The runtime wants to make super sure you’re not dragging data around. The KN runtime is serious about references. So much so that passing hot data into a worker isn’t necessarily an easy operation.
This will fail because there’s a local reference:
fun failedReference(){
val worker = startWorker()
val dat = SomeData("asdf")
worker.schedule(TransferMode.CHECKED, {dat}){
Even if you don’t plan on doing anything to ‘dat’, the KN runtime will get upset. More curious, this works:
var glob = SomeData("asdf")
fun getAndClearGlob():SomeData{
val temp = glob
glob = SomeData("qwert")
return temp
}
fun globalReference(){
val worker = startWorker()
worker.schedule(TransferMode.CHECKED, {getAndClearGlob()}){
After calling ‘getAndClearGlob’, nothing is pointing at the object originally at ‘glob’. That seems logical! How about this?
var glob = SomeData("asdf")
fun getAndClearGlob():SomeData{
val temp = glob
glob = SomeData("qwert")
return temp
}
fun globalReference(){
val worker = startWorker()
glob = SomeData("inside")
worker.schedule(TransferMode.CHECKED, {getAndClearGlob()}){
That will fail. You and I know you’re not doing anything with the reference created inside the method, but KN’s worried about it.
All Threads Have Their Own State
I’m reminding you of this again here so I can point out an issue with the sample above. Keeping data at the “global” scope just so you can pass it to a worker is probably not a great design, but because all threads get their own state, the worker will have it’s own copy of ‘glob’ that does nothing but take up space and init/management cycles.
If you really want to ruin your day, try this:
val worker = startWorker()
fun doNotDoThis(){
worker.schedule(TransferMode.CHECKED, {}){
for(i in 0..1000000)
{
//Let's kill time
val b = (i+i).toDouble()/1000.toDouble()
}
}.consume { }
worker.requestTermination()
}
The top level ‘worker’ can be called from multiple methods to start jobs. Nice! Right? Well, ‘startWorker’ creates the worker, which gets its own copy of state, which creates a worker, which gets its own copy of state…
That’s super bad. Got bit by that right out of the gate.
We’ll probably have a much longer discussion of how to structure “hot” data to get it over the wall to other threads. Here are some other strategies.
Freeze
If you’re following general best practices, the data being passed through threads should be immutable anyway. KN has a special method called ‘freeze’ that makes your data very seriously immutable at a runtime level.
fun freezeLocal(){
val worker = startWorker()
val localData = SomeData("asdf")
localData.freeze()
worker.schedule(TransferMode.CHECKED, {localData}){
println("In thread ${it.a}")
}.consume { }
println("In main ${localData.a}")
worker.requestTermination()
}
Freeze actually modifies metadata flags on runtime objects which tell the runtime that seriously nothing will touch the data. It’s more than just having immutable values. You can now pass that frozen object to multiple threads and reference it from your calling thread. No problem!
Well, some problems.
Common Kotlin has no concept of “freeze”. That’s a minor issue. We should be able to do something with extension functions to get a noop method into common and the JVM. More pressing is that KN and everybody else is going to be different at runtime. Could the concept of freeze be ported to the JVM? Would that be valuable? Questions for another time, and certainly somebody else 😛.
A more immediate issue is class design. The SQLDelight demo, for example. SQLiteDatabase and the Helper objects involved all have state that is protected by Java threading constructs. That design is going to need to be rethought.
Or you can just do this:
fun freezeLocal(){
val worker = startWorker()
val localData = SomeData("asdf")
worker.schedule(TransferMode.UNCHECKED, {localData}){
println("In thread ${it.a}")
}.consume { }
println("In main ${localData.a}")
worker.requestTermination()
}
No freeze. Just pass in as TransferMode.UNCHECKED. In this case, probably not a huge deal. The String in SomeData is a val. Nothing is changing anyway. In other, more complex cases, we’ll need to be more careful.
Workers?
I’m looking at this through the lens of shared mobile architecture. That means interacting with the host system. You can’t have a worker for the home thread, so out of the box, you’d need to implement some kind of regular Future checking mechanism. That doesn’t sound great.
iOS has thread event queues. That’s how you communicate back to the main thread. In future posts we’ll explore the idea of leveraging the same checks and concepts involved in Worker “transfer” to ensure the data safety and memory management of KN, but actually perform the threading with iOS’s dispatch queues, and be able to post messages back to the main thread. This will almost certainly involve a new library and possibly some PR’s to the KN repo. See how it goes!
For multiplatform, we’ll need some Worker-ike interface that can resolve back to Looper/Handler on Android. In JVM, we won’t have the data checking and freeze stuff, so testing is going to have to be robust enough to run likely scenarios and catch data integrity check issues on the iOS side.
Or just pass everything unchecked and watch out for this guy.
Demogorgon, obv
Future Chapters
Some possible future chapters:
- Wrapping iOS dispatch queues instead of “Worker”.
- Rethinking SQLite and (possibly) SQLDelight design. Listeners watch queries, which pass data base to the main thread (ultimately). In JVM, that data can exist across threads, but not in KN (out of the box).
- Exploring global state options. You can, but that’s like hanging out near the portal.
- Enter the Upside Down and poke around a bit.
Nice suits
Touchlab
Is looking for orgs that want to get started with Kotlin multiplatform, and hiring mobile developers interested in it. Reach out.
¹ The demogorgon is the memory manager? Probably best not to overthink the analogy. Also, Netflix legal team. Love the show! Please don’t get mad about the images. XOXO