Пакет sync и атомики в Go: примитивы синхронизации на практике

Когда каналов мало: Mutex и RWMutex (и почему RW не всегда быстрее), Once, Cond, WaitGroup, sync.Map и sync.Pool, атомарные операции и их семантика — с честным разбором цены контеншена и false sharing

Каналы — красивый способ координации, но не универсальный. Когда нужно просто защитить общее состояние, счётчик или кэш, мьютекс и атомики короче, понятнее и быстрее. Пакет sync и sync/atomic — это тот слой, на котором стоят и каналы, и весь высокоуровневый код; знать его нужно, чтобы не платить лишнего за синхронизацию и не ловить трудноуловимые баги.

Это вторая статья серии «Конкурентность в Go». В первой был обзор горутин и каналов; здесь — примитивы синхронизации вглубь. Какие гарантии видимости эти примитивы дают на самом деле — тема следующей статьи о модели памяти; в этой мы разбираем практику: что выбрать и как не выстрелить себе в ногу.

Верстак инструментов синхронизации Go: atomic-рычаг (0041→0042), запертый Mutex-счётчик, полосы RWMutex read/write, бак sync.Pool, кнопка Once, табло WaitGroup (0003→0) — «the right tool per job»

В статье

Mutex и RWMutex

sync.Mutex — базовая взаимоблокировка. Нулевое значение уже готово к работе, инициализация не нужна. Lock захватывает, Unlock освобождает; всё между ними — критическая секция, куда единовременно входит одна горутина.

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock() // Unlock даже при panic внутри секции
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

defer c.mu.Unlock() — почти всегда правильный стиль: он гарантирует освобождение даже при раннем return или панике. Ручной Unlock без defer оправдан лишь когда критическая секция короткая, а после неё идёт долгая работа, которую держать под замком не нужно.

Важные свойства Mutex:

  • Не рекурсивный. Повторный Lock в той же горутине без промежуточного Unlock — это дедлок, а не «захват своей же блокировки». В Go нет reentrant-мьютексов, и это намеренно: рекурсивный захват почти всегда признак запутанной структуры.
  • Не привязан к горутине. Освободить мьютекс может любая горутина, не обязательно та, что захватила. Но полагаться на это как на приём координации — плохая идея; для передачи владения есть каналы.
  • TryLock (с Go 1.18). Неблокирующая попытка захвата: вернёт false, если мьютекс занят. Полезен редко — как правило, это признак, что дизайн стоило бы переосмыслить (документация прямо предупреждает об этом).

RWMutex: когда выигрывает, а когда вредит

sync.RWMutex разделяет доступ на два режима: много читателей (RLock/RUnlock) или один писатель (Lock/Unlock). Идея очевидна — если данные читают чаще, чем пишут, читателям не нужно ждать друг друга.

