How can you protect shared mutable state in coroutines using single-threaded confinement, and how does limitedParallelism(1) compare to newSingleThreadContext()?
answer
- Confine state to one thread => serialized, lock-free
- limitedParallelism(1): no close, borrows pool
- newSingleThreadContext: dedicated thread, must close()
- Keep read-modify-write in ONE withContext
- @DelicateApi on newSingleThreadContext
basics
~20 sRun all updates to the shared state on a dispatcher that uses only one thread, so they happen one at a time and never race. limitedParallelism(1) does this by borrowing one slot of an existing pool; newSingleThreadContext creates a dedicated thread you must close.
solid answer
~40 sSingle-threaded confinement means all access to a piece of mutable state is dispatched to one thread, so operations are serialized and you avoid data races without locks. Two ways: `newSingleThreadContext("name")` spawns a dedicated thread (an `ExecutorCoroutineDispatcher`) that you must `close()` to avoid a thread leak; or `Dispatchers.Default.limitedParallelism(1)` (preferred today) gives a serialized view that borrows a single slot from a shared pool, needs no cleanup, and avoids a wasted dedicated thread. With confinement you wrap state mutations in `withContext(confined) { ... }`. It serializes the *dispatched* blocks, so individual `withContext` calls are atomic relative to each other — but a read-modify-write spread across multiple separate dispatches still needs care. Alternatives for shared state include `Mutex.withLock`, atomics, or an actor.
code
kotlin · 6 linesclass Counter {
private val ctx = Dispatchers.Default.limitedParallelism(1)
private var value = 0
suspend fun inc() = withContext(ctx) { value++ } // atomic block
suspend fun get(): Int = withContext(ctx) { value }
}go deeper
Understands the idea: route state changes to a single thread so they don't overlap.
Compares newSingleThreadContext (dedicated thread, close()) vs limitedParallelism(1) (view, no close) and picks the modern one.
Explains the per-dispatch atomicity scope, the @DelicateApi rationale, and when to prefer Mutex/actor instead.
Weighs confinement vs locks vs actors at architecture scale, considers thread-budget impact and ownership boundaries for state.
## The problem When multiple coroutines mutate the same variable concurrently, you get a **data race** — interleaved read-modify-write loses updates. Example: `var counter = 0; counter++` from many coroutines on `Dispatchers.Default` can under-count. ## Single-threaded confinement **Confinement** = pin all access to that state onto **one thread**, so the JVM never runs two mutations at the same instant. Because everything is serialized, no locks are needed for the confined state. You route mutations through a single-threaded dispatcher with `withContext`: ```kotlin val stateDispatcher = Dispatchers.Default.limitedParallelism(1) private var counter = 0 suspend fun increment() = withContext(stateDispatcher) { counter++ } ``` ## Two ways to get a single-threaded dispatcher ### 1. `newSingleThreadContext("name")` - Returns an `ExecutorCoroutineDispatcher` backed by **its own dedicated OS thread**. - You **must call `close()`** (or use `.use { }`), or you leak the thread. - It's marked `@DelicateApi` because of that footgun. ### 2. `limitedParallelism(1)` (preferred) - `Dispatchers.Default.limitedParallelism(1)` returns a **serialized view** over the shared pool. - Borrows a single concurrency slot — **no dedicated thread, no `close()`**. - The actual thread may differ between dispatches, but only **one runs at a time**, which is all confinement requires. ```kotlin // Old / delicate: val ctx = newSingleThreadContext("counter") // must close() try { /* ... */ } finally { ctx.close() } // Modern, preferred: val ctx = Dispatchers.Default.limitedParallelism(1) // no close needed ``` ## Important nuance: atomicity scope Confinement serializes **each dispatched block**. A single `withContext(ctx) { read; modify; write }` is atomic relative to other dispatches. But if you split a read and a later write into **two** separate `withContext` calls, another coroutine's block can run in between. Keep the whole read-modify-write inside one confined block. ## Alternatives - `Mutex().withLock { }` — explicit non-blocking lock. - `AtomicInteger` / `atomic` (atomicfu) — for simple counters. - Actor pattern (channel + single coroutine) — message-based serialization. Confinement shines when you have **one owner of complex state** and want lock-free, easy-to-reason serialization.
- Why is newSingleThreadContext marked delicate?Because it allocates a dedicated OS thread you must close() yourself; forgetting leaks the thread. limitedParallelism(1) avoids this.
- Does limitedParallelism(1) guarantee the same physical thread each time?No. It guarantees only-one-at-a-time execution; the underlying thread may vary between dispatches, which is enough for confinement.
A single cashier handling one customer at a time: orders can't collide, even without anyone explicitly locking the register.
saying these in an interview costs you the question
- Forgetting to close() a newSingleThreadContext
- Splitting read-modify-write across two withContext calls and expecting atomicity
- Claiming limitedParallelism(1) pins one fixed thread
- Saying confinement needs a Mutex too
- Using @Volatile and thinking it makes counter++ atomic