Пакет sync/atomic нужен для атомарных операций над отдельными значениями: счётчиками, флагами и указателями. Он позволяет обойтись без мьютекса, если вся операция умещается в одно изменение такого значения. Но атомарная операция не делает потокобезопасной всю структуру и не объединяет несколько действий в одну транзакцию. Разберу современные типы atomic.Int64, atomic.Bool, atomic.Pointer и atomic.Value, покажу, когда нужен CompareAndSwap, и сравню атомарные операции с мьютексом на бенчмарке.
Примеры проверены на Go 1.26.9 для linux/amd64.
Почему counter++ нельзя использовать из разных горутин
Начнём с обычного счётчика:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int64
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
counter++
})
}
wg.Wait()
fmt.Println(counter)
}
WaitGroup.Go появился в Go 1.25. В более ранних версиях перед запуском горутины нужно вызвать wg.Add(1), а внутри неё вызвать defer wg.Done().
Операция counter++ выглядит неделимой, но для программы это чтение старого значения, увеличение на единицу и запись результата. Две горутины могут прочитать одно число, вычислить одинаковый результат и записать его дважды. Одно увеличение потеряется.
Запуск с race detector подтверждает проблему:
$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c0000140d8 by goroutine 8:
main.main.func1()
main.go:15 +0x44
Previous write at 0x00c0000140d8 by goroutine 7:
main.main.func1()
main.go:15 +0x44
==================
Вывод сокращён. Даже если программа иногда печатает 1000, гонка никуда не исчезает: в этот раз планировщик мог просто не создать конфликтующий порядок выполнения.
Исправление через atomic.Int64:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
counter.Add(1)
})
}
wg.Wait()
fmt.Println(counter.Load())
}
Теперь каждое увеличение выполняется атомарно. Load атомарно читает итоговое значение. Программа печатает 1000, а go run -race main.go не сообщает о гонке.
Если значение изменяется через атомарные операции, читать его тоже нужно атомарно. Обычное чтение одновременно с Store или Add создаёт гонку. Поэтому после завершения всех горутин в примере используется counter.Load(), а не прямой доступ к внутреннему полю.
Нулевое значение atomic.Int64 готово к работе. Конструктор не нужен.
Современные типы вместо функций
В пакете есть два набора API. Старый состоит из функций, которые принимают указатели:
var counter int64
atomic.AddInt64(&counter, 1)
value := atomic.LoadInt64(&counter)
Новый использует типизированные значения:
var counter atomic.Int64
counter.Add(1)
value := counter.Load()
Для нового кода я выбираю второй вариант. Операции собраны в методах конкретного типа, код короче, а нулевое значение по-прежнему рабочее. Типизированные обёртки также обеспечивают выравнивание 64-битных значений, необходимое для атомарных операций на 32-битных платформах. При использовании старых функций выравнивание приходилось учитывать самостоятельно.
Основные типы:
| Тип | Для чего подходит |
|---|---|
atomic.Bool | Флаг: запущено, остановлено, инициализировано |
atomic.Int32, atomic.Int64 | Знаковые счётчики и числовые состояния |
atomic.Uint32, atomic.Uint64 | Целые числа без знака, битовые маски |
atomic.Uintptr | Значения размера машинного слова, обычно низкоуровневый код |
atomic.Pointer[T] | Атомарная замена указателя на объект типа T |
atomic.Value | Хранение значений одного конкретного типа через any |
Типы из sync/atomic нельзя копировать после первого использования. Это то же правило, что у sync.Mutex и sync.WaitGroup. Поэтому структуры с атомарными типами обычно передают по указателю. Подробнее такие ошибки я разбирал в статье про копирование мьютекса.
Операции над одним значением
У atomic.Int64 есть пять основных методов:
var counter atomic.Int64
counter.Store(10) // записать 10
value := counter.Load() // прочитать значение
old := counter.Swap(20) // записать 20 и вернуть прежнее значение
counter.Add(5) // атомарно прибавить 5
changed := counter.CompareAndSwap(25, 30)
Store заменяет значение. Load читает его. Swap полезен, когда нужен не только новый результат, но и прежнее состояние. Add возвращает новое значение.
Последняя строка требует отдельного разбора. CompareAndSwap меняет значение только в том случае, если оно всё ещё равно ожидаемому. Если другая горутина успела его изменить, метод ничего не записывает и возвращает false.
Важно не заменять одну атомарную операцию парой Load и Store:
// Так нельзя: между Load и Store значение может измениться.
if counter.Load() == 25 {
counter.Store(30)
}
Здесь проверка и запись разделены. CompareAndSwap(25, 30) выполняет их как одну атомарную операцию.
Где нужен CompareAndSwap
Допустим, несколько горутин обрабатывают результаты и обновляют общий максимум:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func updateMax(max *atomic.Int64, value int64) {
for {
current := max.Load()
if value <= current {
return
}
if max.CompareAndSwap(current, value) {
return
}
}
}
func main() {
var max atomic.Int64
var wg sync.WaitGroup
for _, value := range []int64{12, 7, 42, 18, 31} {
wg.Go(func() {
updateMax(&max, value)
})
}
wg.Wait()
fmt.Println(max.Load())
}
Программа печатает 42.
Здесь одного Swap недостаточно: горутина с меньшим результатом могла бы перезаписать больший. Load с последующим Store тоже не помогает: между ними другой поток способен обновить максимум.
Цикл CompareAndSwap решает задачу так:
- Прочитать текущее значение.
- Проверить, нужно ли обновление.
- Попытаться заменить именно прочитанное значение.
- При неудаче повторить всё сначала.
Такой приём называют CAS-циклом. Он хорошо подходит для простых вычислений над одним значением. Если внутри цикла появляется сложная логика, обращение к нескольким полям или ввод-вывод, мьютекс обычно даёт более понятный код.
atomic.Bool для флага
atomic.Bool подходит для состояния с двумя значениями. Например, одна горутина сообщает об остановке, другая проверяет флаг:
var stopped atomic.Bool
stopped.Store(true)
if stopped.Load() {
// Завершаем текущую работу.
}
Атомарный флаг не заменяет механизм отмены сам по себе. Он сообщает состояние, но не будит заблокированную горутину, не отменяет сетевой запрос и не закрывает каналы. Для отмены операций в Go обычно используют context.Context; я разбирал его в отдельной статье.
У atomic.Bool есть CompareAndSwap. С его помощью можно выполнить инициализацию только один раз:
var initialized atomic.Bool
if initialized.CompareAndSwap(false, true) {
// Этот блок выполнит только одна горутина.
}
Но если инициализация может вернуть ошибку или должна гарантированно завершиться до работы остальных горутин, лучше посмотреть на sync.Once или sync.OnceValues. Установка флага сама по себе не описывает результат и ошибку.
atomic.Pointer и атомарная замена конфигурации
Иногда требуется заменить целый объект, пока другие горутины читают прежнюю версию. Для этого подходит atomic.Pointer[T]:
package main
import (
"fmt"
"sync/atomic"
)
type Config struct {
Endpoint string
Timeout int
}
func main() {
var current atomic.Pointer[Config]
current.Store(&Config{Endpoint: "https://api.example.com", Timeout: 5})
config := current.Load()
fmt.Println(config.Endpoint)
current.Store(&Config{Endpoint: "https://backup.example.com", Timeout: 10})
}
Читатель получает указатель на одну из версий конфигурации. Такой подход удобен, когда конфигурации неизменяемы после публикации: обновление создаёт новый объект и атомарно заменяет указатель.
Если же читатель и писатель одновременно меняют поля одного Config, atomic.Pointer эту гонку не устраняет. Он синхронизирует доступ к указателю, а не ко всему объекту за ним.
atomic.Value и его ограничения
atomic.Value хранит значение через интерфейс:
var value atomic.Value
value.Store("production")
current := value.Load().(string)
У него есть важное ограничение: все вызовы Store должны сохранять значения одного конкретного типа. Нельзя сначала записать строку, а затем число. Нельзя также сохранить nil или скопировать использованный atomic.Value.
Поэтому atomic.Value не стоит воспринимать как универсальную замену любому полю под мьютексом. Для указателя на конфигурацию atomic.Pointer[Config] сохраняет тип в API и обычно удобнее. atomic.Value полезен там, где нужен его интерфейсный контракт и тип значения контролируется внутри небольшого пакета.
Что атомарные операции не защищают
Главная ошибка при работе с sync/atomic состоит в переносе гарантии одной операции на всё состояние объекта.
Предположим, сервис хранит баланс и число операций:
type Account struct {
balance atomic.Int64
operations atomic.Int64
}
func (a *Account) Withdraw(amount int64) {
a.balance.Add(-amount)
a.operations.Add(1)
}
Каждая строка атомарна. Но другая горутина может прочитать новый баланс и старое число операций между ними. Более того, два одновременных вызова могут каждый изменить баланс, не проверив, достаточно ли средств.
Если бизнес-операция требует согласованного изменения нескольких полей, используйте мьютекс:
type Account struct {
mu sync.Mutex
balance int64
operations int64
}
func (a *Account) Withdraw(amount int64) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return false
}
a.balance -= amount
a.operations++
return true
}
Здесь проверка баланса, его изменение и увеличение счётчика образуют одну критическую секцию. Заменить её двумя атомарными типами нельзя.
Тот же принцип действует для map, слайсов и связанных структур данных. Атомарный счётчик длины не защищает саму map. Атомарный указатель на слайс не делает изменение его элементов безопасным.
Атомарные типы или sync.Mutex
Я выбираю не по общему правилу «атомарные типы быстрее», а по форме операции.
| Ситуация | Что обычно выбрать |
|---|---|
| Увеличить счётчик | atomic.Int64 |
| Прочитать или переключить флаг | atomic.Bool |
| Атомарно заменить неизменяемую конфигурацию | atomic.Pointer[T] |
| Обновить максимум по текущему значению | CAS-цикл |
| Проверить и изменить несколько полей | sync.Mutex |
| Защитить map или слайс | sync.Mutex или sync.RWMutex |
| Нужна сложная последовательность действий | sync.Mutex |
У мьютекса есть преимущество, которое наносекунды в бенчмарке не показывают: он явно очерчивает инвариант. Всё, что должно оставаться согласованным, находится между Lock и Unlock.
CAS-цикл при сильной конкуренции может повторять попытки. Мьютекс ставит горутину в ожидание блокировки. Какой вариант лучше, зависит от длины критической секции, числа конкурирующих горутин и реальной нагрузки.
Бенчмарк atomic.Int64 против sync.Mutex
Проверим два варианта счётчика. Сначала последовательные вызовы:
func BenchmarkAtomicCounter(b *testing.B) {
var counter atomic.Int64
for b.Loop() {
counter.Add(1)
}
}
func BenchmarkMutexCounter(b *testing.B) {
var mu sync.Mutex
var counter int64
for b.Loop() {
mu.Lock()
counter++
mu.Unlock()
}
}
Затем общий счётчик из параллельных горутин:
func BenchmarkAtomicCounterParallel(b *testing.B) {
var counter atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
counter.Add(1)
}
})
}
func BenchmarkMutexCounterParallel(b *testing.B) {
var mu sync.Mutex
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
counter++
mu.Unlock()
}
})
}
Запускаю пять раз на Go 1.26.9, linux/amd64, AMD Ryzen 5 PRO 4650U:
$ go test -bench . -benchmem -count=5 .
Диапазоны результатов:
| Бенчмарк | Время на операцию | Аллокации |
|---|---|---|
AtomicCounter | 7,9–8,1 нс | 0 |
MutexCounter | 9,1–15,3 нс | 0 |
AtomicCounterParallel | 9,4–9,8 нс | 0 |
MutexCounterParallel | 99–109 нс | 0 |
На графиках серым показан лучший результат из пяти запусков, синим — худший.
В последовательном тесте разница небольшая и нестабильная. На параллельном общем счётчике мьютекс в этом эксперименте оказался примерно на порядок медленнее. Это не универсальный коэффициент: результат относится к пустой критической секции, конкретному процессору и сильной конкуренции за одно значение.
Сам бенчмарк не говорит, что все мьютексы надо заменить атомарными типами. Он показывает стоимость конкретной операции. Если под блокировкой выполняется полезная работа или меняются несколько связанных полей, упростить её до Add нельзя. Методику сравнения и необходимость нескольких запусков я подробнее разбирал в статье о бенчмарках.
Типичные ошибки
Использовать атомарный тип для составной операции. Проверка через Load и последующий Store не атомарны вместе. Нужны CompareAndSwap или блокировка.
Считать всю структуру безопасной. atomic.Int64 защищает только своё значение. Соседние поля этой гарантии не получают.
Менять объект через атомарный указатель. Публикация нового неизменяемого объекта безопасна. Совместное изменение его полей требует отдельной синхронизации.
Копировать атомарный тип. После первого использования такие значения копировать нельзя. Храните их в структуре и передавайте структуру по указателю. go vet ./... помогает найти часть таких копирований.
Выбирать атомарные типы только из-за скорости. CAS-цикл с несколькими полями и ветвлениями сложнее читать и легче сломать. Понятный мьютекс часто лучше, пока профиль не доказал обратное.
Итог
sync/atomic хорошо работает там, где состояние помещается в одно значение: счётчик, флаг или указатель на неизменяемый объект. Для нового кода используйте типизированные atomic.Int64, atomic.Bool и atomic.Pointer[T] вместо старых функций над указателями.
Если решение зависит от текущего значения, нужен CompareAndSwap, а не раздельные Load и Store. Если операция связывает несколько полей, атомарные типы не сохраняют инвариант между ними, здесь нужен мьютекс.
Бенчмарк подтверждает преимущество атомарного счётчика под конкурентной нагрузкой, но не отменяет главное правило: сначала выберите корректную модель синхронизации, затем измеряйте её на реальном сценарии.
Теги: