- PVSM.RU - https://www.pvsm.ru -
Mutex, CAS, акторы, STM, CRDT, иммутабельность, MVCC, Disruptor…
Когда читаешь про многопоточность, кажется, что способов — десятки, и каждый требует отдельного изучения.
На самом деле их ровно три. Всё остальное — реализации и комбинации.
Эта статья — попытка навести порядок в голове. После неё вы сможете:
за 5 секунд классифицировать любой подход к конкурентности;
понимать, почему Erlang выбрал акторы, а Java предлагает synchronized;
не изобретать велосипеды и не зацикливаться на «единственно правильном» решении;
проектировать многопоточный код, держа в голове простую модель.
Если статья покажется вам сложной идите сразу в комментарии. Там самое интересное. Как всегда.
Откройте любую статью про конкурентность. Вас накроет волной:
Mutex, Semaphore, Monitor, synchronized
Atomic, CAS, Lock-free, Wait-free
Actor Model, CSP, Channels
STM, MVCC, Snapshot Isolation
Immutable, Persistent Data Structures
CRDT, Eventual Consistency
Disruptor, Ring Buffer, Single Writer
Каждый термин тянет за собой статьи, книги, фреймворки. Создаётся впечатление, что конкурентность — это бездонная кроличья нора.
Но это иллюзия.
Если задать правильный вопрос, всё становится простым.
Вот он:
Что происходит, когда два потока одновременно хотят изменить один объект?
Не «как устроен mutex». Не «чем CAS лучше lock». А именно: что случится при конфликте?
Ответов ровно три:
Один победит, другой переделает работу (First Win + Retry [1]) - конфликт обнаруживается и разрешается
Один подождёт, пока другой закончит (Single Writer [2]) - конфликта нет
Последний затрёт первого (Last Win) - конфликт игнорируется, результат часто случайный.
Всё. Это полный список. Других вариантов не существует.
*Что такое "работа", "конфликт" и почему "переделать"?
Потому что бизнес логика / валидатор дан на стейт. А стейт изменён в промежутке между:
1 чтением,
2 вычислением нового состояния,
3 валидацией,
4 записью нового состояния.
Изменен вот почему:
State_new = f(State_old, command)
Если один поток поменял состояние, то работа второго теперь зависит от нового состояния, надо всё переделать.
Оптимист начнет работу, надеясь что его не опередят.
Пессимист подождёт [3]
Логика элементарная. Два вопроса:
Вопрос 1: Разрешаем ли мы двум потокам писать одновременно?
Нет ==> Кто-то должен ждать ==> Single Writer (стратегия 2)
Да ==> Переходим ко второму вопросу
Вопрос 2: Проверяем ли мы при записи, что данные не изменились?*
Да ==> При конфликте кто-то проигрывает ==> First Win (стратегия 1)
Нет ==> Последняя запись затирает ==> Last Win (стратегия 3)
*Проверка в случае first win делается именно в момент записи, в тот же момент (атомарно). Это должна быть одна неделимая операция.
На уровне процессора (CAS): Инструкция делает проверку и замену за один такт.
Если проверять заранее (отдельным шагом), то между проверкой и записью успеет вклиниться другой поток, и данные будут испорчены.
Дерево решений:
Параллельная запись разрешена?
├── Нет ==> [2] Single Writer (ждём очереди)
└── Да ==> Проверяем конфликт?
├── Да ==> [1] First Win (retry/abort)
└── Нет ==> [3] Last Win (затирание)
Других веток нет. Математически.
Суть: Потоки работают параллельно. При попытке записать — проверяем, не изменилось ли состояние. Если изменилось — проигравший переделывает.
Схема:
Поток A: read v1 ==> compute ==> try write (v1==>v2) ✓ победил
Поток B: read v1 ==> compute ==> try write (v1==>v3) ✗ конфликт ==> retry
class OrderService {
private final AtomicReference<OrderState> ref = new AtomicReference<>(initial);
public void apply(Command cmd) {
while (true) {
OrderState current = ref.get(); // снапшот
OrderState next = current.apply(cmd); // валидация + расчёт
if (ref.compareAndSet(current, next)) { // попытка записи
return; // победили
}
// кто-то успел раньше ==> retry
}
}
}
|
Плюсы |
Минусы |
|---|---|
|
Высокий параллелизм |
При частых конфликтах — retry |
|
Нет ожидания блокировок |
CPU тратится на пересчёт |
|
Простая модель |
Работа может быть выброшена |
Суть: В каждый момент времени пишет только один. Остальные ждут.
|
|
2.1: OS Lock |
2.2: Очередь |
|---|---|---|
|
Механизм |
Mutex / synchronized |
Mailbox / Queue |
|
Кто ждёт |
Потоки (спят в ОС) |
Команды (лежат в очереди) |
|
Накладные расходы |
Context switch |
Память под очередь |
|
Пример |
Java |
Actor, Event Loop |
Блокировка потока может быть реализована по-разному: mutex усыпляет поток (context switch), spinlock крутится в цикле ожидания, futex комбинирует оба подхода. Семантика одна - сериализация (строгая последовательность) доступа
class Account {
private long balance;
public synchronized void withdraw(long amount) {
if (balance < amount) throw new IllegalStateException();
balance -= amount;
}
}
class AccountActor {
private long balance;
private final Queue<Command> mailbox = new MpscQueue<>();
// Вызывается из любых потоков
public void send(Command cmd) {
mailbox.offer(cmd); // быстрая вставка
}
// Выполняется в одном потоке
private void processLoop() {
while (true) {
Command cmd = mailbox.poll();
if (cmd != null) {
apply(cmd); // никаких локов — мы единственный писатель
}
}
}
}
|
Плюсы |
Минусы |
|---|---|
|
Гарантированный порядок |
Ожидание в очереди |
|
Никто не делает лишнюю работу |
Точка сериализации |
|
Простая отладка |
Может стать узким местом |
Суть: Никакого контроля. Кто последний записал — тот и прав.
// Два потока, без синхронизации
obj.value = 1; // поток A
obj.value = 2; // поток B
// Результат: 2 (или 1 — как повезёт)
Логи (потеря нескольких записей не критична)
Метрики и счётчики (важен порядок величин, не точность)
Кэши с периодическим обновлением
Last-write-wins в распределённых системах
|
Плюсы |
Минусы |
|---|---|
|
Максимальная скорость |
Потеря данных |
|
Никаких накладных расходов |
Нет гарантий порядка |
|
Простота |
Подходит не для всего |
|
Технология |
Как реализует |
|---|---|
|
|
|
|
Immutable + swap |
Новый объект + CAS ссылки |
|
|
Штамп версии + валидация |
|
|
Read-set + commit-time проверка |
|
|
Версии строк + конфликт при коммите |
|
Optimistic Locking в ORM |
Поле |
|
Lock-free структуры |
CAS для доступа к ячейкам |
|
Технология |
Подвариант |
|---|---|
|
|
2.1 (OS Lock) |
|
|
2.1 (OS Lock) |
|
DB |
2.1 (OS Lock) |
|
Erlang/BEAM процессы |
2.2 (Queue) |
|
Akka/Orleans Actors |
2.2 (Queue) |
|
Go channels (single receiver) |
2.2 (Queue) |
|
Node.js Event Loop |
2.2 (Queue) |
|
Redis |
2.2 (Queue) |
|
LMAX Disruptor |
2.2 (Queue) |
|
Технология |
Применение |
|---|---|
|
Запись без синхронизации |
Обычно баг |
|
High-throughput логгеры |
Допускают потерю |
|
Approximate счётчики |
StatsD, метрики |
|
Cassandra LWW |
Last Write Wins policy |
|
DNS / кэши |
Последний ответ актуален |
CRDT (Conflict-free Replicated Data Types) снимают проблему конфликта математически: операции коммутативны, порядок не важен. CRDT = данные + способы их объединения в одном флаконе, как Git Merge но с важным отличием: мерж встроен в тип данных, порядок применения не важен.
Но физически CRDT реализуются через те же стратегии:
CAS для локальных обновлений (стратегия 1)
Single Writer для репликации (стратегия 2)
Термин «lock-free» часто путает. Кажется, это что-то особенное.
На самом деле lock-free — это реализация существующих стратегий без обращения к ОС: AtomicReference + immutable, Bit packing* + AtomicLong, Lock-free Queue (стратегия 1 для очереди, 2.2 для обработчика).
*64 бита можно свапнуть за один такт командой процессора. Однако, очень часто требуется атомарно условно заменить две связанные переменные. Например: указатель на буфер и его длину, указатель на начало и конец данных и т. д. Для этих целей в процессорах Intel введены команды CMPXCHG (32-bit), CMPXCHG8B (64-bit) и CMPXCHG16B (x86 64).
Lock-free очередь — это «клей»:
Producers ==> [Lock-free Queue] ==> Single Writer
(CAS) (no locks)
Producers используют стратегию 1 для вставки в очередь
Consumer использует стратегию 2.2 для обработки
Новых пунктов в классификации не требуется.
Каждая стратегия несёт свои риски. И есть неочевидная связь: чем сложнее читать код — тем выше вероятность ошибки.
|
Риск |
Описание |
Как проявляется |
|---|---|---|
|
Livelock |
Потоки бесконечно мешают друг другу |
Все делают retry, никто не продвигается |
|
Starvation |
Один поток постоянно проигрывает |
«Невезучий» поток никогда не запишет |
|
ABA-проблема |
Значение вернулось к исходному |
CAS успешен, но данные испорчены |
Читаемость: Средняя. CAS-цикл компактен, но retry-логика с условиями усложняет понимание всех путей исполнения.
// Легко пропустить edge case
while (true) {
State current = ref.get();
State next = compute(current); // Что если тут исключение?
if (ref.compareAndSet(current, next)) return;
// А если retry 1000 раз?
}
|
Риск |
Описание |
Как проявляется |
|---|---|---|
|
Deadlock |
Взаимная блокировка |
A ждёт B, B ждёт A — оба висят вечно |
|
Priority Inversion |
Низкоприоритетный держит лок |
Высокоприоритетный поток ждёт |
|
Забытый unlock |
Исключение до освобождения |
Лок никогда не отпустят |
|
Вложенные локи |
Неочевидный порядок захвата |
Deadlock в неожиданном месте |
Читаемость: Низкая при сложной логике. Блоки synchronized разбросаны по коду, порядок захвата локов неочевиден.
// Классический deadlock — попробуйте найти с первого взгляда
class Transfer {
void transfer(Account a, Account b, int amount) {
synchronized (a) {
synchronized (b) {
a.withdraw(amount);
b.deposit(amount);
}
}
}
}
// Поток 1: transfer(X, Y) — захватил X, ждёт Y
// Поток 2: transfer(Y, X) — захватил Y, ждёт X
// Deadlock!
Коварство: Чем больше кода между lock и unlock, тем сложнее отследить все пути. Исключение в середине — и лок завис.
|
Риск |
Описание |
Как проявляется |
|---|---|---|
|
Deadlock акторов |
A ждёт ответа от B, B от A |
Оба актора висят на receive |
|
Переполнение очереди |
Producers быстрее consumer'а |
OutOfMemory или потеря сообщений |
|
Сложность отладки |
Асинхронность |
Stack trace бесполезен |
Читаемость: Высокая для изолированного актора. Логика сосредоточена в одном месте, нет разбросанных локов. Но взаимодействие между акторами может быть запутанным.
%% Логика актора читается линейно
loop(State) ->
receive
{Command, From} ->
NewState = handle(Command, State),
From ! {ok, NewState},
loop(NewState)
end.
|
Риск |
Описание |
Как проявляется |
|---|---|---|
|
Lost Update |
Изменения затёрты |
Данные пользователя исчезли |
|
Torn Read/Write |
Частичное чтение |
Прочитали половину старого, половину нового |
|
Race Condition |
Непредсказуемый результат |
«Иногда работает» |
Читаемость: Высокая — кода синхронизации просто нет. Но это обманчивая простота: ошибка проявится в продакшене под нагрузкой.
Правило: Чем сложнее понять код синхронизации — тем выше вероятность ошибки.
Это не просто эстетика. Это математика:
|
Сложность кода |
Вероятность ошибки |
Время обнаружения |
|---|---|---|
|
Очевидный лок в одном месте |
Низкая |
Code review |
|
Локи разбросаны по классам |
Средняя |
Интеграционные тесты |
|
Вложенные локи с условиями |
Высокая |
Продакшен под нагрузкой |
|
Lock-free с ручным CAS |
Очень высокая |
Через полгода, в 3 часа ночи |
Не потому что магия. А потому что:
Локальность логики. Всё состояние и вся логика — в одном файле, в одном receive-блоке.
Нет разбросанных локов. Не нужно помнить порядок захвата.
Явный протокол. Сообщения — это контракт, его видно.
// Плохо. Локи: разбросаны по файлам, порядок в голове не удержать
// файл OrderService.java
synchronized (lockA) {
synchronized (lockB) { ... } // строка 47
}
// совсем другой файл - сначала найди его! PaymentService.java
synchronized (lockB) {
synchronized (lockA) { ... } // строка 89 — DEADLOCK
}
// Хорошо. Актор: вся логика в одном месте
// OrderActor.java
void onMessage(Message msg) {
state = handle(msg, state); // один поток, нет локов
}
Для стратегии 1 (CAS):
Используйте готовые абстракции (AtomicReference.updateAndGet)
Ограничивайте число retry
Логируйте частоту конфликтов — если > 30%, меняйте стратегию
Для стратегии 2.1 (Locks):
Всегда используйте try-finally или try-with-resources
Один лок на агрегат, не на поле
Документируйте порядок захвата
Используйте tryLock с таймаутом
// Плохо
synchronized (a) {
synchronized (b) { ... }
}
// Лучше — фиксированный порядок
void transfer(Account a, Account b) {
Account first = a.id < b.id ? a : b;
Account second = a.id < b.id ? b : a;
synchronized (first) {
synchronized (second) { ... }
}
}
Для стратегии 2.2 (Actors):
Избегайте синхронных вызовов между акторами (ask ==> deadlock)
Настройте backpressure для очередей
Используйте supervision trees (Erlang/Akka)
Для стратегии 3 (Last Win):
Явно документируйте, что потеря допустима
Используйте volatile для visibility
Не применяйте для бизнес-данных
Привет упорному евангелисту Erlang/BEAM!
Erlang возвёл стратегию 2.2 в абсолют:
Всё — процессы с mailbox
Никакого shared state
Коммуникация только через сообщения
Это прекрасно работает для:
Телекома (изолированные сессии)
Чатов (пользователь = актор)
IoT (устройство = процесс)
Но это не серебряная пуля:
Java интересна тем, что предлагает всё:
// Стратегия 1: Optimistic
AtomicReference<State> ref = new AtomicReference<>(initial);
ref.updateAndGet(state -> state.apply(cmd));
// Стратегия 2.1: OS Lock
synchronized (lock) {
state = state.apply(cmd);
}
// Стратегия 2.2: Single Writer Queue
executor.submit(() -> state = state.apply(cmd));
// Стратегия 3: Last Win
volatile State state;
state = state.apply(cmd);
Даже synchronized внутри адаптивен: Biased Lock ==> Thin Lock ==> Fat Lock.
Важное наблюдение: классическое ООП не имеет модели конкурентности.
order.setTotal(100); // поток A
order.setTotal(200); // поток B
Без синхронизации это стратегия 3 — Last Win.
ООП родилось в эпоху однопоточности (Simula 1967, Smalltalk 1972). Многопоточность потребовала внешних инструментов:
FIRST WIN Проигравший переделывает
(Optimistic) ==> CAS, STM, MVCC, версии
Риск: livelock, starvation
SINGLE WRITER Один пишет, остальные ждут
(Pessimistic) ==> 2.1 Lock (поток спит)
Риск: deadlock, priority inversion
==> 2.2 Queue (команда ждёт)
Риск: переполнение, actor deadlock
LAST WIN Последний затирает
(No control) ==> Потеря данных ОК
Риск: lost update, race condition
|
Ситуация |
Стратегия |
Почему |
|---|---|---|
|
Конфликты редки |
1 (First Win) |
Retry почти не случается |
|
Конфликты часты |
2 (Single Writer) |
Не тратим CPU на retry |
|
Важен строгий порядок |
2 (Single Writer) |
FIFO гарантирован |
|
Нужна скорость записи |
1 или 3 |
Нет ожидания |
|
Потеря допустима |
3 (Last Win) |
Зачем платить за sync? |
|
Важна читаемость |
2.2 (Queue/Actor) |
Логика локализована |
Фундаментальных стратегий — три:
First Win — оптимистично пробуем, при конфликте retry
Single Writer — сериализуем доступ (lock или queue)
Last Win — не контролируем, последний затирает
Всё остальное — реализации:
Mutex, Monitor, synchronized ==> 2.1
Actor, Channel, Event Loop ==> 2.2
CAS, AtomicReference, STM, lock-free ==> 1
Без синхронизации ==> 3
У каждой стратегии свои риски:
First Win ==> livelock, starvation
OS Lock ==> deadlock, сложный код
Queue/Actor ==> переполнение, actor deadlock
Last Win ==> потеря данных
Читаемость влияет на надёжность. Сложный код синхронизации = ошибки в продакшене.
Классификация полна — других веток в дереве решений нет.
Запомнить легко. Проектировать просто.
Не нужно изучать каждый фреймворк как отдельную вселенную. Достаточно понять, какую из трёх стратегий он реализует — и вы уже знаете его trade-offs.
The Art of Multiprocessor Programming [4] — Herlihy, Shavit
Java Concurrency in Practice [5] — Brian Goetz
The LMAX Architecture [6] — Martin Fowler
Erlang Processes [7]
Clojure STM [8]
CRDT Paper [9] — Shapiro et al.
Всё, что описано выше — про in-memory конкурентность в одном процессе.
Когда данные уходят в сеть (БД, другой сервис), классификация остаётся той же:
First Win ==> Optimistic Locking (WHERE version = ?)
Single Writer ==> SELECT FOR UPDATE, distributed locks
Last Win ==> Прямая запись, LWW в eventual consistent системах
Но появляются новые проблемы:
Частичные отказы (запрос ушёл, ответа нет)
Необходимость идемпотентности
Распределённые транзакции (Saga, 2PC)
Консенсус между узлами (Paxos, Raft)
Это тема для отдельного разговора. Хорошие точки входа:
Designing Data-Intensive Applications [10], Martin Kleppmann - знаменитая книга с кабанчиком
Jepsen [11] тесты распределённых систем на прочность
CAP Theorem [12]
P.S.
Если железа много на все пересчёты, то оптимистическая блокировка (first win) обычно быстрее.
А ещё можно шахматной доской по голове и разбить единый стейт на шарды, уходя в eventual consistentcy
Автор: Dhwtj
Источник [13]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/438495
Ссылки в тексте:
[1] First Win + Retry: https://www.plantuml.com/plantuml/png/VLJ1Rjf04BtlLqnz0eb43TmYkGhk9t3bOe7JMY86sQRd4Ygr4b4vjATARN_1eNNZG32_CFj7dQTTKSUDSq7ipCwRDs_U-Y99_JYTNqrK6gQJWFWhRpWZFd2fhxdaBMSTuf9_Sy5x6gH-6iXvAv6z_i0xYMxrDN51gE0DQlHxtdEcroH_Chu546TA-QDq6fF3FzaYKzyXFw7-4naF6VBKJBY5et3cHwdalqY_KW0ptkaRwR_Y-s44IW6kqq0froN-WHkaFtA1llepw5d-u8aE2FtXdFG2JFcWFp5YhglwTFAA1dHESU2FMssS4ZHugRocJBMhjCbvPxiw_-RNImS2POX8ViUv6O7BOzE-G8-cLxDv6XZWis7qpf-4OxyIQ5Kb9P2t9Dg6drS7wTP1ki-2O9JgWaWlzKA8vp05vz3j3e9n8U5CvheXP0Lm8zjuUAJWw_wWPN0xbdzR3UZ4ZfhCHwCWIUp-oBLNwWmymTSh44LtlT3h_nc08su82-4fkopudd3WExs4paYQzNgDvBg6d97mqWydypWOHdMZdvEX-y8zBZ-QoZaEtxnDQNefbYNYRxKYn7uzzYwNndubgCllMa85GaiOquhHGM5MUKQrXmbrRlKAXbC4DEi-mR9xFGfJyeDbV0hGx-W35kJO_UMcs4ZgW38VK6VUaGbRMiG5qbW3vjXmQLWjgdTFLH2D2UBObTQ_3i-EvRgDx-simUczkjDxZKw74-jEWDosxkY4HflsbBgGyUIh-HS0
[2] Single Writer: https://www.architecture-weekly.com/p/architecture-weekly-190-queuing-backpressure
[3] подождёт: https://m.vk.com/video293426700_456240709
[4] The Art of Multiprocessor Programming: https://www.amazon.com/Art-Multiprocessor-Programming-Revised-Reprint/dp/0123973376
[5] Java Concurrency in Practice: https://jcip.net/
[6] The LMAX Architecture: https://martinfowler.com/articles/lmax.html
[7] Erlang Processes: https://www.erlang.org/doc/reference_manual/processes.html
[8] Clojure STM: https://clojure.org/reference/refs
[9] CRDT Paper: https://hal.science/hal-00932836/document
[10] Designing Data-Intensive Applications: https://dataintensive.net/
[11] Jepsen: https://jepsen.io/
[12] CAP Theorem: https://en.wikipedia.org/wiki/CAP_theorem
[13] Источник: https://habr.com/ru/articles/974198/?utm_source=habrahabr&utm_medium=rss&utm_campaign=974198
Нажмите здесь для печати.