Иногда для синхронизации не нужен целый мьютекс. Если вся «общая память» — это
один счётчик, один флаг или один указатель, то парковать горутины и звать
планировщик жаль: можно обойтись одной атомарной инструкцией процессора. Этим и
занимается пакет sync/atomic.
В этой главе разберёмся, что именно гарантирует «атомарность», как atomic связан с
моделью памяти и happens-before, когда atomic честно быстрее
мьютекса, а когда наоборот превращается в драку за
кэш-линию. Попутно соберём lock-free счётчик, потрогаем CompareAndSwap и поймём,
почему «атомарное поле» — это ещё не «атомарная операция».
Атомарная операция над ячейкой памяти выполняется неделимо: любая другая
горутина видит либо состояние «до», либо состояние «после», но никогда —
промежуток. Нет момента, когда инкремент «наполовину применился».
Это самый низкоуровневый примитив синхронизации в Go. Под капотом он опирается не
на блокировки и парковку горутин, а на специальные инструкции процессора —
atomic add, compare-and-swap и так далее. Поэтому atomic так дёшев: в удачном
случае это буквально одна машинная команда, без обращения к планировщику.
Начиная с Go 1.19 предпочтительны типизированные обёртки вместо старых функций
над голыми int64/uintptr. Они читаются лучше и закрывают целый класс ошибок,
о которых поговорим ниже.
var n atomic.Int64n.Add(1) // атомарный инкремент, возвращает новое значениеv := n.Load() // атомарное чтениеn.Store(10) // атомарная записьold := n.Swap(5) // записать новое и вернуть прежнееok := n.CompareAndSwap(5, 6) // CAS: если сейчас 5 — поставить 6, вернуть true
Доступны atomic.Int32/Int64/Uint32/Uint64/Bool, обобщённый atomic.Pointer[T]
и atomic.Value для произвольного значения. Обёртки, как и мьютекс, нельзя
копировать — копия получит свою независимую ячейку, и синхронизация развалится
молча. Передавайте их по указателю или держите внутри структуры, которую тоже не
копируете.
Вот живой счётчик: десять горутин дружно крутят Add(1), и сумма всегда сходится.
package mainimport ( "fmt" "sync" "sync/atomic")func main() { var n atomic.Int64 var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 100; j++ { n.Add(1) } }() } wg.Wait() fmt.Println("итог:", n.Load()) // ровно 1000, без гонки}
Обратите внимание: мы ждём wg.Wait() перед чтением. Без этого Load мог бы
выполниться раньше, чем горутины досчитали, — атомарность защищает каждую
операцию по отдельности, но не выстраивает их в нужный нам порядок во времени.
Атомарные операции — это не просто «быстрый инкремент». Они ещё и акты
синхронизации в смысле модели памяти: атомарная запись
happens-before атомарного чтения, которое эту запись увидело. То есть atomic
переносит между горутинами не только своё значение, но и всё, что было записано
до него.
Это даёт классический паттерн «публикация по флагу готовности»:
var ready atomic.Boolvar data []byte// писательdata = build() // обычная, неатомарная записьready.Store(true) // атомарная публикация — happens-before// читательif ready.Load() { // если увидели true... use(data) // ...гарантированно видим результат build()}
Логика такая: запись data идёт доready.Store(true), а use(data) — после
успешного ready.Load(). Раз Store happens-before Load, то и build()
happens-before use. Читатель не увидит наполовину собранный data.
Но у этой гарантии есть граница, о которую легко споткнуться: она работает
только через цепочку с этой конкретной атомарной переменной. Защитить целую
структуру одним флагом можно лишь при условии, что все записи в неё happens-before
Store, а все чтения — happens-after Load. Если читатель полез в data,
не дождавшись Load, никакая атомарность флага его не спасёт.
Когда брать atomic, а когда мьютекс? Граница проходит по
тому, сколько ячеек охватывает операция.
atomic хорош ровно для одной машинной переменной: счётчик, флаг,
указатель, одно значение конфигурации. Это lock-free — горутина не паркуется, нет
передачи управления планировщику.
mutex нужен, когда критическая секция охватывает несколько переменных или
составную операцию: «проверить условие и согласованно обновить три поля». Atomic
так не умеет в принципе — он неделим только в пределах одной ячейки.
А вот по производительности всё решает contention — насколько горутины дерутся
за одну и ту же ячейку:
При низкой конкуренции atomic заметно быстрее мьютекса. Нет парковки, нет
пробуждения, нет похода в планировщик — почти бесплатно.
При высокой конкуренции atomic-инкремент по одной ячейке вырождается в
busy-CAS: ядра начинают перебрасывать друг другу одну кэш-линию (это называют
cache-line ping-pong), и весь выигрыш тает. Иногда шардированный счётчик —
по счётчику на ядро/P, с суммированием при чтении — обгоняет и atomic, и mutex,
потому что каждое ядро пишет в свою линию.
Базовый lock-free счётчик выглядит так:
// потокобезопасный счётчик без единого мьютексаtype Counter struct{ n atomic.Int64 }func (c *Counter) Inc() { c.n.Add(1) }func (c *Counter) Value() int64 { return c.n.Load() }
Практическое правило: для единственного счётчика или флага — atomic; для
согласованной группы данных — mutex; при экстремальной нагрузке на одну
ячейку — не угадывайте, а меряйте и думайте про шардирование.
CompareAndSwap (CAS) — это кирпич, из которого строят неблокирующие структуры.
Идея простая: «я прочитал значение, посчитал новое, и записываю его только если за
это время никто не успел поменять старое». Если успел — начинаем заново.
package mainimport ( "fmt" "sync" "sync/atomic")// атомарно прибавляем, но только пока значение положительноеfunc addIfPositive(n *atomic.Int64, d int64) bool { for { old := n.Load() if old <= 0 { return false // условие нарушено — выходим } if n.CompareAndSwap(old, old+d) { return true // успели первыми — готово } // кто-то опередил между Load и CAS — повторяем попытку }}func main() { var n atomic.Int64 n.Store(100) var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(1) go func() { defer wg.Done() addIfPositive(&n, 1) }() } wg.Wait() fmt.Println("итог:", n.Load()) // 150: 50 успешных прибавлений}
Цикл retry — это и есть суть lock-free: вместо того чтобы блокироваться и ждать,
горутина просто повторяет попытку. Масштабируется хорошо, но у медали есть обратная
сторона — при высокой конкуренции таких «холостых» итераций будет много, и CPU
сгорает впустую.
И обязательно помните про проблему ABA: значение могло смениться A→B→A между
вашим Load и CAS. С точки зрения CAS «ничего не изменилось» (значение снова A),
и он успешно срабатывает — хотя на деле мир за это время дважды перевернулся. Для
монотонных счётчиков это обычно не проблема (значение только растёт), а вот для
указателей на переиспользуемые объекты ABA — реальная ловушка.
Когда нужно атомарно опубликовать целый снапшот — конфиг, таблицу маршрутов,
готовый кэш — на помощь приходят atomic.Pointer[T] и atomic.Value. Писатель
готовит новый объект целиком и атомарно подменяет указатель; читатели всегда видят
какую-то одну согласованную версию, без блокировок вообще.
Это паттерн copy-on-write: старые читатели спокойно дочитывают старую версию,
новые берут новую, и никто не ждёт. На горячем пути чтения нет ни Mutex, ни даже
RWMutex — а именно чтение конфига обычно и есть горячий путь.
Соберём упрощённую модель такого реестра. Типобезопасный atomic.Pointer[Config]
читается чуть приятнее, но браузерный yaegi не поддерживает обобщённые типизованные
атомики, поэтому ниже для запуска используем atomic.Value (хранит *Config под
any, читатель делает приведение типа) — семантика та же:
package mainimport ( "fmt" "sync/atomic")type Config struct { Version int Workers int}func main() { var cfg atomic.Value // в проде удобнее atomic.Pointer[Config] cfg.Store(&Config{Version: 1, Workers: 4}) // читатель взял снапшот ДО перезагрузки snap := cfg.Load().(*Config) // писатель публикует новую версию целиком cfg.Store(&Config{Version: 2, Workers: 8}) now := cfg.Load().(*Config) fmt.Printf("старый снапшот: v%d/%d workers\n", snap.Version, snap.Workers) fmt.Printf("текущий конфиг: v%d/%d workers\n", now.Version, now.Workers)}
Ключевая мысль: читатель, схвативший snap, продолжает работать со
согласованной старой версией, даже когда писатель уже подменил указатель.
Полуобновлённого конфига (новый Version, но старый Workers) увидеть нельзя —
подменяется указатель целиком, одной атомарной записью.
Всё, что выше, мы спокойно запускали — потому что atomic делает результат
детерминированным. А вот настоящую гонку данных продемонстрировать «вживую»
в этом плейграунде нельзя: yaegi исполняет горутины кооперативно, в одном потоке,
без настоящего параллелизма и без race-детектора. Поэтому следующий пример —
статический, не запускаемый; разбираем его глазами.
// НЕ запускать: это иллюстрация data race.// На настоящем многоядерном Go под `go run -race` здесь сработает детектор гонок,// а итог почти наверняка окажется меньше 1000.var n int64 // обычный int64, без atomicvar wg sync.WaitGroupfor i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 100; j++ { n++ // читать-инкрементить-записать: три неатомарных шага } }()}wg.Wait()fmt.Println(n) // на железе: непредсказуемо, часто < 1000
n++ — это на самом деле три действия: прочитать, прибавить, записать. Два ядра
читают одно и то же старое значение, оба прибавляют единицу, оба записывают — и
один инкремент бесследно теряется. Замена int64 на atomic.Int64 и n++ на
n.Add(1) (как в самом первом примере) делает шаг неделимым, и гонка исчезает.
Запускайте такие сценарии у себя локально через go run -race и go test -race —
это главный инструмент против подобных багов.
Смешивать атомарный и обычный доступ к одной переменной. Если переменная
«атомарная», то все обращения к ней должны идти через atomic-методы. Один
обычный n++ или x = n рядом с n.Add(1) — это data race, даже если кажется,
что «там же только чтение».
Считать, что два atomic-вызова подряд атомарны вместе.Load, потом
Store — это две отдельные неделимые операции, но между ними прекрасно
вклинивается другая горутина. Нужна составная атомарность («проверить и обновить»)
— берите CAS-петлю или честный мьютекс.
Копировать atomic-обёртку. Передали Counter по значению — и у копии своя
ячейка, синхронизация молча перестала работать. Только по указателю.
Тащить atomic туда, где нужен mutex. Как только логика охватывает несколько
полей или составную инвариантность, atomic перестаёт помогать: он неделим лишь на
одну ячейку. Не пытайтесь «собрать мьютекс» из пары atomic-флагов.
Думайте об atomic как о «мьютексе на одну переменную ценой в одну CPU-инструкцию».
Он даёт ровно две вещи: неделимость операции и happens-before — но только для
одной ячейки памяти. Как только в игру вступают несколько переменных или связка
«прочитать → подумать → записать» как единое целое, выбор сужается до двух честных
вариантов: CAS-петля или мьютекс.
Атомики — ядро топика 2 (5–8) и топика 5 (16–20): lock-free счётчики, флаги
готовности, CAS-структуры, copy-on-write конфиги. Главный навык, который проверяют
задачи, — обоснованный выбор между atomic и
мьютексом по характеру операции и уровню contention, с
опорой на happens-before из модели памяти.