skip to content

How can you protect shared mutable state in coroutines using single-threaded confinement, and how does limitedParallelism(1) compare to newSingleThreadContext()?

level: middleimportance: should knowfreq 55%

answer

  1. Confine state to one thread => serialized, lock-free
  2. limitedParallelism(1): no close, borrows pool
  3. newSingleThreadContext: dedicated thread, must close()
  4. Keep read-modify-write in ONE withContext
  5. @DelicateApi on newSingleThreadContext

basics

~20 s

Run 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 s

Single-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 lines
kotlin
class 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

for a junior

Understands the idea: route state changes to a single thread so they don't overlap.

for a middle

Compares newSingleThreadContext (dedicated thread, close()) vs limitedParallelism(1) (view, no close) and picks the modern one.

for a senior

Explains the per-dispatch atomicity scope, the @DelicateApi rationale, and when to prefer Mutex/actor instead.

for a principal

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

context