JavaRush/Courses/Kotlin SELF/Mutex.withLock: a correct critical section

Mutex.withLock: a correct critical section

Open

1. Why do coroutines need Mutex?

When you write sequential code, var balance = 100 looks harmless: it’s just a number. But in a concurrent world, a variable suddenly develops a “social life”: different coroutines start coming up to it and changing it “right now” — and you no longer control the order in which it happens. The problem is not that Kotlin is “bad” or coroutines are “dishonest”, but that several independent executors interfere with the same data without any rules.

In the Kotlin coroutine documentation, this idea is phrased like this: when multiple coroutines access the same mutable state, without coordination you can get a race condition — unpredictable interference between operations.

To bring rules back into the game, we need a mechanism like “only one person in the room with the state at a time”. In coroutines, such a mechanism is often a Mutex.

Critical section and invariants

The phrase “critical section” sounds like we’re about to have a survival lecture for the apocalypse. In practice it’s simpler: a critical section is the smallest piece of code that must run as a single whole, without other coroutines “wedging in” between steps.

The most common beginner trap is to think that we are protecting “the variable balance”. In reality, we are protecting an integrity rule — an invariant. For example: “the balance must not go negative”, “a withdrawal must be a check‑then‑act as a single atomic action”, “the expense list must not break when adding in parallel”.

Once you can say the invariant out loud, the boundaries of the critical section are determined almost automatically: the critical section contains exactly the piece of logic that must not be split.

2. Mutex and withLock: setup and the basic template

How to set up and how to think about Mutex

Mutex in coroutines is not a “heavy lock” from the thread world, but a cooperative synchronization primitive for suspend code. It lets a coroutine “wait” without blocking a thread (i.e., without occupying the precious resource of a “real” thread).

In practice you almost always need two imports:

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

Then you create a Mutex() like a regular object:

val mutex = Mutex()

It’s important to agree with yourself on style right away: a Mutex should almost never be “scattered across the project” like some global magic thing. Usually it belongs to a specific object/service that owns the state. That gives you a clear contract: “if you want to touch the state — do it via this service’s methods, and the service will synchronize access itself”.

Why withLock { ... } is the primary template

Once locking starts, beginners naturally get the urge: “I’ll do everything manually, like an adult”. At that moment, a bug smiles somewhere, because when you do it “manually” you are almost guaranteed to forget some unlock() on an if branch, on an exception, or on an early return.

withLock { ... } solves this in a simple way: you give it a lambda, the library itself “enters” the lock and reliably “exits” it after the block completes. Even if an exception happens inside, you won’t leave the lock in a “held forever” state.

Visually, it looks like this:

flowchart TD
    A[Coroutine wants to change state] --> B["mutex.withLock { ... }"]
    B --> C[Acquire Mutex]
    C --> D[Critical section]
    D --> E[Release Mutex]
    E --> F[Coroutine continues work]

Now you can focus on what matters most: where the boundaries of the critical section are, not “did I forget an unlock”.

4. Critical section boundaries: how not to turn Mutex into a “bottleneck”

It’s very tempting to write: “take the lock — and do everything inside so it’s definitely safe”. That works, but it often turns the program into one long queue: all coroutines stand and wait while one coroutine finishes a huge chunk of work.

The rule is simple: hold the Mutex only for the lines that actually protect the invariant. Everything you can do before locking or after unlocking — do it before or after. Be especially careful with long operations: delay(), file reads, network requests, heavy computations. Even if delay() doesn’t block a thread, you still hold the lock and prevent other coroutines from updating the state, which reduces throughput.

Compare two approaches “at a glance”:

Approach What happens Result
“Large” critical section Mutex is held for a long time; others wait Safe, but slow and stressful
“Minimal” critical section Mutex is held only while updating state Safe and noticeably snappier

5. Examples: a counter and check-then-act

Protecting counter++ with Mutex.withLock

A counter is a classic example. It’s simple, but very illustrative: counter++ looks like a single operation, but in reality it’s “read → add → write”. If another coroutine intervenes between those steps, you lose increments.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

