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

sync/atomic

12 мин чтенияКонтент v0.12.1

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

Сервис считает обработанные запросы, чтобы отдавать число в метрики. Счётчик увеличивают тысячи горутин в секунду. Мьютекс справится, но ради одного n++ горутины будут вставать в очередь, а при занятом мьютексе ещё и засыпать и просыпаться через планировщик. Для такого случая процессор умеет больше: увеличить число в памяти одной неделимой инструкцией. Доступ к таким инструкциям даёт пакет sync/atomic.

В главе разберём, что значит «атомарно», какие гарантии видимости даёт atomic с точки зрения модели памяти, когда atomic быстрее мьютекса, а когда нет, как устроен цикл с CompareAndSwap и как атомарно подменить целую структуру данных.

Перед чтением

Нужны указатели, WaitGroup, Mutex и различие между гонкой данных и логической гонкой (race condition) из главы о модели памяти. Начните с методов Add, Load и Store и с вопроса, почему два атомарных вызова подряд не становятся одной неделимой операцией. Разделы про CAS и atomic.Pointer можно перечитать после первой задачи; для atomic.Pointer[T] нужен синтаксис дженериков (квадратные скобки с типом).

Два слова, которые встретятся дальше. Опубликовать данные — сделать полностью подготовленный объект доступным другим горутинам. Снимок (snapshot) — версия данных, которую после публикации больше не меняют.

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

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

Обычный n++ неделимым не является. Это три шага: прочитать n из памяти, прибавить единицу, записать обратно. Если две горутины выполнят эти шаги вперемешку, одно увеличение потеряется. Атомарное Add делает все три шага одной операцией процессора, в которую другие ядра вклиниться не могут.

С Go 1.19 в пакете есть типы-обёртки, и пользоваться стоит ими:

var n atomic.Int64
n.Add(1)                       // атомарно прибавить, возвращает новое значение
v := n.Load()                  // атомарно прочитать
n.Store(10)                    // атомарно записать
old := n.Swap(5)               // записать новое, вернуть прежнее
ok := n.CompareAndSwap(5, 6)   // если сейчас 5 — записать 6 и вернуть true

Есть atomic.Int32, Int64, Uint32, Uint64, Uintptr, Bool, обобщённый atomic.Pointer[T] и atomic.Value для значения произвольного типа.

Старый стиль — функции над обычными переменными: atomic.AddInt64(&n, 1), atomic.LoadInt64(&n). Он работает, но у него две проблемы, которые обёртки решают. Во-первых, ничто не мешает где-то рядом написать обычное n++ или x := n, и это сразу гонка данных: переменная выглядит как любая другая. У atomic.Int64 поле спрятано, и обратиться к нему можно только через методы. Во-вторых, на 32-битных платформах (386, ARM32) 64-битные атомарные функции требуют, чтобы переменная лежала по адресу, кратному 8; поле int64 в середине структуры может оказаться невыровненным, и программа упадёт с паникой. Тип atomic.Int64 выравнивается автоматически.

Обёртки, как и мьютекс, нельзя копировать после первого использования: у копии будет своя независимая ячейка. Держите их внутри структуры, которую передают по указателю; go vet предупредит о копировании.

Десять горутин по сто раз вызывают 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() перед чтением. Атомарность защищает каждую операцию по отдельности, но не говорит, когда закончатся остальные. Без Wait Load мог бы выполниться, когда горутины досчитали только до 300, и это было бы корректное, но бесполезное значение.

Гарантии видимости

Атомарные операции — ещё и действия синхронизации. Если атомарное чтение увидело значение, записанное атомарной записью, то запись happens-before чтения. Значит, вместе со значением атомика читатель видит и всё, что писатель записал до атомарной записи.

На этом построена публикация по флагу готовности:

var ready atomic.Bool
var data []byte
 
// писатель
data = build()        // обычная запись
ready.Store(true)     // публикация
 
// читатель
if ready.Load() {     // увидели true...
    use(data)         // ...значит, видим и результат build()
}

Цепочка: data = build() раньше ready.Store(true) по порядку строк писателя; Store happens-before Load, который увидел true; Load раньше use(data) по порядку строк читателя. По транзитивности build() happens-before use(data).

У гарантии есть граница. Она работает, только если читатель обращается к data после того, как увидел true, и только если после публикации data никто не меняет. Если писатель после Store(true) продолжит дописывать в data, это снова гонка: флаг уже поднят, а новые записи ни с чем не связаны.

Все атомарные операции в программе ведут себя так, будто выполняются в одном общем порядке, который согласован с порядком строк каждой горутины (последовательная согласованность). В других языках бывают «ослабленные» атомики с более слабыми гарантиями; в Go их нет.

atomic или мьютекс

Граница проходит по тому, сколько данных должна охватить операция.

atomic подходит для одной переменной: счётчик, флаг, указатель на неизменяемый объект.

Мьютекс нужен, когда операция затрагивает несколько переменных или состоит из нескольких шагов: «проверить остаток и согласованно обновить три поля». Набор атомарных вызовов такой участок неделимым не делает. Бывают алгоритмы на CAS и неизменяемых снимках, которые обходятся без мьютекса, но мьютекс почти всегда проще написать и проверить.

