Concurrency model writeup. (#1422)
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
|||||||
|
### Concurrency in Kotlin/Native
|
||||||
|
|
||||||
|
Kotlin/Native runtime doesn't encourage a classical thread-oriented concurrency
|
||||||
|
model with mutually exclusive code blocks and conditional variables, as this model is
|
||||||
|
known to be error-prone and unreliable. Instead, we suggest collection of
|
||||||
|
alternative approaches, allowing to use hardware concurrency and implement blocking IO.
|
||||||
|
Those approaches are as following, and will be elaborated in further sections:
|
||||||
|
* Workers with message passing
|
||||||
|
* Object subgraph ownership transfer
|
||||||
|
* Object subgraph freezing
|
||||||
|
* Object subgraph detachment
|
||||||
|
* Raw shared memory using C globals
|
||||||
|
* Coroutines for blocking operations (not covered in this document)
|
||||||
|
|
||||||
|
## Workers
|
||||||
|
|
||||||
|
Instead of threads Kotlin/Native runtime offers concept of workers: concurrently executing
|
||||||
|
control flow streams with an associated request queue. Workers are very similar to actors
|
||||||
|
in the Actor Model. Worker can exchange Kotlin objects with other workers, so that at the moment
|
||||||
|
each mutable object is owned by the single worker, but ownership could be transferred.
|
||||||
|
See section [Object transfer and freezing](#transfer).
|
||||||
|
|
||||||
|
Once worker is started with `startWorker` function call, it can be uniquely addressed with an integer
|
||||||
|
worker id. Other workers, or non-worker concurrency primitives, such as OS threads, could send a message
|
||||||
|
to the worker with `schedule` call.
|
||||||
|
```kotlin
|
||||||
|
val future = schedule(TransferMode.CHECKED, { SomeDataForWorker() }) {
|
||||||
|
// data returned by the second function argument comes to the
|
||||||
|
// worker routine as 'input' parameter.
|
||||||
|
input ->
|
||||||
|
// Here we create an instance to be returned when someone consumes result future.
|
||||||
|
WorkerResult(input.stringParam + " result")
|
||||||
|
}
|
||||||
|
|
||||||
|
future.consume {
|
||||||
|
// Here we see result returned from routine above. Note that future object or
|
||||||
|
// id could be transferred to another worker, so we don't have to consume future
|
||||||
|
// in same execution context it was obtained.
|
||||||
|
result ->
|
||||||
|
println("result is $result")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The call to `schedule` uses function passed as its second parameter to produce an object subgraph
|
||||||
|
(i.e. set of mutually referring objects) which is passed as the whole to that worker, and no longer
|
||||||
|
available to the thread that initiated the request. This property is checked if the first parameter
|
||||||
|
is `TransferMode.CHECKED` by graph traversal and just assumed to be true, if it is `TransferMode.UNCHECKED`.
|
||||||
|
Last parameter to schedule is a special Kotlin lambda, which is not allowed to capture any state,
|
||||||
|
and is actually invoked in target worker's context. Once processed, result is transferred to whoever consumes
|
||||||
|
the future, and is attached to object graph of that worker/thread.
|
||||||
|
|
||||||
|
If an object is transferred in `UNCHECKED` mode and is still accessible from multiple concurrent executors,
|
||||||
|
program will likely crash unexpectedly, so consider that last resort in optimizing, not a general purpose
|
||||||
|
mechanism.
|
||||||
|
|
||||||
|
For more complete example please refer to the [workers example](https://github.com/JetBrains/kotlin-native/tree/master/samples/workers)
|
||||||
|
in the Kotlin/Native repository.
|
||||||
|
|
||||||
|
|
||||||
|
## <a name="transfer"></a>Object transfer and freezing
|
||||||
|
|
||||||
|
Important invariant that Kotlin/Native runtime maintains is that object is either owned by a single
|
||||||
|
thread/worker, or is immutable (_shared XOR mutable_). This ensures that the same data has a single mutator, and so no need for
|
||||||
|
locking exists. To achieve such an invariant, we use concept of not externally referred object subgraphs.
|
||||||
|
This is a subgraph which has no external references from outside of the subgraph, what could be checked
|
||||||
|
algorithmically with O(N) complexity (in ARC systems), where N is number of elements in such a subgraph.
|
||||||
|
Such subgraphs are usually produced as a result of lambda expression, for example some builder, and may not
|
||||||
|
contain objects, referred externally.
|
||||||
|
|
||||||
|
Freezing is a runtime operation making given object subgraph immutable, by modifying the object header
|
||||||
|
so that future mutation attempts lead to throwing an `InvalidMutabilityException`. It is deep, so
|
||||||
|
if an object has a pointer to another objects - transitive closure of such objects will be frozen.
|
||||||
|
Freezing is the one way transformation, frozen objects cannot be unfrozen. Frozen objects has a nice
|
||||||
|
property that due to their immutability, they could freely shared between multiple workers/threads
|
||||||
|
not breaking the "mutable XOR shared" invariant.
|
||||||
|
|
||||||
|
If object is frozen could be checked with an extension property `isFrozen`, and if it is, object sharing
|
||||||
|
is allowed. Currently, Kotlin/Native runtime only freezes enum objects after creation, although additional
|
||||||
|
autofreezing of certain provably immutable objects could be implemented in the future.
|
||||||
|
|
||||||
|
## <a name="detach"></a>Object subgraph detachment
|
||||||
|
|
||||||
|
Object subgraph without external references could be disconnected using `detachObjectGraph` to
|
||||||
|
a `COpaquePointer` value, which could be stored in `void*` data, so disconnected object subgraphs
|
||||||
|
could be stored in C data structure, and later attached back with `attachObjectGraph<T>` in arbitrary thread
|
||||||
|
or worker. Combined with [raw memory sharing](#shared) it allows side channel object transfer between
|
||||||
|
concurrent threads, if worker mechanisms are insufficient for the particular task.
|
||||||
|
|
||||||
|
## <a name="shared"></a>Raw shared memory
|
||||||
|
|
||||||
|
Considering strong ties of Kotlin/Native with C via interoperability, in conjunction with other mechanisms
|
||||||
|
mentioned above one could build popular data structures, like concurrent hashmap or shared cache with
|
||||||
|
Kotlin/Native. One could rely upon shared C data, and store in it references to detached object subgraphs.
|
||||||
|
Consider the following .def file:
|
||||||
|
```
|
||||||
|
package = global
|
||||||
|
|
||||||
|
---
|
||||||
|
typedef struct {
|
||||||
|
int version;
|
||||||
|
void* kotlinObject;
|
||||||
|
} SharedData;
|
||||||
|
|
||||||
|
SharedData sharedData;
|
||||||
|
```
|
||||||
|
After running cinterop tool it allows sharing Kotlin data in versionized global structure,
|
||||||
|
and interact with it from Kotlin transparently via autogenerated Kotlin like this:
|
||||||
|
```kotlin
|
||||||
|
class SharedData(rawPtr: NativePtr) : CStructVar(rawPtr) {
|
||||||
|
var version: Int
|
||||||
|
var kotlinObject: COpaquePointer?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
So combined with the top level variable declared above, it allows seeing the same memory from different
|
||||||
|
threads and building traditional concurrent structures with platform-specific synchronization primitives.
|
||||||
Reference in New Issue
Block a user