Shared mutable state: Mutex, atomics, and confinement
A race does not need two threads. It needs a suspension point between the check and the act — and a single-threaded dispatcher will not save you.
Explore the full explanation 8 ideas
The most dangerous misconception about coroutines is that a single-threaded dispatcher makes shared state safe. It does not. Coroutines interleave at **suspension points**, and any code of the shape "read a value, suspend, write it back" can have every other coroutine run in the gap. `val current = stock; delay(1); stock = current - 1` will oversell on `Dispatchers.Main`, in `runTest`, on one thread, deterministically. Concurrency, not parallelism, is what breaks compound operations, and coroutines give you a great deal of concurrency.
The failure mode has a name — the **lost update** — and a distinctive signature. Five coroutines read a stock of three, all compute `3 - 1`, all write `2`. Five sales, three units, and a final count of two: a state that is arithmetically impossible, which is the polite version. Most lost updates are not impossible-looking; they just quietly under-count a metric or double-apply a discount, and nothing alerts because no exception is ever thrown.
The first tool is `Mutex`. `mutex.withLock { … }` is the coroutine-native lock: it **suspends** rather than blocking a thread, so it is safe on an event loop where a `ReentrantLock` would park a Netty thread and be a genuine outage. It is `lock()` plus `try/finally { unlock() }`, so a throwing body cannot leak the lock. Two properties are worth memorising: it is **not reentrant** — calling a `withLock` function from inside another `withLock` on the same mutex deadlocks permanently, with no exception and no timeout — and it protects only what is *inside* the block, so a lock around the write but not the read is exactly the bug you started with.
The second tool is an **atomic**: `AtomicInteger`, `AtomicReference`, or Kotlin's `atomicfu`. These are perfect for a *single* compare-and-set — `counter.incrementAndGet()` is correct, lock-free and cheap. They are useless on their own for "read, decide, then write", because the decision happens outside the atomic operation. The honest form is `updateAndGet { … }` (or a CAS retry loop), which re-runs the transformation until it wins; anything longer than that wants a lock.
The third tool is **confinement**, and it is usually the best one. Give the state a single owner — a coroutine that reads commands from a `Channel` and is the only code that touches the data — and send it messages instead of sharing a reference. There is no lock to forget, no critical section to get wrong, and the invariants live in one place you can read top to bottom. It costs a message hop, and it stops being convenient when a caller needs a synchronous answer (which you get back by sending a `CompletableDeferred` along with the request). This is the actor model, and `kotlinx.coroutines` used to ship an `actor { }` builder for it; the pattern outlived the builder and is written today with a plain `Channel`.
The fourth tool is not sharing at all. Most "shared state" in a request-scoped server does not need to be shared: build the result immutably, return it, and let the caller combine. `coroutineScope { val a = async { … }; val b = async { … }; combine(a.await(), b.await()) }` has two concurrent operations and zero shared mutable state, and needs no synchronisation of any kind. When you find yourself reaching for a lock inside a request handler, it is worth asking whether the variable needs to outlive the call at all.
A word on `@Volatile`, because it is the wrong answer offered most often. `@Volatile` fixes **visibility** — it guarantees that a write by one thread is seen by another — and does nothing for **atomicity**. `@Volatile var count = 0; count++` is still a lost update, because `++` is a read, an add and a write. If the operation is compound, volatility is irrelevant; if it is a single write of an independently-computed value, volatility is exactly what you need.
The decision, compressed. One counter, one operation → an atomic. A short compound operation on one piece of state → a `Mutex`. A cluster of related state with real invariants → confine it behind a channel. State that does not need to outlive the request → do not share it. And whichever you choose, be able to say *why* in one sentence, because "we used a Mutex" is not an answer and "the read and the write have to be one indivisible step, so they are both inside the lock" is.
Let’s figure it out.
Ask me about this course or where to start.
import kotlinx.coroutines.*import kotlinx.coroutines.sync.*import kotlinx.coroutines.test.*class Inventory(var stock: Int) {val sold = mutableListOf<Int>()private val mutex = Mutex()// check ... suspend ... act: every buyer reads the same stale value.suspend fun buyUnsafe(id: Int) {val current = stockdelay(1)if (current > 0) { stock = current - 1; sold.add(id) }}// The lock spans the READ as well as the write, so each buyer sees what// the previous one wrote.suspend fun buySafe(id: Int) = mutex.withLock {val current = stockdelay(1)if (current > 0) { stock = current - 1; sold.add(id) }}}@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {val unsafe = Inventory(3)coroutineScope { (1..5).forEach { id -> launch { unsafe.buyUnsafe(id) } } }println("unsafe: sold " + unsafe.sold.size + " from 3, stock left " + unsafe.stock)val safe = Inventory(3)coroutineScope { (1..5).forEach { id -> launch { safe.buySafe(id) } } }println("safe: sold " + safe.sold.size + " from 3, stock left " + safe.stock)}
The lost update, reproduced deterministically on a single-threaded test dispatcher — and fixed by putting the whole read-modify-write inside one critical section.
Arena IDEimport kotlinx.coroutines.*import java.util.concurrent.atomic.AtomicIntegerfun main() = runBlocking {// Correct: the whole operation is one CAS.val good = AtomicInteger(0)coroutineScope { repeat(1_000) { launch(Dispatchers.Default) { good.incrementAndGet() } } }println("incrementAndGet = " + good.get())// Also correct for a compound TRANSFORMATION: updateAndGet retries the CAS.val capped = AtomicInteger(0)coroutineScope {repeat(1_000) { launch(Dispatchers.Default) { capped.updateAndGet { if (it < 500) it + 1 else it } } }}println("updateAndGet cap = " + capped.get())// WRONG: the decision happens OUTSIDE the atomic operation.val bad = AtomicInteger(0)coroutineScope {repeat(1_000) { launch(Dispatchers.Default) { val v = bad.get(); if (v < 500) bad.set(v + 1) } }}println("read-then-set = " + bad.get() + " (target was 500)")}
Atomics are for ONE indivisible operation. incrementAndGet is correct; a manual read-decide-write around an atomic is the same lost update with extra ceremony.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.channels.*sealed interface Cmddata class Reserve(val id: Int, val reply: CompletableDeferred<Boolean>) : Cmddata class Report(val reply: CompletableDeferred<Int>) : Cmdfun CoroutineScope.inventory(initial: Int): SendChannel<Cmd> {val cmds = Channel<Cmd>(Channel.UNLIMITED)launch {var stock = initial // owned by THIS coroutine alonefor (cmd in cmds) when (cmd) {is Reserve -> {val ok = stock > 0if (ok) stock--cmd.reply.complete(ok)}is Report -> cmd.reply.complete(stock)}}return cmds}fun main() = runBlocking {val inv = inventory(3)val granted = (1..5).map { id ->val reply = CompletableDeferred<Boolean>()inv.send(Reserve(id, reply))reply.await()}.count { it }val left = CompletableDeferred<Int>().also { inv.send(Report(it)) }.await()println("granted = " + granted + ", stock left = " + left)inv.close()}
Confinement: one coroutine owns the state, everyone else sends messages. No lock, and a CompletableDeferred carries the answer back when a caller needs one.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.sync.*class Ledger {private val mutex = Mutex()private var total = 0suspend fun add(n: Int) = mutex.withLock { total += n }// BUG: add() takes the same mutex we are already holding.suspend fun addTwice(n: Int) = mutex.withLock {add(n)add(n)}suspend fun addTwiceFixed(n: Int) = mutex.withLock { total += 2 * n }suspend fun total(): Int = mutex.withLock { total }}fun main() = runBlocking {val ledger = Ledger()val deadlocked = withTimeoutOrNull(200) { ledger.addTwice(5) } == nullprintln("reentrant call deadlocked: " + deadlocked)val ok = Ledger()ok.addTwiceFixed(5)println("non-reentrant version total: " + ok.total())}
The trap that has no exception and no timeout: Mutex is NOT reentrant, so a locking function called from inside the lock deadlocks forever. withTimeoutOrNull is how you prove it without hanging the demo.
Arena IDE🧠 Check your understanding
0/1 · 0/1 answered1. Your service runs on `Dispatchers.Main.immediate` — a single thread. A method reads a counter, calls a suspending repository, then writes the counter back. Is it safe?