Kotlin/Native (KN) has special rules around state and concurrency. We cover them in usable detail in Practical Kotlin Native Concurrency and in more detail in KotlinConf 2019: Kotlin Native Concurrency Explained.
There are basically two rules. Mutable state can only be accessed by one thread at a time, and immutable state can be shared. When sharing state we freeze it, and mutable state generally stays thread confined. However, you can pass mutable state between threads. In all of my discussions, I mostly ignore that possibility, because it's fairly impractical and we don't use it anywhere (in production), but today we'll discuss it a bit.
Transferring State
In order to transfer mutable state between threads, you need to make sure there are no external references to it. The Worker.execute
function and the DetachedObjectGraph
constructor both take a producer
function argument. The purpose of this function is to "produce" the state that you want to transfer, in such a way that all external references can be omitted.
That is the first complication and critical concept to understand. You'll often want to add mutable data to a DetachedObjectGraph
from existing state, but this is syntactically difficult.
@Test
fun failLocal(){
val d = Dat("Hello")
assertFails {
DetachedObjectGraph {d}
}
}
data class Dat(val s:String)
The DetachedObjectGraph
constructor takes a lambda producer argument. In it we try to return d
, but d
is still referenced from outside the lambda, specifically here in the local val d
, so the transfer fails.
This case is overly simplistic. You can "solve" it with the following.
@Test
fun directReturn(){
DetachedObjectGraph {Dat("Hello")}
}
data class Dat(val s:String)
The Dat
instance returned from the producer has no external references, so you can transfer it. However, use your imagination and extrapolate how difficult it will be to maintain mutable state in a DetachedObjectGraph
and pass it around. It quickly becomes impractical.
I would abbreviate DetachedObjectGraph
with DOG, but DetachedObjectGraph
is not your friend, and dogs are, so I will use the full name DetachedObjectGraph
and clutter your visual space because we don't like them. Anyway...
You can build some managed access, with something like the following.
class SharedDetachedObject<T:Any>(producer: () -> T) {
private val adog :AtomicReference<DetachedObjectGraph<Any>?>
private val lock = Lock()
init {
val detachedObjectGraph = DetachedObjectGraph { producer() as Any }.freeze()
adog = AtomicReference(detachedObjectGraph.freeze())
}
fun <R> access(block: (T) -> R): R = lock.withLock{
val holder = FreezableAtomicReference<Any?>(null)
val producer = { grabAccess(holder, block) as Any }
adog.value = DetachedObjectGraph(TransferMode.SAFE, producer).freeze()
val retult = holder.value!!
holder.value = null
retult as R
}
private fun <R> grabAccess(holder:FreezableAtomicReference<Any?>, block: (T) -> R):T{
val attach = adog.value!!.attach()
val t = attach as T
holder.value = block(t)
return t
}
fun clear(){
adog.value?.attach()
}
}
You can find this code in a new Stately branch.
This is somewhat complex to look at, but the concept is relatively simple. You create an instance of SharedDetachedObject
with a producer, similar to the previous examples. The state returned is kept in a DetachedObjectGraph
, and you can access that state with the access
method. From that method you can return a value, although make absolutely sure it isn't the mutable state you're holding in the DetachedObjectGraph
.
val detachedObject = SharedDetachedObject { mutableListOf("a", "b")}
repeat(50_000){rcount ->
detachedObject.access {
val element = "row $rcount"
it.add(element)
element
}
}
The code above creates a mutable list that can be accessed in a shared way, from multiple threads. Anything leaving the access
lambda that's still referenced from inside it should be frozen, of course (String
gets special treatment in KN. It's always frozen).
OK! Transferring state may be syntactically messy, but it works, right! Sure, but there's another important consideration.
Performance
Transferring state requires that you inspect all of the state you want to transfer, to ensure nobody external is referencing it. All of the state. In the example above, that means on the last run, all 50k entries. Doing that isn't free.
Imagine a common use case. Building a HashMap cache. One of the primary benefits of a HashMap is read and write time. In the good case, they're constant time. Keeping a map in a DetachedObjectGraph
sounds like a good idea, but each access of the map is followed by detaching so you can put it back in the DetachedObjectGraph
for another thread to use. That turns constant time into linear time. A linear time hash map is a shitty hash map.
In our simple list case above, the time per-insert grows as the list gets larger.
We have a new set of concurrent/mutable state holders coming out for Stately. You can see the current branch here. In the next post we'll cover how to use the new state objects.
Hiring!
Touchlab is hiring! Looking for Android-focused mobile developers, and also for experienced or very interested Kotlin Multiplatform devs. We really need to find a solid Android dev at this precise moment, though. Just FYI. Remote friendly (US-only, for now).
Top comments (0)