JavaRush /Курсы /Kotlin SELF /Mutex.withLock: корректная критическая секция

Mutex.withLock: корректная критическая секция

Kotlin SELF
55 уровень , 1 лекция
Открыта

1. Зачем корутинам Mutex?

Когда вы пишете последовательный код, var balance = 100 выглядит безобидно: ну число и число. Но в конкурентном мире у переменной внезапно появляется «социальная жизнь»: к ней начинают подходить разные корутины и менять её «прямо сейчас» — и вы уже не контролируете, в каком порядке это происходит. Проблема не в том, что Kotlin «плохой» или корутины «нечестные», а в том, что несколько независимых исполнителей вмешиваются в одни и те же данные без правил.

В документации Kotlin про корутины эта мысль формулируется так: когда несколько корутин обращаются к одному изменяемому состоянию, без координации можно получить race condition — непредсказуемое взаимное вмешательство операций.

Чтобы вернуть правила в игру, нам нужен механизм «в комнате с состоянием одновременно только один человек». В корутинах таким механизмом часто выступает Mutex.

Критическая секция и инварианты

Слово «критическая секция» звучит так, будто сейчас будет лекция по выживанию в условиях апокалипсиса. На практике всё проще: критическая секция — это минимальный участок кода, который должен выполняться как единое целое, без «вклинивания» других корутин между шагами.

Самая распространённая ловушка новичка — думать, что мы защищаем «переменную balance». На самом деле мы защищаем правило целостности, то есть инвариант. Например: «баланс не должен уходить в минус», «операция списания должна быть check‑then‑act одним атомарным действием», «список расходов не должен ломаться при параллельном добавлении».

Когда вы умеете произнести инвариант вслух, границы критической секции начинают определяться почти автоматически: в критическую секцию входит ровно тот кусок логики, который нельзя разрывать.

2. Mutex и withLock: подключение и базовый шаблон

Как подключить и как мыслить про Mutex

Mutex в корутинах — это не «тяжёлый замок» из мира потоков, а кооперативный примитив синхронизации для suspend-кода. Он позволяет корутине «подождать», не блокируя поток (то есть не занимая драгоценный ресурс «настоящего» thread’а).

На практике вам почти всегда нужны два импорта:

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

И дальше вы создаёте Mutex() как обычный объект:

val mutex = Mutex()

Важно сразу договориться с собой о стиле: Mutex почти никогда не должен быть «разбросан по проекту» как глобальная волшебная штука. Обычно он принадлежит конкретному объекту/сервису, который владеет состоянием. Тогда получается понятный контракт: «если хочешь трогать состояние — делай это через методы этого сервиса, а сервис сам синхронизирует доступ».

Почему withLock { ... } — основной шаблон

Когда начинаются блокировки, у новичков появляется естественное желание: «сейчас я всё сделаю вручную, как взрослые». В этот момент где‑то улыбается баг, потому что «вручную» вы почти гарантированно забудете какой-нибудь unlock() в ветке if, при исключении или при раннем return.

withLock { ... } решает эту проблему простым образом: вы даёте ему лямбду, библиотека сама «входит» в замок и гарантированно «выходит» из него после выполнения блока. Даже если внутри случилось исключение, вы не оставите замок в состоянии «занято навсегда».

Визуально это выглядит так:

flowchart TD
    A[Корутина хочет изменить состояние] --> B["mutex.withLock { ... }"]
    B --> C[Захват Mutex]
    C --> D[Критическая секция]
    D --> E[Освобождение Mutex]
    E --> F[Корутина продолжает работу]

И теперь можно сосредоточиться на главном: где границы критической секции, а не «не забыл ли я unlock».

4. Границы критической секции: как не превратить Mutex в «пробку»

Очень хочется написать так: «взяли замок — и делаем всё внутри, чтобы точно было безопасно». Это работает, но часто превращает программу в одну длинную очередь: все корутины стоят и ждут, пока одна корутина закончила огромный кусок работы.

Правило простое: держим Mutex только на тех строках, которые реально защищают инвариант. Всё, что можно сделать до блокировки или после — делаем до или после. Особенно осторожно с долгими операциями: delay(), чтение файлов, сетевые запросы, тяжёлые вычисления. Даже если delay() не блокирует поток, вы всё равно удерживаете замок и не даёте другим корутинам обновлять состояние, а это уже потеря пропускной способности.

Сравните два подхода на «пальцах»:

Подход Что происходит Итог
«Большая» критическая секция Mutex занят долго, остальные ждут Безопасно, но медленно и нервно
«Минимальная» критическая секция Mutex занят только на обновлении состояния Безопасно и заметно бодрее

5. Примеры: счётчик и check-then-act

Защищаем counter++ через Mutex.withLock

Счётчик — классический пример. Он прост, но очень показателен: counter++ выглядит одной операцией, но на деле это «прочитал → прибавил → записал». Если между этими шагами вмешается другая корутина, вы теряете инкременты.

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
}

Здесь важная мысль не в том, что «мы сделали 1000». Важная мысль в том, что мы чётко выделили критическую секцию: ровно одна строка обновления состояния. Всё остальное — параллельно.

check-then-act и инвариант «баланс не уходит в минус»

Счётчик — это разминка. Реальная боль начинается там, где логика состоит из нескольких шагов, которые должны быть вместе. Типичный паттерн гонки — check-then-act: «проверил условие → выполнил действие». Если эти два шага не защищены как единый блок, две корутины могут пройти проверку «почти одновременно», а потом обе выполнить действие — и инвариант ломается.

Пусть у нас есть balance, и мы хотим списывать деньги только если их хватает:

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
        }
    }
}

Обратите внимание на смысл: мы не делаем две отдельные операции «проверка» и «списание». Мы делаем одну критическую секцию, внутри которой проверка и списание живут вместе. Это и есть защита инварианта.

И да, здесь функция withdrawsuspend. Это не «красивость», а фундаментальный контракт: раз внутри есть withLock, а он при ожидании может приостанавливать корутину, то и наш helper должен быть suspend.

6. Дизайн helper-функций: suspend вместо runBlocking

Вот здесь начинается зона, где люди с добрыми намерениями создают себе злого начальника в будущем.

Иногда хочется написать helper так, чтобы его можно было вызвать откуда угодно, даже из обычного кода без корутин. И появляется «гениальная» идея: «а я внутри helper сделаю runBlocking, и всё!». Это выглядит удобно… пока вы не встретите странные тормоза, подвисания или пока ваш «неблокирующий» код внезапно начнёт блокировать потоки.

Антипаттерн: runBlocking внутри helper

Плохой пример обычно выглядит так:

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]++
        }
    }
}

Технически «работает», но по смыслу вы только что сделали синхронную блокировку потока ради того, чтобы притвориться, что у вас всё корутинно. Вызывающий код ожидал обычную функцию, а получил скрытое ожидание, скрытые блокировки и очень хитрые проблемы с производительностью. Это примерно как «я просто хотел чай», а вам принесли чайник, плиту и счёт за газ.

Правильный вариант: честный suspend-helper

Хороший вариант — честный suspend-helper:

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

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

Теперь контракт прозрачный: хочешь вызвать incrementRight — будь добр, делай это из корутины (или из другой suspend-функции). Никаких скрытых блокировок, никаких сюрпризов.

7. Практический пример: потокобезопасный ExpenseLedger

Чтобы это не осталось «примером про баланс», давайте поработаем над примером: консольный учёт расходов (expense tracker). Ранее мы хранили расходы в коллекциях, добавляли команды и отчёты. Теперь представим, что мы импортируем расходы параллельно из нескольких источников (например, «банк A» и «банк B»), и оба импорта пишут в один общий реестр.

Сделаем маленькую модель и хранилище. Сразу договоримся о правильном контракте: доступ к состоянию (списку расходов) идёт только через методы ExpenseLedger, а сам ExpenseLedger внутри защищает инварианты.

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

Минимальная критическая секция в add

Теперь сам реестр с 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)
    }
}

Обратите внимание, насколько «маленькая» критическая секция: мы не делаем там форматирование, печать, сортировки и отчёты. Мы делаем одну вещь: меняем внутренний список.

snapshot() вместо выдачи наружу изменяемого списка

Теперь часто хочется ещё уметь читать данные. И тут появляется тонкий момент: если вы возвращаете наружу items, вы отдаёте наружу ссылку на ваш внутренний изменяемый список. Это почти как дать соседям ключи от вашего холодильника и удивляться, что пельмени исчезают.

