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)
Всё. Это полный список. Других вариантов не существует.
*Что такое "работа"?
read snapshot ==> validate ==> compute new state ==> try write
При retry повторяется всё:
Read: Снапшот устарел
Validate: На новых данных может не пройти
Compute: Результат зависит от текущего состояния
Write: Новая попытка CAS
Почему их ровно три?
Логика элементарная. Два вопроса:
Вопрос 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 |
Пример 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 но с важным отличием:
Git Merge |
CRDT |
|
|---|---|---|
Данные |
Файлы, строки |
Специальные структуры |
Merge |
3-way алгоритм |
Встроен в тип данных |
Конфликты |
Возможны (ручное разрешение) |
Невозможны (by design) |
Но физически CRDT реализуются через те же стратегии:
CAS для локальных обновлений (стратегия 1)
Single Writer для репликации (стратегия 2)
Lock-free: не новая стратегия
Термин «lock-free» часто путает. Кажется, это что-то особенное.
На самом деле lock-free — это реализация существующих стратегий без обращения к ОС:
Lock-free техника |
Что делает |
Стратегия |
|---|---|---|
|
Атомарная смена ссылки |
1 (First Win) |
Bit packing + |
Несколько полей в 64 битах* |
1 (First Win) |
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Не применяйте для бизнес-данных
Сравнение рисков: сводная таблица
Стратегия |
Deadlock |
Livelock |
Lost Update |
Читаемость |
Отладка |
|---|---|---|---|---|---|
1. First Win |
Нет |
Да |
Нет |
Средняя |
Средняя |
2.1 OS Lock |
Да |
Нет |
Нет |
Низкая |
Сложная |
2.2 Queue |
Возможен* |
Нет |
Нет |
Высокая |
Средняя |
3. Last Win |
Нет |
Нет |
Да |
Высокая |
Простая |
* Deadlock акторов — при синхронном ожидании ответа друг от друга.
Erlang/BEAM: отличный выбор, но не единственный
Привет упорному евангелисту Erlang/BEAM!
Erlang возвёл стратегию 2.2 в абсолют:
Всё — процессы с mailbox
Никакого shared state
Коммуникация только через сообщения
Это прекрасно работает для:
Телекома (изолированные сессии)
Чатов (пользователь = актор)
IoT (устройство = процесс)
Но это не серебряная пуля:
Ситуация |
Проблема с акторами |
Лучше |
|---|---|---|
Редкие конфликты |
Очередь — лишний overhead |
CAS |
Нужен sync-ответ |
Блокируемся на receive |
Lock |
Read-heavy |
Каждый read — сообщение |
AtomicReference |
Потеря допустима |
Зачем очередь? |
Last Win |
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). Многопоточность потребовала внешних инструментов:
Инструмент |
Откуда |
|---|---|
Mutex / synchronized |
Примитивы ОС |
Atomic / CAS |
Инструкции CPU |
Actors / Channels |
Отдельная модель (Erlang, CSP) |
Immutability |
Функциональное программирование |
Шпаргалка для проектирования
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) |
Логика локализована |
Антипаттерны
«У нас всё на акторах»
Даже для счётчика создаём актора с mailbox.
Решение: AtomicLong.incrementAndGet() — одна инструкция CPU.
«CAS — это всегда быстрее»
При высокой конкуренции CAS-loop превращается в бесконечный retry.
Решение: Мониторьте retry rate. Если > 30% — переходите на Single Writer.
«Mutex — это медленно и стыдно»
Преждевременная оптимизация усложняет код и повышает риск ошибок.
Решение: synchronized в современных JVM оптимизирован. Начните с него, профилируйте, потом решайте.
«Напишем свою lock-free структуру»
Lock-free код сложен в написании, отладке и доказательстве корректности. Ошибки проявляются под нагрузкой через месяцы.
Решение: Используйте проверенные библиотеки: java.util.concurrent, JCTools, Disruptor.
«Локи разбросаем по методам — так гибче»
Чем больше мест с синхронизацией, тем выше риск deadlock и тем сложнее ревью.
Решение: Один лок на агрегат. Одна точка входа для изменений.
Итого
Фундаментальных стратегий — три:
First Win — оптимистично пробуем, при конфликте retry
Single Writer — сериализуем доступ (lock или queue)
Last Win — не контролируем, последний затирает
Всё остальное — реализации:
Mutex, Monitor,
synchronized==> 2.1Actor, 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 — 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 тесты распределённых систем на прочность
Комментарии (16)

