Все главы учебника
Содержание учебника
Глава 06 / Основы

sync/atomic

11 мин чтенияКонтент v0.7.1

О чём эта глава

Иногда для синхронизации не нужен целый мьютекс. Если вся «общая память» — это один счётчик, один флаг или один указатель, то парковать горутины и звать планировщик жаль: можно обойтись одной атомарной инструкцией процессора. Этим и занимается пакет 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.Int64
n.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 main
 
import (
	"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.Bool
var 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 против мьютекса: дело в contention

Когда брать 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; при экстремальной нагрузке на одну ячейку — не угадывайте, а меряйте и думайте про шардирование.

CAS и lock-free алгоритмы

CompareAndSwap (CAS) — это кирпич, из которого строят неблокирующие структуры. Идея простая: «я прочитал значение, посчитал новое, и записываю его только если за это время никто не успел поменять старое». Если успел — начинаем заново.

package main
 
import (
	"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 и atomic.Value

Когда нужно атомарно опубликовать целый снапшот — конфиг, таблицу маршрутов, готовый кэш — на помощь приходят atomic.Pointer[T] и atomic.Value. Писатель готовит новый объект целиком и атомарно подменяет указатель; читатели всегда видят какую-то одну согласованную версию, без явного мьютекса в пользовательском коде. Опубликованный объект нельзя менять без отдельной синхронизации: атомарна подмена указателя, а не его поля.

var cfg atomic.Pointer[Config]
 
func Reload(c *Config) *Config { cfg.Store(c); return c } // публикация целиком
func Current() *Config         { return cfg.Load() }       // целостный снапшот

Это паттерн copy-on-write: старые читатели спокойно дочитывают старую версию, новые берут новую, и никто не ждёт. На горячем пути чтения нет ни Mutex, ни даже RWMutex — а именно чтение конфига обычно и есть горячий путь.

Соберём упрощённую модель такого реестра. Типобезопасный atomic.Pointer[Config] читается чуть приятнее, но браузерный yaegi не поддерживает обобщённые типизованные атомики, поэтому ниже для запуска используем atomic.Value (хранит *Config под any, читатель делает приведение типа) — семантика та же:

package main
 
import (
	"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, без atomic
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++ // читать-инкрементить-записать: три неатомарных шага
        }
    }()
}
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 из модели памяти.

Закрепи на практике06. Потокобезопасный кэш (95% reads)