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». А именно: что случится при конфликте?

Ответов ровно три:

  1. Один победит, другой переделает работу (First Win + Retry)

  2. Один подождёт, пока другой закончит (Single Writer)

  3. Последний затрёт первого (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 synchronized

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

Технология

Как реализует

AtomicReference / CAS

CompareAndSet

Immutable + swap

Новый объект + CAS ссылки

StampedLock (optimistic)

Штамп версии + валидация

STM (Clojure, Haskell)

Read-set + commit-time проверка

MVCC / Snapshot Isolation

Версии строк + конфликт при коммите

Optimistic Locking в ORM

Поле version + WHERE version = ?

Lock-free структуры

CAS для доступа к ячейкам

Стратегия 2: Single Writer

Технология

Подвариант

synchronized / Mutex

2.1 (OS Lock)

ReentrantLock

2.1 (OS Lock)

DB SELECT FOR UPDATE

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 техника

Что делает

Стратегия

AtomicReference + immutable

Атомарная смена ссылки

1 (First Win)

Bit packing + AtomicLong

Несколько полей в 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 часа ночи

Почему акторы часто безопаснее локов

Не потому что магия. А потому что:

  1. Локальность логики. Всё состояние и вся логика — в одном файле, в одном receive-блоке.

  2. Нет разбросанных локов. Не нужно помнить порядок захвата.

  3. Явный протокол. Сообщения — это контракт, его видно.

// Плохо. Локи: разбросаны по файлам, порядок в голове не удержать

// файл 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

Функциональное программирование


Шпаргалка для проектирования

  1. FIRST WIN Проигравший переделывает
    (Optimistic) ==> CAS, STM, MVCC, версии
    Риск: livelock, starvation

  2. SINGLE WRITER Один пишет, остальные ждут
    (Pessimistic) ==> 2.1 Lock (поток спит)
    Риск: deadlock, priority inversion
    ==> 2.2 Queue (команда ждёт)
    Риск: переполнение, actor deadlock

  3. 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)

Логика локализована


Антипаттерны

«У нас всё на акторах»

Даже для счётчика создаём актора с 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 и тем сложнее ревью.

Решение: Один лок на агрегат. Одна точка входа для изменений.


Итого

Фундаментальных стратегий — три:

  1. First Win — оптимистично пробуем, при конфликте retry

  2. Single Writer — сериализуем доступ (lock или queue)

  3. 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.


Ссылки


За пределами одного процесса. Что появляется в распределенных системах.

Всё, что описано выше — про in-memory конкурентность в одном процессе.

Когда данные уходят в сеть (БД, другой сервис), классификация остаётся той же:

  • First Win ==> Optimistic Locking (WHERE version = ?)

  • Single Writer ==> SELECT FOR UPDATE, distributed locks

  • Last Win ==> Прямая запись, LWW в eventual consistent системах

Но появляются новые проблемы:

  • Частичные отказы (запрос ушёл, ответа нет)

  • Необходимость идемпотентности

  • Распределённые транзакции (Saga, 2PC)

  • Консенсус между узлами (Paxos, Raft)

Это тема для отдельного разговора. Хорошие точки входа:

Комментарии (16)


  1. kmatveev
    07.12.2025 17:35

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


    1. Dhwtj Автор
      07.12.2025 17:35

      Я так старался с таблицами. Что не так? Наверное, не оформление, а подача материала?


    1. Dhwtj Автор
      07.12.2025 17:35

      А, понял. Вы принципиально не любите классификаторы. А это статья - классификатор. )

      Специально для вас поставлю теги

      статья - классификатор, LLM, спискота


  1. Dhwtj Автор
    07.12.2025 17:35

    P.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(&current, Arc::new(next)),
                    &current,
                ) {
                    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(&current, Arc::new(next)),
                    &current,
                ) {
                    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;

    -при конфликте — повторяют


    1. rsashka
      07.12.2025 17:35

      Извините, сбился на Rust. Не хочу на Java, не понимаю до конца что там происходит.

      Ничего удивительно, так как Rust на это в принципе не способен без unsafe (изменять один объект из нескольких потоков)


      1. 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);
            }
        }


  1. Melpomenna
    07.12.2025 17:35

    Спасибо за статью, интересная мысль для расмышления


  1. uvelichitel
    07.12.2025 17:35

    Параллельная запись разрешена?
    ├── Нет ==> [2] Single Writer (ждём очереди)
    └── Да ==> Проверяем конфликт? ├── Да ==> [1] First Win (retry/abort) └── Нет ==> [3] Last Win (затирание)

    Мне не понравилась таксономия. ждём очереди , а почему например не стоим в стеке? Проверяем конфликт , а зачем проверять если разрешено параллельно? А в какой момент уже можно читать? А CommunicatingSequentialProcesses Хоаре куда отнести?
    Классификация мне кажется противоречивой и не исчерпывающей...


    1. 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 и ожидание ответа.


  1. Alex-ZiX
    07.12.2025 17:35

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


    1. 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 над одной ячейкой за один неделимый шаг, всё остальное (валидация, вычисления) происходит вне этого шага и может выполняться параллельно.


    1. kmatveev
      07.12.2025 17:35

      Автор вынес за скобки обновление структурированных данных, он свёл задачу к обновлению чего-то одного. В примере с OrderService он выкрутился тем, что OrderState - иммутабельный , метод apply() создаёт изменённую копию, и нужно только переставить атомарную глобальную ссылку на созданную копию.

      Насчёт того, зачем нужен First Win. Отличие варианта 1 от варианта 2 в том, что вариант 1 - оптимистический: сделал работу, веря, что результат удастся сохранить, потом пытаемся сохранить, а вариант 2 - пессимистический, не верим, что никто не обновит за то время, пока работаем, поэтому блокируемся. В некоторых случаях оптимистичный вариант лучше.

      В качестве одного из способов, как сделать single writer (я бы назвал exclusive writer), предложена блокировка/mutex. Кстати, самый распространённый способ, как блокировку можно реализовать - это использовать First Win для ячейки памяти, пометив её идентификатором потока-владельца. И хотя это внутренние детали реализации примитива синхронизации, получается так что способ 2 достигается применением способа 1. С очередями аналогичная история.


      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


  1. Alex-ZiX
    07.12.2025 17:35

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


    1. 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) успех


    1. kmatveev
      07.12.2025 17:35

      Второй уничтожил данные, которые рассчитал, исходя из старого значения ячейки памяти. Он зачитает обновлённые данные, заново пересчитает, скорее всего получит другое значение по сравнению с первой попыткой, и сохранит. Итого в памяти окажется результат выполнения двух действий, а не только первого.