type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache) Set(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

Ключевое заблуждение: RWMutex не всегда быстрее Mutex. Причины:

  • Оверхед больше. RLock/RUnlock делают больше работы, чем Lock/Unlock обычного мьютекса: ведут счётчик читателей и синхронизируются на нём. Для очень коротких критических секций (прочитать одно поле) эта бухгалтерия перевешивает выигрыш от параллельного чтения.
  • Общий счётчик читателей — точка контеншена. Когда десятки ядер одновременно делают RLock, они конкурируют за одну и ту же кэш-линию со счётчиком. Параллельные читатели не блокируют друг друга логически, но упираются в железо.
  • Писатель имеет приоритет. Чтобы писатель не голодал вечно под потоком читателей, ожидающий Lock не пропускает новых читателей вперёд. Значит, при частых записях выигрыш для чтения тает.

RWMutex реально выигрывает, когда читателей подавляющее большинство и критическая секция чтения не микроскопическая (например, обход структуры, а не одно поле). В сомнительных случаях — измерьте оба варианта через бенчмарк, а не выбирайте RWMutex «на всякий случай». Для многих задач обычный Mutex проще и быстрее, а если счётчик читателей всё-таки становится узким местом — часто ответ не RWMutex, а атомики или шардирование.

sync.Once: инициализация ровно один раз

sync.Once гарантирует, что функция выполнится единственный раз, сколько бы горутин ни вызвали Do конкурентно. Остальные вызовы блокируются, пока первый не завершится, — и после этого не выполняют функцию вовсе.

type Service struct {
    once sync.Once
    conn *Conn
}

func (s *Service) Conn() *Conn {
    s.once.Do(func() {
        s.conn = dial() // выполнится ровно один раз
    })
    return s.conn
}

Тонкости:

  • Даже при панике внутри f вызов считается совершённым. Повторный Do уже не выполнит функцию. Если инициализация может провалиться и её нужно повторять — Once не подходит, нужна ручная логика с блокировкой и повторами.
  • С Go 1.21 есть удобные обёртки: sync.OnceFunc(f) возвращает функцию-обёртку, sync.OnceValue(f) — ленивое единственное значение, sync.OnceValues(f) — пару значений. Они закрывают самый частый сценарий «посчитать один раз и потом отдавать готовое» без явного объявления Once.
// config вычислится при первом вызове, дальше вернётся кэшированный результат.
var config = sync.OnceValue(func() Config {
    return loadConfig()
})
// использование: c := config()

sync.WaitGroup: ожидание группы горутин

WaitGroup считает незавершённые горутины. Add(delta) увеличивает счётчик, Done() уменьшает на единицу, Wait() блокирует, пока счётчик не станет нулём.

var wg sync.WaitGroup
for _, task := range tasks {
    wg.Add(1) // Add ДО запуска горутины
    go func(t Task) {
        defer wg.Done() // Done через defer — сработает при любом выходе
        process(t)
    }(t)
}
wg.Wait() // ждём завершения всех

Самая частая ошибка — Add внутри самой горутины:

for _, task := range tasks {
    go func(t Task) {
        wg.Add(1)       // гонка: Wait может выполниться раньше этого Add
        defer wg.Done()
        process(t)
    }(t)
}
wg.Wait() // может вернуться, не дождавшись ни одной горутины

Здесь Wait в главной горутине может успеть увидеть нулевой счётчик и вернуться до того, как запустившиеся горутины сделают Add. Правило простое: Add с положительным дельтой должен happens-before соответствующего Wait — то есть вызывается в вызывающей горутине, до go. race-детектор такую ошибку обычно ловит и печатает «WaitGroup is reused before previous Wait has returned» или гонку на счётчике.

Ещё нюансы:

  • WaitGroup можно переиспользовать, но только после того, как предыдущий Wait вернулся; нельзя вызывать Add с положительным дельтой одновременно с активным Wait.
  • WaitGroup нельзя копировать после первого использования (см. раздел про копирование) — передавайте указателем.
  • В Go 1.25 добавлен wg.Go(func()) — он сам делает Add(1) и Done(), что убирает целый класс ошибок с ручным балансом. Если нужен ещё и сбор ошибок с отменой — смотрите errgroup из статьи о паттернах.

sync.Cond: когда он действительно нужен

sync.Cond — переменная условия: горутины ждут наступления некоторого события, а другая сторона их будит. Wait() атомарно освобождает связанный Locker и усыпляет горутину; Signal() будит одну ожидающую, Broadcast() — всех.

c := sync.NewCond(&sync.Mutex{})
// ожидающая сторона:
c.L.Lock()
for !conditionMet() { // ИМЕННО for, не if
    c.Wait()          // отпускает L, засыпает; при пробуждении снова берёт L
}
// ... условие выполнено, работаем ...
c.L.Unlock()

// будящая сторона:
c.L.Lock()
changeState()
c.L.Unlock()
c.Broadcast() // разбудить ожидающих — пусть перепроверят условие

Условие всегда проверяют в цикле: после пробуждения состояние могло измениться (другая горутина успела его «съесть»), а Broadcast будит всех сразу. if вместо for — классическая ошибка.

Почему в Go Cond встречается редко: у него нет способа встроиться в select, нет таймаута и отмены через context. Почти всегда то же самое элегантнее выражается каналом — закрытие канала как broadcast-сигнал, буферизованный канал как сигнал одному. Cond оправдан в узких случаях: много ожидающих одного изменяемого состояния под общим мьютексом, где канал плодил бы лишнюю сложность. Если сомневаетесь — берите канал.

sync.Map: не всегда лучше map + Mutex

sync.Map — конкурентная мапа, оптимизированная под два конкретных сценария (так и написано в документации):

  1. Запись один раз, чтений много — ключ выставляется однажды и потом только читается (append-only кэш).
  2. Непересекающиеся наборы ключей — разные горутины работают с разными ключами, почти не конкурируя.

В этих случаях sync.Map снижает контеншен по сравнению с map под одним мьютексом за счёт внутренней read-only копии, которую читатели видят без блокировки. Во всех остальных случаях — обычная map под Mutex (или RWMutex) обычно и быстрее, и понятнее.

type Store struct {
    mu sync.Mutex
    m  map[string]int
}

func (s *Store) Load(k string) (int, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    v, ok := s.m[k]
    return v, ok
}

Минусы sync.Map, о которых стоит помнить:

  • Нет типобезопасности. Ключи и значения — any, поэтому на входе/выходе идут упаковка в интерфейс и приведение типа. Обычная map[K]V под мьютексом — типизирована и не боксирует.
  • API непривычный: Load, Store, LoadOrStore, LoadAndDelete, Delete, Range (обход без гарантий согласованного снимка), Swap/CompareAndSwap (Go 1.20), Clear (Go 1.23).
  • Range не атомарен относительно параллельных записей — он видит некоторый набор пар, но не «снимок на момент времени».

Практическое правило: начинайте с map + Mutex. Переходите на sync.Map только если профиль показал контеншен именно на этой блокировке и ваш паттерн доступа совпадает с одним из двух сценариев выше.

sync.Pool: переиспользование под давлением GC

sync.Pool — набор временных объектов, которые можно переиспользовать, чтобы снизить количество аллокаций и давление на сборщик мусора. Get возвращает объект из пула (или вызывает New, если пул пуст), Put возвращает объект обратно.

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func render(w io.Writer, data []Item) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()             // ОБЯЗАТЕЛЬНО очистить перед использованием
    defer bufPool.Put(buf)  // вернуть в пул после работы

    for _, it := range data {
        buf.WriteString(it.String())
    }
    w.Write(buf.Bytes())
}

