JavaRush /Курси /Kotlin SELF /Confinement і actor через Channel: один власник стану

Confinement і actor через Channel: один власник стану

Kotlin SELF
Рівень 55 , Лекція 3
Відкрита

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 брати».

Невеличка табличка, щоб усе розклалося по двох шухлядах:

Підхід Що відбувається зі станом Як досягається безпека Де зазвичай зручно
Mutex
Стан спільний, але доступ «по черзі» Блокування критичної секції Невеликі правки поверх наявного коду
Actor / confinement
Стан не ділиться взагалі Один власник + повідомлення Централізований стан (лічильник, баланс, черга задач, «сховище»)

І ще один важливий нюанс: 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 у вже закритий канал. Протокол має бути один — простий і передбачуваний. Тоді й відлагодження буде по-людськи.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