Поэтому лучше возвращать снимок (копию списка), тоже под 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() возвращает неизменяемый снаружи List<Expense> (read-only интерфейс), а внутри — новую коллекцию. Это защищает ваш инвариант: «реестр расходов меняется только через мои методы».

8. Параллельный импорт: несколько корутин добавляют расходы в один реестр

Теперь соберём маленький сценарий: две корутины «импортируют» расходы (мы симулируем импорт простыми данными), и в конце печатаем, сколько получилось.

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
}

Если бы ExpenseLedger не защищал список, вы могли бы ловить «редкие» и неприятные проблемы, особенно если кроме add были бы ещё операции вроде «проверить уникальность id и потом добавить» (тот самый check‑then‑act).

9. Где ставить withLock: внутри метода или снаружи

Иногда возникает вопрос: «а можно я в main сам возьму mutex.withLock и под ним буду делать операции?» Можно, но это почти всегда делает код менее читаемым и более хрупким. Причина простая: вызывающий код начинает знать слишком много про внутренности вашего объекта и должен помнить, что и как блокировать.

Гораздо приятнее, когда объект сам отвечает за свою корректность: «внутри меня — Mutex, снаружи — простой API». Это как в кафе: вы не обязаны идти на кухню и контролировать температуру духовки, чтобы получить чизкейк.

С точки зрения архитектуры это также помогает держать инварианты рядом с данными и не размазывать синхронизацию по всему проекту.

10. Типичные ошибки при работе с Mutex.withLock

Ошибка №1: слишком широкая критическая секция («давайте всё под замком, так надёжнее»).
Обычно это выглядит так: вы взяли withLock, а внутри сделали и вычисления, и форматирование отчёта, и println, и, возможно, delay() «для симуляции». Формально оно безопасно, но по факту вы превращаете Mutex в очередь, где одна корутина держит замок долго, а остальные простаивают. В итоге вы получаете «почему параллельность не ускоряет программу?», хотя вы сами её и выключили.

Ошибка №2: защищать переменную, а не инвариант, и поэтому поставить withLock не там.
Часто человек видит balance и думает «надо защитить строку balance -= amount». Но проблема в том, что инвариант «баланс не уходит в минус» зависит и от проверки if (balance >= amount). Если withLock покрывает только уменьшение, две корутины всё равно могут одновременно пройти проверку и обе списать деньги. withLock должен покрывать весь блок «проверил → изменил».

Ошибка №3: несколько разных Mutex для одного и того же состояния.
Иногда в проекте появляется два замка «на всякий случай»: один в add, другой в snapshot. В итоге вы случайно создаёте иллюзию безопасности: add и snapshot синхронизируются каждый «сам с собой», но не друг с другом. Если состояние одно, то и защита должна быть одной (или вы должны очень чётко разделить состояние на независимые части, что для новичка — почти всегда преждевременно).

Ошибка №4: прятать runBlocking внутрь helper-функции, чтобы «не думать про suspend».
Это одна из самых дорогих ошибок по последствиям. Ваш helper превращается в скрыто блокирующий вызов: его можно вызвать «где угодно», но ценой блокировки потока и неожиданного поведения. В корутинном приложении такой helper особенно опасен: он может блокировать поток пула и ухудшать выполнение других корутин. Правильный путь — делать helper suspend и честно требовать корутинный контекст у вызывающего кода.

Ошибка №5: возвращать наружу внутреннюю изменяемую коллекцию, даже если внутри всё защищено Mutex.
Если ваш ExpenseLedger возвращает MutableList<Expense>, то внешний код может начать менять список без всякого Mutex. Формально вы «использовали Mutex», но фактически отдали состояние «в общий доступ». Гораздо безопаснее возвращать снимок (toList()), а все изменения проводить только через методы, которые сами используют withLock.

1
Задача
Kotlin SELF, 55 уровень, 1 лекция
Недоступна
Сейф под замком
Сейф под замком
1
Задача
Kotlin SELF, 55 уровень, 1 лекция
Недоступна
Честный помощник
Честный помощник
1
Задача
Kotlin SELF, 55 уровень, 1 лекция
Недоступна
Снятие без минуса
Снятие без минуса
1
Задача
Kotlin SELF, 55 уровень, 1 лекция
Недоступна
Безопасный журнал
Безопасный журнал
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