Что критично понимать про семантику:

  • Пул НЕ гарантирует хранение. Любой объект в пуле может быть удалён в любой момент без уведомления. sync.Pool — это кэш для снижения аллокаций, а не механизм управления временем жизни или пул соединений с фиксированным размером.
  • GC чистит пул. Объекты, не востребованные между циклами сборки мусора, удаляются. С Go 1.13 реализован «victim cache»: объект переживает один цикл GC и удаляется на следующем, если его так и не забрали, — это сглаживает провалы, но не отменяет очистку.
  • Всегда сбрасывайте состояние. Get может вернуть как свежий объект из New, так и ранее использованный с остаточными данными. Забыли Reset()/обнуление — получите утечку старого содержимого в новый запрос.
  • Осторожно с растущими буферами. Если в пул кладутся []byte/bytes.Buffer, которые изредка раздуваются до огромного размера, пул начнёт держать большие буферы. Иногда имеет смысл не возвращать в пул объекты сверх порогового размера.

sync.Pool оправдан под высокой частотой аллокаций одинаковых временных объектов (парсеры, сериализаторы, HTTP-хендлеры с буферами). На низкой нагрузке выгоды может не быть вовсе — а сложность добавляется. Как всегда: подтверждайте выигрыш профилем аллокаций и бенчмарком, а не ощущением.

sync/atomic: атомарные операции

