Compare single-threaded confinement (limitedParallelism(1)) against Mutex and the actor pattern for protecting shared mutable state under high contention. When does each win, and what are the failure modes?
answer
- Confinement: serialize dispatched blocks, lock-free
- Mutex: suspending, NON-reentrant, guards section
- Actor: state in one coroutine, channel back-pressure
- Single serial point => throughput ceiling
- actor{} is ObsoleteCoroutinesApi
basics
~20 sConfinement runs all updates on one thread so they can't collide; a Mutex lets work run on many threads but takes turns at the critical section; an actor uses one coroutine reading a channel of messages. Confinement and actors serialize ownership; a Mutex guards a section.
solid answer
~60 sAll three serialize access but differ in granularity and cost. `limitedParallelism(1)` confines every dispatched block to one-at-a-time execution — simple, lock-free for the confined state, but every operation pays a **dispatch** to that view and a CPU-bound block stalls the queue. `Mutex.withLock` lets coroutines run on any dispatcher and only serialize the critical section; it's finer-grained and avoids re-dispatching, but you must hold the lock minimally and it's **not reentrant** (re-locking the same Mutex deadlocks). The **actor** (a coroutine consuming a `Channel` of messages, classically `actor {}` though now experimental/deprecated; the modern idiom is a manual channel + loop) gives strong encapsulation: state lives inside one coroutine, callers send messages, back-pressure is built in. Confinement/actors win when one owner manages complex state and you want lock-free reasoning; a Mutex wins for small, well-scoped critical sections across mixed dispatchers. Failure modes: confinement — CPU work blocking the single slot, and forgetting that separate dispatches aren't jointly atomic; Mutex — non-reentrancy deadlock and holding across suspension too long; actor — channel back-pressure/unbounded growth and message ordering assumptions.
go deeper
Can name the three approaches and that each serializes access.
Picks confinement vs Mutex for a given case and knows confinement is lock-free for its state.
Explains Mutex non-reentrancy, the per-block atomicity caveat, and actor back-pressure, choosing correctly per workload.
Reasons about throughput ceilings under contention, designs ownership/encapsulation boundaries, and weighs serialization granularity against scalability and operational risk.
## The shared goal All three prevent **data races** on shared mutable state by ensuring mutations don't truly overlap. They differ in *how* they serialize and *what* it costs. ## 1. Single-threaded confinement — `limitedParallelism(1)` ```kotlin val ctx = Dispatchers.Default.limitedParallelism(1) suspend fun update() = withContext(ctx) { /* mutate state */ } ``` - **Model**: every dispatched block runs one-at-a-time on a borrowed-but-serialized slot. - **Pros**: lock-free for the confined state; trivially correct; no dedicated thread; no `close()`. - **Cons**: each call pays a **context switch / dispatch** to the view; a **CPU-bound** non-suspending block holds the only slot and serializes everything behind it; cross-dispatch atomicity is **per block only** — read in one `withContext` and write in another is racy. ## 2. `Mutex.withLock` ```kotlin val mutex = Mutex() suspend fun update() = mutex.withLock { /* mutate state */ } ``` - **Model**: coroutines run anywhere; only the critical section is serialized. The lock is **suspending** (non-blocking) — a waiter suspends rather than blocking a thread. - **Pros**: fine-grained; no forced re-dispatch; works across mixed dispatchers. - **Cons**: **not reentrant** — calling `withLock` again while already holding it **deadlocks**. Holding the lock across long suspensions kills throughput. You must scope it tightly. ## 3. Actor pattern (channel + single consumer) ```kotlin sealed interface Msg fun CoroutineScope.counterActor() = Channel<Msg>().also { ch -> launch { var state = 0 for (msg in ch) { /* handle, mutate state */ } } } ``` - **Model**: state lives **inside one coroutine**; callers `send` messages; the consumer processes them sequentially. (The built-in `actor {}` builder is `ObsoleteCoroutinesApi`; the manual channel + loop is the recommended modern form.) - **Pros**: strongest encapsulation (state never escapes); natural **back-pressure** via channel capacity; easy to add request/response with `CompletableDeferred`. - **Cons**: more boilerplate; **back-pressure / unbounded** channels can grow memory; you must reason about **message ordering** and lifecycle/cancellation of the consumer. ## Decision guide | Scenario | Prefer | |---|---| | One owner of complex evolving state, want lock-free | confinement or actor | | Small critical section, callers already on various dispatchers | Mutex | | Need back-pressure + message protocol + encapsulation | actor | | Simple counter | `AtomicInteger`/atomicfu (none of the above) | ## Common failure modes recap - **Confinement**: CPU-bound block stalls the slot; split read-modify-write isn't atomic. - **Mutex**: non-reentrant deadlock; lock held across long suspension. - **Actor**: channel growth/back-pressure; consumer cancellation drops in-flight messages. ## High contention Under heavy contention, confinement and actors funnel everything through one serial point — they bound parallelism intentionally, which can become a **throughput ceiling**. A Mutex with a tiny critical section may scale better because non-critical work runs in parallel. The right answer depends on how much of the operation must be serialized.
- Why can a Mutex deadlock where confinement wouldn't?Kotlin's Mutex is non-reentrant: if code holding the lock calls another withLock on the same Mutex, it waits forever. Confinement just re-queues the block.
- Under high contention, why might a Mutex out-throughput limitedParallelism(1)?A Mutex serializes only the critical section, letting the rest run in parallel, whereas confinement funnels the entire dispatched block through one serial slot.
- What's the modern stance on the actor {} builder?It's marked ObsoleteCoroutinesApi; the recommended pattern is a plain coroutine consuming a Channel in a for-loop, optionally with CompletableDeferred for replies.
Confinement = one chef cooks every dish in order; Mutex = many chefs but one shared knife they pass around; actor = a kitchen window where you drop order tickets and one cook works the queue.
saying these in an interview costs you the question
- Claiming Kotlin's Mutex is reentrant
- Saying confinement always beats a Mutex regardless of critical-section size
- Ignoring channel back-pressure/unbounded growth in the actor
- Holding a Mutex across long suspensions
- Recommending actor {} without noting it's obsolete
- Not recognizing the single-serial-point throughput ceiling