1. Introduction
When you start writing coroutines, it’s easy to fall into an illusion: “Well, these aren’t threads, so thread dangers don’t apply.” Unfortunately (or fortunately—otherwise it would be boring), coroutines absolutely can run concurrently, and on Dispatchers.Default they can also run in parallel on a thread pool. That means two coroutines can literally execute your code at the same time on different CPU cores.
The official coroutine materials emphasize that coroutines are started via builders (launch, async), and their behavior is defined by the context, including a CoroutineDispatcher, which decides “where the coroutine runs.”
As soon as we have concurrent execution, we get the “good old” class of problems from multithreading: races, races, and more races. Coroutines are modern and convenient, but they don’t repeal the laws of the scheduler.
Shared mutable state
Before we complain about race conditions, we need to agree on terms. Shared mutable state means “shared mutable state.” In other words, there is some data that is simultaneously accessible for writing from multiple coroutines. The “for writing” part is crucial: if everyone only reads, it’s usually safer (though there can be nuances even then).
An example of shared mutable state is a variable var counter = 0 that many coroutines increment at once. Or var balance = 100, from which several coroutines try to withdraw money. Or a MutableList, where multiple coroutines add elements while other coroutines iterate over it.
The problem with shared mutable state is that it breaks not “according to the code’s logic,” but according to how the execution steps happened to interleave at a particular moment. And that starts to feel less like programming and more like trying to negotiate with a cat that decides for itself whether it will be affectionate today.
2. Compound operations: where exactly the race is hiding
counter++ is not a single operation
Here comes the moment that ruins life for beginners (and sometimes even not-so-beginners): counter++ looks like one operation, but semantically it’s at least three steps:
1) read counter from memory into a register/variable,
2) add 1,
3) write the new value back into counter.
If a single coroutine performs these steps, everything is great. But if two coroutines do it at the same time, they can “step on each other’s writes.”
Imagine both coroutines read counter = 10. Both compute 10 + 1. And both write 11. Result: instead of two increments, you got one. No mysticism—just logic being overwritten.
Here’s a small example that often demonstrates the problem even on an ordinary laptop:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
var counter = 0
val jobs = List(1_000) {
launch(Dispatchers.Default) {
counter++ // race: read → add → write
}
}
jobs.forEach { it.join() }
println(counter) // often < 1000
}
If you’re lucky, you’ll see something like 972, 998, 1000… and that “sometimes 1000” is its own special source of moral pain. Because a bug that “sometimes doesn’t reproduce” usually reproduces during a demo in front of your manager.
Why races look like magic
A very unpleasant property of race conditions is unpredictability. The scheduler for threads and coroutines (simplifying) decides who gets CPU time and when. It decides quickly, often, and it doesn’t ask your opinion. On one machine, in one run, you get one interleaving of steps. On the next run, a different one. And if you add a println, everything might “fix itself” (because printing is slow, and the interleaving changes).
To feel this in your hands, it’s convenient to run the same experiment several times and see how the result “wanders”:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
repeat(5) { attempt ->
var counter = 0
val jobs = List(1_000) {
launch(Dispatchers.Default) { counter++ }
}
jobs.forEach { it.join() }
println("attempt=$attempt counter=$counter") // attempts differ
}
}
What matters here is not that the result is “sometimes not 1000,” but that it can be different every time. That’s the main emotional marker of races: your logic is the same, but reality is new each run.
Check-then-act: “checked → did” as a trap
If counter++ is the classic race, then check-then-act is its more devious relative. This is a situation where you first check a condition and then perform an action, and it feels safe because “well, I checked.” But between the check and the action another coroutine can slip in and change the state.
Let’s imagine a mini scenario: a small ticket “booth.” There is a variable tickets = 1. Two coroutines try to buy a ticket: “if tickets exist—decrease by 1.”
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
var tickets = 1
val j1 = launch(Dispatchers.Default) {
if (tickets > 0) {
delay(10) // increase the chance of interleaving
tickets -= 1
}
}
val j2 = launch(Dispatchers.Default) {
if (tickets > 0) {
delay(10)
tickets -= 1
}
}
j1.join()
j2.join()
println("tickets=$tickets") // can become -1
}
The trick is that both coroutines can pass the check tickets > 0 while tickets is still equal to 1. Then both perform the decrement—and we get -1. In real life this is called “sold two tickets for one seat,” and in programming it’s “we just created a bug that will cost money and reputation.”
Note: the problem here is not delay. delay just makes the race more visible. The race exists without it too—it can just hide better.
3. Invariants and critical sections
What exactly are we protecting
When conversations about concurrency begin, it’s very tempting to say: “OK, I’ll protect everything.” But that’s a path to code that either becomes slow or turns into an incomprehensible mess. So we need an important word: invariant.
An invariant is an integrity rule that must always remain true, despite concurrency. In the case of our ticket booth, possible invariants include:
| State | Invariant (rule) | Why it matters |
|---|---|---|
|
|
you can’t sell more tickets than you have |
|
|
tickets must not “appear out of thin air” |
|
|
you can’t go negative if the rules forbid it |
The most useful thing about invariants is that they answer the question: “which exact part of the code must be made indivisible?” In other words, which logic must not be allowed to be “torn” in the middle by other coroutines.
For example, in check-then-act the invariant usually breaks because the check and the update must be one “atomic” block in meaning: “checked and immediately did it, without yielding control between them.”
How to find the “minimal piece” that must not be interrupted
The term critical section sounds like we’re about to build a bunker. In reality it’s simpler: a critical section is a code region that must execute as if it were the “one and only” one at that moment. That is, while one coroutine is executing this region, others must not simultaneously execute a conflicting region over the same state.
But in this lecture what matters is not “how to protect a critical section” (tools will come later), but how to identify it correctly. And here the “pencil on the code” exercise is useful:
You take a region where shared state is read and written, and ask yourself: “If I get interrupted right after this line, could the invariant already be violated?” If yes, then the boundary of the critical section was chosen incorrectly, and you need to expand the block so it includes dependent steps.
For tickets it looks like this:
if (tickets > 0) { // read tickets and made a decision
tickets -= 1 // wrote tickets
}
If we’re interrupted between the lines, another coroutine can make a decision based on the old value too. That means both lines must be treated as one indivisible chunk—one logical purchase operation.
A good rule for a beginner: if the logic looks like “read → decide → write”, it is almost certainly a candidate for a critical section. Even if the code is only two lines.
4. TicketOffice training sandbox
So the examples don’t hang in the air, let’s set up a small training application that we will “grow” in the next lectures. For now it will be intentionally unsafe—and that’s normal: we’re building a “broken bicycle” so we can later understand which bolts need tightening.
Let’s start with the simplest model: there’s a ticket office that stores tickets and sold. A sale is check-then-act. For now, without any protection at all.
import kotlinx.coroutines.delay
suspend fun buyTicketUnsafe(): Boolean {
if (tickets > 0) {
delay(1) // increase the chance of a race, does NOT "fix" the problem
tickets -= 1
sold += 1
return true
}
return false
}
But there’s a nuance: Kotlin doesn’t like “bare” global var, and that’s correct. So let’s make the simplest possible “box” for the state via IntArray (so we can mutate elements by passing it by reference), without going deep into OOP yet. It looks a bit hacky, but it’s transparent for understanding.
import kotlinx.coroutines.delay
suspend fun buyTicketUnsafe(state: IntArray): Boolean {
val tickets = state[0]
if (tickets > 0) {
delay(1)
state[0] = tickets - 1 // tickets
state[1] += 1 // sold
return true
}
return false
}
Now let’s write main, which launches many “buyers” in parallel. We expect sold not to exceed the initial number of tickets. But reality may surprise us.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val state = intArrayOf(10, 0) // [tickets=10, sold=0]
val buyers = List(1_000) {
launch(Dispatchers.Default) {
buyTicketUnsafe(state)
}
}
buyers.forEach { it.join() }
println("tickets=${state[0]}, sold=${state[1]}") // can be weird
}
Why can the result be “weird”? Because we protected nothing. We read tickets into a local variable, then wait delay(1), then write back. During that time another coroutine could do the same, and part of the sales gets “overwritten.” Or, if you change the logic to a more direct one (state[0] -= 1 after the check), you can get other kinds of breakage.
The main idea: as soon as you see shared mutable state and compound logic, you must suspect a race.
5. How an update gets “lost”
Sometimes it’s useful not just to take it on faith, but to see a “cartoon” of how steps interleave. Here’s schematically how one increment gets lost with counter++:
sequenceDiagram
participant A as Coroutine A
participant B as Coroutine B
participant M as counter (memory)
A->>M: read counter (=10)
B->>M: read counter (=10)
A->>A: compute 10+1 (=11)
B->>B: compute 10+1 (=11)
A->>M: write 11
B->>M: write 11
Note over M: Result = 11, although it "should" be 12
For check-then-act the diagram is similar, only instead of “added 1” we have “checked a condition and decremented.” And in both cases the key problem is the same: another coroutine can slip in between logically related steps.
6. Typical mistakes
Mistake #1: thinking “coroutines aren’t threads, so there are no races.”
Coroutines are indeed lighter and cheaper than threads, but they happily run on a thread pool, especially on Dispatchers.Default, and can run in parallel. If multiple coroutines write to the same variable without coordination, you get a classic race.
Mistake #2: believing a race only happens with counter++.
++ is just a very visual example because it looks like “one line.” But any compound logic of the form “read → compute → write” or “check → act” is subject to the same problems. Check-then-act in particular looks “reasonable,” but breaks most often.
Mistake #3: concluding “it printed 1000 once, so everything is OK.”
A race condition is devious precisely because it might not show up in a single run. It depends on timing, CPU load, background processes, and even whether you have a browser tab open playing music. Reliability built on luck usually ends abruptly.
Mistake #4: trying to “fix” a race by adding delay() or “one more println”.
delay and println change the execution schedule, so the bug can “hide” or “show up” more often. But they don’t eliminate the cause. It’s like treating a crack in a wall with a “don’t crack” sticker: the wall doesn’t care.
Mistake #5: not formulating an invariant and therefore protecting “everything.”
If you can’t say which integrity rule must always hold (tickets >= 0, sold + tickets == total, etc.), you won’t be able to identify the critical section properly. This typically ends with either protecting too small a piece (the race remains) or too large a piece (the program becomes slow and awkward).
Mistake #6: confusing “shared access” with “shared writes.”
Sometimes a student sees that a variable is accessible from different places and starts to panic. But the real risk zone is concurrent writes to the data. Reading by itself is more often safe (though there are subtleties), but writing without coordination is almost guaranteed to be a candidate for problems.