1. Зачем нам atomic, если уже есть Mutex?
Когда вы впервые узнали про Mutex, могло появиться ощущение: «Ну всё, теперь я просто оберну любой доступ к данным в Lock/Unlock и буду жить спокойно». И это неплохой план для большинства задач. Но иногда хочется чего-то попроще: например, просто посчитать, сколько раз вызвали функцию, или поднять флаг «мы уже остановились» так, чтобы вторая goroutine не «останавливала» то, что уже остановлено.
И вот тут Mutex может быть избыточным. Не потому что он «плохой», а потому что его смысл — защищать инварианты и несколько связанных действий. А когда нужно сделать одну маленькую операцию над одной переменной, вроде «увеличить счётчик на 1» или «установить флаг в true», sync/atomic даёт способ сделать это корректно без явной блокировки.
Важно сразу зафиксировать настроение дня: atomic — не «модная оптимизация» и не «замена Mutex». Это как скальпель: полезно, когда нужна точность на маленьком участке, но странно резать им хлеб, колбасу и жизненные проблемы.
2. Ментальная модель: “атомарно” — значит «как единое действие»
Слово «атомарно» здесь означает: операция выполняется так, будто она неделима для других goroutine. То есть либо она ещё не произошла, либо уже произошла целиком — без «полудоступов» и без «я прочитал старое значение, а ты уже успел записать новое».
Самая частая ошибка новичка — думать, что n++ это «одна операция». Для человека это одна операция. Для процессора и компилятора это обычно «прочитать n → прибавить 1 → записать обратно». Между чтением и записью другая goroutine может тоже влезть и сделать своё «прочитать → прибавить → записать», и в итоге вы теряете инкременты.
Посмотрим на это как на мини-схему:
n++ (в реальности) = Load(n) -> Add(1) -> Store(n)
Если два потока делают это одновременно, то оба могут прочитать одно и то же значение, а потом оба записать один и тот же результат.
Мини-пример «как сломать счётчик» (код специально неправильный):
package main
import (
"fmt"
"sync"
)
func main() {
var n int
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); n++ }()
go func() { defer wg.Done(); n++ }()
wg.Wait()
fmt.Println(n) // может быть 1 или 2 (недетерминированно)
}
Да, иногда вы увидите 2, иногда 1. И именно это «иногда» — главный красный флаг конкурентного кода: «иногда работает» почти всегда означает «иногда ломается, но вы ещё не заметили».
3. Атомарные типы в Go: atomic.Int64, atomic.Bool
Пакет sync/atomic в современном Go предлагает удобные типы-обёртки: atomic.Int64, atomic.Uint64, atomic.Bool и другие. Они хороши тем, что читаются как нормальный API, а не как набор загадочных функций.
Ключевой плюс для нас как для людей: вместо «вызови atomic.AddInt64(&x, 1) и не перепутай амперсанд» можно писать x.Add(1).
Счётчик: atomic.Int64
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var hits atomic.Int64
hits.Add(1)
hits.Add(5)
fmt.Println(hits.Load()) // 6
}
Здесь Add — атомарное «увеличить и вернуть новое значение», а Load — атомарное «прочитать текущее значение».
Флаг: atomic.Bool
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var ready atomic.Bool
ready.Store(true)
fmt.Println(ready.Load()) // true
}
Это полезно, когда вам нужно хранить состояние «да/нет», которое читают и пишут из разных goroutine.
Шпаргалка по основным методам
Чтобы не превращать atomic в религию, удобно держать в голове простой набор операций:
| Что хотим сделать | Типично используем | Смысл |
|---|---|---|
| Прочитать значение | |
безопасное чтение «прямо сейчас» |
| Записать значение | |
безопасная запись |
| Увеличить/уменьшить | |
атомарное изменение счётчика |
| «Поменять и вернуть старое» | |
иногда удобно для сброса |
| «Поменять, только если…» | |
CAS: «первый победил» |
4. Пример: TaskStore и атомарные счётчики
Чтобы atomic не остался абстракцией, продолжим нашу сквозную историю с задачами (мини‑todo). У нас есть хранилище задач в памяти: внутри map, доступ к нему должен быть защищён (это мы уже делали через Mutex/RWMutex, потому что map сам по себе не потокобезопасен).
А вот метрики вроде «сколько задач создали» или «сколько раз читали задачу» — идеальный кандидат на атомарные счётчики. Почему? Потому что это независимые числа, и нам не нужно держать общий «инвариант» между ними и map. Если счётчик чуть-чуть «убежит вперёд» относительно реального состояния на миллисекунду — мир не рухнет, это статистика.
Опишем структуру TaskStore (кусочек):
package main
import (
"sync"
"sync/atomic"
)
type TaskStore struct {
mu sync.RWMutex
tasks map[int]string
nextID atomic.Int64
creates atomic.Int64
reads atomic.Int64
}
Здесь:
- tasks защищаем через RWMutex (потому что это настоящие данные, где порядок и согласованность важны),
- nextID, creates, reads — атомарные, потому что это просто числа.
Инициализация:
package main
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[int]string),
}
}
Создание задачи: атомарно берём новый id, под Lock пишем в map, а счётчик creates увеличиваем атомарно.
package main
func (s *TaskStore) Create(title string) int {
id := int(s.nextID.Add(1))
s.mu.Lock()
s.tasks[id] = title
s.mu.Unlock()
s.creates.Add(1)
return id
}
Чтение задачи: под RLock читаем map, но счётчик чтений увеличиваем атомарно (и делаем это уже без удержания RLock, чтобы не растягивать критическую секцию).
package main
func (s *TaskStore) Get(id int) (string, bool) {
s.mu.RLock()
title, ok := s.tasks[id]
s.mu.RUnlock()
s.reads.Add(1)
return title, ok
}
Снимок метрик для вывода:
package main
type Stats struct {
Creates int64
Reads int64
}
func (s *TaskStore) Stats() Stats {
return Stats{
Creates: s.creates.Load(),
Reads: s.reads.Load(),
}
}
Здесь важно понимать: Stats() возвращает снимок, и между Load() двух полей могут пройти наносекунды, за которые другой поток ещё что-то успеет обновить. Но для метрик это нормально. Мы не строим банковскую систему, мы просто считаем, сколько раз что-то произошло.
5. Флаг состояния: «закрыто» через atomic.Bool
Теперь добавим в TaskStore идею жизненного цикла: хранилище можно «закрыть», и после закрытия любые операции должны корректно отказать.
Это частый практический кейс: у вас есть сервис, который останавливается, и вы не хотите, чтобы в момент остановки пара горутин продолжала делать работу «как ни в чём не бывало».
Добавим поле:
package main
import "sync/atomic"
type TaskStore struct {
// ...
closed atomic.Bool
}
Метод Close:
package main
func (s *TaskStore) Close() {
s.closed.Store(true)
}
И проверка в методе Create (упрощённо):
package main
import "errors"
func (s *TaskStore) Create(title string) (int, error) {
if s.closed.Load() {
return 0, errors.New("task store is closed")
}
id := int(s.nextID.Add(1))
// ...
return id, nil
}
В этой точке можно задать хороший инженерный вопрос: «А что если Close() случится ровно между проверкой closed.Load() и записью в map?» Ответ честный: такое возможно. Если вам нужно строгое правило «после закрытия — никаких записей вообще», тогда одной atomic.Bool недостаточно, и придётся расширить дизайн (например, закрывать под Lock и проверять флаг тоже под Lock). То есть atomic решает простую часть задачи, но не отменяет необходимость думать про границы.
CAS: CompareAndSwap — «первый победил»
CompareAndSwap (часто сокращают до CAS) — это операция вида: «замени значение на новое, но только если текущее значение равно ожидаемому». И она тоже атомарна.
Для флага «закрыто» CAS даёт удобный паттерн «закрыть один раз»:
package main
func (s *TaskStore) CloseOnce() bool {
return s.closed.CompareAndSwap(false, true)
}
Теперь вызывающая сторона может понять, кто был первым:
package main
import "fmt"
func main() {
s := NewTaskStore()
fmt.Println(s.CloseOnce()) // true (первый закрыл)
fmt.Println(s.CloseOnce()) // false (уже было закрыто)
}
Этот приём полезен не только для «закрыть». Он часто встречается в конкурентном коде для вещей вроде «стартовать один раз», «инициализировать лениво» (хотя для инициализации обычно приятнее sync.Once), «первый запрос делает прогрев кэша».
6. Когда atomic НЕ подходит: «инвариант живёт между полями»
Очень хочется взять atomic и начать атомарить всё подряд: и map, и slice, и «сразу три числа», и вообще сделать «lock-free», чтобы звучало как название рок-группы.
Проблема в том, что atomic гарантирует целостность операции над одной переменной. А вот если ваш смысл (инвариант) распределён между несколькими полями, то вам нужен Mutex (или другой более высокий механизм), потому что вы защищаете не строку кода и не переменную, а правило согласованности данных.
Представьте, что вы храните:
- sum — сумма всех оценок,
- count — количество оценок,
и хотите считать среднее.
Даже если оба поля атомарные, между Load() этих двух полей другое потоковое обновление может изменить одно из них, и вы получите «среднее» из значений, которые никогда не существовали одновременно. Для аналитики это может быть терпимо, а для бизнес‑логики (например, расчёт скидки, лимитов или тарифа) — уже нет.
Здесь важно не впадать в крайности: иногда «примерное значение» — норм, но если вы ловите себя на мысли «мне нужно, чтобы эти два поля менялись и читались согласованно», то это почти всегда означает Mutex и критическую секцию, где вы меняете оба поля вместе.
7. Когда atomic оправдан
На практике sync/atomic чаще всего оправдан в двух сценариях: счётчики и флаги. Счётчик — потому что инкремент/декремент над одним числом идеально ложится на атомарную модель. Флаг — потому что «да/нет» обычно не требует сложного инварианта.
Но есть нюанс, который ломает популярный миф: атомарные операции не обязаны быть дешевле Mutex. На реальном железе атомарные операции могут вызывать сильную конкуренцию за одну cache line (и это отдельный вид боли). Поэтому atomic — не «турбо-режим по умолчанию», а инструмент для конкретных случаев.
Хорошая иллюстрация из мира инструментов Go: режим покрытия -covermode=atomic нужен, когда важно точно считать выполненные инструкции в параллельных тестах, но он может быть заметно дороже, потому что использует атомарные операции sync/atomic. Это ровно та философия, которую стоит перенести в голову: atomic берём, когда нужна корректность/точность для простых переменных под параллельной нагрузкой, а не потому что «хочу быстро».
Сведём выбор в таблицу (как «памятка для живых людей»):
| Инструмент | Что защищает | Когда брать | Типичный пример |
|---|---|---|---|
|
инвариант, связанный набор полей/операций | почти всегда, как базовый выбор | map, slice, несколько полей структуры |
|
инвариант, но с разными режимами доступа | когда реально много чтений и мало записей | справочник, кэш «read-heavy» |
|
одну переменную | независимый счётчик/флаг, CAS «первый победил» | метрики, nextID, closed |
8. Типичные ошибки при работе с sync/atomic
Ошибка №1: смешивать атомарный и обычный доступ к одной переменной.
Самый частый сценарий выглядит так: «счётчик у нас atomic.Int64, но я тут для удобства сделаю fmt.Println(x) или x = 0 напрямую». И вот в этот момент вы возвращаете гонку данных обратно. Если переменная объявлена атомарной, то все чтения и записи должны идти через Load/Store/Add/Swap/CompareAndSwap. Иначе вы как бы пристёгиваете ремень безопасности только на половине туловища.
Ошибка №2: пытаться atomic-ом поддерживать согласованность нескольких полей.
Две атомарные переменные — это не «атомарная пара». Если бизнес‑правило требует, чтобы A и B менялись вместе, и читались как единое целое, то atomic тут не спасает. В лучшем случае вы получите немного «плавающие» значения, в худшем — сломаете важный инвариант, и баг будет выглядеть как «очень редкое странное поведение на проде».
Ошибка №3: удерживать иллюзию, что Load() даёт «стабильный снимок мира».
Load() честно читает текущее значение. Но это не значит, что через микросекунду оно не изменится. Поэтому любые решения вида «прочитал флаг — и теперь гарантированно десять строк кода можно выполнять без проверок» нужно либо оформлять в более сильный контракт (через Mutex/состояние), либо явно признавать, что это «best effort».
Ошибка №4: копировать структуры с атомарными полями после начала использования.
Как и с Mutex, копирование конкурентного состояния — почти всегда плохая идея. У атомарных типов есть внутренняя семантика, которая подразумевает корректное совместное использование одного экземпляра. Когда вы копируете структуру, вы получаете «две версии счётчика», и дальше легко начать считать «в разные стороны». Правило простое: такие структуры обычно передают по указателю и не копируют после запуска конкурентной работы.
Ошибка №5: использовать atomic как «оптимизацию по умолчанию».
Иногда atomic добавляют просто потому, что «это же конкурентность, надо atomic». На практике это усложняет код, ухудшает читаемость и может даже сделать медленнее из-за contention. Гораздо здоровее начинать с понятного Mutex, а atomic применять точечно: счётчики, флаги, CAS‑переключатели «первый победил».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