По скорости всё решает конкуренция (contention): насколько часто горутины на разных ядрах обращаются к одной и той же ячейке.

  • При низкой конкуренции atomic заметно дешевле мьютекса: никто не засыпает и не просыпается.
  • При высокой конкуренции за одну ячейку обе схемы упираются в одно и то же. Чтобы изменить значение, ядро должно получить строку кэша с этой ячейкой в своё исключительное владение, и строка начинает непрерывно перебрасываться между ядрами. В таком случае помогает шардирование: несколько независимых счётчиков (скажем, 32), каждая горутина пишет в «свой» по хешу или номеру, а при чтении значения суммируются. Чтобы соседние счётчики не делили одну строку кэша (64 байта на большинстве процессоров), между ними иногда вставляют пустые поля-прокладки.

Простейший счётчик на atomic:

type Counter struct{ n atomic.Int64 }
 
func (c *Counter) Inc()         { c.n.Add(1) }
func (c *Counter) Value() int64 { return c.n.Load() }

Практическое правило: одиночный счётчик или флаг — atomic; согласованная группа данных — мьютекс; очень горячая ячейка — сначала измерить (go test -bench с -cpu=1,4,8), потом думать о шардировании.

CAS: прочитать, вычислить, записать, если никто не помешал

CompareAndSwap(old, new) записывает new, только если в ячейке сейчас old, и сообщает, получилось ли. Из этого строят «условные» атомарные изменения: прочитали значение, вычислили новое, попытались записать; если кто-то успел изменить ячейку, повторяем.

package main
 
import (
	"fmt"
	"sync"
	"sync/atomic"
)
 
// addIfPositive прибавляет d, только если текущее значение больше нуля.
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
		}
		// значение изменили между 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

Разберём одну неудачную попытку. Горутина A прочитала old = 120. Пока она вычисляла 121, горутина B успешно записала 121. Теперь A вызывает CompareAndSwap(120, 121): в ячейке не 120, а 121, запись не происходит, метод возвращает false, и A идёт на второй круг, читая уже 121. Ни одно увеличение не теряется.

Цена такой схемы — повторные попытки. При высокой конкуренции горутины много раз читают, вычисляют и проигрывают, тратя процессор.

Вторая ловушка — проблема ABA. Между вашим Load и CAS значение могло смениться с A на B и обратно на A. CAS увидит A и решит, что ничего не изменилось. Для счётчиков, которые только растут, это не страшно. Для указателей на объекты, которые переиспользуются (например, узлы самописного lock-free стека), это реальная ошибка: указатель тот же, а объект за ним уже другой.

atomic.Pointer и atomic.Value: подмена целого снимка

Конфигурацию сервиса читают на каждом запросе, а меняют раз в несколько минут. Мьютекс на каждом чтении здесь лишний. Вместо этого писатель собирает новый объект конфигурации целиком и атомарно подменяет указатель на него. Читатели всегда получают какую-то одну целую версию: старую или новую.

var cfg atomic.Pointer[Config]
 
func Reload(c *Config) { cfg.Store(c) }        // публикуем новую версию целиком
func Current() *Config { return cfg.Load() }   // берём текущий снимок

Этот приём называют copy-on-write («копирование при записи»): изменение — это всегда новая копия, а не правка старой. Читатель, взявший старый снимок, спокойно дорабатывает с ним; следующий читатель получит новый. Главное условие: опубликованный объект никто не меняет. Атомарна только подмена указателя, а не поля объекта за ним.

Запускаемый пример использует atomic.Value: в браузерной песочнице нет обобщённого atomic.Pointer[T]. Смысл тот же, только при чтении нужно приведение типа:

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)
}
старый снимок: v1, 4 workers
текущий конфиг: v2, 8 workers

Увидеть «половину» обновления (новый Version со старым Workers) невозможно: подменяется указатель целиком, одной атомарной записью.

Частые ошибки

  • Атомарный и обычный доступ к одной переменной. Если переменную меняют через atomic, все обращения к ней должны быть атомарными. Одно обычное чтение рядом с atomic.AddInt64 — гонка данных. Типы-обёртки не дают совершить эту ошибку.
  • Два атомарных вызова считают одной операцией. Load, потом Store — две неделимые операции, между которыми может вклиниться другая горутина. Для «проверить и обновить» нужен CAS-цикл или мьютекс.
  • Копирование обёртки. Counter, переданный по значению, — это другой счётчик.
  • Несколько atomic-полей вместо мьютекса. Каждое поле по отдельности атомарно, но согласованности между полями нет: читатель может увидеть новое значение одного поля и старое значение другого.

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

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

Попробуйте сами

Замените в счётчике n.Add(1) на два вызова: old := n.Load() и n.Store(old + 1). Для двух горутин и начального значения 0 выпишите порядок шагов, при котором итог будет

  1. Найдёт ли эту ошибку go test -race? Как сохранить требование «каждый вызов увеличивает ровно на один»?

Проверка

A читает 0; B читает 0; A записывает 1; B записывает 1. Итог 1, одно увеличение потеряно.

Race detector эту ошибку не найдёт: все обращения атомарные, гонки данных нет. Это логическая гонка (race condition): неделимыми оказались отдельные шаги, а не вся операция. Исправление — n.Add(1). Если новое значение зависит от проверки старого, нужен CAS-цикл, как в addIfPositive, или мьютекс на всю операцию.

Что дальше

Следующая глава, context, возвращается к каналам: она показывает, как одним сигналом остановить целое дерево горутин.

В задачах курса atomic нужен в блоке 2 (задача 05: счётчик на мьютексе и на atomic) и в блоке 5 (лимитеры, счётчики токенов). Задачи проверяют не столько знание API, сколько обоснованный выбор: почему здесь atomic, а здесь мьютекс.

Продолжить

Остались вопросы?