1. Введение
Когда вы пишете конкурентный код, мозг очень быстро переходит в режим «я сейчас всё аккуратно синхронизирую, честно-честно». Но реальность жестока: чем больше мест в коде меняют один и тот же var, тем больше вероятность поймать редкий, неприятный и «не воспроизводится на моём ноутбуке» баг. Поэтому сегодня мы поговорим о подходе, который не чинит гонки, а старается сделать так, чтобы гонок не было по конструкции.
В корутинах проблема общего изменяемого состояния (shared mutable state) возникает, когда несколько корутин могут читать и писать одни и те же данные без координации — и тогда легко появляются race condition. Можно решать это 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 сообщений». Это резко снижает шанс случайно сделать что-то не так.
4. Минимальный 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 в уже закрытый канал. Протокол должен быть одним, простым и предсказуемым — тогда и отладка будет человеческой.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