Гонки, блокировки и атомики

Почему counter++ из двух потоков теряет обновления, как устроен мьютекс, откуда берутся дедлоки, что гарантируют атомарные операции и почему процессор вправе переставить ваши записи.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки. Предыдущая тема — процессы и потоки.

Гонка данных: counter++ — это три операции

Процессор не умеет «прибавить к памяти» одним неделимым шагом без специального префикса. Компилятор превращает инкремент в загрузку в регистр, сложение и запись обратно.

mov  eax, [counter]   ; load
add  eax, 1           ; add
mov  [counter], eax   ; store

Гонка данных (data race) — два потока обращаются к одной переменной без синхронизации, и хотя бы один пишет. Попробуйте выбрать порядок шагов так, чтобы одно прибавление потерялось.

Потерянные обновления в цифрах

  • На многоядерной машине потоки идут по-настоящему параллельно: окна «load…store» разных ядер постоянно перекрываются.
  • Реальный прогон на Go: 4 горутины × 1 000 000 инкрементов дают около 1,3–2,5 млн вместо 4 млн. Каждый запуск — другое число.
  • Чем больше ядер и короче цикл, тем больше потерь. На одном ядре гонка срабатывает редко — переключение должно попасть между load и store, — но тогда поток затирает всё, что другие насчитали за его отсутствие.
  • Атомарный инкремент — lock xadd на x86 — держит кэш-линию на время операции, и ни одно обновление не теряется.

Проверьте себя

В CPython есть GIL: в каждый момент байткод выполняет только один поток. Безопасен ли counter += 1 из нескольких потоков threading?

LOAD_GLOBAL  counter
LOAD_CONST   1
BINARY_OP    +=
STORE_GLOBAL counter
GIL защищает внутренности интерпретатора, а не ваши инварианты. += — несколько байткодов, и переключение между LOAD и STORE теряет обновление. В 3.10+ переключения стали реже, и тест «вроде проходит», но гарантии нет, а в сборке без GIL (3.13t) потери видны сразу. Нужен threading.Lock. В Node.js гонки данных между колбэками невозможны — один поток, — но гонки условий через await никуда не делись.

Гонка условий: проверил — и опоздал

Гонка условий (race condition) шире гонки данных: каждая операция может быть атомарной, а результат всё равно зависит от порядка. Классика — «проверить, потом действовать».

  • Двойная бронь, двойное списание, два пользователя с одним email.
  • TOCTOU: if not exists(path): open(path, "w"). Между проверкой и созданием файл может появиться. Правильно — одним вызовом: O_CREAT | O_EXCL.
  • Мьютекс в процессе не спасает, если сервис запущен в двух экземплярах.

Выберите вариант и проходите по шагам: запросы Алисы и Боба приходят одновременно.

Критическая секция и мьютекс

Мьютекс превращает «load, add, store» в критическую секцию: внутри в каждый момент только один поток. Остальные ждут у входа.

mu.Lock()
counter++ // критическая секция
mu.Unlock()

Счётчик теперь точный, но за это платят ожиданием. Двигайте ползунок: чем большую долю времени поток проводит под замком, тем больше остальные стоят в очереди и тем меньше выигрыш от добавления потоков.

Как устроен мьютекс: спин, futex, сон

  • Мьютекс — это слово в памяти. Свободный захват — одна атомарная операция CAS, ~10–20 нс, без ядра.
  • Занято — можно покрутиться (spin) в надежде, что владелец скоро выйдет. Если нет — уснуть через futex(FUTEX_WAIT): ядро снимает поток с CPU.
  • Будить спящего — системный вызов FUTEX_WAKE и переключение контекста: единицы микросекунд, в сотни раз дороже захвата.
  • Go: sync.Mutex немного крутится, потом паркует горутину; если ждут дольше 1 мс — переходит в честный режим. Java: synchronized так же крутится, потом паркует поток.

Взаимная блокировка

Перевод денег: заблокировать счёт-источник, потом счёт-получатель. Два встречных перевода — и каждый держит то, что нужно другому.

Дедлок возможен, только если выполнены все четыре условия Коффмана: взаимное исключение, «держу и жду», нельзя отобрать ресурс, циклическое ожидание. Проще всего сломать последнее: брать блокировки всегда в одном порядке, например по id.

Ходите потоками по одному шагу. Потом включите упорядочивание и попробуйте снова.

Кто ловит дедлоки, и чем ещё можно застрять

Как застревают

  • Дедлок — все ждут друг друга, CPU не тратится. Go замечает только полную остановку: fatal error: all goroutines are asleep. Частичный дедлок висит молча.
  • Livelock — все заняты, прогресса нет: два потока одновременно отпускают замок, ждут одинаково и снова сталкиваются. CPU 100%.
  • Голодание — система работает, но один поток никогда не получает замок: его постоянно обгоняют.

Что помогает

  • Единый порядок захвата; не вызывать чужой код под замком.
  • Тайм-ауты: tryLock(timeout), context с дедлайном, lock_timeout в Postgres. Postgres сам ищет циклы через deadlock_timeout (1 с) и откатывает одну транзакцию.
  • Случайная пауза перед повтором (jitter) против livelock; честные блокировки и очереди против голодания.

Атомарные операции и CAS

Процессор умеет атомарно выполнить «прочитать — изменить — записать» для одного слова: lock xadd, lock cmpxchg на x86, ldaxr/stlxr или LSE-инструкции на ARM.

for {
    old := atomic.LoadInt64(&x)
    if atomic.CompareAndSwapInt64(&x, old, old+1) {
        break // никто не успел вмешаться
    }
    // кто-то изменил x — повторяем
}

CAS (compare-and-swap) записывает новое значение, только если в памяти всё ещё старое. Добавляйте потоки и смотрите, как растёт число повторов.

Lock-free стек и проблема ABA

Стек Трайбера: pop читает вершину A и её next, затем делает CAS(top, A, next). Если за это время вершина успела смениться и вернуться к A, CAS ничего не заметит.

Так бывает, когда узлы переиспользуются: пул объектов, ручное освобождение памяти в C/C++. Лечится счётчиком версий рядом с указателем (двойной CAS) или безопасным освобождением: hazard pointers, epoch-based reclamation. В Go и Java сборщик мусора не отдаст узел, пока на него ссылается поток, — поэтому классическая ABA там редкость.

Процессор переставляет операции

У каждого ядра есть буфер записи (store buffer): запись уходит в него и попадает в кэш позже, а следующее чтение не ждёт. Для чужого ядра это выглядит так, будто чтение выполнилось раньше записи.

x = y = 0
T1: x = 1; r1 = y      T2: y = 1; r2 = x

При любом чередовании операций результат r1 = r2 = 0 невозможен. На реальном x86 он случается. ARM переставляет ещё больше, плюс переставляет компилятор. Пройдите по шагам, затем запустите тысячу прогонов с барьером и без.

Happens-before: что гарантирует язык

Модели памяти Go, Java, C++ и JS устроены одинаково: если в программе нет гонок данных, она ведёт себя так, будто операции идут по очереди. Порядок между потоками создают только синхронизирующие операции.

  • Unlock мьютекса происходит-до следующего Lock.
  • Отправка в канал — до получения; wg.Done() — до возврата wg.Wait().
  • Атомарная запись — до атомарного чтения, которое её увидело. Java: volatile, C++: std::atomic, JS: Atomics.
var data int
var ready bool // нужен atomic.Bool

go func() {
    data = 42
    ready = true
}()

for !ready { } // может никогда не выйти
fmt.Println(data) // может напечатать 0

Без синхронизации компилятор вправе вынести чтение ready из цикла, а процессор ARM — сделать запись ready видимой раньше data. volatile в C/C++ здесь не помогает: он только для памяти устройств.

Ошибка двойной проверки

Ленивая инициализация «без лишних блокировок»: проверить без замка, и только если пусто — взять замок и создать. Объект при этом может стать виден другому потоку недостроенным.

if (instance == null) {            // без замка
  synchronized (lock) {
    if (instance == null)
      instance = new Config();     // выделить, заполнить, опубликовать
  }
}
return instance;

Компилятор и процессор вправе опубликовать ссылку раньше, чем заполнены поля. Пройдите по шагам. Исправление: volatile в Java, sync.Once в Go, std::call_once или статическая локальная переменная в C++.

Проверьте себя

Горутина-воркер пишет результат в result, а затем ставит done = true (обычные переменные). Главная горутина ждёт for !done {} и читает result. Тесты на ноутбуке проходят. Что на самом деле?

Атомарность записи bool не даёт порядка и видимости. Компилятор Go вправе прочитать done один раз до цикла — и цикл не закончится даже на x86. go test -race такой код ловит сразу. Правильно — atomic.Bool, канал или sync.WaitGroup: они создают отношение happens-before.

