skip to content

Explain the precise semantics of limitedParallelism: how parallelism is counted, what happens to excess coroutines, and the pitfalls of chaining or misusing it (e.g. on Dispatchers.Main or Unconfined).

level: seniorimportance: should knowfreq 30%

answer

  1. Per-view FIFO gate of width n
  2. Each call => independent counter; reuse one view
  3. IO can exceed 64 for its limited slice
  4. Unconfined.limitedParallelism throws
  5. Don't chain; apply to a base dispatcher

basics

~20 s

It limits how many coroutines run at once on that view; extras queue and run as slots free up. Each call returns an independent view with its own limit. It's meant for multi-threaded dispatchers like IO/Default, not for Main or Unconfined.

solid answer

~50 s

limitedParallelism(n) returns an independent dispatcher view whose own concurrency never exceeds n; the cap is enforced per-dispatch by counting active tasks, with excess coroutines parked in a FIFO queue and resumed as slots free. The view borrows threads from the parent — it does not subdivide the parent's limit, so `IO.limitedParallelism(100)` can exceed IO's default 64 because IO permits elastic expansion for that named slice (`Dispatchers.IO` is special-cased). Chaining (`view.limitedParallelism(m)`) is generally discouraged/unsupported and can throw; apply it once to a base dispatcher. It's intended for multi-threaded dispatchers; on `Dispatchers.Main` it has limited meaning (Main is already single-threaded) and on `Dispatchers.Unconfined` it throws `UnsupportedOperationException`. Calls dispatched to the view still suspend cooperatively, so an in-flight coroutine that suspends frees its slot only when it actually yields/finishes the dispatched block — long CPU-bound blocks hold the slot.

code

kotlin · 11 lines
kotlin
// WRONG: two views, combined parallelism = 4, not 2
val w1 = Dispatchers.IO.limitedParallelism(2)
val w2 = Dispatchers.IO.limitedParallelism(2)

// RIGHT: one shared view => true cap of 2
val shared = Dispatchers.IO.limitedParallelism(2)
async(shared) { /* ... */ }
async(shared) { /* ... */ }

// THROWS:
// Dispatchers.Unconfined.limitedParallelism(2)

go deeper

for a junior

Knows extras queue and the view caps concurrency; may not know edge cases.

for a middle

Understands independent views and that you should reuse one view for a single cap.

for a senior

Articulates per-dispatch slot accounting, IO's elastic special case, and the Unconfined/chaining pitfalls precisely.

for a principal

Designs dispatcher topology for the whole app, anticipates version-specific UnsupportedOperationException behavior, and audits for accidental multi-view budget inflation.

## Counting parallelism `limitedParallelism(n)` tracks how many coroutines are **currently executing** on the view. When a coroutine is dispatched: - If fewer than `n` are running, it runs immediately (on a borrowed parent thread). - Otherwise it's **enqueued** (FIFO) and starts when a running one finishes its dispatched slice or **suspends back to the dispatcher**. A coroutine holds its slot for the duration of a **dispatched continuation**, not the whole coroutine lifetime — when it hits a suspension point that re-dispatches, the slot can be reused. So `n` bounds *simultaneously executing* continuations, not total in-flight coroutines. ## Independent views Each `limitedParallelism` call returns a **separate** view with its **own** counter: ```kotlin val a = Dispatchers.IO.limitedParallelism(2) val b = Dispatchers.IO.limitedParallelism(2) // a and b each allow 2 => up to 4 IO coroutines combined ``` They do **not** share a budget. To enforce one shared cap, create the view **once** and reuse it. ## Special case: Dispatchers.IO `Dispatchers.IO` is special-cased so that `IO.limitedParallelism(n)` can use up to `n` threads **even beyond IO's default soft limit of 64** — the slice is elastic. This is by design for blocking IO. On `Dispatchers.Default`, the view is still bounded by the CPU-sized pool. ## Pitfalls ### 1. Chaining ```kotlin val v = Dispatchers.IO.limitedParallelism(10) val w = v.limitedParallelism(5) // discouraged / may throw ``` Apply `limitedParallelism` to a **base** dispatcher, not to another limited view. Re-limiting a view is not a supported composition and can throw `UnsupportedOperationException` depending on version. ### 2. Unconfined / unsupported dispatchers `Dispatchers.Unconfined.limitedParallelism(n)` throws `UnsupportedOperationException` — Unconfined doesn't dispatch normally, so there's nothing to limit. ### 3. Dispatchers.Main Main is already effectively single-threaded; limiting it is usually meaningless and platform-dependent. ### 4. CPU-bound blocks hold slots A slot is freed only when the dispatched block finishes or suspends. A long, non-suspending CPU loop inside a `n=1` view will serialize everything behind it. ### 5. Creating views in hot paths Create the view once (e.g., a property), not per call — repeated creation defeats the shared-cap intent and adds overhead. ## Summary Think of it as a **per-view FIFO gate of width n** over borrowed threads: independent counters, elastic for IO, single-shot application, and not for Unconfined.

  • Can Dispatchers.IO.limitedParallelism(100) use more than 64 threads?
    Yes. IO is special-cased so its limited slice can elastically use up to n threads even beyond IO's default 64 soft limit.
  • What happens with Dispatchers.Unconfined.limitedParallelism(2)?
    It throws UnsupportedOperationException; Unconfined doesn't dispatch normally, so there's nothing to limit.
  • If you call limitedParallelism(2) twice, is total concurrency 2?
    No. Each call is an independent view with its own counter, so combined concurrency is 4. Reuse one view for a single cap.

saying these in an interview costs you the question

  • Assuming two views share one budget
  • Chaining limitedParallelism on an already-limited view
  • Calling it on Dispatchers.Unconfined and expecting it to work
  • Believing IO can never exceed 64 even for a limited slice
  • Creating a fresh view on every call in a hot path

context