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

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

  1. Один победит, другой переделает работу (First Win + Retry [1]) - конфликт обнаруживается и разрешается

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

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

Других веток нет. Математически.


Стратегия 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

Блокировка потока может быть реализована по-разному: 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

Технология

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

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 но с важным отличием: мерж встроен в тип данных, порядок применения не важен.

Но физически 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 часа ночи

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

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

  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

  • Не применяйте для бизнес-данных


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). Многопоточность потребовала внешних инструментов:


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

  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)

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


Итого

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

  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)

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

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