Конкурентные парадигмы: акторы, CSP, STM, разделяемая память
Одна строчка, которая ломается
Вот код. Он неправильный. Найдите ошибку за пять секунд:
counter = 0
def worker():
global counter
for _ in range(100_000):
counter += 1
Ошибки не видно, потому что её здесь нет — до тех пор, пока функцию не запустят два потока сразу.
counter += 1 в исходнике выглядит как один шаг, а в байткоде это три: прочитать, прибавить, записать.
python3 -c "import dis; dis.dis('counter += 1')"
# LOAD_NAME counter ← прочитали 41
# LOAD_CONST 1
# BINARY_OP +=
# STORE_NAME counter ← записали 42
Между «прочитали» и «записали» помещается вся история другого потока. Он тоже прочитал 41, тоже прибавил, тоже записал 42. Два инкремента — одно увеличение. Потеряли единицу. И это не редкость на миллион: на 8 потоках по 100 000 итераций теряется обычно 30–60% приращений.
Вся эта статья — про то, какими способами человечество научилось не писать такой код. Способов ровно четыре семейства, и они действительно разные по духу, а не по синтаксису:
- Разделяемая память + блокировки — «пусть все ходят к одной переменной, но по очереди».
- CSP (каналы) — «не общайтесь через память, общайтесь через трубу».
- Акторы — «состояние вообще не разделяется, есть только письма и почтовые ящики».
- STM (транзакционная память) — «пиши как обычно, а атомарность обеспечит рантайм, как в БД».
Два разных слова: конкурентность и параллелизм
Прежде чем идти дальше, надо развести два понятия, которые в русском часто сливаются в «многопоточность».
Конкурентность (concurrency) — это структура программы: способ описать несколько независимо продвигающихся дел. Параллелизм (parallelism) — это свойство исполнения: несколько дел физически идут одновременно на разных ядрах.
Формулировка Роба Пайка из доклада «Concurrency Is Not Parallelism» (видео и слайды): конкурентность — это про то, как справляться с многими вещами сразу; параллелизм — про то, как делать многие вещи сразу. Однопоточный Node.js конкурентен, но не параллелен. Векторная инструкция AVX параллельна, но не конкурентна. GPU-ядро на 10 000 нитей параллельно и почти не конкурентно — все нити делают одно и то же.
Это различение сразу даёт практический критерий выбора:
- Задача вычислительная (перемножить матрицы, обработать 40 ГБ логов) → нужен параллелизм → смотрите в сторону data parallelism, fork-join, SIMD, и вам, скорее всего, вообще не нужны каналы и акторы.
- Задача координационная (10 000 клиентов на сокетах, каждый в своём состоянии) → нужна конкурентность → вот здесь акторы, CSP и async/await окупаются.
Карта моделей
модели)) Разделяемая память Мьютексы и семафоры Атомики и CAS Lock-free структуры Модель памяти happens-before и барьеры Передача сообщений CSP Синхронный rendezvous Каналы как значения select и альтернативы Акторы Асинхронный mailbox Адрес вместо канала Супервизия и рестарт Транзакции STM Оптимистичный commit Композиция транзакций retry и orElse Ограничение эффектов Data parallelism Владение Rust Send и Sync Structured concurrency Nursery и отмена по дереву
Обратите внимание на нижнюю ветку: «ограничение эффектов» — это не пятая независимая модель, а подход, который делает остальные четыре безопаснее. Неизменяемость из https://courses.digitable.life/post/paradigms/03-functional/ — самый дешёвый вид синхронизации: то, что нельзя изменить, не нужно защищать. Именно поэтому функциональная парадигма и конкурентность так часто ходят парой.
Корень зла: три разные беды, а не одна
«Гонка данных» — слишком грубое слово. На самом деле в разделяемой памяти вас подстерегают три независимые проблемы, и разные механизмы лечат разные из них.
| Проблема | В чём суть | Пример поломки | Чем лечится |
|---|---|---|---|
| Атомарность | Составная операция прерывается посередине | counter += 1 теряет инкременты |
мьютекс, атомарный CAS, транзакция |
| Видимость | Запись одного ядра не видна другому | флаг stop = true не останавливает поток |
volatile, atomic, барьер, unlock/lock |
| Порядок | Компилятор и CPU переставляют операции | объект «опубликован» до того, как сконструирован | барьеры памяти, release/acquire |
Первую проблему видят все. Вторую и третью — почти никто, пока не наступит.
Видимость: почему цикл не останавливается
// Java. Классическая ловушка.
class Worker extends Thread {
private boolean stop = false; // БЕЗ volatile
public void run() {
while (!stop) { } // может крутиться вечно
}
public void shutdown() { stop = true; }
}
JIT имеет полное право поднять чтение stop из цикла (это называется hoisting) и превратить код в
if (!stop) while (true) {}. С точки зрения однопоточной семантики — эквивалентное преобразование,
никаких правил компилятор не нарушил. Спасает volatile boolean stop — он запрещает и кэширование
в регистре, и переупорядочивание вокруг обращения. Формально это описано в Java Memory Model
(JLS §17.4) через отношение
happens-before.
Порядок: буфер записи
Даже x86 — самая «строгая» из массовых архитектур (модель x86-TSO) — разрешает чтению обогнать предшествующую запись, потому что запись сначала попадает в store buffer, а не в когерентный кэш. На ARM и POWER свободы ещё больше. Практический вывод простой и жёсткий:
Порядок операций между потоками не определён вашим исходником. Он определён моделью памяти языка. Единственный способ что-то гарантировать — явно попросить: мьютекс, атомик с нужным упорядочиванием, барьер. «Я же написал сверху вниз» — это не аргумент.
Каноническое чтение: Herlihy & Shavit, «The Art of Multiprocessor Programming» (2-е изд., 2020), и статья Preshing «Memory Barriers Are Like Source Control Operations» — лучшая из существующих интуитивных объяснялок барьеров.
Модель 1. Разделяемая память и блокировки
Самая старая и самая распространённая модель: данные лежат в общей памяти, доступ к ним сериализуется взаимным исключением. Мьютекс изобрёл Дейкстра в 1965 году вместе с семафорами («Cooperating Sequential Processes»).
import threading
class Account:
"""Потокобезопасный счёт: инвариант — баланс не отрицателен."""
def __init__(self, balance: int) -> None:
self._lock = threading.Lock()
self._balance = balance
def withdraw(self, amount: int) -> bool:
with self._lock: # критическая секция
if self._balance < amount: # проверка и изменение
return False # должны быть атомарны вместе
self._balance -= amount
return True
@property
def balance(self) -> int:
with self._lock: # чтение тоже под замком —
return self._balance # иначе увидим «рваное» состояние
Ключевой момент, который упускают чаще всего: атомарной должна быть не операция, а инвариант.
Разнести if и -= по двум разным with self._lock — значит получить классический TOCTOU
(time-of-check to time-of-use) и уйти в минус.
Deadlock: четыре условия Коффмана
# Перевод между счетами. Выглядит невинно, кладёт прод.
def transfer(a: Account, b: Account, amount: int) -> None:
with a._lock:
with b._lock:
a._balance -= amount
b._balance += amount
transfer(x, y, 100) в одном потоке и transfer(y, x, 50) в другом — взаимная блокировка. Каждый взял
первый замок и вечно ждёт второй. Дедлок требует одновременного выполнения четырёх условий Коффмана
(Coffman et al., 1971): взаимное исключение, удержание с ожиданием, отсутствие вытеснения, циклическое
ожидание. Убрать достаточно любое одно — обычно убирают циклическое ожидание через глобальный порядок
захвата:
def transfer(a: Account, b: Account, amount: int) -> None:
"""Всегда берём замки в порядке возрастания id — цикл невозможен."""
first, second = (a, b) if id(a) < id(b) else (b, a)
with first._lock:
with second._lock:
if a._balance >= amount:
a._balance -= amount
b._balance += amount
Приём называется lock ordering и работает всегда, когда множество замков известно заранее. Когда
не известно — берут try_lock с таймаутом и откатом (backoff), как в базах данных.
Что ещё ломается в мире блокировок
- Livelock — потоки не заблокированы, но и не продвигаются: бесконечно уступают друг другу.
- Lock convoy — держателя замка вытеснил планировщик, все остальные встали в очередь, пропускная способность упала в разы.
- Инверсия приоритетов — низкоприоритетный поток держит замок, нужный высокоприоритетному. Именно это в 1997 году перезагружало Mars Pathfinder; чинится наследованием приоритета (разбор JPL).
- Композиция не работает. Два потокобезопасных метода подряд не дают потокобезопасной операции:
if not queue.empty(): queue.get()падает, хотя обе операции «атомарны». Это фундаментальный, а не исправимый дефект модели.
Цена: Амдал, USL и почему 32 ядра дают ускорение 6х
Закон Амдала: если доля последовательного кода s, то ускорение на n ядрах ограничено
S(n) = 1 / (s + (1 - s) / n)
При s = 0.05 предел — 20х, сколько бы ядер вы ни докупили. Но реальность хуже: закон Амдала
оптимистичен, потому что не учитывает когерентность — стоимость согласования состояния между ядрами.
Универсальный закон масштабируемости Гуннтера (USL) добавляет квадратичный член:
S(n) = n / (1 + σ(n - 1) + κ·n(n - 1))
σ — сериализация (contention), κ — когерентность (crosstalk)
При κ > 0 кривая имеет максимум: после некоторого числа ядер производительность начинает падать.
Это не теория — это то, что вы видите на графике, когда добавляете воркеров и становится медленнее.
Подробно: Neil Gunther, «Guerrilla Capacity Planning», и краткое изложение USL.
Откуда берётся член κ: ложное разделение
Классический случай: массив счётчиков, по одному на поток, замков нет вообще — а масштабирование
отрицательное. Причина в том, что протокол когерентности MESI оперирует кэш-линиями по 64 байта,
а не переменными. Диагностика на Linux — perf c2c record (показывает HITM-события), лечение — паддинг
до размера линии.
// Go: паддинг структуры, чтобы счётчики воркеров не делили кэш-линию
type paddedCounter struct {
v atomic.Uint64
_ [56]byte // 64 - 8 = 56 байт добивки
}
counters := make([]paddedCounter, runtime.NumCPU())
Lock-free: атомики и CAS
Альтернатива замкам — атомарная операция compare-and-swap: «запиши новое значение, только если старое всё ещё такое, как я думаю».
# Псевдокод CAS-цикла — это скелет ЛЮБОГО lock-free алгоритма
def atomic_increment(cell):
while True:
old = cell.load() # 1. прочитали снимок
new = old + 1 # 2. посчитали новое значение (чисто!)
if cell.compare_and_swap(old, new): # 3. попытались зафиксировать
return new
# иначе кто-то опередил — считаем заново
Сложность: одна успешная итерация — O(1), но при k конкурирующих потоках ожидаемое число попыток
растёт как O(k), а трафик когерентности — как O(k²). Lock-free гарантирует прогресс системы в целом
(кто-то всегда завершится), но не отдельного потока — это уже wait-free, гораздо более дорогое свойство.
Главная ловушка — ABA: значение изменилось с A на B и обратно на A, CAS этого не заметил и записал
поверх испорченного состояния. Лечится счётчиком версий рядом с указателем (tagged pointer),
hazard pointers или epoch-based reclamation. Практический совет: не пишите свои lock-free структуры.
Берите готовые — java.util.concurrent, crossbeam в Rust, folly в C++. Разница между «работает
на моей машине» и «корректно по модели памяти ARM» стоит месяцев.
Модель 2. CSP: не общайтесь через память
В 1978 году Хоар опубликовал «Communicating Sequential Processes» — статью, которая предложила радикально другую основу: процессы не имеют общего состояния вообще, они синхронизируются на передаче сообщения. Отправитель и получатель встречаются в точке обмена (rendezvous) — оба ждут друг друга.
пока нет читателя C->>CH: <-ch (готов принять) CH-->>P: передача состоялась CH-->>C: item Note over P,C: Оба продолжают. Никакой общей памяти,
но есть жёсткая связь по времени C->>CH: <-ch (снова ждёт) P->>CH: close(ch) CH-->>C: zero value, ok = false Note right of C: Закрытие — это широковещательный
сигнал «данных больше не будет»
Девиз Go — «Do not communicate by sharing memory; instead, share memory by communicating» (Go blog: Share Memory By Communicating). Практически это выглядит так:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Пайплайн: генератор -> N воркеров -> агрегатор.
// Состояние не разделяется: каждое значение принадлежит ровно одной горутине.
func generate(ctx context.Context, n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // закрывает ТОЛЬКО отправитель
for i := 1; i <= n; i++ {
select {
case out <- i:
case <-ctx.Done(): // отмена: иначе горутина утечёт
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in { // range сам завершится при close(in)
select {
case out <- v * v:
case <-ctx.Done():
return
}
}
}()
return out
}
// fan-in: слияние нескольких каналов в один
func merge(chans ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range chans {
wg.Add(1)
go func(c <-chan int) { defer wg.Done(); for v := range c { out <- v } }(c)
}
go func() { wg.Wait(); close(out) }() // закрыть, когда все источники иссякли
return out
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
src := generate(ctx, 10)
// fan-out: три воркера читают из одного канала — балансировка бесплатно
workers := []<-chan int{square(ctx, src), square(ctx, src), square(ctx, src)}
sum := 0
for v := range merge(workers...) {
sum += v
}
fmt.Println("сумма квадратов:", sum) // 385
}
Что здесь важно понять помимо синтаксиса:
- Канал — это значение, его можно передать в функцию, положить в структуру, вернуть. Отсюда композиция: пайплайны собираются как функции.
- Fan-out бесплатен: три горутины, читающие один канал, автоматически балансируют нагрузку — кто освободился, тот и взял.
- Буферизация — это про backpressure.
make(chan int)(без буфера) означает: производитель не убежит вперёд потребителя.make(chan int, 1000)разрешает убежать на 1000 элементов. Неограниченного буфера в Go нет намеренно — неограниченная очередь это отложенный OOM. - Владение направлением: типы
<-chanиchan<-в сигнатуре — это документация и проверка компилятором того, кто отправляет, а кто принимает.
select — то, ради чего всё затевалось
Без select каналы были бы просто очередями. select даёт альтернативу — ожидание нескольких
событий сразу, что и делает CSP пригодным для координации:
select {
case v := <-work:
process(v)
case <-ticker.C:
flush() // периодическая задача
case <-ctx.Done():
return ctx.Err() // отмена
default:
// неблокирующая ветка: если ничего не готово — идём дальше
}
Типичные ошибки в CSP
- Утечка горутин. Отправка в канал, который никто больше не читает, блокирует горутину навсегда.
Каждая — это 8 КиБ стека минимум и живые ссылки, которые не соберёт GC. Лечится
contextв каждомselectи правилом «у каждой горутины есть явный путь завершения». Диагностика:pprof goroutine, тест сgoleak. - Кто закрывает канал. Закрывать должен единственный отправитель. Закрытие закрытого канала —
паника; отправка в закрытый — паника. При нескольких отправителях используют отдельный
done-канал илиsync.Once. nil-канал блокирует вечно — это не баг, а идиома: занулив канал внутриselect, вы «выключаете» ветку. Но случайныйnilиз неинициализированной структуры даёт зависание без единой ошибки в логе.- Каналы там, где нужен мьютекс. Канал стоит ~100 нс на операцию против ~20 нс у ненагруженного мьютекса. Для защиты одного поля счётчика канал — оверинжиниринг. Сам Go-team пишет: «use whichever is most expressive».
Детали CSP на практике разбираются в треке https://courses.digitable.life/post/golang/00-overview/.
Модель 3. Акторы: адрес, почтовый ящик и право упасть
Модель акторов старше CSP: Карл Хьюитт, 1973 год, формализована Гулом Агхой в 1986-м. Актор — сущность, которая в ответ на одно сообщение может сделать ровно три вещи:
- отправить конечное число сообщений другим акторам;
- создать конечное число новых акторов;
- определить, как обрабатывать следующее сообщение (то есть изменить своё поведение/состояние).
Отличия от CSP на первый взгляд косметические, на практике — решающие:
| CSP (каналы) | Акторы (mailbox) | |
|---|---|---|
| Адресация | анонимная: адресуем канал | именованная: адресуем актор |
| Отправка | обычно синхронная (rendezvous) | всегда асинхронная («выстрелил и забыл») |
| Буфер | ограниченный, backpressure встроен | обычно неограниченный mailbox |
| Топология | статическая, задаётся при сборке пайплайна | динамическая, адреса передаются в сообщениях |
| Отказы | ошибка = значение, возвращаемое по каналу | отдельная иерархия супервизии |
| Естественная граница | внутри процесса | прозрачно расширяется на сеть |
Ключевое следствие последней строки: акторы изначально задумывались как распределённая модель. Отправка сообщения по сети и локально выглядит одинаково — а значит, и локальный код обязан быть готов к тому, что сообщение потеряется, придёт дважды или получатель умрёт.
Актор в Elixir/OTP
defmodule Bank.Account do
@moduledoc """
Актор-счёт. Состояние живёт ВНУТРИ процесса и не разделяется ни с кем.
Единственный способ взаимодействия — сообщение.
"""
use GenServer
# --- Клиентский API (выполняется в вызывающем процессе) ---
def start_link(opts) do
id = Keyword.fetch!(opts, :id)
GenServer.start_link(__MODULE__, Keyword.get(opts, :balance, 0), name: via(id))
end
def balance(id), do: GenServer.call(via(id), :balance) # синхронно, ждёт ответа
def withdraw(id, amt), do: GenServer.call(via(id), {:withdraw, amt})
def audit(id, event), do: GenServer.cast(via(id), {:audit, event}) # «выстрелил и забыл»
defp via(id), do: {:via, Registry, {Bank.Registry, id}}
# --- Серверные колбэки (выполняются В процессе-акторе, строго по одному) ---
@impl true
def init(balance), do: {:ok, %{balance: balance, log: []}}
@impl true
def handle_call(:balance, _from, state), do: {:reply, state.balance, state}
def handle_call({:withdraw, amount}, _from, %{balance: b} = state) when amount <= b do
# Гонок нет ПО ПОСТРОЕНИЮ: сообщения обрабатываются строго последовательно,
# никакой другой код не может влезть между проверкой и изменением.
{:reply, :ok, %{state | balance: b - amount}}
end
def handle_call({:withdraw, _amt}, _from, state),
do: {:reply, {:error, :insufficient_funds}, state}
@impl true
def handle_cast({:audit, event}, state), do: {:noreply, %{state | log: [event | state.log]}}
end
Обратите внимание: ни одного мьютекса, ни одного атомика. Взаимное исключение здесь — следствие архитектуры, а не библиотечный примитив. Один актор = один поток управления над своим состоянием. Подробнее об экосистеме — https://courses.digitable.life/post/elixir/00-overview/.
Супервизия: «let it crash»
Вторая половина модели акторов, без которой она бессмысленна, — обработка отказов. Идея Джо Армстронга (диссертация «Making reliable distributed systems in the presence of software errors», 2003) звучит контринтуитивно: не пишите защитный код внутри актора. Пусть падает. Восстанавливать должен кто-то другой, у кого состояние заведомо не испорчено.
обработка сообщения Running --> Failed: необработанное исключение
или {:stop, reason, state} Running --> Draining: получен сигнал shutdown Draining --> Terminated: terminate/2, слив состояния Failed --> Restarting: супервизор поймал EXIT Restarting --> Init: в пределах max_restarts Restarting --> Escalated: лимит рестартов исчерпан Escalated --> [*]: падает сам супервизор,
отказ поднимается выше по дереву Terminated --> [*] note right of Failed Состояние актора теряется целиком. Это ФИЧА: испорченное состояние не переживает рестарт. end note
Стратегии супервизора задают, что делать с «соседями» упавшего процесса: :one_for_one (перезапустить
только его), :one_for_all (перезапустить всю группу — когда процессы связаны инвариантом),
:rest_for_one (перезапустить его и всех запущенных после него). Плюс параметры max_restarts
и max_seconds — защита от бесконечного цикла падений: если процесс падает чаще, чем разрешено,
отказ эскалируется вверх по дереву. Это превращает надёжность из свойства кода в свойство топологии.
Типичные ошибки в акторной модели
- Переполнение mailbox. Ящик неограничен: если производитель быстрее потребителя, процесс
раздувается до OOM. Мониторьте
Process.info(pid, :message_queue_len); вводите явный backpressure (GenStage,:jobs, семафор передcast). Это цена, которую акторы платят за асинхронность — и главное их отличие от CSP, где backpressure бесплатен. - Дедлок на синхронных
call. A вызываетcallв B, B в тот же моментcallв A. Оба стоят, через 5 секунд оба падают по таймауту. Это в каком-то смысле «мягкий дедлок» — таймаут его развяжет, — но правило простое: избегайте циклов синхронных вызовов, используйтеcast+ ответное сообщение. - Актор стал бутылочным горлышком. Один актор обрабатывает сообщения строго последовательно. Единственный «менеджер», через которого ходят все запросы, ограничивает систему одним ядром. Лечится шардированием: N акторов по хешу ключа.
handle_infoне покрывает всё. Незнакомое сообщение (например,:DOWNот монитора или таймаут TCP) вызывает падение приhandle_infoбез catch-all. Всегда пишите fallback-ветку.- Неидемпотентная обработка. «Ровно один раз» не существует. Рестарт + повторная доставка = двойное списание. Сообщения, меняющие деньги, обязаны нести идентификатор операции и проверяться на дубль.
Как это выглядит в проде
WhatsApp держал ~2 миллиона TCP-соединений на одном сервере FreeBSD/Erlang (инженерный доклад 2012 года); Discord на Elixir обслуживал миллионы одновременных пользователей и подробно писал о том, как чинил именно проблему «актор-горлышко» через шардирование и ETS (Discord Engineering blog). В JVM-мире эквивалент — Akka/Pekko, в .NET — Orleans с «виртуальными акторами» (grain), где актор активируется по требованию и мигрирует между узлами кластера.
Модель 4. STM: атомарность как у базы данных
У блокировок, как мы видели, нет композиции. STM (Software Transactional Memory) решает именно эту проблему: вы описываете блок кода как транзакцию, а рантайм гарантирует, что он выполнится атомарно относительно других транзакций — оптимистично, с откатом и повтором при конфликте.
Идея восходит к Herlihy & Moss (1993, аппаратная TM) и получила практичное воплощение в Haskell: Harris, Marlow, Peyton Jones, Herlihy, «Composable Memory Transactions» (PPoPP 2005) — одна из самых влиятельных статей о конкурентности вообще.
writeTVar → записать в write-set (локально!) Active --> Validating: конец блока Validating --> Committed: read-set не изменился
другими транзакциями Validating --> Aborted: конфликт — кто-то
перезаписал наши чтения Aborted --> Active: полный откат
и повтор с нуля Active --> Blocked: вызван retry Blocked --> Active: изменился TVar
из read-set Committed --> [*] note right of Aborted Откат возможен только потому, что внутри транзакции ЗАПРЕЩЕНЫ побочные эффекты. Систему типов Haskell это гарантирует статически. end note
import Control.Concurrent.STM
type Account = TVar Int
-- Перевод между счетами. Ни одного замка, ни одного порядка захвата.
transfer :: Account -> Account -> Int -> STM ()
transfer from to amount = do
balance <- readTVar from
check (balance >= amount) -- не хватает денег? retry: заснуть до изменения
writeTVar from (balance - amount)
modifyTVar' to (+ amount)
-- ГЛАВНОЕ СВОЙСТВО: транзакции КОМПОЗИРУЮТСЯ.
-- Две атомарные операции, склеенные вместе, снова атомарны.
-- С мьютексами так нельзя в принципе.
atomicSwap :: Account -> Account -> Account -> Account -> Int -> IO ()
atomicSwap a b c d amount = atomically $ do
transfer a b amount
transfer c d amount -- либо оба перевода, либо ни одного
-- orElse: альтернатива. Попробуй первое, если оно retry — попробуй второе.
withdrawFromAny :: Account -> Account -> Int -> IO ()
withdrawFromAny a b amount =
atomically (withdraw a amount `orElse` withdraw b amount)
where
withdraw acc n = do
v <- readTVar acc
check (v >= n)
writeTVar acc (v - n)
Разберём, почему это сильнее блокировок:
retry— это не busy-wait. Транзакция сообщает рантайму «мне нечего делать»; поток засыпает и будится ровно тогда, когда изменится хотя бы однаTVarиз его read-set. Условная переменная, которую невозможно использовать неправильно: нет потерянных сигналов, нетwaitбез цикла проверки.orElse— это выбор, аналогselectиз CSP, но составляемый из произвольных транзакций.- Дедлоков не существует. Порядок захвата не нужен, потому что захвата нет: конфликт разрешается откатом, а не ожиданием. Голодание (starvation) при этом возможно — длинная транзакция может бесконечно откатываться из-за коротких.
Ограничения STM, которые надо знать до, а не после
- Побочные эффекты в транзакции недопустимы. Транзакция может выполниться 5 раз — если внутри
отправка письма, клиент получит 5 писем. В Haskell тип
STM aне даёт выполнитьIO— компилятор просто не пропустит. В Clojure/Scala/Java такой защиты нет, и это главный источник багов в STM-коде на этих платформах. - Цена конфликта. Оптимистичный контроль хорош при низкой конкуренции. При высокой (все пишут
в один
TVar) вы платите откатами: полезная работа деградирует, throughput падает. Правило то же, что в БД: транзакции должны быть короткими и трогать мало ячеек. - Стоимость доступа. Каждое чтение/запись
TVar— это работа с журналом транзакции, а не одна инструкция. Накладные расходы 2–10x на операцию. STM выигрывает не скоростью примитива, а корректностью и композицией. - Аппаратный TM оказался не панацеей. Intel TSX (RTM/HLE) был выпущен в 2013 и многократно отключался микрокодом из-за ошибок и уязвимостей (TAA, 2019). Аппаратные транзакции ограничены размером L1 и обязаны иметь программный fallback.
Практическое воплощение вне Haskell: ref/dosync в Clojure (STM + MVCC, вместе с
atom/agent для более простых случаев — см. Clojure: Refs and Transactions),
ZSTM в ZIO для Scala, TVar в модуле stm для Rust.
Модель 5 (сквозная). Ограничение эффектов: типы и структура
Не столько отдельная модель, сколько способ сделать остальные безопасными.
Владение и типы (Rust). Гонки данных в безопасном Rust — ошибка компиляции, а не рантайма.
Правило: либо одна изменяемая ссылка, либо сколько угодно неизменяемых, никогда одновременно.
Маркерные типажи Send (можно передать в другой поток) и Sync (можно разделить по ссылке)
выводятся автоматически и проверяются на границе.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc — атомарный счётчик ссылок; Mutex — взаимное исключение.
// Без Arc компилятор не даст переместить значение в несколько потоков,
// без Mutex — не даст изменять то, что разделено.
let counter = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (0..8)
.map(|_| {
let c = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..100_000 {
*c.lock().unwrap() += 1; // замок берётся и отдаётся по RAII
}
})
})
.collect();
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // ровно 800000, всегда
}
Обратите внимание: Mutex<T> в Rust владеет данными, а не лежит рядом с ними. Обратиться к данным
не взяв замок технически невозможно — самая частая ошибка в C/Java/Python здесь вычеркнута из языка.
Structured concurrency. Идея Натаниэля Смита
(«Notes on structured concurrency, or: Go statement considered harmful», 2018):
задача не может пережить свою лексическую область видимости. Как {} дисциплинировали goto,
так nursery/scope дисциплинируют «запустить фоновую задачу и забыть». Реализации: trio.open_nursery
и asyncio.TaskGroup в Python 3.11+, StructuredTaskScope в Java 21+ (JEP 453), errgroup в Go,
скоупы корутин в Kotlin.
async def fetch_all(urls: list[str]) -> list[str]:
"""Либо все задачи завершились, либо все отменены. Утечь задаче некуда:
выход из with её дожидается, ошибки прилетают одним ExceptionGroup."""
async with asyncio.TaskGroup() as tg: # Python 3.11+
tasks = [tg.create_task(fetch(u)) for u in urls]
return [t.result() for t in tasks]
Как выбирать
или координация?"} B -->|"Ускорить вычисление"| D["Data parallelism:
map/reduce, fork-join, rayon.
Свёртки — локальные аккумуляторы
с паддингом против false sharing"] B -->|"Координировать сущности"| F{"Сколько живого состояния
и где оно живёт?"} F -->|"Одна маленькая ячейка"| G["Атомик или мьютекс.
Не усложняйте"] F -->|"Поток данных, этапы обработки"| H["CSP: каналы, пайплайн,
fan-in/fan-out, backpressure"] F -->|"Много независимых сущностей
со своим жизненным циклом"| I["Акторы: mailbox,
супервизия, шардирование"] F -->|"Несколько ячеек,
связанных инвариантом"| J{"Нужна композиция
атомарных операций?"} J -->|"Да"| K["STM: транзакции, retry, orElse"] J -->|"Нет, порядок захвата известен"| L["Мьютексы с lock ordering"] D --> M["Проверить: race detector,
нагрузочный тест, USL-кривая"] G --> M H --> M I --> M K --> M L --> M M --> N{"Масштабирование
сублинейно?"} N -->|"Да"| O["Искать σ и κ: perf c2c,
профиль замков, длина очередей"] N -->|"Нет"| P["Готово"]
Сравнение по двум осям, которые реально определяют выбор в проде:
Сводная таблица издержек:
| Модель | Гонки данных | Дедлоки | Композиция | Backpressure | Типичная цена операции |
|---|---|---|---|---|---|
| Мьютексы | возможны | возможны | нет | ручной | 20–50 нс без конкуренции |
| Атомики/CAS | возможны (ABA, ordering) | нет | нет | нет | 5–20 нс, O(k²) трафика |
| CSP | исключены по построению | возможны (взаимное ожидание) | да, пайплайны | встроенный | ~100 нс на канал |
| Акторы | исключены по построению | «мягкие», по таймауту | ограниченная | ручной | ~1 мкс на сообщение |
| STM | исключены | невозможны | да, полная | нет | 2–10x к обычной записи |
| Владение (Rust) | исключены компилятором | возможны | н/д | н/д | нулевая в рантайме |
Инструменты: как проверять, а не надеяться
Конкурентные баги невоспроизводимы — значит, тестирование «запустил, работает» не работает принципиально. Что действительно помогает:
go test -race ./... # Go: TSan под капотом, замедление 2-20x
clang++ -fsanitize=thread -g -O1 a.cpp # C/C++/Rust: ThreadSanitizer
java -jar jcstress.jar -t MyRaceTest # Java: перебор исходов по модели памяти
python -X faulthandler app.py # Python: детектора нет, ловим зависания
- Детекторы гонок находят только то, что выполнилось — гонка на редком пути останется. Поэтому их комбинируют со стресс-тестами и fuzzing-нагрузкой.
- Property-based тестирование с моделью: генерируем случайные последовательности конкурентных операций и проверяем линеаризуемость против последовательной эталонной реализации (stateful testing в Hypothesis, PropEr в Erlang, Jepsen для распределённых систем — его разборы это лучший учебник по тому, что ломается на практике).
- Детерминированная симуляция — самая сильная техника: подменить время, планировщик и сеть на управляемые и переигрывать сценарий с разными seed. Так тестируют FoundationDB (доклад), TigerBeetle, Antithesis. Найденный баг воспроизводится побитово.
- Формальная верификация протоколов — TLA+ до написания кода. AWS применяет её к S3 и DynamoDB («How AWS Uses Formal Methods», CACM 2015).
Типичные ошибки: сводный чек-лист
- Защищать операции, а не инварианты. Каждый метод под замком, а составная операция всё равно рвётся.
- Двойная проверка блокировки без барьера. Сломанный singleton: ссылка публикуется раньше, чем
конструктор дописал поля. В Java лечится
volatile, лучше — статическим holder-классом. - Неограниченные очереди — это OOM с отложенным сроком. Всегда задавайте границу и решайте заранее: блокировать отправителя, дропать или сбрасывать на диск.
- Блокирующий вызов в асинхронном контексте.
time.sleepв корутине, синхронный HTTP в event loop,Thread.sleepв акторе — вешает не одну задачу, а весь пул. - Пул потоков, из которого задачи ждут задачи из того же пула — классическое исчерпание пула.
- Ретрай без идемпотентности. Повтор после таймаута = вторая доставка = списание денег дважды. Нужны ключ идемпотентности, экспоненциальный backoff с джиттером и circuit breaker.
- Измерение среднего вместо хвоста. Конкурентная система деградирует по p99, а не по среднему.
Мини-итог
- Разные модели решают одну и ту же задачу, но переносят сложность в разные места: блокировки — в голову программиста, CSP — в топологию пайплайна, акторы — в дерево супервизии, STM — в рантайм.
- Разделяемая память + блокировки — быстро и универсально, но не композируется и даёт дедлоки. Берите для маленьких, локальных, хорошо видимых кусочков состояния.
- CSP — лучшая модель для потоков данных: пайплайны, воркер-пулы, встроенный backpressure. Плата — риск утечки процессов и дедлока на взаимном ожидании.
- Акторы — лучшая модель для множества независимых сущностей с жизненным циклом и для распределённых систем. Плата — неограниченный mailbox и необходимость думать об идемпотентности.
- STM — единственная модель с настоящей композицией атомарных операций. Плата — откаты при высокой конкуренции и запрет эффектов внутри транзакции.
- Гарантии, которые даёт система типов (Rust
Send/Sync, HaskellSTM), дешевле любых, которые вы обеспечиваете дисциплиной. Всегда предпочитайте компилятор код-ревью. - И главное правило, которое экономит больше всего времени: лучшая конкурентность — та, которой нет. Неизменяемые данные, шардирование по ключу, однопоточный event loop на ядро — эти решения убирают проблему, а не управляют ею.
Источники
- C. A. R. Hoare. Communicating Sequential Processes. CACM, 1978 — PDF
- Carl Hewitt et al. A Universal Modular Actor Formalism for Artificial Intelligence. IJCAI, 1973
- Gul Agha. Actors: A Model of Concurrent Computation in Distributed Systems. MIT Press, 1986
- Joe Armstrong. Making Reliable Distributed Systems in the Presence of Software Errors. PhD thesis, 2003 — PDF
- Harris, Marlow, Peyton Jones, Herlihy. Composable Memory Transactions. PPoPP, 2005 — PDF
- Herlihy, Shavit, Luchangco, Spear. The Art of Multiprocessor Programming, 2nd ed., 2020
- Brian Goetz et al. Java Concurrency in Practice, 2006 — всё ещё лучшая книга про модель памяти на практике
- Mara Bos. Rust Atomics and Locks, 2023 — читается онлайн бесплатно
- Martin Kleppmann. Designing Data-Intensive Applications, 2017 — главы 7–9 про транзакции и согласованность
- Jeff Preshing, блог о низкоуровневой конкурентности
- Go Memory Model и Java Language Specification, ch. 17
- Jepsen: анализы реальных распределённых систем
Что дальше
Мы разобрали, как организовать одновременность. Следующий шаг — что происходит, когда конкурентные потоки данных становятся первоклассными значениями, на которые можно подписаться и которые можно композировать: события, потоки, операторы и автоматическое распространение изменений.
Читайте дальше: Реактивное программирование и dataflow.
Полезно также перечитать https://courses.digitable.life/post/paradigms/03-functional/ — неизменяемость лежит в основе почти всех безопасных конкурентных моделей — и заглянуть в https://courses.digitable.life/post/paradigms/09-choosing-and-mixing/ о совмещении парадигм в одной кодовой базе. Общая карта — в https://courses.digitable.life/post/paradigms/00-overview/.