Функциональное программирование Конкурентность в ФП: неизменяемость, STM, акторы
0%

Конкурентность в ФП: неизменяемость, STM, акторы

Конкурентность в ФП: неизменяемость, STM, акторы

Есть фраза, которую любят повторять на конференциях: «в функциональном программировании конкурентность бесплатна, потому что данные неизменяемы». Это полуправда, и вторая половина важнее первой.

Неизменяемость действительно убивает целый класс багов — гонки за данные (data races), когда два потока одновременно пишут в одну ячейку памяти и получают мусор. Но она не убивает координацию. Если задача звучит «перевести деньги с одного счёта на другой так, чтобы никто не увидел промежуточного состояния» — неизменяемость сама по себе не даст ответа. Нужен механизм, который скажет: вот эти два изменения происходят вместе или не происходят вообще.

Эта статья про то, какие механизмы ФП предлагает вместо мьютексов, чем они реально лучше, и сколько за них платят. Обзорный взгляд на парадигму есть в статье Функциональное программирование — здесь мы копаем в глубину.

Часть 1. Боль: почему замки не работают

Гонка, которую видно

Начнём с кода, который выглядит правильно и правильным не является.

# Счётчик просмотров. Два потока, каждый инкрементит миллион раз.
import threading

counter = 0

def worker():
    global counter
    for _ in range(1_000_000):
        counter += 1          # НЕ атомарно: read, add, write

threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)   # ожидаем 2_000_000, получаем что-то меньше

counter += 1 — это три операции: прочитать, прибавить, записать. Между чтением и записью второй поток успевает вклиниться со своим чтением, и один из инкрементов теряется. В CPython этот пример из-за GIL часто «везёт» и печатает правильное число, но в свободнопоточной сборке (PEP 703, экспериментальная в 3.13, официально поддерживаемая с 3.14) — уже нет. На JVM или в Go он ломается стабильно.

Классический ответ — мьютекс. Он работает. Проблема начинается на следующем шаге.

Гонка, которую не видно: замки не композируются

import threading

class Account:
    def __init__(self, balance):
        self.balance = balance
        self.lock = threading.Lock()

    def withdraw(self, amount):
        with self.lock:
            if self.balance < amount:
                raise ValueError("недостаточно средств")
            self.balance -= amount

    def deposit(self, amount):
        with self.lock:
            self.balance += amount

Оба метода корректны. Каждый по отдельности потокобезопасен. А теперь склеим их:

def transfer(src, dst, amount):
    src.withdraw(amount)     # ← между этими двумя строками
    dst.deposit(amount)      #   деньги не существуют нигде

Между строками есть момент, когда сумма пропала из системы: любой отчёт, снятый в этот миг, покажет недостачу. Хуже — если deposit упадёт, деньги исчезнут навсегда. Значит, надо брать оба замка сразу:

def transfer(src, dst, amount):
    with src.lock:
        with dst.lock:
            ...

И тут же появляется дедлок: поток A переводит с 1 на 2, поток B — с 2 на 1, каждый взял свой первый замок и ждёт второй. Лечится глобальным порядком захвата (например, по id), который надо помнить всей команде, во всех модулях, вечно.

Вот главный тезис этой части: замки не композируются. Из двух корректных функций с замками нельзя механически собрать третью корректную функцию. Свойство «потокобезопасен» не наследуется композицией — а вся ФП построена на том, что из корректных кусков собираются корректные целые (см. Композиция и каррирование). Именно поэтому ФП-сообщество искало другие модели.

Разделяемая память против акторов

Что именно чинит неизменяемость (и что нет)

Разложим проблемы конкурентности по полочкам:

Проблема Чинится неизменяемостью?
Data race: два потока пишут в одну ячейку Да, полностью — писать некуда
Разорванное чтение: увидел половину обновлённой структуры Да — снимок либо старый целиком, либо новый целиком
Публикация без барьера памяти: увидел объект с неинициализированными полями Да — неизменяемый объект после конструктора не меняется
Потерянное обновление: два процесса прочитали одно, записали разное Нет — нужна координация
Атомарность нескольких изменений Нет — нужна транзакция
Порядок событий, взаимные блокировки на ресурсах Нет — нужен протокол
Дедлок Косвенно: меньше замков — меньше поводов

Неизменяемость — это фундамент, а не решение. Она делает чтение бесплатным и безопасным: любой поток может держать ссылку на структуру сколько угодно и никто её не испортит (детали — в статьях Неизменяемость и Персистентные структуры данных). Остаётся вопрос: как согласованно менять то, что всё-таки должно меняться. На этот вопрос есть четыре разных ответа.

Часть 2. Карта моделей

Прежде чем углубляться, зафиксируем разницу двух слов, которые постоянно путают.

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

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

Роб Пайк сформулировал это так: «Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once» (доклад Concurrency is not Parallelism). Разные модели ниже решают разные из этих двух задач, и путать их — типичная ошибка при выборе инструмента.

Часть 3. STM: транзакции для памяти

Идея

Проблема с замками — что программист вручную решает, как защитить данные. Software Transactional Memory переворачивает это: программист говорит, что должно произойти атомарно, а рантайм разбирается сам. Ровно как в базе данных: вы пишете BEGIN ... COMMIT, а не расставляете блокировки на страницы.

import Control.Concurrent.STM

type Account = TVar Int

-- Перевод денег. Обратите внимание: ни одного замка.
transfer :: Account -> Account -> Int -> STM ()
transfer src dst amount = do
  balance <- readTVar src
  if balance < amount
    then retry                      -- подождать, пока средства появятся
    else do
      writeTVar src (balance - amount)
      modifyTVar' dst (+ amount)

main :: IO ()
main = do
  alice <- newTVarIO 100
  bob   <- newTVarIO 50
  atomically $ transfer alice bob 30

Здесь важны три вещи, и каждая нетривиальна.

Первая: transfer возвращает STM (), а не IO (). Это не транзакция, это описание транзакции — обычное значение, которое можно передавать и комбинировать. Выполняется оно только в момент atomically. Та же идея, что с эффектами в статье Эффекты и ввод-вывод: сначала строим описание, потом отдаём его исполнителю.

Вторая: транзакции композируются. То, чего не умеют замки:

-- Две корректные транзакции, склеенные в третью корректную транзакцию.
-- Промежуточное состояние (деньги у Боба) не увидит НИКТО.
atomically $ do
  transfer alice bob   30
  transfer bob   carol 30

Это не библиотечный трюк, а следствие того, что STM — монада (Монады без страха). >>= для STM означает «выполни оба действия в одном журнале», поэтому склейка транзакций автоматически даёт транзакцию.

Третья: retry — это блокировка, которая композируется. Вызов retry говорит «эта транзакция сейчас не может выполниться, откати и разбуди меня, когда что-то изменится». Рантайм знает read set транзакции и подписывает поток ровно на те TVar, которые она читала: никакого busy-wait, никаких condition variables, которые надо не забыть просигналить. А orElse даёт альтернативу:

-- Взять деньги с основного счёта, а если там пусто — с резервного.
-- Если пусто на обоих, оба retry сложатся в один: ждём изменения любого счёта.
withdrawAny :: Account -> Account -> Int -> STM ()
withdrawAny primary backup amt =
  withdraw primary amt `orElse` withdraw backup amt

Попробуйте выразить это на мьютексах и condvar’ах — получится страница кода с тонкими ошибками. Каноническая работа: Harris, Marlow, Peyton Jones, Herlihy, «Composable Memory Transactions» (PPoPP 2005) — именно там retry/orElse и появились.

Как это работает внутри

STM в GHC оптимистичен: транзакция не блокирует ничего, а ведёт локальный журнал.

Оптимистичная транзакция STM

Отсюда сразу следуют практические свойства:

  • Побочные эффекты внутри транзакции запрещены. Транзакция может выполниться пять раз — отправленное письмо пять раз не отзовёшь. В Haskell это не соглашение, а гарантия типов: в STM нельзя вызвать IO. Clojure ту же вещь может только попросить в документации, и это реальная разница в надёжности. (unsafeIOToSTM существует, но, как и всё с приставкой unsafe, это способ отстрелить ногу.)
  • Стоимость коммита растёт с размером журнала: валидация линейна по read set. Транзакция, читающая 10 000 TVar, — плохая идея.
  • Livelock реален. Длинная транзакция на горячей переменной может бесконечно откатываться из-за коротких. Лечится шардированием (не один TVar на всю систему, а массив), уменьшением транзакций, а в тяжёлых случаях — переходом на очередь.
  • Конфликт — это конфликт по переменной, а не по смыслу. Инкремент счётчика конфликтует с инкрементом счётчика, хотя порядок неважен. В Clojure для этого есть commute — «применяй в любом порядке», который снимает конфликт для коммутативных операций.

То же самое в Clojure

Clojure сделал STM частью языка и добавил разделение по типу координации — это очень полезная классификация даже вне Clojure:

;; atom — одна ссылка, без координации. CAS в цикле.
(def counter (atom 0))
(swap! counter inc)         ; читает, применяет функцию, CAS; при неудаче повторяет

;; ref — координированное изменение НЕСКОЛЬКИХ ссылок в dosync
(def alice (ref 100))
(def bob   (ref 50))
(dosync
  (alter alice - 30)
  (alter bob   + 30))       ; либо оба, либо ни одного

;; agent — асинхронное изменение одной ссылки, эффекты разрешены
(def log-agent (agent []))
(send log-agent conj "событие")   ; вернётся немедленно

Матрица, которую стоит запомнить: координировано/некоординировано × синхронно/асинхронно. ref — координированный синхронный, atom — некоординированный синхронный, agent — некоординированный асинхронный. Официальная справка: clojure.org/reference/refs.

Отдельно про swap!: это CAS-цикл, и функция внутри него тоже может выполниться несколько раз. Тот же запрет на эффекты, только без поддержки компилятора.

Этот же цикл — основа AtomicReference.updateAndGet в Java, atomicModifyIORef' в Haskell и оптимистичных обновлений с полем version в базе. Одна идея на всех уровнях.

Аналог на TypeScript

STM можно смоделировать и в языке без него — полезно, чтобы понять механику:

// Учебная реализация: одна "память" из версионированных ячеек.
type Cell<T> = { value: T; version: number };

class STM {
  private log = new Map<Cell<unknown>, { read: number; write?: unknown }>();

  read<T>(cell: Cell<T>): T {
    const entry = this.log.get(cell as Cell<unknown>);
    if (entry) return (entry.write ?? cell.value) as T;
    this.log.set(cell as Cell<unknown>, { read: cell.version });
    return cell.value;
  }

  write<T>(cell: Cell<T>, value: T): void {
    const entry = this.log.get(cell as Cell<unknown>) ?? { read: cell.version };
    entry.write = value;
    this.log.set(cell as Cell<unknown>, entry);
  }

  // Валидация: ни одна прочитанная ячейка не должна была измениться.
  commit(): boolean {
    for (const [cell, e] of this.log) if (cell.version !== e.read) return false;
    for (const [cell, e] of this.log) {
      if ("write" in e) { cell.value = e.write; cell.version++; }
    }
    return true;
  }
}

export function atomically<T>(body: (tx: STM) => T): T {
  for (;;) {                       // повторяем, пока не сойдётся
    const tx = new STM();
    const result = body(tx);       // ← поэтому здесь нельзя делать эффекты
    if (tx.commit()) return result;
  }
}

В однопоточном JS это игрушка (нет вытеснения между read и commit), но она честно показывает: read set, write set, валидация версий, повтор. Настоящая реализация добавляет блокировку на время коммита, экспоненциальный откат и подписки для retry.

Часть 4. Акторы: состояние, которое никому не показывают

STM говорит «пусть память будет разделяемой, но изменения — транзакционными». Акторы говорят радикальнее: разделяемой памяти не будет вообще. Каждый актор — изолированный процесс со своей кучей; общаться можно только сообщениями, а сообщение при отправке копируется.

Модель придумал Карл Хьюитт в 1973-м, но производственной её сделал Erlang. Elixir — самый практичный способ её сегодня потрогать (см. курс по Elixir).

Актор — это хвостовая рекурсия с почтовым ящиком

Самое красивое в акторах для функционального программиста: изменяемого состояния нет и здесь. Состояние — это аргумент бесконечного рекурсивного цикла.

defmodule Counter do
  def start(initial \\ 0), do: spawn(fn -> loop(initial) end)

  # Состояние `count` — обычный неизменяемый аргумент функции.
  # "Изменение" состояния = хвостовой вызов с другим аргументом.
  defp loop(count) do
    receive do
      {:inc, n}          -> loop(count + n)
      {:get, from}       -> send(from, {:count, count}); loop(count)
      :stop              -> :ok
    end
  end
end

loop/1 — чистая функция «старое состояние + сообщение → новое состояние», обёрнутая в хвостовой вызов, который не растит стек (см. Рекурсия и хвостовые вызовы). Гонок нет по построению: у переменной count ровно один читатель и писатель — сам процесс. Сериализация доступа получается бесплатно, из очереди сообщений.

На практике этот цикл пишут не руками, а через GenServer, который отделяет чистую логику от протокола:

defmodule Bank.Account do
  use GenServer

  # --- клиентский API: обычные функции, конкурентность спрятана ---
  def start_link(balance), do: GenServer.start_link(__MODULE__, balance)
  def balance(pid),         do: GenServer.call(pid, :balance)
  def deposit(pid, amount), do: GenServer.cast(pid, {:deposit, amount})

  def withdraw(pid, amount), do: GenServer.call(pid, {:withdraw, amount})

  # --- серверные колбэки: чистые переходы состояния ---
  @impl true
  def init(balance), do: {:ok, balance}

  @impl true
  def handle_call(:balance, _from, balance), do: {:reply, balance, balance}

  def handle_call({:withdraw, amount}, _from, balance) when amount <= balance do
    {:reply, {:ok, amount}, balance - amount}
  end

  def handle_call({:withdraw, _amount}, _from, balance) do
    {:reply, {:error, :insufficient_funds}, balance}
  end

  @impl true
  def handle_cast({:deposit, amount}, balance), do: {:noreply, balance + amount}
end

Обратите внимание: handle_call/3 — чистая функция от (запрос, состояние) к (ответ, новое состояние). Её можно тестировать без запуска процессов, вызывая напрямую. Это ровно тот приём «функциональное ядро, императивная оболочка», к которому мы вернёмся в статье про архитектуру.

Отказ как часть модели

Второе, ради чего берут акторов, — не производительность, а надёжность. Изоляция памяти означает, что упавший процесс не может испортить состояние соседа: портить нечего, память не общая. Значит, падение можно не обрабатывать, а просто перезапускать процесс с чистого состояния — «let it crash».

children = [
  {Bank.Account, 100},
  {Bank.Ledger, []}
]
# :one_for_one — упал один ребёнок, перезапускается только он
Supervisor.start_link(children, strategy: :one_for_one,
                      max_restarts: 3, max_seconds: 5)

Ключевая мысль Джо Армстронга: обработка ошибок должна происходить вне процесса, где ошибка случилась, потому что упавший процесс по определению в неизвестном состоянии и не может чинить сам себя. Разбор — в его диссертации «Making reliable distributed systems in the presence of software errors», одном из самых читабельных инженерных текстов вообще.

Честная цена акторов

Здесь надо быть аккуратным, потому что маркетинг BEAM оптимистичнее реальности.

Копирование сообщений. Отправка сообщения копирует данные из кучи отправителя в кучу получателя. Для маленьких сообщений это дешевле, чем кажется (нет синхронизации), но передача мегабайтной структуры между процессами — это реальный мегабайт копирования. Исключение: бинарные данные больше 64 байт хранятся в общей refc-куче и передаются по ссылке — поэтому большие payload’ы в Elixir стоит держать бинарями, а не списками.

Нет транзакции на нескольких акторах. Помните transfer? В STM он решался тривиально. В акторах два счёта — два процесса, и атомарно изменить оба нельзя. Варианты: сделать один процесс-«книгу» для обоих счетов (и получить узкое место), реализовать двухфазный коммит или сагу с компенсацией. Это фундаментальное ограничение, а не недоработка: та же цена, что у микросервисов.

Неограниченный почтовый ящик. Если производитель быстрее потребителя, ящик растёт, пока не съест память. Ни receive, ни GenServer.cast не имеют встроенного противодавления. Отсюда GenStage/Flow с явным спросом (consumer сообщает, сколько готов принять) — и правило: call даёт естественное противодавление, cast не даёт.

Один процесс — одно ядро. Актор обрабатывает сообщения последовательно. Горячий процесс — это узкое место, которое не лечится добавлением ядер; нужно шардирование по ключу.

Селективный receive может быть квадратичным. Если в ящике 10 000 сообщений, а вы ждёте конкретный паттерн, рантайм сканирует ящик; частый антипаттерн в старом коде.

ETS как честный побег. Когда нужен реально разделяемый быстрый кэш, Erlang даёт ETS — таблицу в общей памяти вне процессных куч. Она изменяема, конкурентна и с гарантиями атомарности только на уровне отдельной операции. Это признание, что чистая акторная модель не покрывает всё.

Акторы против STM

Компромисс читается так: STM даёт композируемую атомарность, но работает только в пределах одной машины и одной кучи. Акторы отказываются от общей атомарности — и взамен получают модель, которая одинаково выглядит внутри узла и между узлами, переживает падения и масштабируется по сети. Выбор между ними — это выбор «мне нужны транзакции» против «мне нужна устойчивость и распределённость».

Практический ответ обычно гибридный: акторы как каркас системы, а внутри узла — атомарные ссылки или STM там, где нужна общая согласованность.

Часть 5. Каналы и потоки: CSP

Третья модель — Communicating Sequential Processes Тони Хоара. Отличие от акторов в одном, но принципиальном пункте: первоклассная сущность — не адресат, а канал. Отправитель не знает, кто прочитает.

# В Elixir каналов нет, но идею конвейера прекрасно выражает Flow/Stream:
# ленивый конвейер, распараллеленный по ядрам, с ограничением конкурентности.
"access.log"
|> File.stream!(read_ahead: 100_000)
|> Stream.map(&parse_line/1)
|> Stream.reject(&is_nil/1)
|> Task.async_stream(&enrich_with_geoip/1, max_concurrency: 20, timeout: 5_000)
|> Stream.filter(fn {:ok, %{status: s}} -> s >= 500 end)
|> Enum.take(100)

Task.async_stream — рабочая лошадь конкурентности в Elixir: max_concurrency ограничивает число одновременных задач (то самое противодавление), ленивость Stream означает, что файл не читается целиком (см. Ленивость и потоки).

В Go тот же паттерн выражается каналами (подробнее — в курсе по Go), в Clojure — библиотекой core.async. Функционального в CSP меньше, чем в STM, но идея «данные передаются, а не разделяются» — та же самая, и она отлично уживается с неизменяемостью.

Часть 6. Чистый параллелизм: когда координировать нечего

Всё предыдущее — про конкурентность, то есть про управление изменением. Есть случай проще: вычисление, у которого вообще нет состояния. Тогда чистота даёт то, чего не даёт ни одна другая модель — право менять порядок вычислений, ничего не проверяя.

Если f чистая, то map f xs можно считать в любом порядке, на любом числе ядер, с любым разбиением — результат тот же. Компилятору и рантайму не нужен анализ алиасов и зависимостей: их гарантирует система типов (Чистые функции).

import Control.Parallel.Strategies

-- Последовательно
solutions :: [Board] -> [Int]
solutions = map evaluate

-- Параллельно: та же функция, добавлена стратегия вычисления.
-- rdeepseq — вычислять каждый элемент до нормальной формы, иначе
-- параллельно посчитается только "обёртка" и вся работа уедет обратно в main.
solutionsPar :: [Board] -> [Int]
solutionsPar boards = map evaluate boards `using` parList rdeepseq

Разделение «что вычисляем» и «как вычисляем» — сильная сторона подхода: алгоритм не переписывается, к нему прикладывается стратегия. Но здесь же прячется главная ловушка ленивых языков.

Гранулярность и «испарившиеся» искры. par в GHC создаёт spark — подсказку «это можно посчитать параллельно», а не поток. Искра может «испариться» (fizzle), если значение уже вычислено, или переполнить очередь искр. Типичный провал: распараллелили map на списке из миллиона элементов — накладные расходы на искру больше самой работы, программа стала медленнее. Второй типичный провал: забыли rdeepseq, распараллелили вычисление до WHNF — то есть построение thunk’а, — и не получили ничего. Диагностика без ThreadScope практически невозможна.

Детерминированную альтернативу даёт монада Par (пакет monad-par) с IVar: результат не зависит от планировщика и гонок нет по построению. Лучший разбор всего этого — свободно доступная книга Саймона Марлоу «Parallel and Concurrent Programming in Haskell».

В мейнстриме та же идея выглядит скромнее, но работает:

# Чистая функция + пул процессов. Работает, потому что score не трогает
# глобальное состояние: обход GIL через процессы, данные копируются.
from concurrent.futures import ProcessPoolExecutor

def score(candidate: tuple[float, ...]) -> float:
    return sum(x * x for x in candidate)     # чистая

with ProcessPoolExecutor() as pool:
    results = list(pool.map(score, candidates, chunksize=64))

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

Часть 7. Асинхронность как монада

Последний кусочек — конкурентность ввода-вывода. Тут ФП внесла, пожалуй, самый заметный вклад в мейнстрим: Promise/Future/Task — это монады, а async/await — синтаксический сахар для do-нотации.

// Последовательно: 3 круга ожидания сети подряд.
const user    = await fetchUser(id);
const orders  = await fetchOrders(user.id);
const profile = await fetchProfile(user.id);   // не зависел от orders!

// Параллельно: зависимости выражены явно, независимое запускается вместе.
const user = await fetchUser(id);
const [orders, profile] = await Promise.all([
  fetchOrders(user.id),
  fetchProfile(user.id),
]);

Разница ровно та, что между монадой и аппликативом (Функторы и аппликативы): монадическая связка >>= вынуждает последовательность, потому что следующий шаг зависит от результата предыдущего. Аппликативная — Promise.all, <*> — знает, что шаги независимы, и может выполнить их параллельно. Это не аналогия, а буквальная причина: Haskell’евский Haxl и Scala’ин Cats Parallel автоматически батчат и распараллеливают именно аппликативные комбинации запросов.

Важный практический нюанс — обработка ошибок:

// Promise.all: первый reject убивает всё, остальные результаты потеряны.
// Promise.allSettled: собираем все исходы, включая частичные успехи.
const results = await Promise.allSettled([a(), b(), c()]);
const ok   = results.filter(r => r.status === "fulfilled").map(r => r.value);
const bad  = results.filter(r => r.status === "rejected").map(r => r.reason);

Это ровно противопоставление «короткое замыкание против накопления ошибок» из статьи Обработка ошибок в ФП, только в конкурентной обёртке.

# Python: структурная конкурентность через TaskGroup (3.11+).
# Если одна задача упала — остальные отменяются, группа не "утекает".
import asyncio

async def load(ids: list[int]) -> list[dict]:
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(i)) for i in ids]
    return [t.result() for t in tasks]

Структурная конкурентность (её сформулировал Мартин Сустрик в заметке «Notes on structured concurrency», популяризовал проект Trio) — важная идея, родственная ФП: у задачи есть лексическая область видимости, и она не может пережить свой блок. Ровно как значение не переживает свою область — только для процессов.

Часть 8. Честная цена

Раздел, ради которого стоит читать всю статью. ФП-подход к конкурентности не бесплатен.

1. Аллокации и давление на сборщик мусора. Неизменяемость означает, что вместо записи в существующую ячейку вы выделяете новую. В параллельном коде это может упереться в аллокатор и GC. На BEAM смягчено кучей на процесс (сборка мусора локальна и не останавливает мир), на JVM и в GHC — нет: GHC использует параллельный stop-the-world сборщик, и на 64-ядерной машине с большой живой кучей паузы становятся заметны. Для низколатентных систем это может быть блокирующим фактором.

2. Локальность кэша. Персистентные структуры — это деревья указателей. Обход миллиона элементов в Vector из Clojure проигрывает обходу примитивного массива в разы: промахи кэша, разыменования, боксинг. В численном коде это разница между «успевает» и «не успевает», и именно поэтому даже в Haskell горячие циклы пишут через Data.Vector.Unboxed и ST с изменяемым буфером.

3. Копирование в акторных системах. Иммутабельность внутри процесса плюс копирование между процессами = данные могут существовать в N экземплярах. Для системы на 100 000 процессов это реальная память.

4. Кривая обучения и найм. Понять retry/orElse, разницу монадической и аппликативной композиции, supervision-стратегии, семантику commute — это недели, а не часы. Команда, у которой одна STM-транзакция написана неправильно, будет отлаживать её дольше, чем мьютекс, потому что инструментов меньше и опыт редок.

5. Иллюзия безопасности. Самая коварная цена. «У нас неизменяемые данные, значит гонок нет» — ложное утверждение. Потерянные обновления, нарушения порядка, несогласованные снимки между сервисами, идемпотентность повторов — всё это остаётся, просто перестаёт выглядеть как гонка. Неизменяемость убирает низкоуровневый шум и делает логические ошибки более заметными — но не устраняет их.

6. Где ФП мешает прямо. Лок-фри структуры (кольцевые буферы, work-stealing очереди), работа с mmap-регионами, GPU-ядра, драйверы — всё это построено на контролируемой мутации конкретных адресов. Здесь функциональная модель не помогает, а мешает: нужны атомики, барьеры памяти и точный контроль раскладки. Правильный ответ — не бороться, а изолировать: изменяемое ядро с чистым интерфейсом (ST/runST в Haskell — ровно этот приём: мутация внутри, чистота снаружи, гарантирована типами).

Часть 9. Как выбирать: практическая шпаргалка

Последняя ветка — не шутка. Огромная доля самописной координации в памяти существует потому, что кто-то не захотел сделать SELECT ... FOR UPDATE. База данных — это отлаженная STM с персистентностью, и в 90% продуктовых задач она правильный ответ.

Типичные ошибки

Эффект внутри транзакции или swap!. Отправка письма, запись в лог, HTTP-вызов внутри блока, который может повториться. В Haskell компилятор не даст, в Clojure и в самописных CAS-циклах — даст, и вы получите дубликаты в проде. Правило: транзакция вычисляет что сделать, эффект выполняется после коммита.

Транзакция размером с бизнес-операцию. Чем длиннее транзакция, тем выше шанс конфликта и дороже валидация. Делайте их маленькими: прочитать, решить, записать.

GenServer.cast вместо call там, где нужен обратный клапан. cast не ждёт ответа, значит производитель может завалить потребителя. Если нет явного механизма спроса — используйте call.

Один процесс/актор на всю подсистему. Легко пишется, отлично работает в тестах, становится узким местом под нагрузкой. Шардируйте по ключу с самого начала: :erlang.phash2(key, shards).

Распараллеливание без измерения гранулярности. parMap на списке из миллиона мелких элементов, ProcessPoolExecutor без chunksize, Task.async_stream на операции в 5 микросекунд — всё это делает программу медленнее. Порог примерно: если единица работы меньше ~50–100 микросекунд, накладные расходы съедают выигрыш. Всегда сравнивайте с последовательной версией.

Ожидание, что неизменяемость даст согласованность между сервисами. Не даст. Между процессами и машинами вам всё равно нужны версии, идемпотентные ключи и явные протоколы.

Смешивание моделей в одном месте. Акторы, которые внутри берут мьютексы, которые внутри трогают STM. Каждая модель работает, когда её граница совпадает с границей модуля.

Мини-итог

  • Главная проблема конкурентности — не потоки, а разделяемое изменяемое состояние. Неизменяемость убирает целый класс гонок, но не отменяет координацию.
  • Замки не композируются — из корректных частей не собирается корректное целое. Это фундаментальный дефект, а не вопрос дисциплины.
  • STM возвращает композируемость: транзакция — это значение, retry и orElse — блокировка и выбор, которые складываются. Цена: оптимистичные повторы, запрет эффектов, работа только внутри одной кучи.
  • Акторы убирают разделяемую память вообще. Состояние — аргумент хвостовой рекурсии, изоляция даёт «let it crash» и распределённость. Цена: копирование, отсутствие транзакций через несколько акторов, необходимость явного противодавления.
  • Чистый параллелизм — единственный случай, где всё действительно почти бесплатно: чистые функции можно вычислять в любом порядке. Ловушка — гранулярность и ленивость.
  • Async — это монада; Promise.all и аппликативная композиция — способ выразить независимость и получить параллелизм.
  • Честная цена: аллокации и GC, локальность кэша, копирование, кривая обучения и иллюзия безопасности. Для лок-фри структур и численного кода мутация уместнее — изолируйте её, а не запрещайте.

Что дальше

Мы разобрали, как ФП думает про время и одновременность. Дальше — практический вопрос: как применять всё это в языках, которые функциональными не задумывались.

ФП в обычных языках: JavaScript, TypeScript, Python, Java, C#, Kotlin

Источники

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

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

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

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