fun main() = runBlocking {
    var counter = 0
    val mutex = Mutex()

    val jobs = List(1_000) {
        launch(Dispatchers.Default) {
            mutex.withLock {
                counter++
            }
        }
    }

    jobs.forEach { it.join() }
    println("counter=$counter") // counter=1000
}

The key point here is not that “we got 1000”. The key point is that we clearly isolated the critical section: exactly one line that updates the state. Everything else runs in parallel.

check-then-act and the invariant “the balance must not go negative”

A counter is a warm-up. Real pain starts where logic consists of several steps that must stay together. A typical race pattern is check-then-act: “check a condition → perform an action”. If those two steps are not protected as a single block, two coroutines can pass the check “almost at the same time” and then both perform the action — and the invariant breaks.

Let’s say we have balance, and we want to withdraw money only if there’s enough:

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

suspend fun withdraw(
    mutex: Mutex,
    amount: Int,
    get: () -> Int,
    set: (Int) -> Unit
): Boolean {
    return mutex.withLock {
        val balance = get()
        if (balance >= amount) {
            set(balance - amount)
            true
        } else {
            false
        }
    }
}

Notice the meaning: we are not doing two separate operations, “check” and “withdraw”. We do one critical section where the check and the withdrawal live together. That is how you protect the invariant.

And yes, the withdraw function here is suspend. This is not “for prettiness”, but a fundamental contract: since there is withLock inside, and it may suspend the coroutine while waiting, our helper must also be suspend.

6. Helper function design: suspend instead of runBlocking

This is where people with good intentions create an evil future boss for themselves.

Sometimes you want to write a helper so it can be called from anywhere, even from regular code without coroutines. And then a “brilliant” idea appears: “I’ll just put runBlocking inside the helper, and that’s it!”. It looks convenient… until you hit weird slowdowns, hangs, or your “non-blocking” code suddenly starts blocking threads.

Anti-pattern: runBlocking inside a helper

A bad example usually looks like this:

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

fun incrementWrong(mutex: Mutex, box: IntArray) {
    runBlocking {
        mutex.withLock {
            box[0]++
        }
    }
}

Technically it “works”, but in meaning you just introduced a synchronous thread block in order to pretend everything is coroutine-based. The calling code expected a regular function, but got hidden waiting, hidden blocking, and very tricky performance problems. It’s roughly like “I just wanted some tea”, and you were brought a kettle, a stove, and a gas bill.

The correct option: an honest suspend helper

A good option is an honest suspend helper:

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

suspend fun incrementRight(mutex: Mutex, box: IntArray) {
    mutex.withLock {
        box[0]++
    }
}

Now the contract is transparent: if you want to call incrementRight, please do it from a coroutine (or from another suspend function). No hidden blocking, no surprises.

7. Practical example: a thread-safe ExpenseLedger

So this doesn’t remain “an example about a balance”, let’s work through an example: a console expense tracker. Previously we stored expenses in collections, added commands and reports. Now imagine we import expenses in parallel from multiple sources (for example, “bank A” and “bank B”), and both imports write into a single shared ledger.

Let’s build a small model and storage. We’ll agree on the correct contract right away: access to state (the expense list) goes only through ExpenseLedger methods, and ExpenseLedger itself protects its invariants internally.

data class Expense(
    val id: String,
    val amount: Int,
    val category: String
)

Minimal critical section in add

Now the ledger itself with a Mutex:

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class ExpenseLedger {
    private val mutex = Mutex()
    private val items = mutableListOf<Expense>()

    suspend fun add(expense: Expense) = mutex.withLock {
        items.add(expense)
    }
}

Notice how “small” the critical section is: we don’t do formatting, printing, sorting, or reports there. We do one thing: mutate the internal list.

snapshot() instead of exposing a mutable list

Now you’ll often want to read the data too. And here a subtle point appears: if you return items, you are giving out a reference to your internal mutable list. It’s like giving your neighbors keys to your fridge and being surprised that the dumplings disappear.

So it’s better to return a snapshot (a copy of the list), also under the Mutex:

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class ExpenseLedger {
    private val mutex = Mutex()
    private val items = mutableListOf<Expense>()

    suspend fun add(expense: Expense) = mutex.withLock {
        items.add(expense)
    }

    suspend fun snapshot(): List<Expense> = mutex.withLock {
        items.toList()
    }
}