Пакет sync/atomic даёт атомарные операции над отдельными словами памяти без явного мьютекса. «Атомарно» — значит операция неделима: другая горутина не увидит промежуточного состояния.

С Go 1.19 появились типизированные атомарные типы, и это предпочтительный способ: atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64, atomic.Uintptr, atomic.Bool, atomic.Pointer[T], atomic.Value. Они инкапсулируют значение, дают методы Load/Store/Add/Swap/CompareAndSwap и решают проблему выравнивания — «сырые» функции вроде atomic.AddInt64 требуют 8-байтового выравнивания аргумента, что на 32-битных платформах легко нарушить; типизированный atomic.Int64 гарантирует выравнивание сам.

type Metrics struct {
    requests atomic.Int64
    healthy  atomic.Bool
}

func (m *Metrics) Track() {
    m.requests.Add(1)        // атомарный инкремент
}

func (m *Metrics) Requests() int64 {
    return m.requests.Load() // атомарное чтение
}

func (m *Metrics) SetHealthy(v bool) {
    m.healthy.Store(v)       // атомарная запись флага
}

Базовый набор операций:

  • Load / Store — атомарные чтение и запись целиком.
  • Add — атомарный инкремент/декремент (для знаковых можно Add(-1)).
  • Swap — записать новое значение и вернуть старое.
  • CompareAndSwap (CAS) — записать новое значение, только если текущее равно ожидаемому; вернёт bool успеха. На CAS строятся lock-free алгоритмы через цикл повторных попыток.
// Атомарно применяем функцию к значению: читаем, считаем, пытаемся записать;
// если между чтением и записью кто-то успел изменить — повторяем.
func (m *Metrics) ApplyMax(v int64) {
    for {
        old := m.requests.Load()
        if v <= old {
            return
        }
        if m.requests.CompareAndSwap(old, v) {
            return
        }
        // CAS не прошёл — значение изменилось, крутим цикл заново
    }
}

atomic.Value и atomic.Pointer: публикация снапшота

Для атомарной публикации целой структуры (например, «горячая» перезагрузка конфигурации) есть atomic.Value и типизированный atomic.Pointer[T]. Читатели всегда видят согласованный снимок — либо старый целиком, либо новый целиком, без промежуточного состояния.

type Config struct {
    Timeout time.Duration
    Hosts   []string
}

type Holder struct {
    cfg atomic.Pointer[Config] // Go 1.19+, типобезопасно
}

func (h *Holder) Load() *Config {
    return h.cfg.Load() // читатели без блокировки
}

func (h *Holder) Store(c *Config) {
    h.cfg.Store(c) // атомарная замена указателя целиком
}

Важное условие для atomic.Value: все сохраняемые значения должны быть одного конкретного типа — попытка положить значение другого типа приведёт к панике. atomic.Pointer[T] эту проблему снимает на уровне типов и обычно предпочтительнее.

Отдельно о видимости: с Go 1.19 семантика атомиков формально приведена в соответствие с моделью памяти — атомарные операции последовательно согласованы и устанавливают отношения happens-before. Подробно, что именно это гарантирует для остального кода, — в статье про модель памяти.

atomic или mutex: что выбрать

Правило разграничения — размер инварианта, который нужно защитить.

Атомика достаточно, когда защищаемое состояние умещается в одно слово и обновляется одной операцией:

  • счётчики (метрики, request count, счётчик ссылок);
  • булевы флаги (ready, closed, healthy);
  • единичный указатель, подменяемый целиком (снапшот конфигурации);
  • lock-free структуры на CAS.

Нужен мьютекс, когда инвариант составной — затрагивает несколько переменных или требует «проверить-и-изменить» атомарно по отношению к другому состоянию:

// Нужно поддерживать инвариант: sum == сумма всех значений в items.
// Два независимых атомика этого НЕ гарантируют — между обновлением
// среза и счётчика другой горутине видно рассогласованное состояние.
type Ledger struct {
    mu    sync.Mutex
    items []int
    sum   int
}

