JavaRush /Courses /Kotlin SELF /Deadlock, livelock, and starvation: how they appear and h...

Deadlock, livelock, and starvation: how they appear and how to avoid the trap

Kotlin SELF
Level 55, Lesson 4
Available

1. Progress: when “there’s no error,” but everything is bad

If you’re used to single-threaded code, you have a simple model of the world: if the program broke, you’ll get an exception. If there’s no exception, it works. Concurrent code rudely breaks that logic. The program may not crash, may not complain, may not print anything suspicious—and still never finish, or finish but ten times slower, or “stick” in some strange state.

To talk about this concretely, we need a criterion: progress. Progress is when the system makes useful steps: processes commands, transfers money, updates a counter, completes tasks. If coroutines are alive but there’s no progress (or it “shows up only on holidays”)—that’s a symptom of synchronization or design problems.

It’s handy to keep three “anti-progress” cases in mind:

Problem What happens in plain words Typical symptom
Deadlock Everyone waits for each other, and nobody can continue “Sometimes it hangs forever”
Livelock Everyone actively bustles around and yields, but there’s no result “CPU is doing something, but the meaning is zero”
Starvation Someone constantly “doesn’t get a turn” and barely runs “Some tasks run, others starve”

Next, we’ll go through each one via small Kotlin examples with coroutines and Mutex.

2. Deadlock: “you first” — “no, you first”

Deadlock sounds like a rock band name, but it’s more of a tragicomedy genre. In a deadlock you have several participants (coroutines/threads) that have acquired resources (for example, a Mutex) and now are waiting for each other’s resources. Nobody releases what they already hold because “I still need it,” and getting the “extra” is impossible because it’s held by someone else—and they’re waiting too.

The classic way to get a deadlock is to acquire multiple locks in different orders in different parts of the program. This is not “exotic”; it’s a typical everyday situation when you have a transfer between two accounts: you need to lock both accounts so the transfer is atomic.

Let’s imagine our practical mini-app: a console “mini-bank” with accounts and transfers. Each account will have its own Mutex.

Starter: an account and two Mutexes

Let’s make a simple model. The key detail here is that Account stores mutex next to the balance—so we immediately show the idea “one piece of state—one lock.”

import kotlinx.coroutines.sync.Mutex

data class Account(
    val id: Int,
    var balance: Int,
    val mutex: Mutex = Mutex()
)

Now we’ll create a pair of accounts and a function that transfers money. Important: a transfer is an invariant across two objects (“withdraw from one and deposit to the other”), so we need to keep both sides under control.

Deadlock example: two transfers towards each other

Here’s the “bad” version of transfer: it first locks from, then to. At first glance, it seems logical.

import kotlinx.coroutines.sync.withLock

suspend fun transferDeadlock(from: Account, to: Account, amount: Int) {
    from.mutex.withLock {
        to.mutex.withLock {
            from.balance -= amount
            to.balance += amount
        }
    }
}

And now we run two transfers in parallel: A -> B and B -> A.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val a = Account(id = 1, balance = 100)
    val b = Account(id = 2, balance = 100)

    val j1 = launch(Dispatchers.Default) { transferDeadlock(a, b, 10) }
    val j2 = launch(Dispatchers.Default) { transferDeadlock(b, a, 20) }

    j1.join()
    j2.join()
    println("a=${a.balance}, b=${b.balance}") // sometimes it won’t print at all
}

Why “sometimes”? Because a deadlock is often a scheduling race: if j1 managed to lock a, and j2 managed to lock b, then next:

  • j1 waits for b.mutex
  • j2 waits for a.mutex

And that’s it. No progress.

You can visualize it like this:

flowchart LR
    J1["j1 holds lock(A)"] -->|"wants lock(B)"| B["lock(B) is taken"]
    J2["j2 holds lock(B)"] -->|"wants lock(A)"| A["lock(A) is taken"]
    B -->|"held by j2"| J2
    A -->|"held by j1"| J1

The key idea: deadlock is not a runtime error; it’s a design error in acquisition ordering. And in that sense it’s more insidious than exceptions: there’s no stack trace—just “silence.”

Prevention: a single acquisition order

The simplest and strongest rule against deadlocks with multiple locks: everywhere in the code, acquire locks in the same order.

For example: always lock the account with the smaller id first, then the larger one. Then the system simply cannot form a wait cycle like “A waits for B, B waits for A,” because everyone moves in the same direction.

import kotlinx.coroutines.sync.withLock

suspend fun transferOrdered(a1: Account, a2: Account, amount: Int) {
    val first = if (a1.id <= a2.id) a1 else a2
    val second = if (a1.id <= a2.id) a2 else a1

    first.mutex.withLock {
        second.mutex.withLock {
            a1.balance -= amount
            a2.balance += amount
        }
    }
}

And the run:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val a = Account(1, 100)
    val b = Account(2, 100)

    val j1 = launch(Dispatchers.Default) { transferOrdered(a, b, 10) }
    val j2 = launch(Dispatchers.Default) { transferOrdered(b, a, 20) }

    j1.join(); j2.join()
    println("a=${a.balance}, b=${b.balance}") // a=110, b=90 (or swapped depending on amounts)
}

Notice: we did not add delay() “for reliability.” Deadlock is not cured by “magic sleep”; it’s cured by strict rules.

3. Starvation: “everyone works,” but someone almost never does

Starvation is a situation where the system as a whole seems to be moving, but a particular task/coroutine repeatedly fails to get access to a resource or gets it so rarely that the business logic “dies slowly.” In real life it’s like a line at a coffee shop where someone keeps cutting in front of you, and you’ve been holding the same ticket for three hours.

In coroutines, starvation often happens not because someone is evil, but because we:

  • hold a Mutex for too long,
  • do a long operation under the lock,
  • or build a “hot loop” around the lock.

It’s important to understand: Mutex protects correctness, but it does not guarantee fairness in the sense of “everyone gets an equal and timely turn.” So critical-section design is not only about correctness, but also about performance.

Bad example: a long delay() under a Mutex

Let’s go back to our mini-bank. Suppose there’s an operation “apply a bonus” (conceptually) that, for some reason, introduces a delay (for example, waiting for some external step). The delay itself isn’t the problem. The problem is when the delay is inside withLock.

import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.withLock

suspend fun bonusBad(account: Account) {
    account.mutex.withLock {
        delay(200)           // hold the lock while we "wait"
        account.balance += 1
    }
}

If transfers run in parallel, they will start to “starve”: each transfer will wait while the bonus “sleeps.” Nobody dies, but the program becomes sticky, like an IDE on an old laptop.

Good style: a short critical section

A common correct thought is: “If I’m waiting, then I’m not changing anything. So why would I hold the lock?”

import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.withLock

suspend fun bonusGood(account: Account) {
    delay(200) // wait outside
    account.mutex.withLock {
        account.balance += 1
    }
}

Yes, it’s not always possible: sometimes you must “wait” exactly between reading and writing, and then the invariant is more complex. But in most everyday cases, this is a direct improvement.

What starvation looks like “by eye”

Starvation is unpleasant because, from logs, it may look like “everything works.” It’s just that some operations start to “sometimes think for a very long time.” It’s usually diagnosed by symptoms like:

  • some coroutines consistently finish quickly,
  • others “hang” disproportionately long,
  • overall throughput drops.

And almost always the cause is an overly broad critical section or heavy work inside withLock.

4. Livelock: “polite” coroutines with no result

Livelock is similar to deadlock in that there’s no progress, but it differs in dramaturgy: in a deadlock everything is frozen, and in a livelock everything is actively moving, just uselessly.

A common cause of livelock is trying to be “too polite”: “if it didn’t work, I’ll release and try again later,” but both sides do it in sync and block each other forever.

To demonstrate livelock, we’ll use tryLock(): an attempt to acquire a Mutex without waiting. If the lock is taken, it returns false. This is a familiar idea from atomics: “tried—didn’t work—rolled back,” just at the lock level.

Naive “polite” transfer with tryLock()

Let’s make a transfer that tries to acquire both locks. If it fails, it releases what it acquired and retries.

import kotlinx.coroutines.delay

suspend fun transferLivelock(from: Account, to: Account, amount: Int) {
    while (true) {
        if (from.mutex.tryLock()) {
            try {
                if (to.mutex.tryLock()) {
                    try {
                        from.balance -= amount
                        to.balance += amount
                        return
                    } finally {
                        to.mutex.unlock()
                    }
                }
            } finally {
                from.mutex.unlock()
            }
        }
        delay(1) // "yield" so we don't spin the CPU
    }
}

The code looks reasonable, but now imagine two coroutines:

  • the first wants A -> B,
  • the second wants B -> A.

Both can fall into a rhythm:

  1. acquire their “own” first lock
  2. fail to get the second lock
  3. release and wait delay(1)
  4. repeat simultaneously

They aren’t standing still—they’re running in circles. Zero progress.

How to tell livelock from deadlock

Deadlock often looks like “silence”: it just hangs, and that’s it.

Livelock more often looks like “something is happening”: CPU usage can be noticeably high, logs can spam, but the state doesn’t change.

If you add a debug println("try..."), you may get an endless stream of lines. That’s also a separate pain: logging itself becomes a source of slowdown, and you start treating symptoms instead of the cause.

Livelock prevention: asymmetry