Dhwtj Автор
07.12.2025 17:35P.S. интересный вариант реализации на атомиках. Используется паттерн: фасады над общим State
Извините, сбился на Rust. Не хочу на Java, не понимаю до конца что там происходит.
// Order и Wallet как "view" на общий State struct OrderView<'a> { checkout: &'a Checkout, } impl<'a> OrderView<'a> { fn status(&self) -> OrderStatus { self.checkout.state.load().order.status.clone() } fn cancel(&self) -> Result<(), &'static str> { loop { let current = self.checkout.state.load(); if !matches!(current.order.status, OrderStatus::Pending) { return Err("Cannot cancel"); } let next = CheckoutState { order: Order { status: OrderStatus::Cancelled, ..current.order.clone() }, wallet: current.wallet.clone(), // без изменений }; if Arc::ptr_eq( &self.checkout.state.compare_and_swap(¤t, Arc::new(next)), ¤t, ) { return Ok(()); } } } } struct WalletView<'a> { checkout: &'a Checkout, } impl<'a> WalletView<'a> { fn balance(&self) -> i64 { self.checkout.state.load().wallet.balance } fn top_up(&self, amount: i64) -> Result<(), &'static str> { loop { let current = self.checkout.state.load(); let next = CheckoutState { order: current.order.clone(), // без изменений wallet: Wallet { balance: current.wallet.balance + amount, ..current.wallet.clone() }, }; if Arc::ptr_eq( &self.checkout.state.compare_and_swap(¤t, Arc::new(next)), ¤t, ) { return Ok(()); } } } } // Использование impl Checkout { fn order_view(&self) -> OrderView { OrderView { checkout: self } } fn wallet_view(&self) -> WalletView { WalletView { checkout: self } } }Интересен тем, что соединяет строгую консистентность и нормальную модульность кода, не скатываясь ни в «гигантский God‑object», ни в «зоопарк из объектов с рассинхронизацией».
Две независимые бизнес логики поведения объектов и единая модель консистентности
Есть одна ячейка (AtomicReference / ArcSwap), где лежит весь State.
Фасады (OrderView, WalletView и т.п.) не держат у себя состояние, они каждый раз:
-читают State из атомика;
-строят на его основе новый State;
-пытаются записать его обратно через CAS;
-при конфликте — повторяют

rsashka
07.12.2025 17:35Извините, сбился на Rust. Не хочу на Java, не понимаю до конца что там происходит.
Ничего удивительно, так как Rust на это в принципе не способен без unsafe (изменять один объект из нескольких потоков)

Dhwtj Автор
07.12.2025 17:35Согласен.
Не подумал, что Rust умеет лучше. Вот полный код с мьютексом, Compute выполняется ровно один раз, не выбрасывается при конфликте.
use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; // ================== Ошибки ================== #[derive(Debug)] enum ShopError { InsufficientFunds { available: i32, required: i32 }, OrderNotPending, LockPoisoned, } impl<T> From<PoisonError<T>> for ShopError { fn from(_: PoisonError<T>) -> Self { ShopError::LockPoisoned } } // ================== Доменные объекты ================== #[derive(Debug, Clone, PartialEq)] enum OrderStatus { Pending, Paid, Cancelled, } #[derive(Debug)] struct Order { id: u32, status: OrderStatus, total: i32, } #[derive(Debug)] struct Wallet { balance: i32, } impl Order { fn mark_paid(&mut self) -> Result<(), ShopError> { if self.status != OrderStatus::Pending { return Err(ShopError::OrderNotPending); } self.status = OrderStatus::Paid; Ok(()) } } impl Wallet { fn withdraw(&mut self, amount: i32) -> Result<(), ShopError> { if self.balance < amount { return Err(ShopError::InsufficientFunds { available: self.balance, required: amount, }); } self.balance -= amount; Ok(()) } } // ================== Агрегат ================== struct CheckoutState { order: Order, wallet: Wallet, } struct Shop { state: Mutex<CheckoutState>, } impl Shop { fn new(order: Order, wallet: Wallet) -> Self { Shop { state: Mutex::new(CheckoutState { order, wallet }), } } fn pay(&self) -> Result<(), ShopError> { let mut state = self.state.lock()?; let amount = state.order.total; // Сначала списываем деньги state.wallet.withdraw(amount)?; // Потом помечаем заказ оплаченным // Если тут ошибка — деньги уже списаны, но мы под локом, // поэтому можно откатить или паниковать if let Err(e) = state.order.mark_paid() { // Откат state.wallet.balance += amount; return Err(e); } Ok(()) } fn get_balance(&self) -> Result<i32, ShopError> { let state = self.state.lock()?; Ok(state.wallet.balance) } fn get_order_status(&self) -> Result<OrderStatus, ShopError> { let state = self.state.lock()?; Ok(state.order.status.clone()) } } // ================== Использование ================== fn main() { let shop = Arc::new(Shop::new( Order { id: 1, status: OrderStatus::Pending, total: 100, }, Wallet { balance: 500 }, )); match shop.pay() { Ok(()) => println!("Оплата прошла"), Err(ShopError::InsufficientFunds { available, required }) => { println!("Недостаточно средств: {} из {}", available, required); } Err(ShopError::OrderNotPending) => { println!("Заказ уже оплачен или отменён"); } Err(ShopError::LockPoisoned) => { println!("Критическая ошибка: mutex poisoned"); } } // Проверяем состояние if let Ok(balance) = shop.get_balance() { println!("Баланс: {}", balance); } if let Ok(status) = shop.get_order_status() { println!("Статус заказа: {:?}", status); } }

uvelichitel
07.12.2025 17:35Параллельная запись разрешена?├── Нет ==> [2] Single Writer (ждём очереди)└── Да ==> Проверяем конфликт? ├── Да ==> [1] First Win (retry/abort) └── Нет ==> [3] Last Win (затирание)Мне не понравилась таксономия.
ждём очереди, а почему например не стоим в стеке?Проверяем конфликт, а зачем проверять если разрешено параллельно? А в какой момент уже можно читать? А CommunicatingSequentialProcesses Хоаре куда отнести?
Классификация мне кажется противоречивой и не исчерпывающей...
Dhwtj Автор
07.12.2025 17:35Здесь слово «очередь» используется не как структура данных (FIFO), а как механизм сериализации.
Суть Стратегии 2 — Single Writer. Чтобы обеспечить доступ только одного писателя, всех остальных нужно куда-то деть
Стратегия 1 (Optimistic) разрешает параллельное вычисление нового состояния (compute). Но она не может разрешить параллельную запись (commit), иначе мы получим гонку (Strategy 3).
Проверка (compareAndSet) нужна именно для того, чтобы убедиться: пока мы вычисляли S', база S не изменилась.
Дальше
CSP Хоара (как и указанные в статье Go channels) относится к Стратегии 2.2 (Очередь/Single Writer).
В CSP процессы не имеют разделяемой памяти. Они обмениваются сообщениями через каналы.
Если несколько процессов пишут в один канал, а читает из него один процесс-владелец данных — это классическая сериализация доступа через очередь (канал). Владелец данных меняет состояние последовательно, обрабатывая сообщения по одному
Про чтение:
Классификация описывает стратегии разрешения конфликтов при записи (Write Conflict Resolution), так как именно запись нарушает инварианты.
Чтение зависит от реализации:
В Стратегии 1 (CAS) чтение обычно volatile или atomic load (lock-free).
В Стратегии 2 (Mutex) чтение часто тоже под локом (чтобы не прочитать "грязные" данные).
В Стратегии 2.2 (Actor) чтение — это отправка сообщения get и ожидание ответа.

Alex-ZiX
07.12.2025 17:35По мне стратегии 1 и 3 очень странные. Два потока никак не могут менять одни и те же данные одновременно, иначе в памяти будет хаос. Даже если вы провели все расчеты параллельно, то перед записью нужно будет кому-то ждать, пока кто-то другой не завершит запись целиком. При таком раскладе в чём вообще суть First Win? Если нужна такая стратегия, то просто не нужно создавать второй поток и грузить систему, если первый уже взялся делать работу, разве нет? А Last Win ничем абсолютно не будет отличаться от Single write. Сначала один записал данные, потом второй. Кто последний - того данные и остались.

