Concurrency¶
klio runs Kotlin concurrency on real OS threads. Thread.start /
join spawn and join genuine std.Threads. Coroutines within
one runBlocking interleave cooperatively on their driver
thread; Dispatchers.Default / IO dispatch their bodies onto a
shared pool of real worker threads (DefaultDispatcher-worker-N),
so dispatched bodies overlap in wall time across OS threads, and a
coroutine that parks on one worker resumes wherever its resume
lands — another worker, the driver, or a kotlin.concurrent.thread
body. Thread, synchronized, @Volatile, and atomics have
faithful Kotlin semantics; the value model
(runtime.Value, backed by ObjRef over an atomically
reference-counted cell) is safe to share across threads and
the publication protocol is stress-verified race-free. Every cell
carries a per-cell reader/writer spin lock that mediates each
borrow; on an uncontended cell (the common case) a borrow is a
single uncontended cmpxchg, the same fast path a RefCell borrow
flag would take, and the lock's acquire/release ordering is the
happens-before edge that lets a reference escape to another thread
with no separate publication step.
Three ideas structure the design.
Two layers¶
The core Kotlin suspend system and kotlinx.coroutines are kept
strictly separate.
Layer 1 — core suspend. A suspend fun is an ordinary function
with a Continuation and a state machine; resumed synchronously it
is plain sequential code, with no concurrency or happens-before
concern. This layer lives in ir.eval: the
SuspendState / FrameSnapshot save-restore engine,
resumeContinuation, and the EvalError.Suspended unwind. It is
thread-, dispatcher-, and fence-agnostic — it only decides how to
pause and resume, never where or when.
Layer 2 — kotlinx.coroutines. Everything that schedules:
CoroutineScope, Job, Deferred, launch, async,
runBlocking, withContext, coroutineScope, supervisorScope,
cancellation, Dispatchers, delay, Channel. It is the
kotlinx.coroutines pack (Kotlin) plus a few host hooks, built on
Layer 1.
The seam between them is the interceptor. In interp_ir the
default interceptor is CooperativeInterceptor, a per-pump
scheduler exposed through a named seam (interceptSuspend,
drainLaunched, nextReady, takeParked, advanceTime). Every
OS thread that drives coroutines — the runBlocking driver and each
dispatcher pool worker executing a task — owns one pump. Scheduling
happens only here, so this is also the only place a cross-thread
happens-before edge for coroutine code is established.
async/await/join park on a host slot and resume on completion
(routed across pumps through the slot-owner registry and each
driver’s wakeup mailbox, or — once a pump has exited — through the
process-global persisted-continuation registry, which lets a
coroutine hop between OS threads); withContext /
coroutineScope / supervisorScope run inline on the current
interceptor (no nested driver); Channel suspends on empty/full,
rendezvouses across OS threads, and exposes an iterator for
for (v in ch).
One memory model¶
Kotlin on the JVM inherits the Java Memory Model. klio guarantees a model at least as strong as the JMM: every correctly synchronized program behaves identically to the JVM, and racy programs are defined rather than undefined.
- Sequential consistency for data-race-free programs. A single global order of interpreter steps always exists.
- No out-of-thin-air values, and no tearing — ever. Every
Valueslot, includingLong/Doubleand object references, is read and written whole even under a data race. This is stronger than the JVM (which may tear non-volatile 64-bit) and is free here because slots are whole cells. val/ immutable safe publication. A fully constructed object'svalfields are visible to any thread that observes the reference.@Volatile— sequentially consistent per field; part of the total order of volatile/atomic operations.@Synchronized/synchronized(m){}— mutual exclusion; unlock happens-before the next lock on the same monitor.- Atomics (
atomicfu,kotlin.concurrent.atomics,java.util.concurrent.atomic) — atomic and sequentially consistent; explicit memory orders are honored or conservatively upgraded to SC. - Thread
start/join—starthappens-before the body; the body's completion happens-beforejoin()returning. - Coroutines — code before a suspension point happens-before
code after it;
launch/asyncof a child happens-before the child body; child completion happens-beforejoin()/await(). - Channels — a
sendhappens-before the matchingreceive.
The normative statement of these rules, with an executable litmus program for each, is in memory-model.md.
One primitive, many clients¶
Every happens-before edge is established at a closed set of seams:
monitor enter/exit, volatile access, atomic op, thread start/join,
the interceptor's dispatch/resume, and channel send/receive.
ObjRef.publish is the stable, named call site that marks these
boundaries in the code; the concrete edge for each seam is real and
is stated normatively in the fence matrix on the adaptive cell
(shared-object access → the per-cell reader/writer lock plus the
release/acquire state transition on ObjRef.publish; monitors →
the process-wide reentrant monitor; @Volatile → subsumed by
lock-mediated shared access; atomics → the underlying
std.atomic.Value ops; Thread.start/join → std.Thread
spawn/join, with the escaping object graph published deep via
publishValue before spawn). The invariant that keeps the two
layers consistent: core suspend never creates an edge; the
interceptor and the publication path do — so the model is obeyed
identically whether code uses raw suspend or all of
kotlinx.coroutines. The protocol is stress-verified across real OS
threads (src/itests/runtime_objref_threads.zig).
Front end and runtime responsibilities¶
- Front end (
typeck/ lowering) recognizes@Volatile,@Synchronized,synchronized, and the atomic types, keeps the well-formedness diagnostics (e.g.@Volatileonly on mutable, non-delegated fields), and lowers each to the fenced operation rather than a plain access. It does not attempt race detection (undecidable, and not required by the model). - Runtime (
interp_ir/runtime) provides the operation and the no-tearing / safe-publication guarantees structurally.
The value handle and the publication boundary¶
All shared Kotlin heap state — collections, instances, Cell,
StringBuilder, iterator state, delegates — is reached through one
handle, runtime.ObjRef(T), backed by an atomically
reference-counted adaptive cell. Call sites never name the backing;
they use borrow / borrowMut / clone / ptrEq. Because every
shared-object access funnels through this one type, the adaptive
publication protocol — and any future backing change — is localized
to one type and the publication seam, not a codebase-wide concern.
Per-thread interpreter state lives in threadlocal slots (the
cooperative interceptor stack and the constructor-shell guard). This
is the publication boundary: that thread-local state is never shared
across threads directly. Deliberately process-global configuration
(e.g. the stdlib known-packages registry) lives outside the
boundary by design and is already mutex-guarded.
Parallelism¶
True multicore execution is implemented. Value is shareable
across OS threads: ObjRef's adaptive-cell backing is escape-aware
atomic reference counting — a cell stays at RefCell speed (a
single non-atomic borrow flag) while it is reachable from only its
creating thread, and promotes to a reader/writer lock the moment a
reference is published across threads via publishValue at an
escape seam. Thread.start / join spawn and join real
std.Threads; Dispatchers.Default / IO dispatch coroutine
bodies onto a real worker pool for genuine CPU-bound parallelism.
The single cooperative driver per runBlocking is retained for
intra-scope coroutine interleaving.
The dispatcher worker pool¶
One shared pool (src/interp_ir/vm/scheduler.zig) serves both
dispatchers, mirroring the upstream JVM CoroutineScheduler model:
Dispatchers.Defaultis the CPU-bounded view — at mostmax(2, nproc)of its tasks run concurrently.Dispatchers.IOis the elastic view — up tomax(64, nproc)workers in total — over the same threads, so awithContext(Dispatchers.IO)hop from a Default coroutine stays inside the pool.- Dispatcher tasks are daemons, exactly as upstream JVM dispatcher threads are: they never block the run's end. At the run boundary still-queued tasks are dropped without running, and in-flight tasks are abandoned cooperatively (the evaluator and the host sleep primitives poll an abandon flag on worker threads, so even a non-terminating daemon body stops at its next instruction or sleep slice) before every worker is joined.
- Workers are spawned on demand against the queue backlog, park by
polling when idle, and are named
DefaultDispatcher-worker-N. - Per-view execution is FIFO; both dispatchers report
isDispatchNeeded == true, anddispatchposts the upstreamDispatchedContinuationrunnable verbatim — cancellation, the Job tree, and exception propagation are the consumed upstream common code, untouched by where bodies execute. limitedParallelism(n)is the upstream commonLimitedDispatcherover these views: never more thannconcurrent, excess queues in FIFO order, threads shared with the underlying pool.
Each dispatched runnable executes on a worker under its own
cooperative pump (driveRoot, persist mode). A body that parks
indefinitely (join, await, channel) releases its worker: the parked
SuspendState moves to the process-global persisted-continuation
registry keyed by its rendezvous slot, and the eventual resume —
itself a dispatched runnable, a mailbox post, or an inline drive —
claims it (single winner) and continues the coroutine on whatever
thread the resume arrived on. A delay under a dispatcher parks on
the worker’s own pump and resumes there, never on the runBlocking
driver. Coroutines launched in runBlocking’s scope without a
dispatcher stay on the runBlocking thread (the cooperative
KlioDispatcher is the default interceptor).
runBlocking itself is the upstream shape: a BlockingCoroutine
job over the caller's context, the body started as its child, and
the calling thread pumping until the coroutine's whole job tree
completes — the root activation parks on a completion slot that the
job's invokeOnCompletion resumes. The Job machinery, not a
host-side count, decides when blocking ends: children on the local
pump, children dispatched to pool workers, and children resumed
from explicit kotlin.concurrent.threads all complete the job
first, while GlobalScope daemons are not part of it and never
block the return. A failing child cancels the blocking job per
structured concurrency — parked siblings resume with the
cancellation at their suspension points, cross-pump through the
mailbox/persisted routing — and the failure rethrows out of
runBlocking through onCancelled, exactly as kotlinc+kotlinx
propagate it. An uncaught exception in a root coroutine with no
parent (GlobalScope.launch { throw … }) reports through the
final-resort handler to stderr and the program continues, matching
the JVM uncaught-handler behavior.
The driver-exit protocol closes the park/resume race: a pump that exits first persists its indefinitely-parked continuations, then closes its mailbox (taking anything that raced in), then releases its slot-owner registrations, and finally re-routes the raced-in entries through the persisted registry; a resumer whose mailbox post is rejected falls through to the same registry, so no resume can land in a mailbox nobody will drain.
This is validated by three gates:
- Real-thread stress —
src/itests/runtime_objref_threads.zigstress-checks the UNSHARED→SHARED publication protocol (cross-thread borrow after publish, two post-publish writers, reader/writer no-torn-read) across many genuine OS threads. - Threaded litmus —
src/itests/parity_threaded_litmus.zig(programs undertests/fixtures/threaded_litmus/) exercises real-thread guarantees end to end (mutual exclusion, safe publication, no lost update, parallel partition, parallelasync,withContext(IO), many-dispatch, genuine Default overlap, worker thread names, the spin-wait flag handoff, the elastic IO cap,limitedParallelism(1)serialization, delay-resumes-on-worker, undispatched-stays-on-caller, cross-dispatcher channel ping-pong, and the cross-OS-thread channel bridge), each asserting exact stdout and verified against the kotlinc-JVM oracle where plain kotlinx semantics allow. - Single-thread benchmark — the fixed bench corpus
(
tests/fixtures/bench_corpus, run byzig build itest-bench, diffable againstbenches-baseline/HEAD.json) guards the common path against regression; the adaptive cell's UNSHARED fast path keeps it at the pre-parallel cost.
The normative litmus statements live in memory-model.md. The production backing is the adaptive reference-counted cell.
Status¶
launch, async/await, delay, join, job.cancel(),
withTimeout / withTimeoutOrNull, real-thread dispatch,
Channel, Flow, and the memory model are working and gated —
cancellation and timeouts run the consumed upstream common code
end-to-end. The design record for the coroutine engine is
docs/design/COROUTINE-MODEL.md. Do not add library-type-specific
branches to ir / interp_ir to work around a coroutine bug; fix
the general mechanism.