RWMutex, семафор, условная переменная

  • RWMutex — много читателей или один писатель. Помогает при редкой записи и долгом чтении. При частой записи медленнее обычного мьютекса: у него дороже учёт.
  • Семафор на N мест ограничивает параллелизм: не больше N запросов в базу, N загрузок. В Go — буферизованный канал или semaphore.Weighted.
  • Условная переменная — «усни, пока не выполнится условие»: for !cond { c.Wait() }. Всегда в цикле: пробуждение не гарантирует, что условие истинно. Java: wait/notify, Python: threading.Condition.

Переключайте примитив и долю писателей: кто внутри, кто ждёт и сколько успели сделать.

Закон Амдала и цена конкуренции

Если доля s работы выполняется последовательно — под общим замком, в одном потоке, — ускорение на N ядрах не больше 1 / (s + (1 − s) / N). Предел — 1/s, сколько ядер ни добавляй.

На деле хуже: ядра ещё и согласуют кэш-линию замка между собой, и эта цена растёт с числом участников. После некоторого числа ядер добавление потоков замедляет программу. Модель с этим эффектом — универсальный закон масштабируемости Гюнтера.

Лечат дроблением: шардированные счётчики и мапы, локальные буферы на поток со сбросом пачкой, обработка без общего состояния.

Проверьте себя

Сервис перенесли с 8 на 64 ядра, а пропускная способность почти не выросла. Мьютекс-профиль показывает: 40% времени запроса проходит под одним глобальным замком кэша. Какое ускорение максимально возможно, если ничего не менять в коде?

Последовательная доля 0,4 даёт предел 1 / 0,4 = 2,5×. На 8 ядрах уже было 1 / (0,4 + 0,6/8) ≈ 2,1×, на 64 — ≈ 2,4×. Помогут только изменения кода: шардировать кэш, укоротить секцию, перейти на sync.Map или копирование при записи.

Как увидеть у себя

  • go test -race ./... и go run -race — детектор гонок. В 2–20 раз медленнее, ловит только гонки, которые реально случились в прогоне, зато без ложных срабатываний. Держите в CI.
  • C/C++/Rust: -fsanitize=thread (ThreadSanitizer), то же устройство. Java: jcmd <pid> Thread.print сам находит дедлоки. Python: py-spy dump.
  • Контеншен: Go — pprof профили mutex и block; Linux — perf lock, offcputime из bcc; много futex в strace -c — потоки дерутся за замок.
  • Postgres: pg_locks, pg_blocking_pids(), log_lock_waits.
==================
WARNING: DATA RACE
Write at 0x00c0000140a8 by goroutine 8:
  main.inc()
      /app/main.go:12 +0x44

Previous write at 0x00c0000140a8 by goroutine 7:
  main.inc()
      /app/main.go:12 +0x44

Goroutine 8 (running) created at:
  main.main()
      /app/main.go:18 +0x7c
==================

Что делать на практике

По порядку предпочтения

  • Не делить. Состояние принадлежит одной горутине или потоку, остальные шлют ей сообщения через канал или очередь.
  • Не менять. Неизменяемые объекты можно читать откуда угодно. Обновление — новая копия и атомарная замена ссылки.
  • Готовое. Каналы, очереди, ConcurrentHashMap, errgroup, пулы. Потом — мьютекс с короткой секцией. Lock-free своими руками — в последнюю очередь.

Если процессов несколько

  • Мьютекс в памяти защищает один процесс. Между экземплярами сервиса — база: UNIQUE, условный UPDATE … WHERE, SELECT … FOR UPDATE, advisory locks.
  • Оптимистичная блокировка: колонка version, UPDATE … WHERE version = $1, при 0 строк — повторить.
  • Распределённые блокировки в Redis теряются при паузах GC и сбоях. Если от них зависит корректность — нужен fencing token, который проверяет само хранилище.

Главное

  • counter++ — load, add, store. Без синхронизации обновления теряются, и это норма, а не редкость.
  • «Проверил, потом сделал» превращайте в одну атомарную операцию — в том числе в базе.
  • Мьютекс дёшев без конкуренции и дорог с ней. Дедлоки лечатся единым порядком захвата и тайм-аутами.
  • Атомики и CAS — для одного слова. Компилятор и процессор переставляют операции: порядок между потоками дают только синхронизация и атомики.
  • 5% работы под общим замком — не больше 20× ускорения. Гонки ищите с -race и TSan.

Не общайтесь через общую память — делитесь памятью, общаясь.Go Proverbs, Роб Пайк