skip to content

What did multi-threaded coroutines support change on Kotlin/Native, and how do dispatchers behave under the new memory manager?

level: seniorimportance: should knowfreq 40%

answer

  1. native-mt is gone — unified artifact
  2. Dispatchers.Default = real thread pool
  3. Continuations resume on any thread, no freeze
  4. Main still confines to the UI loop
  5. Shared state -> Mutex/atomics/StateFlow

basics

~20 s

Coroutines on Native used to run on one thread only. Now they can run across many threads. Dispatchers.Default uses a real thread pool, and a coroutine can resume on a different thread than it started on.

solid answer

~40 s

With the legacy memory model, kotlinx.coroutines on Native was effectively single-threaded: you needed the special native-mt build, and even then sharing was constrained by freezing. The new memory manager (Kotlin 1.7.20+) plus the unified kotlinx.coroutines artifact lifted that 'single-thread main restriction': Dispatchers.Default is now a genuine multi-threaded pool, Dispatchers.IO exists on Native, and a coroutine can be dispatched to or resumed on any pool thread, so continuations cross threads freely without freezing. Dispatchers.Main still confines to the platform's main/UI loop (and needs the right artifact, e.g. for iOS). newSingleThreadContext/newFixedThreadPoolContext create your own backing threads. Because work now spreads across threads, shared state again needs synchronization — withContext hops threads, and you must use Mutex, atomics, or StateFlow rather than assuming single-thread confinement. limitedParallelism lets you cap a dispatcher's concurrency.

code

kotlin · 16 lines
kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update

val counter = MutableStateFlow(0)

fun main() = runBlocking {
    coroutineScope {
        repeat(100) {
            launch(Dispatchers.Default) {          // many pool threads
                counter.update { it + 1 }          // thread-safe update
            }
        }
    }
    println(counter.value)                          // 100, deterministically
}

go deeper

for a junior

Knows coroutines can now use multiple threads via Dispatchers.Default.

for a middle

Explains continuations resume on any thread and that shared state needs synchronization.

for a senior

Details the unified artifact replacing native-mt, IO/Main behavior, limitedParallelism, and reintroduced race risks.

for a principal

Designs a coroutine threading strategy across iOS/desktop targets, weighs confinement vs locking, and reasons about Main run-loop integration and starvation.

## The old single-thread restriction Under the **legacy memory model**, `kotlinx-coroutines-core` on Native shipped a special `native-mt` flavor, and practical use was **single-threaded**: the default dispatcher ran everything on one thread, and crossing threads tripped freezing rules. This is the 'single-thread main restriction' the leaf refers to. ## What multi-threaded coroutines changed With the **new memory manager** (default in Kotlin **1.7.20**) and the **unified** coroutines artifact (no more separate `native-mt`): - **`Dispatchers.Default`** is a real **multi-threaded** pool sized to the CPUs. - **`Dispatchers.IO`** exists on Native (for blocking/IO-style work). - A coroutine's **continuation can resume on a different thread** than it suspended on — continuations and captured state move across threads **without freezing**. - `withContext(Dispatchers.Default) { ... }` actually **hops threads**. ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val results = (1..4).map { id -> async(Dispatchers.Default) { // runs on the pool heavyCompute(id) // may run on any pool thread } }.awaitAll() println(results) } ``` ## Dispatchers on Native - **`Dispatchers.Default`** — CPU-bound work, multi-threaded. - **`Dispatchers.IO`** — IO/blocking work. - **`Dispatchers.Main`** — confines to the platform main/UI loop; requires the platform integration (e.g. on iOS the main run loop). Still single-threaded by nature. - **`Dispatchers.Unconfined`** — resumes in the current thread. - **`newSingleThreadContext`/`newFixedThreadPoolContext`** — create your own backing thread(s); must be closed. - **`limitedParallelism(n)`** — a view over a dispatcher capping concurrent coroutines, e.g. `Dispatchers.IO.limitedParallelism(4)`. ## Shared state implications Because coroutines now spread across threads, you **cannot** assume single-thread confinement for shared mutable state. Use: - `kotlinx.coroutines.sync.Mutex` for critical sections. - `kotlin.concurrent.AtomicInt`/`AtomicReference` for lock-free fields. - `StateFlow`/`MutableStateFlow` for observable state (thread-safe updates via `update { }`). - Confining mutation to a single-thread context if you want serialization. ## Practical gotchas - `Dispatchers.Main` on iOS still requires linking the right integration and a running run loop; calling it without one fails. - Long blocking calls on `Dispatchers.Default` starve CPU work — use `IO` or your own pool. - `runBlocking` blocks the current native thread; fine for `main`, not inside UI loops.

  • Why is Dispatchers.Main still effectively single-threaded even with multi-threaded coroutines?
    Main dispatches onto the platform's main/UI run loop, which is inherently one thread (e.g. the iOS main run loop). The multi-threaded change applies to the worker pools (Default/IO), not the UI thread.
  • Now that continuations cross threads, what concurrency bugs reappear that the old single-threaded model hid?
    Data races on shared mutable state and visibility issues, because work no longer serializes on one thread. You must synchronize with Mutex, atomics, or confine state to a single-thread context.

The old coroutines ran like a one-lane road forced to a single thread; multi-threaded coroutines opened a multi-lane highway where a car (continuation) can change lanes (threads) mid-trip.

saying these in an interview costs you the question

  • Claiming coroutines on Native are still single-threaded
  • Saying you must use the native-mt artifact today
  • Assuming withContext keeps you on the same thread
  • Believing freezing is required to pass state into a coroutine
  • Thinking Dispatchers.Default and Main are interchangeable

context