func (l *Ledger) Add(v int) {
    l.mu.Lock()
    defer l.mu.Unlock()
    l.items = append(l.items, v) // два поля должны меняться
    l.sum += v                   // как единое целое
}

Если бы items и sum были отдельными атомиками, читатель мог бы увидеть новый sum, но старый items (или наоборот) — инвариант нарушен. Атомарность отдельных операций не даёт атомарности их комбинации. Как только появляется связь между двумя величинами или логика «сначала проверь, потом измени, и чтобы между этим никто не влез» — это территория мьютекса (или CAS-цикла, если всё сводится к одному слову).

Практический ориентир: простой счётчик или флаг — атомик; всё, где «состояние — это больше одного слова» — мьютекс. Не усложняйте ради мнимой производительности: разница между атомиком и коротким мьютексом на большинстве нагрузок несущественна, а корректность важнее.

Цена синхронизации: контеншен и false sharing

Синхронизация не бесплатна, и её стоимость нелинейно растёт с конкуренцией.

Контеншен (contention) — когда много горутин борются за один мьютекс. Под высокой конкуренцией горутины не просто ждут: они уходят в блокировку, планировщик их снимает и будит, кэш-линия с состоянием мьютекса «пингуется» между ядрами. В пределе программа тратит больше времени на координацию доступа, чем на полезную работу. Лечится это не «более быстрым мьютексом», а уменьшением конкуренции: шардирование (разбить одну блокировку на N по хэшу ключа), уменьшение критической секции, переход на атомики там, где инвариант простой, или на «на горутину — своя копия» с последующей сверткой. Увидеть контеншен помогают mutex- и block-профили — об этом подробно в статье об отладке.

False sharing (ложное разделение) — более коварная беда уровня железа. Процессор работает с памятью не байтами, а кэш-линиями (обычно 64 байта). Если две переменные, которые обновляют разные ядра, попали в одну кэш-линию, то запись в одну инвалидирует линию для другого ядра — и они мешают друг другу, хотя логически независимы.

// Проблема: соседние счётчики лежат в одной кэш-линии, разные ядра
// пишут в них и конкурируют на уровне кэша, хотя данные независимы.
type badCounters struct {
    a atomic.Int64
    b atomic.Int64 // почти наверняка в той же 64-байтовой линии, что и a
}

// Решение: развести поля по разным кэш-линиям паддингом.
type paddedCounter struct {
    v atomic.Int64
    _ [56]byte // 8 (int64) + 56 = 64 байта — счётчик занимает линию целиком
}

type goodCounters struct {
    a paddedCounter
    b paddedCounter
}

Паддинг стоит применять точечно — он тратит память и оправдан только на действительно горячих, независимо обновляемых из разных ядер полях (per-CPU счётчики, шардированные структуры). Как и всё в этом разделе: сначала докажите проблему профилем или бенчмарком (go test -bench с ростом -cpu), а уже потом добавляйте паддинг. «Оптимизация по ощущениям» здесь особенно легко превращается в раздутую память без выигрыша — методология измерений разобрана в статье про профилирование.

Примитивы sync нельзя копировать

Все типы из sync (Mutex, RWMutex, WaitGroup, Once, Cond, Pool, Map) нельзя копировать после первого использования. Внутри у них состояние (счётчики, флаги ожидающих), и копия получает срез этого состояния в неконсистентном виде — два «мьютекса», думающих, что они один, ведут к дедлокам и гонкам.

Самый частый способ выстрелить — передать структуру с мьютексом по значению:

type Registry struct {
    mu sync.Mutex
    m  map[string]int
}

// ОШИБКА: получатель по значению копирует мьютекс вместе со структурой.
func (r Registry) Add(k string) { // должно быть (r *Registry)
    r.mu.Lock()
    defer r.mu.Unlock()
    r.m[k]++
}

Это ловит go vet (проверка copylocks) — вывод вида:

./copy_bug.go: Add passes lock by value: Registry contains sync.Mutex

