GraphLMS

Go
0/32 решеноНачать
Глава 06 · Основы~11 мин чтения

sync/atomic

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

Иногда для синхронизации не нужен целый мьютекс. Если вся «общая память» — это один счётчик, один флаг или один указатель, то парковать горутины и звать планировщик жаль: можно обойтись одной атомарной инструкцией процессора. Этим и занимается пакет sync/atomic.

В этой главе разберёмся, что именно гарантирует «атомарность», как atomic связан с моделью памяти и happens-before, когда atomic честно быстрее мьютекса, а когда наоборот превращается в драку за кэш-линию. Попутно соберём lock-free счётчик, потрогаем CompareAndSwap и поймём, почему «атомарное поле» — это ещё не «атомарная операция».

Что такое атомарная операция

Атомарная операция над ячейкой памяти выполняется неделимо: любая другая горутина видит либо состояние «до», либо состояние «после», но никогда — промежуток. Нет момента, когда инкремент «наполовину применился».

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

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 успешных прибавлений
}

Цикл 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-флагов.

Мысленная модель

Думайте об atomic как о «мьютексе на одну переменную ценой в одну CPU-инструкцию». Он даёт ровно две вещи: неделимость операции и happens-before — но только для одной ячейки памяти. Как только в игру вступают несколько переменных или связка «прочитать → подумать → записать» как единое целое, выбор сужается до двух честных вариантов: CAS-петля или мьютекс.

Что дальше

Как это связано с задачами тренажёра

Атомики — ядро топика 2 (5–8) и топика 5 (16–20): lock-free счётчики, флаги готовности, CAS-структуры, copy-on-write конфиги. Главный навык, который проверяют задачи, — обоснованный выбор между atomic и мьютексом по характеру операции и уровню contention, с опорой на happens-before из модели памяти.