Shared state mutated from two coroutines corrupts on iOS but never on Android
This shared code collects analytics events. On Android it behaves; on iOS the report sometimes comes back short, and once in a while the app crashes inside the list. Nothing throws an exception about threading, and no object is frozen anywhere in the module.
Constraint: the fix must live in commonMain and must give both targets the same guarantee. Do not move the work onto a single platform thread from the iOS side.
// commonMain
class EventCollector {
private val events = mutableListOf<String>()
suspend fun record(event: String) = withContext(Dispatchers.Default) {
events.add(event)
}
fun report(): List<String> = events.toList()
}
Diagnose the cause and fix it.
The modern memory manager lets Kotlin/Native threads share mutable objects, so nothing complains: this is an ordinary data race, just as on the JVM. Freezing is gone and is not the fix — guard the state from common code with a Mutex.
- ✗Reaching for
freeze(), which is deprecated at error level and was never a synchronisation primitive - ✗Believing Kotlin/Native still prevents two threads from mutating the same object
- ✗Fixing it on the iOS side when the unguarded state lives in
commonMainand Android races too
- →Why does the same code appear to work on Android far more often than on iOS?
- →When would you prefer confinement to a single dispatcher over taking a
Mutex?
Diagnosis
The legacy Kotlin/Native memory manager really did forbid sharing mutable state across threads, and trying to do so failed loudly. That model is gone. The modern runtime is a tracing garbage collector and is perfectly happy to let two threads hold the same object. freeze() has not vanished from the library, but it is deprecated at error level — and it was never a synchronisation primitive anyway.
So there is no special "native" error here: mutableListOf is not thread-safe, Dispatchers.Default is a thread pool, and two coroutines write to events at once. That is an ordinary data race. Android races too — its ArrayList implementation and scheduler are simply more forgiving.
The fix
// commonMain
class EventCollector {
private val mutex = Mutex()
private val events = mutableListOf<String>()
suspend fun record(event: String) = withContext(Dispatchers.Default) {
mutex.withLock { events.add(event) }
}
suspend fun report(): List<String> = mutex.withLock { events.toList() }
}
Mutex from kotlinx.coroutines suspends the coroutine instead of blocking the thread and lives in commonMain, so the guarantee is identical on both targets. The alternative is to confine every mutation to a single-threaded dispatcher and never share the state at all.