In the previous post we discussed transferring state. I started there because recently several people posted in the Kotlin slack discussing DetachedObjectGraph
.
In my particular journey from JVM to Kotlin/Native (or KN), I went through a phase I think many new KN devs go through. In the JVM I was used to sharing mutable state between threads, and I assumed I must retain that ability at all costs. DetachedObjectGraph
is one of the first things I started to focus on.
It seems like interest in KMP has picked up considerably this first month of 2020, and so we've had a lot of questions about DetachedObjectGraph
. I have found that DetachedObjectGraph
is of limited value, and once I understood the KN state model, I found I never needed it.
You will, however, occasionally need mutable state between threads.
Stately Collections V1
Going back to mid/late 2018, we released Stately.
Along with atomics, locks, and some other concurrency support stuff, there was also a set of concurrent collection classes. These internally used AtomicRef
to implement collections. That meant I actually implemented a linked list and a hash map from scratch with AtomicRef
.
These collections work, but performance is bad, their implementation is basic, and their use is inflexible. Also, just FYI, AtomicRef
can leak memory, so you need to clear these collections when you're done with them.
As it turns out, if you just keep your state isolated, you can implement concurrent collections in an almost trivial manner, with far better performance.
Stately Collections V2 (Isolated State)
The next version of Stately will have a class called IsolateState
. You create an instance in a manner similar to how you interact with DetachedObjectGraph
. Pass in a producer lambda. One critical difference. The value returned from that lambda cannot be frozen.
Once you create this instance of IsolateState
, you interact with it by way of the access
method. access
takes a lambda, which takes one argument: the mutable state.
As an example, let's create a simple shared map:
object StateSample {
val cacheMap = IsolateState { mutableMapOf<String, SomeData>() }
}
data class SomeData(val s: String)
cacheMap
is a simple mutable map, with String
keys and SomeData
values.
To be clear, according to KN state rules, object StateSample
is frozen, so cacheMap
is also frozen. The state it contains, the result of mutableMapOf<String, SomeData>()
, is not frozen, and can never be (we call ensureNeverFrozen()
internally).
Getting to that state and doing stuff with it is easy:
fun doStuff(){
StateSample.cacheMap.access {map ->
map.put("Hello", SomeData("World"))
}
}
Getting values from the state is also straightforward:
fun readStuff(){
val sd = StateSample.cacheMap.access { it.get("Hello") }
println("sd: $sd, isFrozen: ${sd.isFrozen()}")
}
Note, the value returned from access
will be frozen, as is anything captured and passed into access
.
There will be a separate stately-iso-collections
module, but because isolated state is so flexible, I'm not sure how necessary a full MutableMap
implementation, for example, would be as opposed to just using access
to get at the methods you need. I didn't want to clutter the middle of the post, but I've included the entire implementation of a MutableMap
using IsolateState
at the bottom. It really is bordering on trivial.
One of the more inflexible aspects of any concurrent collection is the lack of atomic operations. Calling size
will return a value, but if you want to insert a value based on that result, you could have race conditions.
Take the following example:
fun racyInABadWay(fmap:HashMap<String, SomeData>){
val size = fmap.size
fmap.put("i $size", SomeData("data $size")) // <- may result in missed values
}
Assume fmap
is a concurrent map from Stately v1 collections. Between the call to get size and the call to put a value, the size could change. Your put
call will be invalid. You could fix this by creating a Lock
instance that's associated with fmap
, then passing them both around, but that's ugly.
Using IsolateState
, this is again trivial.
fun atomicOperations(isoMap: IsolateState<MutableMap<String, SomeData>>){
isoMap.access { map ->
map.put("i ${map.size}", SomeData("data ${map.size}"))
}
}
Under the hood, all access
calls run on the same thread, so while your block is running, nothing can change the map. You can implement arbitrarily complex atomic operations.
Performace
Performance is kind of relative. Inserting 50k elements in a v1 Stately map vs v2 is pretty good.
val times = 50_000
val v1Time = measureTimeMillis {
val fmap = frozenHashMap<String, SomeData>()
repeat(times){c ->
fmap.put("i $c", SomeData("data $c"))
}
}
println("v1Time: $v1Time")
val isoTime = measureTimeMillis {
val isomap = IsolateState{ mutableMapOf<String, SomeData>()}
repeat(times){c ->
isomap.access { it.put("i $c", SomeData("data $c")) }
}
}
println("isoTime: $isoTime")
The result:
v1Time: 13785
isoTime: 1937
Compared to using a regular MutableMap
? Well, not as good.
val normalTime = measureTimeMillis {
val map = mutableMapOf<String, SomeData>()
repeat(times){c ->
map.put("i $c", SomeData("data $c"))
}
}
println("normalTime: $normalTime")
isoTime: 1937
normalTime: 201
Of course, you're crossing thread contexts for each call to the IsolateState
instance. There's a lot of overhead involved. Because IsolateState
is inherently more flexible than a fixed concurrent map implementation, inserting in blocks is pretty easy to implement. The performance boost is dramatic.
val isoBlockTime = measureTimeMillis {
val isomap = IsolateState{ mutableMapOf<String, SomeData>()}
val outerTimes = times / 1000
repeat(outerTimes){ c ->
val blockMap = mutableMapOf<String, SomeData>()
repeat(1000){inner ->
val putCount = (c * 1000) + inner
blockMap.put("i $putCount", SomeData("data $putCount"))
}
isomap.access { it.putAll(blockMap) }
}
}
println("isoBlockTime: $isoBlockTime")
normalTime: 201
isoBlockTime: 330
You've inserted 50k elements into a concurrent map structure in a time that's ~65% slower than a local mutable map. It's "slower", relatively, but that performance is fantastic all things considered. That's also without inline
on any of the IsolateState
methods, which may slightly improve the situation.
Can you insert in Stately v1's collections in blocks? Sure, but it's rigid, and because they're implemented with AtomicRef
, the performance is dismal.
val v1BlockTime = measureTimeMillis {
val fmap = frozenHashMap<String, SomeData>()
val outerTimes = times / 1000
repeat(outerTimes){ c ->
val blockMap = mutableMapOf<String, SomeData>()
repeat(1000){inner ->
val putCount = (c * 1000) + inner
blockMap.put("i $putCount", SomeData("data $putCount"))
}
fmap.putAll(blockMap)
}
}
println("v1BlockTime: $v1BlockTime")
It's the same as inserting values individually.
v1Time: 13785
isoTime: 1937
normalTime: 201
isoBlockTime: 330
v1BlockTime: 13623
To round out our timing comparison, if you use DetachedObjectGraph
to insert 50k elements, things get much, much worse.
fun timeTest() {
val times = 50_000
val detachedTime = measureTimeMillis {
val detachedMap = SharedDetachedObject { mutableMapOf<String, SomeData>() }
detachedMap.freeze()
repeat(times){c ->
val sd = SomeData("data $c")
detachedMap.access {
it.put("i $c", sd)
}
}
}
println("detachedTime: $detachedTime")
}
detachedTime: 68334
That's about 35x slower than using IsolateState
. It'll also get worse as the size grows because you lose the Big O advantage of a hash map. You could certainly optimize access to DetachedObjectGraph
a bit, but what's the point?
In summary, use IsolateState
for KMP concurrent state.
We've published an experimental version of Stately to try this out:
implementation "co.touchlab:stately-isolate:0.10.2"
The documentation hasn't been updated, but you can see samples here:
touchlab-lab / StatelyIsoStateSample
Stately IsolatedState sample
We should have a more formal version soon, pending community feedback.
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).
MutableMap Implementation
Here's the code for a MutableMap
implemented with IsolateState
. We're essentially just wrapping mutableMapOf<K, V>()
.
class IsoMutableMap<K, V>(producer: () -> MutableMap<K, V> = { mutableMapOf() })
: IsolateState<MutableMap<K, V>>(createState(producer)), MutableMap<K, V> {
override val size: Int
get() = access { it.size }
override fun containsKey(key: K): Boolean = access { it.containsKey(key) }
override fun containsValue(value: V): Boolean = access { it.containsValue(value) }
override fun get(key: K): V? = access { it.get(key) }
override fun isEmpty(): Boolean = access { it.isEmpty() }
override val entries: MutableSet<MutableMap.MutableEntry<K, V>>
get() = access { IsoMutableSet(StateHolder(it.entries)) }
override val keys: MutableSet<K>
get() = access { IsoMutableSet(StateHolder(it.keys)) }
override val values: MutableCollection<V>
get() = access { IsoMutableCollection(StateHolder(it.values)) }
override fun clear() = access { it.clear() }
override fun put(key: K, value: V): V? = access { it.put(key, value) }
override fun putAll(from: Map<out K, V>) = access { it.putAll(from) }
override fun remove(key: K): V? = access { it.remove(key) }
}
open class IsoMutableCollection<T> internal constructor(stateHolder: StateHolder<MutableCollection<T>>) :
IsolateState<MutableCollection<T>>(stateHolder), MutableCollection<T> {
constructor(producer: () -> MutableCollection<T>) : this(createState(producer))
override val size: Int
get() = access { it.size }
override fun contains(element: T): Boolean = access { it.contains(element) }
override fun containsAll(elements: Collection<T>): Boolean = access { it.containsAll(elements) }
override fun isEmpty(): Boolean = access { it.isEmpty() }
override fun add(element: T): Boolean = access { it.add(element) }
override fun addAll(elements: Collection<T>): Boolean = access { it.addAll(elements) }
override fun clear() = access { it.clear() }
override fun iterator(): MutableIterator<T> = access { IsoMutableIterator(StateHolder(it.iterator())) }
override fun remove(element: T): Boolean = access { it.remove(element) }
override fun removeAll(elements: Collection<T>): Boolean = access { it.removeAll(elements) }
override fun retainAll(elements: Collection<T>): Boolean = access { it.retainAll(elements) }
}
class IsoMutableSet<T> internal constructor(stateHolder: StateHolder<MutableSet<T>>) :
IsoMutableCollection<T>(stateHolder), MutableSet<T> {
constructor(producer: () -> MutableSet<T> = { mutableSetOf() }) : this(createState(producer))
}
class IsoMutableIterator<T> internal constructor(stateHolder: StateHolder<MutableIterator<T>>) :
IsolateState<MutableIterator<T>>(stateHolder), MutableIterator<T> {
override fun hasNext(): Boolean = access { it.hasNext() }
override fun next(): T = access { it.next() }
}
Top comments (0)