snapshot() returns an externally immutable List<Expense> (read-only interface), and internally it is a new collection. This protects your invariant: “the expense ledger changes only through my methods”.

8. Parallel import: multiple coroutines add expenses to a single ledger

Now let’s assemble a small scenario: two coroutines “import” expenses (we simulate import with simple data), and at the end we print how many we got.

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

fun main() = runBlocking {
    val ledger = ExpenseLedger()

    val j1 = launch(Dispatchers.Default) {
        ledger.add(Expense(id = "a1", amount = 100, category = "food"))
        ledger.add(Expense(id = "a2", amount = 250, category = "transport"))
    }

    val j2 = launch(Dispatchers.Default) {
        ledger.add(Expense(id = "b1", amount = 400, category = "food"))
        ledger.add(Expense(id = "b2", amount = 90, category = "coffee"))
    }

    joinAll(j1, j2)

    val all = ledger.snapshot()
    println("total items=${all.size}") // total items=4
}

If ExpenseLedger did not protect the list, you could run into “rare” and unpleasant issues, especially if, besides add, there were also operations like “check id uniqueness and then add” (that same check‑then‑act).

9. Where to put withLock: inside the method or outside

Sometimes a question comes up: “Can I just take mutex.withLock in main myself and do operations under it?” You can, but it almost always makes the code less readable and more fragile. The reason is simple: the calling code starts knowing too much about your object’s internals and must remember what and how to lock.

It’s much nicer when the object itself is responsible for correctness: “inside me there is a Mutex, outside there is a simple API”. It’s like in a cafe: you don’t have to go into the kitchen and control the oven temperature to get cheesecake.

From an architecture standpoint, this also helps keep invariants close to the data and not smear synchronization across the whole project.

10. Common mistakes when working with Mutex.withLock

Mistake #1: a critical section that is too wide (“let’s do everything under the lock, it’s more reliable”).
Usually it looks like this: you take withLock, and inside you do computations, report formatting, println, and maybe even delay() “for simulation”. Formally it’s safe, but in practice you turn Mutex into a queue where one coroutine holds the lock for a long time, and everyone else is idle. The result is “why doesn’t parallelism speed up the program?”, even though you disabled it yourself.

Mistake #2: protecting a variable instead of an invariant, so you place withLock in the wrong spot.
Often a person sees balance and thinks “I need to protect the line balance -= amount”. But the problem is that the invariant “the balance must not go negative” depends on the check if (balance >= amount). If withLock covers only the subtraction, two coroutines can still pass the check at the same time and both withdraw money. withLock must cover the entire “check → change” block.

Mistake #3: multiple different Mutex instances for the same state.
Sometimes two locks appear in a project “just in case”: one in add, another in snapshot. You end up creating an illusion of safety: add and snapshot are each synchronized “with themselves”, but not with each other. If the state is the same, the protection must be the same (or you must very clearly split the state into independent parts, which for a beginner is almost always premature).

Mistake #4: hiding runBlocking inside a helper function so you “don’t have to think about suspend”.
This is one of the most expensive mistakes in terms of consequences. Your helper becomes a hidden blocking call: you can call it “from anywhere”, but at the cost of blocking a thread and surprising behavior. In a coroutine application, such a helper is especially dangerous: it can block a pool thread and degrade other coroutines’ execution. The correct path is to make the helper suspend and honestly require a coroutine context from the calling code.

Mistake #5: returning an internal mutable collection, even if everything is protected by Mutex internally.
If your ExpenseLedger returns a MutableList<Expense>, external code can start mutating the list without any Mutex. Formally you “used Mutex”, but in reality you handed the state “to the public”. It’s much safer to return a snapshot (toList()), and perform all changes only via methods that themselves use withLock.

1
Task
Kotlin SELF,  55 level,  1 lecture
Locked
Safe under lock
Safe under lock
1
Task
Kotlin SELF,  55 level,  1 lecture
Locked
Honest helper
Honest helper
1
Task
Kotlin SELF,  55 level,  1 lecture
Locked
Withdrawal Without Going Negative
Withdrawal Without Going Negative
1
Task
Kotlin SELF,  55 level,  1 lecture
Locked
Safe log
Safe log
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet