Mutex, CAS, акторы, STM, CRDT, иммутабельность, MVCC, Disruptor…
Когда читаешь про многопоточность, кажется, что способов — десятки, и каждый требует отдельного изучения.
На самом деле их ровно три. Всё остальное — реализации и комбинации.
Эта статья — попытка навести порядок в голове. После неё вы сможете:
за 5 секунд классифицировать любой подход к конкурентности;
понимать, почему Erlang выбрал акторы, а Java предлагает
synchronized;не изобретать велосипеды и не зацикливаться на «единственно правильном» решении;
проектировать многопоточный код, держа в голове простую модель.
Если статья покажется вам сложной идите сразу в комментарии. Там самое интересное. Как всегда.
Проблема: слишком много терминов
Откройте любую статью про конкурентность. Вас накроет волной:
Mutex,Semaphore,Monitor,synchronizedAtomic,CAS,Lock-free,Wait-freeActor Model,CSP,ChannelsSTM,MVCC,Snapshot IsolationImmutable,Persistent Data StructuresCRDT,Eventual ConsistencyDisruptor,Ring Buffer,Single Writer
Каждый термин тянет за собой статьи, книги, фреймворки. Создаётся впечатление, что конкурентность — это бездонная кроличья нора.
Но это иллюзия.
Если задать правильный вопрос, всё становится простым.
Правильный вопрос
Вот он:
Что происходит, когда два потока одновременно хотят изменить один объект?
Не «как устроен mutex». Не «чем CAS лучше lock». А именно: что случится при конфликте?
Ответов ровно три:
Один победит, другой переделает работу (First Win + Retry) - конфликт обнаруживается и разрешается
Один подождёт, пока другой закончит (Single Writer) - конфликта нет
Последний затрёт первого (Last Win) - конфликт игнорируется, результат часто случайный.
Всё. Это полный список. Других вариантов не существует.
*Что такое "работа", "конфликт" и почему "переделать"?
Потому что бизнес логика / валидатор дан на стейт. А стейт изменён в промежутке между:
1 чтением,
2 вычислением нового состояния,
3 валидацией,
4 записью нового состояния.
Изменен вот почему:
State_new = f(State_old, command)
Если один поток поменял состояние, то работа второго теперь зависит от нового состояния, надо всё переделать.
Оптимист начнет работу, надеясь что его не опередят.
Пессимист подождёт
Почему их ровно три?
Логика элементарная. Два вопроса:
Вопрос 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 (затирание)
Других веток нет. Математически.
Стратегия 1: First Win + Retry
Суть: Потоки работают параллельно. При попытке записать — проверяем, не изменилось ли состояние. Если изменилось — проигравший переделывает.
Схема:
Поток A: read v1 ==> compute ==> try write (v1==>v2) ✓ победил
Поток B: read v1 ==> compute ==> try write (v1==>v3) ✗ конфликт ==> retry
Пример: AtomicReference
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: Single Writer
Суть: В каждый момент времени пишет только один. Остальные ждут.
Два подварианта
2.1: OS Lock | 2.2: Очередь | |
|---|---|---|
Механизм | Mutex / synchronized | Mailbox / Queue |
Кто ждёт | Потоки (спят в ОС) | Команды (лежат в очереди) |
Накладные расходы | Context switch | Память под очередь |
Пример | Java | Actor, Event Loop |
Блокировка потока может быть реализована по-разному: mutex усыпляет поток (context switch), spinlock крутится в цикле ожидания, futex комбинирует оба подхода. Семантика одна - сериализация (строгая последовательность) доступа
Пример 2.1: synchronized
class Account {
private long balance;
public synchronized void withdraw(long amount) {
if (balance < amount) throw new IllegalStateException();
balance -= amount;
}
}
Пример 2.2: Actor / Queue
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); // никаких локов — мы единственный писатель
}
}
}
}
Характеристики
Плюсы | Минусы |
|---|---|
Гарантированный порядок | Ожидание в очереди |
Никто не делает лишнюю работу | Точка сериализации |
Простая отладка | Может стать узким местом |
Стратегия 3: Last Win
Суть: Никакого контроля. Кто последний записал — тот и прав.
// Два потока, без синхронизации
obj.value = 1; // поток A
obj.value = 2; // поток B
// Результат: 2 (или 1 — как повезёт)
Когда это нормально
Логи (потеря нескольких записей не критична)
Метрики и счётчики (важен порядок величин, не точность)
Кэши с периодическим обновлением
Last-write-wins в распределённых системах
Характеристики
Плюсы | Минусы |
|---|---|
Максимальная скорость | Потеря данных |
Никаких накладных расходов | Нет гарантий порядка |
Простота | Подходит не для всего |
Большая таблица: куда что относится
Стратегия 1: First Win + Retry
Технология | Как реализует |
|---|---|
|
|
Immutable + swap | Новый объект + CAS ссылки |
| Штамп версии + валидация |
| Read-set + commit-time проверка |
| Версии строк + конфликт при коммите |
Optimistic Locking в ORM | Поле |
Lock-free структуры | CAS для доступа к ячейкам |
Стратегия 2: Single Writer
Технология | Подвариант |
|---|---|
| 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) |
Стратегия 3: Last Win
Технология | Применение |
|---|---|
Запись без синхронизации | Обычно баг |
High-throughput логгеры | Допускают потерю |
Approximate счётчики | StatsD, метрики |
Cassandra LWW | Last Write Wins policy |
DNS / кэши | Последний ответ актуален |
CRDT — особый случай
CRDT (Conflict-free Replicated Data Types) снимают проблему конфликта математически: операции коммутативны, порядок не важен. CRDT = данные + способы их объединения в одном флаконе, как Git Merge но с важным отличием: мерж встроен в тип данных, порядок применения не важен.
Но физически CRDT реализуются через те же стратегии:
CAS для локальных обновлений (стратегия 1)
Single Writer для репликации (стратегия 2)
Lock-free: не новая стратегия
Термин «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 для обработки
Новых пунктов в классификации не требуется.
Риски и читаемость кода
Каждая стратегия несёт свои риски. И есть неочевидная связь: чем сложнее читать код — тем выше вероятность ошибки.
Риски стратегии 1 (First Win / CAS)
Риск | Описание | Как проявляется |
|---|---|---|
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 раз?
}
Риски стратегии 2.1 (OS Lock)
Риск | Описание | Как проявляется |
|---|---|---|
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, тем сложнее отследить все пути. Исключение в середине — и лок завис.
Риски стратегии 2.2 (Queue / Actor)
Риск | Описание | Как проявляется |
|---|---|---|
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.
Риски стратегии 3 (Last Win)
Риск | Описание | Как проявляется |
|---|---|---|
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/BEAM!
Erlang возвёл стратегию 2.2 в абсолют:
Всё — процессы с mailbox
Никакого shared state
Коммуникация только через сообщения
Это прекрасно работает для:
Телекома (изолированные сессии)
Чатов (пользователь = актор)
IoT (устройство = процесс)
Но это не серебряная пуля:
Java: все стратегии в одной коробке
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.
ООП: по умолчанию Last Win
Важное наблюдение: классическое ООП не имеет модели конкурентности.
order.setTotal(100); // поток A
order.setTotal(200); // поток B
Без синхронизации это стратегия 3 — Last Win.
ООП родилось в эпоху однопоточности (Simula 1967, Smalltalk 1972). Многопоточность потребовала внешних инструментов:
Шпаргалка для проектирования
FIRST WIN Проигравший переделывает
(Optimistic) ==> CAS, STM, MVCC, версии
Риск: livelock, starvationSINGLE WRITER Один пишет, остальные ждут
(Pessimistic) ==> 2.1 Lock (поток спит)
Риск: deadlock, priority inversion
==> 2.2 Queue (команда ждёт)
Риск: переполнение, actor deadlockLAST 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 — не контролируем, последний затирает
У каждой стратегии свои риски:
First Win ==> livelock, starvation
OS Lock ==> deadlock, сложный код
Queue/Actor ==> переполнение, actor deadlock
Last Win ==> потеря данных
Читаемость влияет на надёжность. Сложный код синхронизации = ошибки в продакшене.
Не нужно изучать каждый фреймворк. Достаточно понять, какую из трёх стратегий он реализует — и вы уже знаете его trade-offs.
Ссылки
The Art of Multiprocessor Programming — Herlihy, Shavit
Java Concurrency in Practice — Brian Goetz
The LMAX Architecture — Martin Fowler
CRDT Paper — Shapiro et al.
За пределами одного процесса. Что появляется в распределенных системах.
Всё, что описано выше — про in-memory конкурентность в одном процессе.
Когда данные уходят в сеть (БД, другой сервис), классификация остаётся той же:
First Win ==> Optimistic Locking (
WHERE version = ?)Single Writer ==>
SELECT FOR UPDATE, distributed locksLast Win ==> Прямая запись, LWW в eventual consistent системах
Но появляются новые проблемы:
Частичные отказы (запрос ушёл, ответа нет)
Необходимость идемпотентности
Распределённые транзакции (Saga, 2PC)
Консенсус между узлами (Paxos, Raft)
Это тема для отдельного разговора. Хорошие точки входа:
Designing Data-Intensive Applications, Martin Kleppmann - знаменитая книга с кабанчиком
Jepsen тесты распределённых систем на прочность
P.S.
Если железа много на все пересчёты, то оптимистическая блокировка (first win) обычно быстрее.
А ещё можно шахматной доской по голове и разбить единый стейт на шарды, уходя в eventual consistentcy
