1. Навіщо корутинам Mutex?
Коли ви пишете послідовний код, var balance = 100 виглядає безневинно: ну число і число. Але в конкурентному світі в змінної раптово зʼявляється «соціальне життя»: до неї починають звертатися різні корутини й змінювати її «просто зараз». І ви вже не контролюєте, у якому порядку це відбувається. Проблема не в тому, що Kotlin «поганий» або корутини «нечесні». Проблема в тому, що кілька незалежних виконавців втручаються в одні й ті самі дані без правил.
У документації Kotlin про корутини цю думку формулюють так: коли кілька корутин звертаються до одного змінюваного стану без координації, можна отримати race condition — непередбачуване взаємне втручання операцій.
Щоб повернути правила в гру, нам потрібен механізм «у кімнаті зі станом одночасно лише одна людина». У корутинах таким механізмом часто є Mutex.
Критична секція та інваріанти
Словосполучення «критична секція» звучить так, ніби зараз буде лекція з виживання під час апокаліпсису. Насправді все простіше: критична секція — це мінімальна ділянка коду, яка має виконуватися як єдине ціле, без «вклинювання» інших корутин між кроками.
Найпоширеніша пастка для новачків — думати, що ми захищаємо «змінну balance». Насправді ми захищаємо правило цілісності, тобто інваріант. Наприклад: «баланс не повинен іти в мінус», «операція списання має бути check‑then‑act як одна атомарна дія», «список витрат не повинен ламатися під час паралельного додавання».
Коли ви вмієте сформулювати інваріант уголос, межі критичної секції майже завжди визначаються самі: у критичну секцію входить рівно той фрагмент логіки, який не можна розривати.
2. Mutex і withLock: підʼєднання та базовий шаблон
Як підʼєднати і як мислити про Mutex
Mutex у корутинах — це не «важкий замок» зі світу потоків, а кооперативний примітив синхронізації для suspend-коду. Він дає змогу корутині «почекати», не блокуючи потік (тобто не займаючи дорогоцінний ресурс «справжнього» потоку).
На практиці вам майже завжди потрібні два імпорти:
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") // лічильник=1000
}
Тут важлива думка не в тому, що «ми зробили 1 000». Важливо інше: ми чітко виділили критичну секцію — рівно один рядок оновлення стану. Усе інше виконується паралельно.
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
}
}
}
Зверніть увагу на зміст: ми не робимо дві окремі операції «перевірка» і «списання». Ми робимо одну критичну секцію, у якій перевірка і списання живуть разом. Це і є захист інваріанта.
І так, тут функція withdraw — suspend. Це не «краса», а фундаментальний контракт: якщо всередині є withLock, а він під час очікування може призупиняти корутину, то і наша допоміжна функція має бути suspend.
6. Дизайн допоміжних функцій: suspend замість runBlocking
Ось тут починається зона, де люди з добрими намірами створюють собі злого начальника в майбутньому.
Іноді хочеться написати допоміжну функцію так, щоб її можна було викликати звідки завгодно, навіть зі звичайного коду без корутин. І зʼявляється «геніальна» ідея: «а я всередині допоміжної функції зроблю runBlocking, і все!». Це виглядає зручно… доки ви не натрапите на дивні гальма й підвисання або доки ваш «неблокувальний» код раптово не почне блокувати потоки.
Антипатерн: runBlocking усередині допоміжної функції
Поганий приклад зазвичай виглядає так:
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-допоміжна функція
Хороший варіант — чесна suspend-допоміжна функція:
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> (інтерфейс лише для читання), а всередині — нову колекцію. Це захищає ваш інваріант: «реєстр витрат змінюється лише через мої методи».
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 = "їжа"))
ledger.add(Expense(id = "a2", amount = 250, category = "транспорт"))
}
val j2 = launch(Dispatchers.Default) {
ledger.add(Expense(id = "b1", amount = 400, category = "їжа"))
ledger.add(Expense(id = "b2", amount = 90, category = "кава"))
}
joinAll(j1, j2)
val all = ledger.snapshot()
println("усього записів=${all.size}") // усього записів=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 усередину допоміжної функції, щоб «не думати про suspend».
Це одна з найдорожчих помилок за наслідками. Ваша допоміжна функція перетворюється на приховано блокувальний виклик: її можна викликати «де завгодно», але ціною блокування потоку й неочікуваної поведінки. У корутинному застосунку така допоміжна функція особливо небезпечна: вона може блокувати потік пулу і погіршувати виконання інших корутин. Правильний шлях — робити допоміжну функцію suspend і чесно вимагати корутинний контекст від коду, що викликає.
Помилка № 5: повертати назовні внутрішню змінювану колекцію, навіть якщо всередині все захищено Mutex.
Якщо ваш ExpenseLedger повертає MutableList<Expense>, то зовнішній код може почати змінювати список без жодного Mutex. Формально ви «використали Mutex», але фактично віддали стан «у спільний доступ». Набагато безпечніше повертати знімок (toList()), а всі зміни робити лише через методи, які самі використовують withLock.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