Dhwtj Автор
07.12.2025 17:35За счёт того, что «момент записи» сводится к одной атомарной операции над одной ячейкой памяти.
Вариантов по сути два:
1) Аппаратно‑атомарная инструкция CPU: CAS / атомарный RMW (compare‑and‑swap, fetch‑add и т.п.) над одним машинным словом. На уровне языка это std::atomic<T>, AtomicReference, AtomicLong и т.п. Процессор и протокол кеш‑когерентности гарантируют, что в каждый момент времени только одно ядро «владеет» этой строкой кеша и выполняет операцию целиком.
2) Лок (мьютекс/монитор). Тогда «один момент записи» обеспечивается тем, что в критическую секцию с обычной записью пускают только один поток.
В First Win речь именно о первом варианте: CAS делает read‑modify‑write над одной ячейкой за один неделимый шаг, всё остальное (валидация, вычисления) происходит вне этого шага и может выполняться параллельно.

kmatveev
07.12.2025 17:35Автор вынес за скобки обновление структурированных данных, он свёл задачу к обновлению чего-то одного. В примере с OrderService он выкрутился тем, что OrderState - иммутабельный , метод apply() создаёт изменённую копию, и нужно только переставить атомарную глобальную ссылку на созданную копию.
Насчёт того, зачем нужен First Win. Отличие варианта 1 от варианта 2 в том, что вариант 1 - оптимистический: сделал работу, веря, что результат удастся сохранить, потом пытаемся сохранить, а вариант 2 - пессимистический, не верим, что никто не обновит за то время, пока работаем, поэтому блокируемся. В некоторых случаях оптимистичный вариант лучше.
В качестве одного из способов, как сделать single writer (я бы назвал exclusive writer), предложена блокировка/mutex. Кстати, самый распространённый способ, как блокировку можно реализовать - это использовать First Win для ячейки памяти, пометив её идентификатором потока-владельца. И хотя это внутренние детали реализации примитива синхронизации, получается так что способ 2 достигается применением способа 1. С очередями аналогичная история.

Dhwtj Автор
07.12.2025 17:35Автор вынес за скобки обновление структурированных данных
Именно так. Новый иммутабельный экземпляр и атомарно переставим ссылку. Но что если это слишком дорого?
Копируй только то, что изменилось. Остальное переиспользуй по ссылке. Не разрушать структуру, а разбить её на логические блоки, обернутые в Arc (Rust) или просто объекты (Java/C#). При создании "копии" переносить ссылки на неизменённые блоки. Для больших коллекций использовать Persistent Collections (im в Rust, PCollections в Java).
В Haskell это Линзы (Lenses), Призмы (Prisms) и Оптика, функциональный ответ на вопрос «Как менять глубоко вложенные данные, не копируя весь мир и не сходя с ума от бойлерплейта».
Завтра напишу подробней
First Win для ячейки памяти, пометив её идентификатором потока-владельца
Это есть в статье
я бы назвал exclusive writer
Я бы тоже, но термин уже есть https://www.architecture-weekly.com/p/architecture-weekly-190-queuing-backpressure

Alex-ZiX
07.12.2025 17:35При атомарной записи через compare exchange сначала один запишет своё, затем второй. Нужно проверять не изменилось ли значение перед записью. И возникает тот же вопрос - зачем? Два потока грузят процессор, затем один записал свои результаты, второй уничтожил. Смысл было запускать второй? Если нужно, чтобы результаты были только от первого, то второй и не имеет смысла стартовать, пока первый работает.

Dhwtj Автор
07.12.2025 17:35поток A: read S0 ==> compute S1 ==> CAS(S0==>S1) успех
поток B: read S0 ==> compute S2 ==> CAS(S0==>S2) конфликт
read S1 ==> compute S3 ==> CAS(S1==>S3) успех

kmatveev
07.12.2025 17:35Второй уничтожил данные, которые рассчитал, исходя из старого значения ячейки памяти. Он зачитает обновлённые данные, заново пересчитает, скорее всего получит другое значение по сравнению с первой попыткой, и сохранит. Итого в памяти окажется результат выполнения двух действий, а не только первого.
kmatveev
Мысли норм, но ужасное LLM-ное оформление с уродскими списками и таблицами очень огорчило. Плюсовал с горьким чувством.
Dhwtj Автор
Я так старался с таблицами. Что не так? Наверное, не оформление, а подача материала?
Dhwtj Автор
А, понял. Вы принципиально не любите классификаторы. А это статья - классификатор. )
Специально для вас поставлю теги
статья - классификатор, LLM, спискота