Правила простые: структуры, содержащие примитивы sync, всегда передавайте и принимайте по указателю; не возвращайте их по значению; не присваивайте одну такую структуру другой. Гоняйте go vet (или go test, который его включает) в CI — тогда копирование не доедет до прода.

Подводные камни

  • Забытый Unlock. Ранний return или паника без defer Unlock оставляет мьютекс навсегда захваченным — все последующие горутины виснут. Дефолт — defer c.mu.Unlock() сразу после Lock.
  • Копирование примитива sync. Передача структуры с мьютексом по значению ломает синхронизацию; go vet это ловит — держите его в CI (см. раздел выше).
  • WaitGroup.Add внутри горутины. Add должен предшествовать Wait в вызывающей горутине; иначе Wait вернётся раньше времени.
  • RWMutex по привычке. Для коротких секций и частых записей он медленнее Mutex. Не выбирайте его без замера.
  • sync.Map вместо map+Mutex. Вне двух «родных» сценариев обычная мапа под мьютексом быстрее и типобезопаснее.
  • Надежда на хранение в sync.Pool. Пул может отдать пустой объект или потерять положенное при GC; это не пул соединений и не кэш с гарантией жизни.
  • Забытый Reset объекта из пула. Остаточные данные из прошлого использования утекают в новый запрос.
  • Cond.Wait под if вместо for. После пробуждения условие нужно перепроверять в цикле — иначе продолжите работу при невыполненном условии.
  • Составной инвариант на нескольких атомиках. Атомарность отдельных операций не даёт атомарности их комбинации — нужен мьютекс.
  • atomic.Value с разными типами. Все сохраняемые значения должны быть одного конкретного типа, иначе паника; предпочитайте atomic.Pointer[T].
  • Невыровненный «сырой» atomic.AddInt64. На 32-битных платформах требует 8-байтового выравнивания; используйте типизированные атомики (Go 1.19+), которые выравнивание гарантируют.

Checklist

  • Критическая секция закрыта defer Unlock сразу после Lock?
  • Структуры с sync.* передаются по указателю; go vet в CI зелёный?
  • WaitGroup.Add вызывается до go, а Done — через defer?
  • Выбор RWMutex vs Mutex подтверждён бенчмарком, а не привычкой?
  • sync.Map применён только под write-once/read-many или непересекающиеся ключи?
  • Объект из sync.Pool сбрасывается перед использованием; код не рассчитывает на сохранность?
  • Cond.Wait обёрнут в for с перепроверкой условия?
  • Простые счётчики/флаги — на типизированных атомиках; составные инварианты — под мьютексом?
  • Для публикации снапшота используется atomic.Pointer[T] (а не atomic.Value с рисками типов)?
  • Горячие независимые счётчики проверены на false sharing (профиль/бенчмарк) до добавления паддинга?
  • Код проходит go test -race?

Демо и версии

  • Код: живой стенд digital-cookbook/go-concurrency/sync/ — примеры к разделам: мьютекс vs атомик, RWMutex под read-heavy нагрузкой, sync.Pool, sync.Map, CAS-цикл и бенч false sharing с паддингом; go test -race ./sync/ зелёный. Код в тексте — выжимки.
  • Версии: типизированные атомарные типы — с Go 1.19; sync.OnceFunc/OnceValue/OnceValues — с Go 1.21; sync.Map.CompareAndSwap/Swap — с Go 1.20, Clear — с Go 1.23; WaitGroup.Go — с Go 1.25. Точные цифры производительности не приводим намеренно: измеряйте на своей нагрузке через go test -bench и benchstat, а не по числам из статьи.

Следующая статья серии — модель памяти Go: какие гарантии видимости на самом деле дают разобранные здесь мьютексы и атомики. Готовые паттерны поверх этих примитивов — в статье о паттернах; как ловить контеншен и гонки — в статье об отладке. Как эти же задачи решаются в других языках — в обзоре моделей конкурентности.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии