1. Вступ
Коли ви пишете конкурентний код, мозок дуже швидко переходить у режим «я зараз усе акуратно синхронізую, чесно-чесно». Та реальність сувора: що більше місць у коді змінюють один і той самий var, то вища ймовірність спіймати рідкісний, неприємний і «не відтворюється на моєму ноутбуці» баг. Тож сьогодні поговоримо про підхід, який не «латає» гонки, а намагається зробити так, щоб гонок не було за конструкцією.
У корутинах проблема спільного змінюваного стану (shared mutable state) виникає, коли кілька корутин можуть читати й записувати ті самі дані без координації — і тоді легко виникають race conditions. Можна боротися з цим через Mutex і атомарні типи, але є третій шлях: узагалі не ділитися станом.
Confinement: «руки геть від мого var»
Confinement (українською часто кажуть «ізоляція стану» або «стан в одному місці») — це правило: дані змінює лише один власник, а всі інші з ним «розмовляють». Це схоже на ситуацію, коли до спільної кухні пускають лише одного кухаря, а решта пишуть йому записки: «додай солі», «зроби чай», «скільки лишилося печива?». Кухар не сперечається сам із собою й не бʼється за сковорідку — бо він один.
На практиці confinement дуже спрощує мислення: ви перестаєте перебирати «всі можливі переплетення потоків/корутин» для конкретної змінної. Натомість ви думаєте про послідовне оброблення повідомлень. А послідовність — це те, що програмісти зазвичай уміють найкраще (ми ж недарма починали курс із println).
Важливо: confinement працює лише тоді, коли правил справді дотримуються. Якщо ви «на хвилинку» дасте комусь прямий доступ до стану, то знову повернетеся у світ гонок, тільки тепер вони будуть іще хитріші: частина змін піде через протокол повідомлень, а частина — «в обхід каси».
2. Підхід actor: власник стану і Channel
Тепер підведімо ідею до коду. Підхід actor — це дуже практична реалізація confinement: у нас є корутина, яка володіє станом, і є Channel, через який інші корутини надсилають їй повідомлення. Channel тут працює як черга: відправники роблять send(...), власник робить receive() (зазвичай у циклі for), обробляє повідомлення й оновлює свій локальний стан.
Нагадаю ключову модель Channel: це спосіб комунікації між корутинами, де кожне повідомлення доставляється рівно одному отримувачу. Це ідеально збігається з ідеєю «один власник»: ми хочемо, щоб усі зміни стану проходили через одну точку.
Намалюємо схему, щоб було легше не лише компілятору, а й голові:
flowchart LR
subgraph Workers["Інші корутини (виробники)"]
W1["worker #1"]
W2["worker #2"]
W3["worker #3"]
end
C["Channel⟨Msg⟩ (поштова скринька)"]
subgraph Owner["Actor (корутина-власник)"]
S["State (лише тут!)"]
L["for (msg in channel) { ... }"]
end
W1 -->|"send(msg)"| C
W2 -->|"send(msg)"| C
W3 -->|"send(msg)"| C
C -->|"receive(msg)"| L
L -->|"змінює"| S
Тут важлива думка: стан узагалі не видно іншим корутинам. Вони бачать лише канал, тобто «API повідомлень». Це різко знижує шанс випадково зробити щось не так.
3. Мінімальний actor: лічильник без гонок
Щоб не починати одразу із «серйозного застосунку», зберемо маленький приклад, який показує механіку. Actor зберігатиме counter, а зовнішні корутини надсилатимуть йому «додай 1».
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val mailbox = Channel<Int>() // повідомлення = delta
val owner = launch {
var counter = 0
for (delta in mailbox) counter += delta
println("final counter=$counter") // final counter=1000
}
repeat(1_000) { launch { mailbox.send(1) } }
mailbox.close()
owner.join()
}
Тут важливо помітити ось що: counter — звичайний var, без Mutex і без AtomicInteger. І водночас усе коректно, бо до нього не торкається ніхто, крім власника. У нас немає shared mutable state — натомість маємо «private state + message passing».
5. Типізовані повідомлення і протокол
У реальному житті повідомлення на кшталт «просто число» дуже швидко перетворюються на кашу: що означає 1? «додай один»? «код помилки»? «кількість котиків»? Тому наступний крок — зробити повідомлення явними й типізованими. Найзручніший інструмент для цього в Kotlin — sealed class.
Ідея проста: замість Channel<Int> робимо Channel<Msg>, де Msg — набір команд для нашого власника.
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
sealed class Msg
data class Add(val value: Int) : Msg()
object Stop : Msg()
fun main() = runBlocking {
val mailbox = Channel<Msg>()
val owner = launch {
var sum = 0
for (msg in mailbox) when (msg) {
is Add -> sum += msg.value
Stop -> break
}
println("sum=$sum") // sum=15
}
mailbox.send(Add(10))
mailbox.send(Add(5))
mailbox.send(Stop)
owner.join()
}
Чому це сильніше, ніж «просто числа»? Бо ви отримуєте документований протокол: є команда Add, є команда Stop, і компілятор допомагає не надіслати «дельту» туди, де очікувалася «команда зупинки».
6. Приклад: actor-сховище витрат
Уявімо, що в процесі курсу в нас виріс консольний застосунок «Budget Tracker»: ми зберігаємо витрати, уміємо їх додавати й підраховувати суму. Досі все було простим: один потік виконання, одне введення даних, одна колекція. Але тепер припустімо, що витрати приходять із різних джерел: одна корутина «імпортує» операції, друга — симулює користувача, третя — додає періодичні списання. Якщо всі вони почнуть змінювати один MutableList, буде непереливки.
Тож зробімо власника стану — «сховище витрат» — і спілкуватимемося з ним повідомленнями.
Почнемо з моделі даних:
data class Expense(
val id: Int,
val title: String,
val amount: Int
)
Тепер — протокол повідомлень. Ми хочемо: «додай витрату», «видали за ID», «зупинися». Зверніть увагу: поки що ми не робимо складних відповідей назад, тому команди будуть «односпрямовані».
sealed class BudgetMsg
data class AddExpense(val expense: Expense) : BudgetMsg()
data class RemoveExpense(val id: Int) : BudgetMsg()
object StopBudget : BudgetMsg()
Зберемо власника (actor), який зберігає список витрат:
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val mailbox = Channel<BudgetMsg>(Channel.BUFFERED)
val owner = launch {
val expenses = mutableListOf<Expense>()
for (msg in mailbox) when (msg) {
is AddExpense -> expenses.add(msg.expense)
is RemoveExpense -> expenses.removeIf { it.id == msg.id }
StopBudget -> break
}
println("stopped, total items=${expenses.size}")
}
mailbox.send(AddExpense(Expense(1, "Coffee", 250)))
mailbox.send(AddExpense(Expense(2, "Pizza", 900)))
mailbox.send(RemoveExpense(1))
mailbox.send(StopBudget)
owner.join()
}
Тут сталося головне: expenses узагалі не можна «випадково зачепити» ззовні. Зовнішній код не може зробити expenses.add(...) або expenses.clear() — бо в нього немає посилання. Це і є confinement у дії: доступ до стану фізично обмежений областю видимості власника.
7. Кілька відправників: імітація конкурентності
Тепер додамо те, заради чого все й затівалося: кілька корутин, які одночасно надсилають команди. Це якраз той момент, коли shared mutable state майже гарантовано вас піймає, а actor продовжить жити спокійно.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val mailbox = Channel<BudgetMsg>(Channel.BUFFERED)
val owner = launch {
var total = 0
for (msg in mailbox) when (msg) {
is AddExpense -> total += msg.expense.amount
StopBudget -> break
else -> Unit
}
println("total=$total") // наприклад: total=3000
}
val w1 = launch(Dispatchers.Default) { mailbox.send(AddExpense(Expense(1, "A", 1000))) }
val w2 = launch(Dispatchers.Default) { mailbox.send(AddExpense(Expense(2, "B", 1000))) }
val w3 = launch(Dispatchers.Default) { mailbox.send(AddExpense(Expense(3, "C", 1000))) }
w1.join(); w2.join(); w3.join()
mailbox.send(StopBudget)
owner.join()
}
Зверніть увагу на приємну річ: порядок приходу повідомлень може бути будь-яким, але підсумкова сума буде коректною, бо у власника немає «паралельних записів» у total. Він обробляє повідомлення послідовно.
8. Як отримувати дані назад: запит і відповідь
Коли застосунок стає трохи реалістичнішим, виникає питання: «Гаразд, ми вміємо надсилати команди… а як дізнатися поточну суму?» І тут дуже легко зісковзнути в поганий дизайн: «то давайте просто дамо назовні expenses або total, нічого страшного». Це рівно той момент, коли ви ламаєте confinement.
Правильний хід — запитувати дані у власника через повідомлення й отримувати відповідь через окремий канал для відповіді. Так, це звучить як «канал до каналу», але для акторів це цілком нормальна техніка.
Зробимо повідомлення GetTotal, яке містить «куди відправити відповідь»:
import kotlinx.coroutines.channels.SendChannel
sealed class BudgetMsg
data class AddExpense(val expense: Expense) : BudgetMsg()
data class GetTotal(val replyTo: SendChannel<Int>) : BudgetMsg()
object StopBudget : BudgetMsg()
Тепер власник може відповідати:
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val mailbox = Channel<BudgetMsg>(Channel.BUFFERED)
val owner = launch {
var total = 0
for (msg in mailbox) when (msg) {
is AddExpense -> total += msg.expense.amount
is GetTotal -> msg.replyTo.send(total)
StopBudget -> break
}
}
mailbox.send(AddExpense(Expense(1, "Coffee", 250)))
val reply = Channel<Int>(capacity = 1)
mailbox.send(GetTotal(reply))
println("total=${reply.receive()}") // total=250
mailbox.send(StopBudget)
owner.join()
}
Ключова ідея: зовнішній код усе ще не бачить total, але може поставити запитання власнику й отримати відповідь у безпечній формі. Це схоже на звернення в підтримку: ви не заходите в їхню базу даних, ви створюєте тікет і отримуєте відповідь.
9. Життєвий цикл: як коректно завершувати actor
Коли ви вперше пишете actor, дуже хочеться, щоб «він просто жив, поки живе програма». А потім ви додаєте реальний сценарій завершення — і раптом розумієте, що завершення протоколу так само важливе, як і запуск. Якщо actor не зупинити, він може тримати ресурси (памʼять, файлові дескриптори, зʼєднання), а якщо зупинити неправильно — відправники почнуть падати з помилками під час send.
Є два базові стилі зупинки, і ви можете обрати будь-який, аби він був послідовним і узгодженим.
Перший стиль: надіслати спеціальне повідомлення StopBudget, обробник робить break, і далі власник коректно завершує цикл. Це зручно тим, що зупинка — частина протоколу.
Другий стиль: закрити канал mailbox.close(). Тоді цикл for завершиться автоматично, коли повідомлення закінчаться. Це зручно тим, що «канал як ресурс» явно закривається ззовні.
У навчальних прикладах часто простіше використовувати Stop, бо він наочніший. Але важливо: не змішуйте стилі без причини. Якщо ви домовилися «закриваємо канал», тоді не треба ще й надсилати Stop, бо можна випадково надіслати його вже в закритий канал (і отримати виняток), або зупинити власника раніше, ніж обробляться накопичені повідомлення.
10. Actor vs Mutex: коротке порівняння
Після кількох прикладів зазвичай зʼявляється питання: «То навіщо тоді Mutex, якщо можна actor?» Відповідь: обидва інструменти корисні, але вони про різний спосіб мислення.
З Mutex у вас лишається shared mutable state, просто ви ставите «шлагбаум» на вході в критичну секцію. Це буває зручно, коли треба швидко захистити невелику ділянку коду і ви точно контролюєте межі.
З actor ви змінюєте архітектуру: ви не захищаєте доступ до даних, ви прибираєте доступ. У вас зʼявляється черга повідомлень і явний протокол команд. Це інколи означає трохи більше коду, але часто — радикально менше помилок, бо ви менше покладаєтеся на «усі розробники завжди памʼятатимуть, який Mutex брати».
Невеличка табличка, щоб усе розклалося по двох шухлядах:
| Підхід | Що відбувається зі станом | Як досягається безпека | Де зазвичай зручно |
|---|---|---|---|
|
Стан спільний, але доступ «по черзі» | Блокування критичної секції | Невеликі правки поверх наявного коду |
|
Стан не ділиться взагалі | Один власник + повідомлення | Централізований стан (лічильник, баланс, черга задач, «сховище») |
І ще один важливий нюанс: actor природно «масштабує мислення». Коли зʼявляється нова команда, ви додаєте новий тип повідомлення і новий when в одному місці. Це не гарантує ідеальності, але дуже допомагає втримувати систему в голові.
Антипатерн: actor є, але стан «витік» назовні
Зараз буде хвилинка болю, бо це дуже типова помилка новачків. Людина чесно зробила actor, а потім подумала: «Мені ж треба десь показати список витрат… я просто поверну MutableList, нічого страшного». І от тут confinement закінчується.
Погана ідея виглядає приблизно так (приклад навмисно «за змістом», не робіть так):
// ПОГАНО: назовні витікає посилання на змінюваний список.
// Тоді будь-хто зможе робити expenses.add(...) паралельно actor.
fun leakState(expenses: MutableList<Expense>): MutableList<Expense> = expenses
Правильний стиль: якщо потрібно «показати список», робіть запит до actor і повертайте або копію списку, або вже підготовлений рядок звіту. Тобто назовні має виходити результат, а не «ручка керування нутрощами».
11. Типові помилки при confinement/actor через Channel
Помилка №1: лишили прямий доступ до стану «про всяк випадок».
Часто це виглядає невинно: глобальна змінна var total, а actor «нібито теж рахує total». Потім один шматок коду випадково оновлює total напряму, інший — через повідомлення, і ви знову отримуєте shared mutable state. Confinement працює лише за жорсткого правила: стан змінює один власник, крапка.
Помилка №2: повідомлення не типізовані й перетворюються на «магічні числа/рядки».
Коли в Channel<Any> або Channel<String> починають літати команди виду "ADD 10" і "STOP", ви самі собі робите мініпарсер протоколу й вручну підтримуєте коректність форматів. Із sealed class і when ваш протокол перевіряється компілятором, і це той рідкісний випадок, коли компілятор не «душить», а реально рятує.
Помилка №3: не домовилися про завершення, і канал ніколи не закривається.
Якщо власник сидить у for і ви жодного разу не надсилаєте Stop і не робите close(), він чекатиме вічно. У консольному застосунку це часто означає «програма зависла після того, як усе зробила». Потрібно заздалегідь вирішити, хто відповідає за зупинку і яким способом.
Помилка №4: закрили канал надто рано, поки відправники ще працюють.
Якщо «головна корутина» закрила mailbox, а інші корутини ще намагаються send, ви отримаєте винятки й втрату повідомлень. Спершу потрібно дочекатися відправників (join()), і лише потім завершувати actor. Так, це звучить нудно. Зате працює.
Помилка №5: важка робота всередині власника перетворює actor на «вузьке місце».
Actor обробляє повідомлення послідовно — це його суперсила і його обмеження. Якщо всередині when ви робите щось довге (наприклад, блокувальний I/O або важкі обчислення), черга почне зростати, а система сповільниться. Хороший стиль — тримати оброблення повідомлень коротким: оновити стан, зафіксувати факт, а важку роботу організовувати так, щоб вона не перетворювала власника на вічного страждальця.
Помилка №6: намагаються «отримати дані» через витік посилань замість запиту/відповіді.
Найспокусливіший шлях — повернути назовні посилання на внутрішній список або обʼєкт стану. Але це повністю ламає сенс confinement. Якщо вже потрібно віддавати дані назовні, віддавайте безпечну форму: копію, число, рядок звіту або відповідь через окремий reply-канал.
Помилка №7: змішують різні стилі протоколу без потреби.
Наприклад, інколи закривають канал close(), а інколи надсилають Stop, а інколи роблять і те, і інше у випадковому порядку. У результаті власник може завершитися раніше, ніж обробить чергу, або відправник може спробувати надіслати Stop у вже закритий канал. Протокол має бути один — простий і передбачуваний. Тоді й відлагодження буде по-людськи.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