Livelock breaks if we add asymmetry. There are many ways, but the idea is the same: both sides must not behave identically.

The simplest and most practical option at a learning level is a single acquisition order (same as for deadlock). Then even with tryLock() you won’t get an endless “polite fight”: everyone tries to take first first, then second.

Let’s make a version with ordering by id and a small random backoff to break synchronized retries:

import kotlin.random.Random
import kotlinx.coroutines.delay

suspend fun transferTryOrdered(a1: Account, a2: Account, amount: Int) {
    val first = if (a1.id <= a2.id) a1 else a2
    val second = if (a1.id <= a2.id) a2 else a1

    while (true) {
        if (first.mutex.tryLock()) {
            try {
                if (second.mutex.tryLock()) {
                    try {
                        a1.balance -= amount
                        a2.balance += amount
                        return
                    } finally {
                        second.mutex.unlock()
                    }
                }
            } finally {
                first.mutex.unlock()
            }
        }
        delay(Random.nextLong(1, 5)) // break the "marching in step"
    }
}

Yes, it’s a bit more complex than just withLock. And that’s also an important moral: once you start “manual lock management,” you’re in a high-attention zone. Sometimes it’s simpler to go back to withLock and a strict order.

How not to design a trap: habits that save you

If you reduce deadlock/livelock/starvation prevention to one idea, it sounds like this: reduce the number of reasons why coroutines need to fight over resources at all.

In the previous lecture about confinement/actor we already saw a design where one owner mutates the state, and everyone else sends messages. Such a design often reduces the risk of deadlock and starvation “by construction,” because you don’t have “two locks, two owners, two orders.” Even if the owner has a Mutex inside, that’s a local story, not a web of mutual waiting.

If you do stay with Mutex, then the most stable set of habits looks like this:

flowchart TD
    A["Need synchronization?"] --> B["Can we remove shared state via confinement?"]
    B -->|yes| C["Actor: one state owner"]
    B -->|no| D["Need multiple locks at the same time?"]
    D -->|yes| E["Single acquisition order and minimal section"]
    D -->|no| F["One Mutex and a short withLock"]

This diagram isn’t “official,” but it disciplines the brain well: before adding a second Mutex, it’s worth asking whether you’re creating a future deadlock “on an installment plan.”

5. Typical mistakes

Mistake #1: acquiring multiple Mutex locks in different orders “in different places, just a little bit.”
This is the most common source of deadlocks. In one method you lock A then B; in another, B then A, because “it reads nicer.” And then the program sometimes hangs forever, and you start suspecting everything: GC, JIT, the phase of the moon, and the neighbor with a microwave. You fix it not by debugging, but by a rule: one lock order for the entire project (for example, by id).

Mistake #2: holding a Mutex during long operations (I/O, delay, heavy computation).
This is how you grow starvation yourself. Even if deadlock is impossible, the system will degrade: one coroutine holds the lock for a long time, others wait, and it feels like “everything is slow,” even though correctness is formally preserved. The right mental model: under the lock, keep only what truly must be atomic.

Mistake #3: trying to “fix hangs” by sprinkling delay() in random places.
delay() can accidentally mask the problem (especially races), and with deadlock it may seem like “it got better,” because the coroutine schedule changed. That’s not a solution. The solution is: critical-section boundaries, a single lock order, or switching the design to confinement.

Mistake #4: manually writing tryLock/unlock without try/finally.
In the examples above, try/finally looks pedantic, but this is exactly where pedantry saves you. Any early return, any exception, any “if it didn’t work” logic—and you risk not releasing the lock. One forgotten unlock() turns the system into “eternal waiting” and makes the root cause hard to reproduce.

Mistake #5: fighting livelock with identical strategies on both sides.
If two coroutines “politely yield” in the same way, they can yield in sync forever. Livelock is cured by asymmetry: a single acquisition order, random backoff, or an architectural solution where competition for the resource simply doesn’t exist (confinement/actor).

Mistake #6: assuming that since we use Mutex, it will “fairly distribute the queue.”
Mutex provides mutual exclusion, but it doesn’t guarantee that every coroutine will get the resource in time. If one part of the code constantly acquires the lock for a long time or very frequently, others may starve. So Mutex is a correctness tool, while “fairness” and “throughput” remain a design responsibility.

1
Task
Kotlin SELF, level 55, lesson 4
Locked
Two locks
Two locks
1
Task
Kotlin SELF, level 55, lesson 4
Locked
Long lock hold
Long lock hold
1
Task
Kotlin SELF, level 55, lesson 4
Locked
Opposite transfer
Opposite transfer
1
Survey/quiz
Synchronization in coroutines, 55 level, 4 lesson
Unavailable
Synchronization in coroutines
Synchronization in coroutines
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION