23. Атомарные операции. CAS, Compare And Swap, Fetch And Add
В предыдущей главе (22. Memory Ordering и CPU Memory Model) мы наблюдали за тем, как процессоры и оптимизирующие компиляторы переставляют инструкции местами, порождая трудноуловимые аппаратные аномалии. Мы выяснили, что спасением служит строгая Модель памяти Go (The Go Memory Model) и её отношение Happens-Before, опирающееся на каналы и мьютексы.
Но если мы заглянем в исходный код стандартной библиотеки Go (src/sync/mutex.go или src/runtime/chan.go), мы обнаружим поразительную вещь: на самом дне рантайма нет никаких «магических» механизмов. (Оговорка: канал hchan в runtime/chan.go защищён собственным внутренним runtime.mutex, но и тот в итоге построен на тех же атомиках и futex.) Вся многопоточность и все высокоуровневые средства синхронизации построены на крошечных, предельно быстрых кирпичиках — Атомарных операциях (Atomic Operations).
Атомики — это фундамент, на котором покоится вся индустрия высокопроизводительного, неблокирующего (lock-free) бэкенда.
Почему counter++ не работает?
Вспомним классический лабораторный пример гонки данных (Data Race). Мы создаем разделяемую переменную counter := 0 и запускаем тысячу горутин, каждая из которых ровно один раз выполняет инкремент: counter++.
Вместо ожидаемой тысячи в переменной оказывается случайное число: например, 942. Куда делись 58 инкрементов?
С точки зрения программиста, строчка counter++ монолитна и проста.
Однако для микропроцессора это классический трехстадийный цикл Read-Modify-Write (RMW). Он раскладывается на три независимые машинные инструкции (как мы видели в 7. Цикл исполнения инструкции. Fetch, Decode, Execute):
- Чтение (Load / Fetch): считать текущее значение
counterиз кэша L1 в регистр процессора (например,RAX). - Модификация (Modify / Add): прибавить число
1к значению в регистреRAXвнутри арифметико-логического устройства ALU. - Запись (Store): сохранить обновленный регистр
RAXобратно в ячейку памяти кэша L1.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph RMW["Неатомарный инкремент RMW (гонка двух ядер)"]
direction TB
C1_Read["Ядро 1: Load (читает 0)"]:::system --> C1_Add["Ядро 1: Add (0 + 1 = 1)"]
C2_Read["Ядро 2: Load (тоже читает 0!)"]:::system --> C2_Add["Ядро 2: Add (0 + 1 = 1)"]
C1_Add --> C1_Write["Ядро 1: Store (пишет 1)"]
C2_Add --> C2_Write["Ядро 2: Store (перетирает 1 единицей!)"]
end
Пока Ядро 1 вычисляет сложение в своем ALU, Ядро 2 успевает прочитать из памяти старый ноль. Оба ядра локально получают единицу и по очереди записывают ее в одну и ту же ячейку памяти. Один из инкрементов оказывается бесследно стерт.
Атомарность на уровне железа
Атомарная операция — это аппаратная гарантия того, что вся последовательность Read-Modify-Write выполнится как неделимая транзакция. Никакое другое ядро процессора не сможет вклиниться в этот интервал, прочитать промежуточное состояние или перезаписать данные.
Как это реализовано на физическом уровне?
В архитектуре x86-64 для превращения обычной инструкции в неделимую используется специальный ассемблерный префикс — LOCK (например, LOCK XADDQ или LOCK CMPXCHG).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph AtomicExecution["Атомарный инкремент (LOCK XADD)"]
Lock["Аппаратный захват кэш-линии (MESI: Modified)"]
Op["Чтение + Сложение + Запись<br>(Единый неделимый шаг конвейера)"]
Release["Снятие блокировки строки"]:::process
Lock --> Op --> Release
end
В архитектурах ARM64 исторически применялась пара инструкций Load-Linked / Store-Conditional (LL/SC) — LDXR и STXR (в 32-битном ARMv7 они назывались LDREX/STREX, но в 64-битном A64 ISA эти мнемоники отсутствуют). С семантикой Acquire/Release используются варианты LDAXR и STLXR. Начиная с ARMv8.1-A, расширение LSE (Large System Extensions) добавило прямые атомарные инструкции LDADD и CAS, сделав длинные циклы LL/SC необязательными для стандартных примитивов (процессоры AWS Graviton2/3/4 поддерживают LSE; Go по умолчанию проверяет его наличие в рантайме и выбирает нужную ветку, а при сборке с GOARM64=v8.1 использует LSE безусловно).
Базовые атомарные операции в Go
В языке Go низкоуровневые атомики собраны в пакете sync/atomic. Начиная с версии Go 1.19, стандартная библиотека пополнилась типобезопасными обертками (atomic.Int64, atomic.Pointer, atomic.Bool), избавившими код от опасного ручного жонглирования указателями unsafe.Pointer.
1. Fetch And Add (FAA)
Самый быстрый и прямолинейный инструмент для потокобезопасных счетчиков и метрик:
package main
import "sync/atomic"
var counter atomic.Int64
func handleRequest() {
// Атомарный инкремент без всяких мьютексов
counter.Add(1)
}
Компилятор Go транслирует метод counter.Add(1) в одну аппаратную инструкцию: LOCK XADDQ.
Сама инструкция XADD атомарно прибавляет единицу и возвращает предыдущее значение из памяти, а метод Add в Go по документации возвращает новое значение — недостающее прибавление выполняет уже сам компилятор. Однако помните о законах кэша из 21. False Sharing и Cache Line Contention: при дикой конкуренции сотен горутин постоянные вызовы counter.Add(1) приведут к Cache Line Contention и замедлят шину.
2. Compare And Swap (CAS)
Инструкция Compare-And-Swap (Сравнение с обменом) — это абсолютная вершина инженерной мысли в многопоточности. Без неё было бы невозможно построить ни планировщик Go, ни рантайм каналов, ни современные базы данных.
Логика функции CompareAndSwap гениальна в своей простоте. Она принимает три параметра: адрес ячейки памяти, ожидаемое старое значение (old) и желаемое новое значение (new):
«Посмотри в память по указанному адресу. Если там лежит то, что я ожидал (
old), атомарно запиши тудаnewи верниtrue. Если же кто-то другой успел изменить значение раньше меня — ничего не перезаписывай и верниfalse».
В ассемблере x86-64 за это отвечает легендарная инструкция LOCK CMPXCHG.
CAS лежит в основе концепции оптимистичных блокировок (Optimistic Concurrency Control). Мы не заставляем другие горутины ждать у закрытой двери (как делает мьютекс). Мы читаем данные, готовим изменения в локальной памяти, а затем пытаемся атомарно подменить результат. Если кто-то опередил нас — мы просто пробуем снова в цикле. Этот паттерн называется CAS Loop (Цикл CAS).
Классический пример безопасного обновления конфигурации без единой блокировки:
package main
import "sync/atomic"
type Config struct {
Timeout int
}
var currentConfig atomic.Pointer[Config]
func updateConfigTimeout(newTimeout int) {
for {
// 1. Читаем текущий указатель
oldConfig := currentConfig.Load()
// 2. Создаем новую неизменяемую копию структуры
newConfig := &Config{Timeout: newTimeout}
// 3. Пытаемся атомарно подменить указатель.
// Если за время шага 2 другая горутина обновила currentConfig,
// CAS вернет false, и мы уйдем на повторный круг цикла.
if currentConfig.CompareAndSwap(oldConfig, newConfig) {
return // Успех! Конфигурация обновлена
}
}
}
(Честная оговорка: в этом конкретном примере новое значение не зависит от старого, поэтому цикл вырождается в простой currentConfig.Store(newConfig). Настоящий CAS-цикл нужен, когда новое значение вычисляется из прочитанного старого — например, «скопировать oldConfig и поменять в копии одно поле».)
Проблема ABA (The ABA Problem)
Там, где применяется оптимистичный CAS, программистов подстерегает классическая аномалия неблокирующих алгоритмов — Проблема ABA.
Она возникает в динамических структурах данных на указателях (например, в неблокирующем стеке Трайбера).
Временная шкала драмы ABA:
Горутина 1: Читает вершину стека -> узел [A] (адрес 0x1000). Хочет переставить на [B].
Засыпает (планировщик снял её с выполнения).
Горутина 2: Достает из стека [A] (0x1000), удаляет [B].
Горутина 2: Создает совершенно НОВЫЙ узел [C], но аллокатор возвращает освободившийся адрес 0x1000!
Новый узел [A'] ложится на вершину стека по старому адресу 0x1000.
Горутина 1: Просыпается. Выполняет операцию `CAS(ожидали A, ставим B)`.
CAS видит 0x1000, думает, что ничего не изменилось, и возвращает true!
Стек разрушен: вершина указывает на удаленный узел [B].
(Примечание: ABA все еще может проявиться в Go, если вы строите lock-free структуры на базе числовых индексов в массиве или пулов свободных элементов).
Экспоненциальная задержка (Exponential Backoff)
В завершение — ключевое правило Mechanical Sympathy при проектировании CAS-циклов.
Если десятки горутин на разных ядрах одновременно пытаются обновить одну и ту же переменную через CAS, возникает тяжелая конкуренция (High Contention). Постоянные неудачные попытки заваливают процессорную шину бесконечными префиксами LOCK CMPXCHG, порождая Invalidate-шторм. Если тысячи горутин крутят пустой цикл for {}, пытаясь выполнить CAS, это полностью парализует ядра.
Если CAS возвращает false, грубейшей ошибкой будет немедленно начинать новый круг без пауз. Идиоматичный подход требует внедрения аппаратной паузы:
- Вызов
runtime.Gosched()передает управление планировщику Go, давая возможность поработать другим горутинам. Это не аппаратная пауза, а уступка процессора другой горутине. - Для самого короткого ожидания на уровне ассемблера применяется специальная процессорная инструкция
PAUSE(в x86). Она сообщает конвейеру ядра, что идет цикл ожидания, на какое-то время притормаживает исполнение (от нескольких до сотни с лишним тактов в зависимости от процессора), снижает энергопотребление и предотвращает тяжелый сброс конвейера при выходе из цикла. Из обычного Go-кодаPAUSEнапрямую недоступна: рантайм использует её внутриruntime.procyield(например, при спиннинге вsync.Mutex). - Хороший спинлок использует экспоненциальный backoff (всё более длинные паузы между попытками) и схему test-and-test-and-set: сначала читает значение обычной загрузкой (она идёт из локального кэша и не бьёт по кэш-линии), и только если ресурс выглядит свободным, пробует CAS.
Итог
- Операция
counter++аппаратно неатомарна и состоит из трех стадий: Load-Modify-Store (RMW). - Атомарные операции делают весь цикл неделимым на уровне кремния за счет блокировки кэш-линии (префикс
LOCKв x86, пара LL/SCLDAXR/STLXRлибо одна атомарная инструкция расширения LSE в ARM64). - Fetch-And-Add (
atomic.Int64.Add) — эталонный способ быстрого параллельного инкремента счетчиков. - Compare-And-Swap (
atomic.Pointer.CompareAndSwap) — основа lock-free алгоритмов, реализующая концепцию оптимистичных блокировок. - Проблема ABA в Go эффективно нивелируется Сборщиком Мусора (GC), который не отдаёт под новые объекты адрес, на который ещё есть ссылки (но не спасает, если узлы переиспользуются вручную, например через пул).
- В прикладных задачах отдавайте предпочтение
sync.Mutex: в отличие от бесконечных CAS-циклов, мьютекс паркует ожидающие горутины черезruntime.Semacquire, сохраняя ресурсы CPU.
Мы научились делать операции неделимыми (Atomicity).
Но атомарность — это только одна сторона медали. В спецификации sync/atomic черным по белому написано: «Атомарные операции создают строгие барьеры видимости памяти (Memory Ordering)».
Каким образом компилятор и процессор устанавливают запреты на перестановку инструкций вокруг атомиков? Что такое семантика Acquire и Release?
Разгадку этой тайны мы узнаем в следующей главе: 24. Барьеры памяти. Memory Fence, Acquire, Release.