Сервис считает обработанные запросы, чтобы отдавать число в метрики. Счётчик
увеличивают тысячи горутин в секунду. Мьютекс справится, но ради одного 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.Int64n.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 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() перед чтением. Атомарность защищает каждую операцию по
отдельности, но не говорит, когда закончатся остальные. Без WaitLoad мог бы
выполниться, когда горутины досчитали только до 300, и это было бы корректное, но
бесполезное значение.
Атомарные операции — ещё и действия синхронизации. Если атомарное чтение увидело
значение, записанное атомарной записью, то запись happens-before чтения. Значит, вместе
со значением атомика читатель видит и всё, что писатель записал до атомарной записи.
На этом построена публикация по флагу готовности:
var ready atomic.Boolvar 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 подходит для одной переменной: счётчик, флаг, указатель на неизменяемый
объект.
Мьютекс нужен, когда операция затрагивает несколько переменных или состоит из
нескольких шагов: «проверить остаток и согласованно обновить три поля». Набор
атомарных вызовов такой участок неделимым не делает. Бывают алгоритмы на 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), потом
думать о шардировании.
CompareAndSwap(old, new) записывает new, только если в ячейке сейчас old, и
сообщает, получилось ли. Из этого строят «условные» атомарные изменения: прочитали
значение, вычислили новое, попытались записать; если кто-то успел изменить ячейку,
повторяем.
package mainimport ( "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 стека), это реальная ошибка:
указатель тот же, а объект за ним уже другой.
Конфигурацию сервиса читают на каждом запросе, а меняют раз в несколько минут.
Мьютекс на каждом чтении здесь лишний. Вместо этого писатель собирает новый объект
конфигурации целиком и атомарно подменяет указатель на него. Читатели всегда получают
какую-то одну целую версию: старую или новую.
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 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)}
старый снимок: 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 выпишите порядок шагов, при котором итог будет
Найдёт ли эту ошибку 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, а здесь мьютекс.