Парадигмы разработки Конкурентные парадигмы: акторы, CSP, STM, разделяемая память
0%

Конкурентные парадигмы: акторы, CSP, STM, разделяемая память

Конкурентные парадигмы: акторы, 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% приращений.

Вся эта статья — про то, какими способами человечество научилось не писать такой код. Способов ровно четыре семейства, и они действительно разные по духу, а не по синтаксису:

  1. Разделяемая память + блокировки — «пусть все ходят к одной переменной, но по очереди».
  2. CSP (каналы) — «не общайтесь через память, общайтесь через трубу».
  3. Акторы — «состояние вообще не разделяется, есть только письма и почтовые ящики».
  4. STM (транзакционная память) — «пиши как обычно, а атомарность обеспечит рантайм, как в БД».

Два разных слова: конкурентность и параллелизм

Прежде чем идти дальше, надо развести два понятия, которые в русском часто сливаются в «многопоточность».

Конкурентность (concurrency) — это структура программы: способ описать несколько независимо продвигающихся дел. Параллелизм (parallelism) — это свойство исполнения: несколько дел физически идут одновременно на разных ядрах.

Формулировка Роба Пайка из доклада «Concurrency Is Not Parallelism» (видео и слайды): конкурентность — это про то, как справляться с многими вещами сразу; параллелизм — про то, как делать многие вещи сразу. Однопоточный Node.js конкурентен, но не параллелен. Векторная инструкция AVX параллельна, но не конкурентна. GPU-ядро на 10 000 нитей параллельно и почти не конкурентно — все нити делают одно и то же.

Это различение сразу даёт практический критерий выбора:

  • Задача вычислительная (перемножить матрицы, обработать 40 ГБ логов) → нужен параллелизм → смотрите в сторону data parallelism, fork-join, SIMD, и вам, скорее всего, вообще не нужны каналы и акторы.
  • Задача координационная (10 000 клиентов на сокетах, каждый в своём состоянии) → нужна конкурентность → вот здесь акторы, CSP и async/await окупаются.

Карта моделей

Обратите внимание на нижнюю ветку: «ограничение эффектов» — это не пятая независимая модель, а подход, который делает остальные четыре безопаснее. Неизменяемость из 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.

Порядок: буфер записи

Store buffer и переупорядочивание StoreLoad

Даже 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) — оба ждут друг друга.

Девиз 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-м. Актор — сущность, которая в ответ на одно сообщение может сделать ровно три вещи:

  1. отправить конечное число сообщений другим акторам;
  2. создать конечное число новых акторов;
  3. определить, как обрабатывать следующее сообщение (то есть изменить своё поведение/состояние).

Отличия от 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) звучит контринтуитивно: не пишите защитный код внутри актора. Пусть падает. Восстанавливать должен кто-то другой, у кого состояние заведомо не испорчено.

Стратегии супервизора задают, что делать с «соседями» упавшего процесса: :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) — одна из самых влиятельных статей о конкурентности вообще.

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]

Как выбирать

Сравнение по двум осям, которые реально определяют выбор в проде:

Сводная таблица издержек:

Модель Гонки данных Дедлоки Композиция 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).

Типичные ошибки: сводный чек-лист

  1. Защищать операции, а не инварианты. Каждый метод под замком, а составная операция всё равно рвётся.
  2. Двойная проверка блокировки без барьера. Сломанный singleton: ссылка публикуется раньше, чем конструктор дописал поля. В Java лечится volatile, лучше — статическим holder-классом.
  3. Неограниченные очереди — это OOM с отложенным сроком. Всегда задавайте границу и решайте заранее: блокировать отправителя, дропать или сбрасывать на диск.
  4. Блокирующий вызов в асинхронном контексте. time.sleep в корутине, синхронный HTTP в event loop, Thread.sleep в акторе — вешает не одну задачу, а весь пул.
  5. Пул потоков, из которого задачи ждут задачи из того же пула — классическое исчерпание пула.
  6. Ретрай без идемпотентности. Повтор после таймаута = вторая доставка = списание денег дважды. Нужны ключ идемпотентности, экспоненциальный backoff с джиттером и circuit breaker.
  7. Измерение среднего вместо хвоста. Конкурентная система деградирует по p99, а не по среднему.

Мини-итог

  • Разные модели решают одну и ту же задачу, но переносят сложность в разные места: блокировки — в голову программиста, CSP — в топологию пайплайна, акторы — в дерево супервизии, STM — в рантайм.
  • Разделяемая память + блокировки — быстро и универсально, но не композируется и даёт дедлоки. Берите для маленьких, локальных, хорошо видимых кусочков состояния.
  • CSP — лучшая модель для потоков данных: пайплайны, воркер-пулы, встроенный backpressure. Плата — риск утечки процессов и дедлока на взаимном ожидании.
  • Акторы — лучшая модель для множества независимых сущностей с жизненным циклом и для распределённых систем. Плата — неограниченный mailbox и необходимость думать об идемпотентности.
  • STM — единственная модель с настоящей композицией атомарных операций. Плата — откаты при высокой конкуренции и запрет эффектов внутри транзакции.
  • Гарантии, которые даёт система типов (Rust Send/Sync, Haskell STM), дешевле любых, которые вы обеспечиваете дисциплиной. Всегда предпочитайте компилятор код-ревью.
  • И главное правило, которое экономит больше всего времени: лучшая конкурентность — та, которой нет. Неизменяемые данные, шардирование по ключу, однопоточный event loop на ядро — эти решения убирают проблему, а не управляют ею.

Источники

Что дальше

Мы разобрали, как организовать одновременность. Следующий шаг — что происходит, когда конкурентные потоки данных становятся первоклассными значениями, на которые можно подписаться и которые можно композировать: события, потоки, операторы и автоматическое распространение изменений.

Читайте дальше: Реактивное программирование и 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/.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов