1. Вступ
Коли ви починаєте писати корутини, легко потрапити в ілюзію: «Ну це ж не потоки — отже, небезпек багатопотоковості тут немає». На жаль (або на щастя — інакше було б нудно), корутини цілком реально можуть виконуватися конкурентно, а на Dispatchers.Default — ще й паралельно, у пулі потоків. Тобто дві корутини справді здатні одночасно виконувати ваш код на різних ядрах CPU.
В офіційних матеріалах про корутини підкреслюють, що їх запускають через функції-білдери (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 лічильник=$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") // може стати -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("квитки=${state[0]}, продано=${state[1]}") // може бути дивно
}
Чому результат може бути «дивним»? Бо ми не захистили нічого. Ми читаємо tickets у локальну змінну, потім чекаємо delay(1), а далі пишемо назад. За цей час інша корутина могла зробити те саме — і частина продажів «перетреться». Або, якщо ви зміните логіку на прямішу (наприклад, state[0] -= 1 після перевірки), можна отримати інші типи поломок.
Головна думка: щойно ви бачите shared mutable state і складену логіку, ви зобовʼязані підозрювати гонку.
5. Як «губиться» оновлення
Іноді корисно не просто вірити на слово, а побачити «мультик» перемішування кроків. Ось схематично, як губиться один інкремент у counter++:
sequenceDiagram
participant A as Корутина A
participant B as Корутина B
participant M as counter (памʼять)
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: плутати «спільний доступ» і «спільний запис».
Іноді студент бачить, що змінна доступна з різних місць, і починає панікувати. Але справжня зона ризику — коли до даних є конкурентний запис. Читання саме по собі частіше безпечне (хоча теж бувають тонкощі), а от запис без координації — майже гарантований кандидат на проблеми.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