JavaRush /Курсы /Go SELF /sync/atomic — счётчик/флаг и когда это оправдано

sync/atomic — счётчик/флаг и когда это оправдано

Go SELF
68 уровень , 2 лекция
Открыта

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 в религию, удобно держать в голове простой набор операций:

Что хотим сделать Типично используем Смысл
Прочитать значение
Load()
безопасное чтение «прямо сейчас»
Записать значение
Store(v)
безопасная запись
Увеличить/уменьшить
Add(delta)
атомарное изменение счётчика
«Поменять и вернуть старое»
Swap(v)
иногда удобно для сброса
«Поменять, только если…»
CompareAndSwap(old, new)
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 берём, когда нужна корректность/точность для простых переменных под параллельной нагрузкой, а не потому что «хочу быстро».

Сведём выбор в таблицу (как «памятка для живых людей»):

Инструмент Что защищает Когда брать Типичный пример
sync.Mutex
инвариант, связанный набор полей/операций почти всегда, как базовый выбор map, slice, несколько полей структуры
sync.RWMutex
инвариант, но с разными режимами доступа когда реально много чтений и мало записей справочник, кэш «read-heavy»
sync/atomic
одну переменную независимый счётчик/флаг, 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‑переключатели «первый победил».

1
Задача
Go SELF, 68 уровень, 2 лекция
Недоступна
Атомарный кликер
Атомарный кликер
1
Задача
Go SELF, 68 уровень, 2 лекция
Недоступна
Параллельные клики
Параллельные клики
1
Задача
Go SELF, 68 уровень, 2 лекция
Недоступна
Сброс метрики
Сброс метрики
1
Задача
Go SELF, 68 уровень, 2 лекция
Недоступна
Закрыть сервис
Закрыть сервис
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