1. Введение
Когда начинаешь писать корутины, легко впасть в иллюзию: «Ну это же не потоки, значит, опасностей потоков нет». К сожалению (или к счастью — иначе было бы скучно), корутины вполне реально выполняются конкурентно, а на Dispatchers.Default ещё и параллельно на пуле потоков. То есть две корутины могут реально исполнять ваш код одновременно на разных ядрах CPU.
В официальных материалах по корутинам подчёркивается, что корутины запускаются через builders (launch, async), а их поведение определяется контекстом, в том числе CoroutineDispatcher, который решает «где корутина бежит».
Как только у нас есть конкурентное выполнение, появляется «старый добрый» класс проблем из многопоточности: гонки, гонки и ещё раз гонки. Корутины — современные и удобные, но законы планировщика они не отменяют.
Shared mutable state
Прежде чем ругаться на race condition, нужно договориться о терминах. Shared mutable state — это «общее изменяемое состояние». То есть есть какие-то данные, которые одновременно доступны на запись из нескольких корутин. Важно именно «на запись»: если все только читают — это обычно безопаснее (хотя и там бывают нюансы).
Пример shared mutable state — переменная var counter = 0, которую увеличивают сразу много корутин. Или var balance = 100, откуда несколько корутин пытаются списывать деньги. Или MutableList, куда несколько корутин добавляют элементы, пока другие корутины по нему проходят.
Проблема shared mutable state в том, что он ломается не «по логике кода», а по тому, как именно в конкретный момент времени перемешались шаги выполнения. И вот это уже похоже не на программирование, а на попытку договориться с котом, который сам решает, будет ли он сегодня ласковым.
2. Составные операции: где именно прячется гонка
counter++ — это не одна операция
Сейчас будет момент, который ломает жизнь новичкам (и иногда даже не новичкам): counter++ выглядит как одна операция, но по смыслу это минимум три шага:
1) прочитать counter из памяти в регистр/переменную,
2) прибавить 1,
3) записать новое значение обратно в counter.
Если эти шаги выполняет одна корутина — всё прекрасно. Но если две корутины делают это одновременно, они могут «наступить друг другу на запись».
Представьте, что обе корутины прочитали counter = 10. Обе посчитали 10 + 1. И обе записали 11. Итог: вместо двух инкрементов получился один. Никакой мистики — просто логика «перетёрлась».
Вот маленький пример, который часто показывает проблему уже на обычном ноутбуке:
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++ // гонка: read → add → write
}
}
jobs.forEach { it.join() }
println(counter) // часто < 1000
}
Если вам повезёт, вы увидите что-то вроде 972, 998, 1000… и вот это «иногда 1000» — отдельный источник моральной боли. Потому что баг, который «иногда не проявляется», обычно проявляется на демо перед руководителем.
Почему гонки выглядят как магия
Очень неприятная особенность race condition — непредсказуемость. Планировщик потоков и корутин (упрощая) решает, кому когда дать время CPU. Решает он быстро, часто и не спрашивает вашего мнения. На одной машине, в одном запуске — получится одно перемешивание шагов. В следующем запуске — другое. А если вы добавите println, то всё может «починиться» (потому что вывод — медленный, и перемешивание становится другим).
Чтобы почувствовать это руками, удобно прогнать один и тот же эксперимент несколько раз и посмотреть, как результат «гуляет»:
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") // попытки отличаются
}
}
Здесь важно не то, что результат «иногда не 1000», а то, что он каждый раз может быть разным. Это и есть главный эмоциональный маркер гонок: логика у вас одна, а реальность каждый раз новая.
Check-then-act: «проверил → сделал» как ловушка
Если counter++ — классическая гонка, то check-then-act — её более коварный родственник. Это ситуация, когда вы сначала проверяете условие, а потом делаете действие, и вам кажется, что всё безопасно, потому что «ну я же проверил». Но между проверкой и действием может вклиниться другая корутина и поменять состояние.
Представим мини-сценарий: маленькая «касса» по продаже билетов. Есть переменная tickets = 1. Две корутины пытаются купить билет: «если билеты есть — уменьшаем на 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) // усиливаем шанс перемешивания
tickets -= 1
}
}
val j2 = launch(Dispatchers.Default) {
if (tickets > 0) {
delay(10)
tickets -= 1
}
}
j1.join()
j2.join()
println("tickets=$tickets") // может стать -1
}
Фокус в том, что обе корутины могут успеть пройти проверку tickets > 0, пока tickets ещё равен 1. Затем обе выполнят списание — и получим -1. В реальной жизни это называется «продали два билета на одно место», а в программировании — «мы только что создали баг, который будет стоить денег и репутации».
Обратите внимание: проблема здесь не в delay. delay просто делает гонку более заметной. Гонка существует и без него — просто может прятаться лучше.
3. Инварианты и критические секции
Что именно мы защищаем
Когда начинаются разговоры про конкурентность, очень хочется сказать: «Окей, буду всё защищать». Но это путь к коду, который либо тормозит, либо превращается в непонятную кашу. Поэтому нужно ввести важное слово: инвариант.
Инвариант — это правило целостности, которое должно оставаться истинным всегда, несмотря на конкурентность. В случае нашей кассы возможны такие инварианты:
| Состояние | Инвариант (правило) | Почему важно |
|---|---|---|
|
|
нельзя продать билетов больше, чем есть |
|
|
число билетов не должно «появляться из воздуха» |
|
|
нельзя уйти в минус, если это запрещено правилами |
Самое полезное в инвариантах то, что они отвечают на вопрос: «какую именно часть кода надо сделать неделимой?» То есть какую логику нельзя позволить другим корутинам «порвать» посередине.
Например, в check-then-act инвариант обычно ломается потому, что проверка и изменение должны быть одним «атомарным» блоком по смыслу: «проверил и тут же сделал, не отдавая управление между ними».
Как найти «минимальный кусок», который нельзя прерывать
Термин критическая секция звучит так, будто сейчас мы будем строить бункер. На самом деле смысл проще: критическая секция — это участок кода, который должен выполняться так, как будто он «один-единственный» в данный момент. То есть пока одна корутина выполняет этот участок, другие не должны одновременно выполнять конфликтующий участок над тем же состоянием.
Но в этой лекции важно не «как защитить критическую секцию» (инструменты будут позже), а как её правильно выделить. И вот тут полезно упражнение «карандашом по коду»:
Вы берёте участок, где читается и пишется общее состояние, и спрашиваете себя: «Если меня прервут ровно после этой строки, инвариант уже может быть нарушен?» Если да — значит, граница критической секции выбрана неверно, и нужно расширять блок, чтобы он включал зависимые шаги.
Для tickets это выглядит так:
if (tickets > 0) { // прочитали tickets и приняли решение
tickets -= 1 // записали tickets
}
Если нас прервали между строками, другая корутина может тоже принять решение на старом значении. Значит, обе строки должны восприниматься как единый «неделимый» кусок — одна логическая операция покупки.
Хорошее правило для новичка: если логика выглядит как “прочитал → решил → записал”, то это почти наверняка кандидат в критическую секцию. Даже если код занимает всего две строки.
4. Учебный полигон TicketOffice
Чтобы примеры не висели в воздухе, заведём маленькое учебное приложение, которое мы будем «доращивать» в следующих лекциях. Сейчас оно будет намеренно небезопасным — это нормально: мы строим «сломанный велосипед», чтобы потом понять, какие гайки нужно затянуть.
Начнём с простейшей модели: есть касса, где хранятся tickets и sold. Продажа — это check-then-act. Пока без всякой защиты.
import kotlinx.coroutines.delay
suspend fun buyTicketUnsafe(): Boolean {
if (tickets > 0) {
delay(1) // усиливаем шанс гонки, НЕ "чинит" проблему
tickets -= 1
sold += 1
return true
}
return false
}
Но тут есть нюанс: Kotlin не любит «голые» глобальные var, и это правильно. Поэтому сделаем максимально простую «коробочку» для состояния через IntArray (чтобы можно было менять элементы, передавая их как ссылку), не лезя пока в ООП глубоко. Это выглядит немного кустарно, зато прозрачно для понимания.
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
}
Теперь напишем main, который запускает много «покупателей» параллельно. Мы ожидаем, что sold не превысит изначальные билеты. А реальность может нас удивить.
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]}") // может быть странно
}
Почему результат может быть «странным»? Потому что мы защитили ничего. Мы читаем tickets в локальную переменную, потом ждём delay(1), потом пишем обратно. За это время другая корутина могла сделать то же самое, и часть продаж «перетрётся». Или, если вы поменяете логику на более прямую (state[0] -= 1 после проверки), можно получить другие виды поломок.
Главная мысль: как только вы видите shared mutable state и составную логику, вы обязаны подозревать гонку.
5. Как «теряется» обновление
Иногда полезно не просто верить на слово, а увидеть «мультик» перемешивания шагов. Вот схематично, как теряется один инкремент у 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: Итог = 11, хотя "должно" быть 12
Для check-then-act схема похожая, только вместо «прибавил 1» у нас «проверил условие и списал». И в обоих случаях ключевая проблема одна: между логически связанными шагами может вклиниться другая корутина.
6. Типичные ошибки
Ошибка №1: думать, что “корутины — не потоки, значит, гонок нет”.
Корутины действительно легче и дешевле потоков, но они спокойно исполняются на пуле потоков, особенно на Dispatchers.Default, и могут работать параллельно. Если несколько корутин пишут в одну переменную без координации, вы получаете классическую гонку.
Ошибка №2: считать, что гонка бывает только в counter++.
++ просто очень наглядный пример, потому что выглядит как «одна строка». Но любая составная логика вида “прочитал → посчитал → записал” или “проверил → сделал” подвержена тем же проблемам. Особенно check-then-act: он выглядит «разумно», но ломается чаще всего.
Ошибка №3: делать вывод “у меня один раз вывело 1000, значит, всё ок”.
Race condition тем и коварен, что может не проявиться в одном запуске. Он зависит от таймингов, загрузки CPU, фоновых процессов и даже того, открыта ли у вас вкладка браузера с музыкой. Надёжность, построенная на удаче, заканчивается обычно внезапно.
Ошибка №4: пытаться “починить” гонку добавлением delay() или “ещё одного println”.
delay и println меняют расписание выполнения, поэтому баг может «прятаться» или «вылезать» чаще. Но они не устраняют причину. Это как лечить трещину в стене наклейкой “не трескайся”: стене всё равно.
Ошибка №5: не формулировать инвариант и из-за этого защищать “всё подряд”.
Если вы не можете сказать, какое правило целостности должно всегда выполняться (tickets >= 0, sold + tickets == total и т.д.), вы не сможете грамотно выделить критическую секцию. Обычно это заканчивается тем, что либо защищают слишком маленький кусок (гонка остаётся), либо слишком большой (программа становится медленной и неудобной).
Ошибка №6: путать “общий доступ” и “общая запись”.
Иногда студент видит, что переменная доступна из разных мест, и начинает паниковать. Но настоящая зона риска — когда к данным есть конкурентная запись. Чтение само по себе чаще безопасно (хотя тоже бывают тонкости), а вот запись без координации — почти гарантированный кандидат на проблемы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