Иногда для синхронизации не нужен целый мьютекс. Если вся «общая память» — это
один счётчик, один флаг или один указатель, то парковать горутины и звать
планировщик жаль: можно обойтись одной атомарной инструкцией процессора. Этим и
занимается пакет sync/atomic.
В этой главе разберёмся, что именно гарантирует «атомарность», как atomic связан с
моделью памяти и happens-before, когда atomic честно быстрее
мьютекса, а когда наоборот превращается в драку за
кэш-линию. Попутно соберём lock-free счётчик, потрогаем CompareAndSwap и поймём,
почему «атомарное поле» — это ещё не «атомарная операция».
Нужны указатели, WaitGroup, Mutex и различие data race и логической гонки. Начните с Add, Load, Store и того, почему два вызова отдельно не становятся одной неделимой операцией. CAS, ABA, публикацию снапшотов и atomic.Pointer можно перечитать после счётчика; для Pointer нужен также синтаксис дженериков.
Здесь int64 — целый тип фиксированной ширины, а &counter — адрес ячейки. atomic.Int64 объединяет ячейку и методы атомарного доступа. «Публикация» означает сделать уже подготовленные данные доступными читателю; «снапшот» — согласованную версию этих данных, которую не меняют после публикации.
Атомарная операция над ячейкой памяти выполняется неделимо: любая другая
горутина видит либо состояние «до», либо состояние «после», но никогда —
промежуток. Нет момента, когда инкремент «наполовину применился».
Это самый низкоуровневый примитив синхронизации в 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 удобен для счётчика, флага или публикации указателя на неизменяемый
объект. Не все API обязаны быть lock-free на любой платформе: пакет
гарантирует атомарность и порядок памяти, а не конкретные инструкции или задержку.
mutex нужен, когда критическая секция охватывает несколько переменных или
составную операцию: «проверить условие и согласованно обновить три поля». Простой набор atomic-вызовов
не делает такой участок неделимым. Возможны специальные алгоритмы с CAS
или публикацией неизменяемого снимка, но мьютекс обычно проще проверить.
А вот по производительности всё решает contention — насколько горутины дерутся
за одну и ту же ячейку:
При низкой конкуренции atomic заметно быстрее мьютекса. Нет парковки, нет
пробуждения, нет похода в планировщик — почти бесплатно.
При высокой конкуренции atomic-инкремент по одной ячейке может упираться в обмен
кэш-линией: ядра начинают перебрасывать друг другу одну кэш-линию (это называют
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 успешных прибавлений}
В CAS-цикле горутина повторяет попытку вместо ожидания мьютекса. Но один 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 даёт неделимость отдельной операции и последовательную согласованность
атомарных операций. Happens-before через atomic может публиковать и другие
данные — как в примере с флагом, — если после публикации их не меняют.
Стоимость операции зависит от архитектуры и конкуренции; одна CPU-инструкция
не является гарантией API. Как только в игру вступают несколько переменных или связка
«прочитать → подумать → записать» как единое целое, выбор сужается до двух честных
вариантов: CAS-петля или мьютекс.
В счётчике мысленно замените Add(1) на два вызова: old := n.Load() и n.Store(old + 1). Для двух горутин и начального значения 0 выпишите порядок, при котором итог будет 1. Будет ли это обязательно data race? Затем выберите способ сохранить условие «увеличить ровно на один».
Горутина A читает 0; B читает 0; A записывает 1; B записывает 1. Все обращения выполнены атомарными методами, однако составная операция потеряла одно увеличение. Это логическая гонка без data race на этой ячейке.
Для обычного счётчика используйте n.Add(1). Если новое значение зависит от проверенного старого, нужна CAS-петля с повторной проверкой после неудачи либо Mutex на всю операцию. Чистый -race не докажет правильность схемы Load → Store.
Атомики — ядро топика 2 (5–8) и топика 5 (16–20): lock-free счётчики, флаги
готовности, CAS-структуры, copy-on-write конфиги. Главный навык, который проверяют
задачи, — обоснованный выбор между atomic и
мьютексом по характеру операции и уровню contention, с
опорой на happens-before из модели памяти.