voidlearn
 

2.4Примитивы синхронизации: sync и atomic

Выберем Mutex, WaitGroup или atomic по тому, какой инвариант нужно сохранить между goroutine.

sync.Mutex изнутри

Структура спартанская: два поля, state int32 и sema uint32. Ни владельца, ни счётчика рекурсии, ни привязки к горутине. Отсюда и растёт половина граблей.

sync.Mutex.state - одно 32-битное слово, всё через atomic.CompareAndSwap waiters биты 3..31 - сколько горутин спит на семафоре starving бит 2 woken бит 1 locked бит 0 Unlock ненагруженного мьютекса - это один atomic.AddInt32(state, -1). Быстрый путь Lock - один CAS 0 в 1. Normal mode - режим по умолчанию Unlock просто снимает бит locked и будит одного из очереди. Разбуженная горутина НЕ получает лок автоматически: она конкурирует с новыми горутинами, которые прямо сейчас крутятся на CPU и уже стоят у входа. Плюс: максимальная пропускная способность - лок отдаётся тому, чьи данные горячие в кэше. Минус: старая горутина может проигрывать снова и снова. Starvation mode - режим голодания Unlock передаёт лок НАПРЯМУЮ первому в очереди. Новые горутины даже не пытаются захватить: они сразу встают в хвост очереди, спиннинг выключен. Плюс: строгий FIFO, никто не голодает. Минус: каждая передача лока - переключение горутины, пропускная способность падает в разы. Выход из режима: очередь опустела или ждали меньше 1 мс. ждём > 1 мс назад Порог 1 мс жёстко зашит в runtime как starvationThresholdNs - это не настройка.
Мьютекс. Всё состояние - 32 бита плюс семафор рантайма. Два режима нужны, чтобы совместить несовместимое: пропускную способность (normal) и отсутствие голодания (starvation). Переключается он сам, снаружи этого не видно.

Что происходит при Lock, по шагам

  1. Fast path. CAS(state, 0, mutexLocked). Если получилось - всё, лок наш, стоимость - единицы наносекунд (Lock с Unlock около 2 нс на M4 Pro, go1.27). Так заканчивается подавляющее большинство вызовов.
  2. Spin. Если лок занят, но мьютекс в normal mode, машина многоядерная, хотя бы ещё один P сейчас работает (скорее всего, на нём и сидит владелец лока), а в локальной очереди нашего P никто не ждёт, горутина крутится активно: до 4 итераций по 30 инструкций PAUSE (procyield). Расчёт простой: владелец отпустит лок через десятки наносекунд, и это дешевле парковки с пробуждением.
  3. Парковка. Спин не помог - увеличиваем счётчик waiters и вызываем runtime_SemacquireMutex. Это семафор рантайма: горутина уходит в _Gwaiting, поток ОС свободен. Никакого futex на уровне потока здесь нет - блокируется именно горутина.
  4. Пробуждение. Проснувшись, горутина смотрит режим: в normal - пробует захватить снова (и может проиграть новичку), в starvation - получает лок сразу, потому что Unlock передал его напрямую.
  5. Переключение режима. Если горутина в момент захвата обнаружила, что ждала дольше 1 мс, она выставляет бит starving. Обратно в normal мьютекс выходит, когда очередь опустела или очередной ждущий провёл в ней меньше 1 мс.
У мьютекса нет владельца, отсюда три следствия
  • Повторный Lock из той же горутины = вечная блокировка. Мьютекс не реентерабельный: он не знает, кто его взял, и не может «пропустить своего». Если это была единственная живая горутина, получишь fatal error: all goroutines are asleep - deadlock!, иначе тихое зависание.
  • Unlock может сделать другая горутина. Формально это разрешено и иногда используется (передача владения), но такой код почти невозможно читать.
  • Unlock незалоченного мьютекса - не паника, а fatal error: sync: unlock of unlocked mutex: процесс падает сразу, и recover её не ловит даже в той же горутине.
// Самоблокировка, классика на собесе
var mu sync.Mutex
func A() { mu.Lock(); defer mu.Unlock(); B() }   // B тоже берёт mu
func B() { mu.Lock(); defer mu.Unlock() }        // deadlock

// Разделяем: публичный метод берёт лок, внутренний зовут уже под локом
func (s *Store) Get(k string) V {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.getLocked(k)
}
func (s *Store) getLocked(k string) V { return s.m[k] }   // лок не берёт

// TryLock (Go 1.18+) для диагностики, а не для попыток взять лок в цикле
if mu.TryLock() {
    defer mu.Unlock()
    // ...
} else {
    metrics.LockContention.Inc()
}

RWMutex: когда действительно быстрее

RWMutex разрешает либо одного писателя, либо любое число читателей. Внутри - обычный Mutex для писателей плюс два счётчика (readerCount, readerWait) и два семафора. RLock делает atomic.AddInt32(&readerCount, 1) и, если значение неотрицательное, сразу возвращается.

СценарийMutexRWMutexКомментарий
Короткая критическая секция (чтение поля)быстреемедленнееатомарных операций у читателя столько же, сколько у Mutex, но все пишут в один readerCount; выигрыш не окупается
Много читателей, редкие записи, секция > ~1 мксузкое местобыстреечитатели идут параллельно
50/50 чтение и записьбыстреемедленнееписатель всё равно всех выстраивает в очередь
Много ядер, очень много читателей-осторожноreaderCount - одна кэш-линия, все ядра дерутся за неё (cache line bouncing)
Write starvation и почему её в Go нет

Наивный RWMutex пускает читателей всегда, пока их счётчик больше нуля: при плотном потоке чтений писатель не пройдёт никогда. В Go это закрыто: Lock писателя вычитает из readerCount константу rwmutexMaxReaders (1<<30), делая счётчик отрицательным. Все новые RLock видят отрицательное значение и уходят спать. Писатель ждёт только тех читателей, которые уже внутри. То есть писатель блокирует новых читателей, но не наоборот, и голодания писателя не будет. Зато работает обратное: плотный поток писателей подтормаживает читателей.

RWMutex не рекурсивен и не апгрейдится
  • Вложенный RLock из-под RLock может задедлочить. Если между ними успел встать писатель, внутренний RLock заблокируется, а внешний не отпустится - классический deadlock, документированный в исходниках.
  • Апгрейда RLock → Lock нет. Нужно отпустить чтение, взять запись и перепроверить состояние: за время между ними мир изменился.
  • RUnlock без RLock - тоже не паника, а fatal error: sync: RUnlock of unlocked RWMutex, без шанса на recover.

Почему нельзя копировать мьютекс

Mutex - это значение, и копия уносит с собой снимок состояния. Скопировали залоченный мьютекс - получили копию, которая считает себя залоченной, но её никто никогда не отпустит. Скопировали разлоченный, пока другая горутина держит оригинал - получили два независимых замка на одни данные, то есть никакой синхронизации.

type Counter struct {
    mu sync.Mutex
    n  int
}

// Плохо: метод на значении копирует при вызове и мьютекс, и счётчик
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }   // go vet: passes lock by value

// Хорошо: указательный ресивер
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

// Плохо: копия при передаче, при возврате, при присваивании, при range по слайсу структур
func process(c Counter) {}
cs := []Counter{{}, {}}
for _, c := range cs { _ = c }     // c копируется со всем содержимым

// Хорошо: работаем через указатели
func process(c *Counter) {}
for i := range cs { cs[i].Inc() }
go vet copylocks

Анализатор copylocks входит в стандартный go vet, но в урезанный набор, который go test гоняет сам, не входит: запускай go vet явно или go test -vet=all. Он ловит копирование любого типа, содержащего sync.Locker (Mutex, RWMutex, WaitGroup, Cond, Once, Pool, atomic-типы Go 1.19+), на любой глубине вложенности. Технически он опирается на встроенное поле noCopy, пустую структуру с методами Lock()/Unlock(), которая и существует ради одной этой проверки. Этот приём работает и в своих типах: положи в структуру noCopy, и vet начнёт защищать её так же.

WaitGroup, Once, Pool

// WaitGroup держит в одном 12-байтном блоке счётчик, счётчик ждущих и семафор.
var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)                       // до go, всегда
    go func() {
        defer wg.Done()             // всегда через defer, иначе при панике Done потеряется
        fetch(u)
    }()
}
wg.Wait()

// Go 1.25: WaitGroup.Go объединяет Add(1), go и Done
var wg2 sync.WaitGroup
for _, u := range urls {
    wg2.Go(func() { fetch(u) })     // с Add/Done уже не ошибёшься
}
wg2.Wait()
Четыре способа сломать WaitGroup
  1. Add внутри горутины. go func(){ wg.Add(1); ... }() - гонка: Wait может увидеть счётчик 0 и вернуться до того, как горутина успеет стартовать. Add строго до go.
  2. Копирование WaitGroup. Передали по значению в функцию - там своя копия счётчика, Wait в оригинале ждёт вечно. Только *sync.WaitGroup или замыкание.
  3. Забытый Done при панике или раннем return. Только defer wg.Done() первой строкой горутины.
  4. Переиспользование до завершения Wait. Новый Add, пока предыдущий Wait ещё не вернулся, - гонка и паника WaitGroup is reused before previous Wait has returned. Лишний Done даёт negative WaitGroup counter.
// Once: fast path обходится одним атомарным чтением, без мьютекса.
type Once struct {
        done atomic.Bool                // с Go 1.25; раньше atomic.Uint32
    m    Mutex
}
func (o *Once) Do(f func()) {
    if !o.done.Load() {             // ~1 нс в горячем пути
        o.doSlow(f)
    }
}
func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if !o.done.Load() {             // двойная проверка уже под локом
        defer o.done.Store(true)    // выставляется после f, иначе гонка
        f()
    }
}

// Go 1.21 добавил удобные обёртки:
getConfig := sync.OnceValue(func() *Config { return loadConfig() })
cfg := getConfig()                  // ленивая инициализация без объявления Once
Две тонкости Once
  • done выставляется после возврата f, а не до. Значит, вторая горутина, попавшая в Do во время выполнения f, заблокируется на мьютексе и дождётся завершения инициализации, а не пробежит мимо с недоинициализированным объектом.
  • Если f запаниковала - done всё равно выставляется (это defer), и повторный Do уже ничего не вызовет. Для «повторить при ошибке» не годится и sync.OnceValue: он тоже зовёт f один раз и запоминает результат вместе с ошибкой. Нужен golang.org/x/sync/singleflight или собственный флаг под мьютексом.
// Pool переиспользует объекты, чтобы не грузить GC.
var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func handle(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                     // обязательно: Pool отдаёт грязный объект
    defer bufPool.Put(buf)
    // ... работаем с buf ...
}

sync.Map: read + dirty

read - atomic.Pointer[readOnly] Чтение БЕЗ мьютекса: один атомарный Load указателя, потом обычный доступ к неизменяемой мапе. m: map[K]*entry amended bool true - в dirty есть ключи, которых тут нет Значения меняются атомарно прямо в *entry, без лока. dirty - обычная map под sync.Mutex Сюда идут все НОВЫЕ ключи. Любое обращение - под мьютексом, то есть медленно. m: map[K]*entry misses int сколько раз промахнулись мимо read Хранит ВСЕ актуальные ключи, включая те, что есть в read. промах misses++ Промоушен: как только misses больше или равно len(dirty) dirty целиком становится новой read (один atomic.Store), dirty обнуляется, misses = 0 Следующая запись нового ключа заново КОПИРУЕТ всю read в dirty - O(n) под мьютексом. Поэтому нагрузка «много новых ключей» превращает sync.Map в мапу под мьютексом плюс копирование.
sync.Map. Две мапы вместо одной: неизменяемая read для быстрых чтений без лока и dirty под мьютексом для всего остального. Выигрыш есть только там, где ключи стабильны, а чтений сильно больше записей.
ОперацияЧто происходитСтоимость
Load, ключ в readatomic-загрузка указателя + чтение мапыочень дёшево, лока нет
Load, ключа в read нет, amendedлок, поиск в dirty, misses++дорого
Store существующего ключаCAS прямо в *entryдёшево, лока нет
Store нового ключалок, при пустой dirty - копирование всей readочень дорого, O(n)
Deleteставит в entry nil, в expunged его переведёт следующее пересоздание dirty; память сразу не освобождаетсясредне
Rangeпри amended сначала промоутит dirty в readдорого, снимок не консистентен
Когда sync.Map оправдана: по документации, дословно
  1. Ключ пишется один раз, читается много раз - кэши только на дозапись.
  2. Несколько горутин читают, пишут и перезаписывают непересекающиеся наборы ключей - шардирование по горутинам.
Почему обычно лучше mutex + map
  • Нет типизации до дженериков: Load возвращает any, нужен type assertion, каждое значение - интерфейс, то есть лишняя аллокация и работа GC. (В Go 1.24 внутренности переписали на HashTrieMap, стало заметно лучше на записи, но API остался нетипизированным.)
  • Нет len: посчитать элементы можно только полным Range.
  • Нет атомарных композитных операций сложнее LoadOrStore / CompareAndSwap: «прочитать, посчитать, записать» атомарно не сделать.
  • Проседает на записи новых ключей: каждое построение dirty - копирование всей мапы.
  • Шардированная мапа обычно быстрее: 32–256 обычных мап, каждая под своим мьютексом, выбор по хешу ключа. Типизирована, поддерживает len, легко расширяется составными операциями.

atomic и модель памяти

// Старый функциональный API (работает всегда)
var n int64
atomic.AddInt64(&n, 1)
v := atomic.LoadInt64(&n)
ok := atomic.CompareAndSwapInt64(&n, old, new)
old = atomic.SwapInt64(&n, 42)

// Go 1.19: типы. Лучше брать их: неатомарно к ним не обратишься,
// выравнивание гарантировано, go vet ловит копирование.
var c atomic.Int64
c.Add(1)
c.Load()
c.CompareAndSwap(old, new)

var flag atomic.Bool
var p atomic.Pointer[Config]        // типизированный указатель, без unsafe

// atomic.Value атомарно подменяет значение любого типа целиком.
var cfg atomic.Value                // храним *Config
cfg.Store(newConfig)
c := cfg.Load().(*Config)           // тип во всех Store должен быть один и тот же
Атомарность операции не равна атомарности логики
// Каждая строка атомарна. Вся конструкция - гонка.
if atomic.LoadInt64(&n) < limit {   // здесь другая горутина может увеличить n
    atomic.AddInt64(&n, 1)          // и лимит будет превышен
}

// Правильно: одна атомарная операция или цикл CAS
for {
    old := atomic.LoadInt64(&n)
    if old >= limit {
        return errLimit
    }
    if atomic.CompareAndSwapInt64(&n, old, old+1) {
        return nil
    }
}

happens-before: зачем оно нужно и что ломается без него

Первый конкурентный код почти все пишут с одной мыслью в голове: «я записал переменную - значит через мгновение другая горутина увидит новое значение». Мысль неверна, и ломается она сразу в двух местах.

Между исходником и тем, что реально исполняется на железе, стоят два оптимизатора, и оба имеют полное право менять порядок операций. Компилятор переставляет независимые строки, держит переменную в регистре вместо памяти, выносит инвариантную проверку из цикла. Пока результат для этой горутины не меняется, ему всё можно. Процессор делает примерно то же в железе: исполняет инструкции не в порядке поступления и складывает записи в буфер записи своего ядра, откуда они уезжают в общую память с задержкой и не обязательно в исходном порядке. Каждое ядро видит согласованную картину для себя - но не для соседа.

Оба соблюдают ровно одно правило: внутри одной горутины программа ведёт себя так, будто выполняется строго по тексту. Про то, что и когда увидит другая горутина, по умолчанию не обещано ничего. Это обещание нужно запросить явно.

Happens-before и есть механизм запроса. Формально это отношение между событиями программы: если событие A happens-before события B, то всё, что горутина записала до A, гарантированно видно той горутине, которая выполняет B и всё, что после него. Чего в определении нет, так это времени. «A happens-before B» не означает «A случилось раньше по часам». Означает: «результаты A обязаны быть видны в B». Отношение это частичное: для двух произвольных событий из разных горутин его может не быть ни в одну сторону, и тогда порядок между ними просто не определён.

Взяться такому обещанию неоткуда, кроме как из конкретного списка операций, которые его дают (их называют рёбрами happens-before): запуск горутины, отправка и приём в канале, close, Unlock перед следующим Lock, Once.Do, WaitGroup.Wait, операции sync/atomic. Всё, чего в списке нет, не гарантирует ничего - в том числе time.Sleep, runtime.Gosched() и рассуждение «ну она же стартовала раньше».

Что именно ломается без ребра - на схеме ниже. Горутина A записывает данные и поднимает флаг; горутина B крутится на флаге и потом читает данные. Отказать может любое из трёх звеньев: компилятор поменяет местами две записи в A, потому что между ними нет зависимости по данным; компилятор в B прочитает ready один раз, положит в регистр и превратит цикл в вечный; процессор доставит запись ready раньше записи data. Симптом при этом самый противный из возможных: на ноутбуке и под -race всё зелено, а в проде на другой архитектуре раз в сутки прилетает ноль вместо значения.

Без синхронизации: компилятор и процессор вправе переставить записи // горутина A data = 42 ready = true компилятор может поменять строки местами: между ними нет зависимости по данным // горутина B for !ready {} use(data) увидит ready = true, но data может быть 0: запись висит в store buffer чужого ядра нет ребра С синхронизацией: канал создаёт ребро happens-before data = 42 ch <- struct{}{} всё, что записано ДО отправки, видно после приёма <-ch use(data) // гарантированно 42 приём синхронизирует память обеих горутин happens -before
Happens-before. Синхронизация разводит горутины по времени, но главное не это: она задаёт порядок видимости записей. Без ребра happens-before вторая горутина имеет право не увидеть данные вообще или увидеть их в другом порядке.
Что гарантирует Go memory model
  • Внутри одной горутины всё выполняется так, будто по порядку исходника (sequential consistency для самой себя).
  • Между горутинами порядок задают только рёбра happens-before: старт горутины, отправка/приём в канале, закрытие канала, Unlock → следующий Lock, Once.Do, WaitGroup.Wait, а с Go 1.19 - явно и операции sync/atomic (они sequentially consistent).
  • Программа с data race не имеет определённого поведения. Гонка в Go - не просто «случайное число»: модель памяти гарантирует только, что чтение одного машинного слова вернёт значение из какой-то реальной записи, а не «из воздуха». Значения из нескольких слов (64-битные типы на 32-битных платформах, интерфейсы, слайсы, строки) можно прочитать разорванными по половинкам, а там, где внутри пара «указатель + длина» или «указатель + тип» (интерфейсы, слайсы, строки, мапы), модель памяти прямо допускает произвольную порчу памяти.

Канал против мьютекса

ЗадачаИнструментПочему
Защитить поле/структуру от одновременного доступаMutexканал тут в 3–5 раз дороже и запутывает
Счётчик, флаг, версия конфигаatomicдешевле всех, одна инструкция
Передать владение данными между горутинамиканалпередача + синхронизация одной операцией
Раздать работу N воркерамканалестественная очередь, FIFO, close как сигнал конца
Сигнал отмены/завершенияканал (ctx.Done())работает в select, broadcast через close
Ограничить число одновременных операцийканал-семафор или semaphore.Weightedёмкость = лимит
Кэш, мапа, пул соединенийMutex + mapsync.Map оправдана редко
Ждать завершения группы горутинWaitGroup / errgroupканал потребовал бы ручного подсчёта
Как это звучит на собесе

«Don't communicate by sharing memory; share memory by communicating» - это про проектирование, а не про запрет мьютексов. Rob Pike в том же выступлении говорит: используйте то, что понятнее. Мой критерий простой: если данные передаются - канал; если данные охраняются и живут на месте - мьютекс; если это одно число или указатель - atomic. В стандартной библиотеке Go мьютексов в разы больше, чем каналов, и это не случайность.

sync.Cond - редкий гость

// Cond = мьютекс + очередь ожидания «изменилось состояние».
type Queue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []int
}

func NewQueue() *Queue {
    q := &Queue{}
    q.cond = sync.NewCond(&q.mu)
    return q
}

func (q *Queue) Push(v int) {
    q.mu.Lock()
    q.items = append(q.items, v)
    q.mu.Unlock()
    q.cond.Signal()             // разбудить одного ждущего
}

func (q *Queue) Pop() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    for len(q.items) == 0 {     // цикл, а не if: возможны ложные пробуждения
        q.cond.Wait()           // отпускает лок, спит, при пробуждении берёт лок обратно
    }
    v := q.items[0]
    q.items = q.items[1:]
    return v
}
Почему Cond почти не используют
  • Не работает с select. Нельзя ждать «Cond или отмену контекста» - а это нужно почти всегда. Единственный обходной путь - отдельная горутина, которая по отмене дёргает Broadcast.
  • Не отменяем. Горутина в Wait ждёт до победного.
  • Легко ошибиться: Wait обязан стоять в цикле проверки условия, Signal/Broadcast - рядом с изменением состояния, копировать Cond нельзя.
  • Есть замена: канал (для сигнала), WaitGroup (для «дождаться всех»), errgroup, semaphore.Weighted. Реальные применения Cond - очередь с ограниченной ёмкостью и множеством ждущих разных условий, например внутри пула соединений.

Вопросы

13
Суть: внутри - state int32 с битами locked/woken/starving/waiters и семафор рантайма. Fast path - один CAS. Мьютекс не реентерабельный: повторный Lock из той же горутины блокирует её навсегда.

Путь захвата

  1. CAS(state, 0, locked) - успех, единицы наносекунд, дальше ничего не происходит.
  2. Спин. Занят, но normal mode, ещё какой-то P работает (там, скорее всего, и владелец), а локальная очередь нашего P пуста - крутимся до 4 раз по 30 инструкций PAUSE. Дешевле, чем парковка, если владелец вот-вот отпустит.
  3. Парковка. runtime_SemacquireMutex: горутина в _Gwaiting, поток ОС свободен и берёт другую работу. Это семафор рантайма, а не futex потока.
  4. Пробуждение. В normal mode разбуженная горутина конкурирует с новичками и может проиграть: новички уже на CPU. В starvation mode Unlock отдаёт лок ей напрямую.

Два режима и порог 1 мс

  • Normal: максимальная пропускная способность. Лок достаётся тому, кто ближе - кэш горячий, переключений меньше. Риск: старая горутина в очереди голодает.
  • Starvation: включается, когда какая-то горутина прождала больше 1 мс (starvationThresholdNs). Строгий FIFO, спиннинг выключен, новые горутины сразу в хвост. Пропускная способность падает, зато хвост латентности перестаёт расти.
  • Выход обратно в normal: очередь опустела либо очередной ждущий провёл в ней меньше 1 мс.

Повторный Lock

var mu sync.Mutex
mu.Lock()
mu.Lock()      // здесь горутина уходит в _Gwaiting навсегда

У мьютекса нет поля «владелец» - он не знает, кто его взял, и не может пропустить «своего». Горутина встаёт в очередь ожидания самой себя. Если это была единственная живая горутина, рантайм напечатает fatal error: all goroutines are asleep - deadlock!; если в программе есть другая работа - просто тихо зависнет одна горутина, и это утечка.

Как ловят на практике

Реальная самоблокировка почти никогда не выглядит как два Lock подряд. Она выглядит так: публичный метод берёт лок и вызывает другой публичный метод того же типа, который тоже берёт лок. Лечение - разделить на публичный (берёт лок) и приватный ...Locked() (предполагает, что лок уже взят). Именно поэтому в Go нет реентерабельного мьютекса: он маскировал бы кривую структуру кода. Рекурсивный лок сделал бы допустимым состояние «функция вызвана посреди чужой критической секции с полуобновлёнными инвариантами».

Суть: выигрывает только при долгих критических секциях и подавляющем перевесе чтений. На коротких секциях обычный Mutex быстрее. Write starvation в Go закрыта: писатель блокирует новых читателей.

Как устроен

Внутри - Mutex w (очередь писателей между собой), readerCount int32, readerWait int32 и два семафора. RLock: атомарный инкремент readerCount; если результат неотрицательный - сразу внутрь. Lock: берём w, потом вычитаем из readerCount константу rwmutexMaxReaders = 1<<30, делая счётчик отрицательным, и ждём, пока уже вошедшие читатели выйдут.

Когда даёт выигрыш

  • Читателей на порядок больше писателей и критическая секция длинная (десятки микросекунд и больше): парсинг, обход структуры, сериализация под локом.
  • Не даёт выигрыша на «прочитать одно поле»: атомарных операций у читателя столько же, сколько у Mutex (по одной на RLock и RUnlock), и обе пишут в общий readerCount. На секции в 20 нс читатели почти всё время толкаются за эту кэш-линию, и параллелизма не выходит, а без конкуренции пара RLock/RUnlock даже чуть дороже: 3,3 нс против 1,9 нс (M4 Pro, go1.27).
  • Бывает и хуже при большом числе ядер: readerCount лежит в одной кэш-линии, и десятки ядер, инкрементящих её, устраивают cache line bouncing. В таких случаях выигрывает шардирование (свой мьютекс на каждый шард) или атомарный снимок через atomic.Pointer (copy-on-write).

Write starvation

Если пускать читателей всегда, пока readerCount > 0, сплошной поток чтений закроет писателю дорогу навсегда. Go решает это тем, что Lock сразу уводит readerCount в минус: все новые RLock уходят спать, писатель дожидается только тех, кто уже внутри. Голодания писателя нет. Обратная сторона: при плотном потоке писателей подтормаживают уже читатели, но это честная очередь.

Два способа задедлочить RWMutex
// 1. Рекурсивный RLock. Если между строками встрял писатель:
mu.RLock()
mu.RLock()      // блокируется: писатель уже увёл readerCount в минус
                // а писатель ждёт нас -> deadlock

// 2. «Апгрейд» блокировки - его не существует
mu.RLock()
mu.Lock()       // Lock ждёт выхода читателей, включая нас самих
// Правильно: RUnlock -> Lock -> ПЕРЕПРОВЕРИТЬ состояние, оно могло измениться
Практическое правило

Начинай с Mutex. Переходи на RWMutex, только когда бенчмарк показал контенцию на чтении (профиль -blockprofile или mutexprofile). Замена «на всякий случай» в половине случаев замедляет код, и на собесе за фразу «RWMutex всегда лучше, если есть чтения» снимают балл.

Суть: копия уносит снимок состояния. Скопировали залоченный - получили вечно залоченную копию; скопировали разлоченный - получили два независимых замка на одни данные, то есть отсутствие синхронизации.

Что именно ломается

  • Копия залоченного. В state стоит бит locked, но отпускать эту копию никто не будет - оригинал разблокирует себя, а не её. Первый же Lock на копии виснет навсегда.
  • Копия разлоченного. Две горутины берут разные мьютексы и спокойно лезут в одни и те же данные. Синхронизации нет, а выглядит код правильным - race detector такое поймает, а глаз обычно нет.
  • Счётчик waiters тоже копируется: у копии он говорит, что кто-то ждёт, хотя на семафоре копии не спит никто. Unlock попытается разбудить несуществующего ждущего.

Где копирование происходит незаметно

type S struct {
    mu sync.Mutex
    n  int
}

func (s S) Bad() {}            // 1) метод на значении копирует при каждом вызове
func f(s S) {}                 // 2) аргумент по значению
func g() S { return S{} }      // 3) возврат по значению (пустой можно, живой нельзя)
var a, b S; a = b              // 4) присваивание
m := map[string]S{}            // 5) значения мапы вообще неадресуемы
for _, s := range []S{{}, {}} {}  // 6) range копирует элемент
var i any = s                  // 7) упаковка в интерфейс копирует
go f(s)                        // 8) аргумент go-вызова копируется в момент запуска

go vet copylocks

Анализатор copylocks входит в базовый набор go vet, но в урезанный набор, который go test гоняет сам, не входит: его запускают явно, go vet ./... в CI. Он ищет копирование любого типа, реализующего sync.Locker или содержащего такой тип на любой глубине: Mutex, RWMutex, WaitGroup, Cond, Once, Pool, atomic.Int64 и остальные типы atomic из Go 1.19. Сообщение выглядит как call of f copies lock value: S contains sync.Mutex.

Как это работает и как повторить у себя

Механика - пустая структура noCopy с методами-заглушками Lock() и Unlock(). Она не делает ничего в рантайме, занимает 0 байт и существует ровно для того, чтобы vet увидел sync.Locker. Тот же приём доступен и тебе: объяви type noCopy struct{} с этими двумя методами, положи в свою структуру, и vet начнёт запрещать её копирование. Так помечают, например, типы с внутренними указателями на самих себя. Оговорка: это только статическая проверка, компилятор копирование не запрещает.

Правило

Тип, содержащий мьютекс, - всегда указательный: методы на *T, передача как *T, в мапах и слайсах храни *T. И держи go vet в CI: эта ошибка почти никогда не ловится на code review, зато vet находит её за секунду.

Суть: счётчик плюс семафор. Add - строго до go, Done - строго через defer, передавать - только указателем. В Go 1.25 появился wg.Go(f), который делает всё это за вас.

Как устроена

Три значения: счётчик активных задач, счётчик ждущих в Wait и семафор. Add(n) прибавляет к счётчику, Done() это Add(-1). Когда счётчик доходит до нуля, все ждущие в Wait просыпаются через runtime_Semrelease. Счётчик ушёл в минус: паника sync: negative WaitGroup counter.

Четыре классические ошибки

// 1) Add внутри горутины даёт гонку: Wait может увидеть 0 раньше старта
for _, u := range urls {
    go func() { wg.Add(1); defer wg.Done(); fetch(u) }()   // неправильно
}
wg.Wait()   // может вернуться мгновенно, ничего не дождавшись

// 2) WaitGroup скопирован: у функции своя копия счётчика
func work(wg sync.WaitGroup) { defer wg.Done() }           // неправильно
func work(wg *sync.WaitGroup) { defer wg.Done() }          // правильно

// 3) Done без defer: паника внутри горутины навсегда подвесит Wait
go func() { fetch(u); wg.Done() }()                        // неправильно

// 4) WaitGroup переиспользуют до возврата Wait
wg.Add(1); go f(); go func(){ wg.Wait() }(); wg.Add(1)     // неправильно:
// panic: WaitGroup is reused before previous Wait has returned

Правильный шаблон и Go 1.25

// До 1.25
var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)
    go func() {
        defer wg.Done()
        fetch(u)
    }()
}
wg.Wait()

// Go 1.25: Add, go и Done в одном вызове
var wg sync.WaitGroup
for _, u := range urls {
    wg.Go(func() { fetch(u) })
}
wg.Wait()

wg.Go(f) внутри делает wg.Add(1), запускает горутину и через defer вызывает wg.Done(), когда f вернётся, - то есть ошибки 1, 3 и частично 2 становятся невозможными. Панику внутри f он не перехватывает: она по-прежнему роняет процесс. А Done при панике он с Go 1.26 намеренно не зовёт: иначе Wait проснулся бы и main мог успеть завершиться раньше, чем паника уронит процесс. Документация прямо требует, чтобы f не паниковала.

Чем добить
  • Начиная с Go 1.22 переменная цикла - своя на каждую итерацию, поэтому go func(){ fetch(u) }() больше не требует передачи u параметром. На старых версиях это была ошибка номер один.
  • WaitGroup умеет только «дождаться всех» - он не собирает ошибки и не умеет отменять. Как только нужны ошибки или отмена по первой из них, берут errgroup.Group.
  • Внутри Go 1.25 WaitGroup ещё и научили работать в «пузыре» testing/synctest (о нём в главе 2.6): в атомарном 64-битном состоянии, которое было у него и раньше, под это отвели один бит. На поведение обычного кода это не влияет.
  • Ошибку №1 теперь ловит инструмент: с Go 1.25 в go vet входит анализатор waitgroup, который на go func(){ wg.Add(1); … }() выдаёт WaitGroup.Add called from inside new goroutine. А переписать старый тройной ритуал на wg.Go автоматически умеет go fix: в Go 1.27 у него есть модернизатор waitgroupgo (в 1.26 он назывался просто waitgroup).
Суть: errgroup.Group - это WaitGroup плюс сбор первой ошибки плюс автоматическая отмена общего контекста при этой ошибке плюс ограничение параллелизма через SetLimit.
import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
    g, ctx := errgroup.WithContext(ctx)   // ctx отменится при первой ошибке
    g.SetLimit(8)                          // максимум 8 одновременных горутин
    results := make([]Result, len(urls))   // пишем по индексу, гонки нет

    for i, u := range urls {
        g.Go(func() error {
            r, err := fetch(ctx, u)        // ctx уже отменяемый
            if err != nil {
                return fmt.Errorf("fetch %s: %w", u, err)
            }
            results[i] = r
            return nil
        })
    }
    if err := g.Wait(); err != nil {       // вернёт первую ошибку
        return nil, err
    }
    return results, nil
}

Что даёт сверх WaitGroup

  • Ошибки. g.Go принимает func() error, а g.Wait() возвращает первую ненулевую (остальные отбрасываются).
  • Отмена по первой ошибке. errgroup.WithContext возвращает производный контекст; как только любая функция вернула ошибку, он отменяется, и все остальные горутины должны сами это увидеть по ctx.Done().
  • SetLimit(n). Встроенный семафор на канале ёмкости n. g.Go при исчерпании лимита блокируется, пока не освободится слот; g.TryGo - неблокирующая версия, возвращает false. SetLimit нужно вызывать до первого Go.
  • Паника не перехватывается. recover() внутри errgroup нет, и паника в любой горутине группы роняет процесс - Wait до неё не доживёт. Перехват недолго существовал в x/sync v0.14–v0.15 и был откачен сознательно. Если фоновая работа может паниковать, recover пишут сами, внутри переданной в Go функции.
Что здесь легко сделать неправильно
  • Не тот контекст. g, ctx := errgroup.WithContext(ctx) - заметь: ctx переприсваивается. Если использовать внутри горутин внешний контекст, отмена по первой ошибке работать не будет.
  • Ошибка одна. Если нужны все ошибки, errgroup не подходит: собирай их сам в слайс под мьютексом (или в срез по индексу) и склеивай через errors.Join.
  • Отмена - кооперативная. Контекст отменится, но горутина, которая его не проверяет, продолжит работать. g.Wait() дождётся всех, а не вернётся сразу при первой ошибке.
  • Запись в общий слайс. Писать по разным индексам заранее выделенного слайса безопасно и это идиома. Писать в мапу или делать append - гонка, нужен мьютекс или канал.
Суть: флаг done плюс мьютекс. Быстрый путь - одно атомарное чтение без лока; медленный - мьютекс с двойной проверкой. Флаг выставляется после завершения функции, поэтому опоздавшие честно дожидаются инициализации.
type Once struct {
        done atomic.Bool               // с Go 1.25; раньше atomic.Uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if !o.done.Load() {            // fast path: ~1 нс, без лока
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if !o.done.Load() {            // double-check уже под локом
        defer o.done.Store(true)   // после f, а не до
        f()
    }
}

Три вопроса, которые задают следом

  • Почему done ставится после f? Если бы до, вторая горутина увидела бы done == true и пошла работать с наполовину проинициализированным объектом. Сейчас она блокируется на мьютексе и выходит из Do только когда f реально закончила. Это и есть гарантия «после возврата из Do инициализация завершена».
  • Что если f запаникует? done всё равно выставится (это defer), и повторный Do ничего не сделает. Логика «повторить при ошибке» через Once не выражается - нужен singleflight или собственный флаг.
  • Почему нельзя просто if !inited { init(); inited = true }? Это data race: два потока увидят false и вызовут init дважды, а без синхронизации ещё и не увидят чужой записи вообще.

Обёртки Go 1.21

// OnceFunc: просто «выполнить один раз»
initLog := sync.OnceFunc(func() { setupLogging() })

// OnceValue: ленивое значение
config := sync.OnceValue(func() *Config { return mustLoadConfig() })
cfg := config()                        // первый вызов грузит, остальные возвращают готовое

// OnceValues: значение и ошибка
loadDB := sync.OnceValues(func() (*sql.DB, error) { return sql.Open(...) })
db, err := loadDB()
Где применяют и где не стоит
  • По делу: ленивое подключение к внешнему сервису, компиляция регулярки, загрузка конфига, разовая регистрация метрик, singleton в библиотеке.
  • Не стоит: как замена нормальному конструктору. Если объект можно инициализировать явно в main и передать зависимостью - так и надо делать: Once прячет момент инициализации и её ошибки, усложняет тесты и делает порядок запуска неявным.
  • Копировать Once нельзя - go vet ругается, а копия, снятая до первого Do, живёт своей жизнью и выполнит f второй раз.
Суть: кэш временных объектов, чтобы снизить давление на GC. Ключевая особенность - содержимое очищается при каждом цикле GC, с одним поколением отсрочки (victim cache). Это не пул ресурсов и не средство ограничения.

Как устроен

У каждого P - свой poolLocal с приватным слотом (доступ вообще без синхронизации) и локальной очередью, из которой другие P могут воровать. Поэтому Get/Put почти бесплатны и хорошо масштабируются по ядрам. Если ничего не нашлось - вызывается New.

Связь с GC - самое важное

  1. При старте цикла GC хук poolCleanup проходит по всем пулам.
  2. Текущие локальные наборы переезжают в victim-кэш, а старые victim выбрасываются насовсем.
  3. Get сначала смотрит в свой primary, потом ворует у соседей, потом лезет в victim, и только потом зовёт New.

Итог: объект живёт в пуле максимум два цикла GC. Victim-кэш появился в Go 1.13 именно чтобы убрать провал производительности сразу после сборки, когда пул опустошался целиком и все шли в New.

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

func render(w io.Writer, data any) error {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                       // обязательно: в пуле лежит грязный объект
    defer bufPool.Put(buf)
    if err := tmpl.Execute(buf, data); err != nil {
        return err
    }
    _, err := w.Write(buf.Bytes())
    return err
}

// Для слайсов кладут указатель, иначе каждый Put аллоцирует
var slicePool = sync.Pool{New: func() any { s := make([]byte, 0, 4096); return &s }}
Пять подводных камней
  1. Объект грязный. Get может вернуть что угодно из прошлой жизни. Не сбросили - утечка данных между запросами, вплоть до чужих персональных данных в ответе. Это реальная уязвимость, а не теория.
  2. Put после того, как отдали наружу. Если buf.Bytes() уехал в структуру, которая переживёт defer Put, ты вернул в пул память, на которую есть живая ссылка, и следующий Get перезапишет её.
  3. Класть в пул слайс (не указатель) - аллокация. Put(any) упаковывает значение в интерфейс; для не-указателя это боксинг в кучу, то есть ровно то, от чего мы уходили.
  4. Растущие объекты. Если в пул возвращать буферы после обработки гигантского запроса, пул начнёт хранить их, и память вырастет. Лечение - не класть обратно, если cap превысил порог.
  5. Это не пул соединений. Нет ограничения количества, нет времени жизни, содержимое исчезает при GC. Для соединений - database/sql, http.Transport или свой пул на канале.
Когда действительно помогает

Профиль показывает много аллокаций одинакового размера, которые живут недолго и повторяются часто: буферы сериализации, []byte под тело запроса, промежуточные структуры парсера, gzip.Writer. Именно так sync.Pool используется в encoding/json, net/http и fmt. Если аллокаций мало или объекты живут долго - пул только усложнит код. Мерять обязательно: go test -bench . -benchmem до и после.

Суть: две мапы - неизменяемая read под atomic.Pointer для чтений без лока и dirty под мьютексом для новых ключей. Оправдана в двух узких сценариях; в остальных обычная мапа под RWMutex или шардированная мапа быстрее и удобнее.

Механика

  • read - структура readOnly{m map[K]*entry, amended bool}, заменяемая целиком одним atomic.Store. Чтение существующего ключа - атомарная загрузка указателя и обычный доступ к мапе. Лока нет вообще.
  • amended = «в dirty есть ключи, которых нет в read». Если false и ключ не найден в read - можно сразу возвращать «нет», не трогая мьютекс.
  • dirty - обычная мапа под мьютексом. Содержит все актуальные ключи. Все новые ключи попадают только сюда.
  • misses - счётчик промахов мимо read. Когда misses >= len(dirty), происходит промоушен: dirty становится новой read, dirty = nil, misses = 0.
  • Следующая запись нового ключа заново копирует всю read в dirty - операция O(n) под мьютексом.
  • Delete не удаляет сразу: ставит в entry nil. При очередном пересоздании dirty такие записи помечаются expunged и в неё не копируются, а физически исчезают, когда эта dirty станет новой read.
  • Обновление существующего ключа идёт через CAS прямо в *entry, то есть тоже без лока - read и dirty ссылаются на один и тот же entry.

Два сценария, где она оправдана

  1. Ключ записывается один раз, читается много раз - кэш, который только растёт: скомпилированные шаблоны, метаданные типов, справочники.
  2. Разные горутины работают с непересекающимися наборами ключей - тогда промахи редки, а read у каждой своя по факту доступа.

Почему обычно хуже mutex+map

sync.Mapmap + RWMutexшардированная мапа
Типизацияany, нужен каст, боксингполнаяполная
lenнет, только полный Rangeестьсумма по шардам
Композитные операциитолько LoadOrStore/CASлюбые под локомлюбые под локом шарда
Много новых ключейплохо: копирование O(n)нормальнохорошо
Чтение при стабильных ключахотличносреднехорошо
Понятность кодасредняямаксимальнаясредняя
Что изменилось в Go 1.24

Внутренности sync.Map переписали с пары read/dirty на HashTrieMap - конкурентное хеш-дерево, где узлы обновляются локально и не требуют копирования всей мапы. Запись новых ключей стала кратно быстрее, а провалы на смешанной нагрузке ушли. API не изменился, нетипизированность осталась. Хороший ответ на собесе упоминает и классическое устройство (его спрашивают чаще), и то, что в актуальных версиях оно уже другое.

Как отвечать

«По умолчанию беру map под Mutex - это понятно и типизировано. Если профиль показывает контенцию и нагрузка read-heavy со стабильным набором ключей, смотрю на sync.Map. Если ключей много и они постоянно меняются - делаю шардирование по хешу на 64–256 мап. sync.Map - специализированный инструмент, а не быстрая мапа на все случаи.»

Суть: одна процессорная инструкция с блокировкой кэш-линии вместо протокола захвата лока. Без конкуренции выигрыш невелик (около 2 нс, почти как Lock с Unlock на M4 Pro), зато под нагрузкой в разы лучше. Но атомарна только одна операция, а не последовательность из них.

Что есть в пакете

ОперацияСмысл
Load / Storeатомарное чтение и запись целиком, без разрывов
Addприбавить и вернуть новое значение
Swapзаписать новое, вернуть старое
CompareAndSwapзаписать, только если сейчас лежит ожидаемое; основа lock-free алгоритмов
atomic.Valueатомарная подмена значения любого типа (тип должен быть один и тот же)
atomic.Pointer[T]типизированный указатель, Go 1.19, без unsafe

Типы Go 1.19 вместо функций

// Старый API: легко случайно обратиться неатомарно
var n int64
atomic.AddInt64(&n, 1)
fmt.Println(n)                 // 1, но это гонка: обычное чтение переменной,
                               // которую пишут атомарно. -race поймает

// Новый API: неатомарного доступа просто нет
var n atomic.Int64
n.Add(1)
fmt.Println(n.Load())          // 1, и прочитать можно только так

Типы atomic.Int32/Int64/Uint32/Uint64/Bool/Pointer[T] закрывают сразу три дыры: нельзя обратиться к полю напрямую, у Int64/Uint64 гарантировано 8-байтное выравнивание (на 32-битных платформах старый API требовал ручной заботы, иначе паника), и go vet ловит копирование через встроенный noCopy. Старый atomic.Value (Go 1.4) закрывает только первую: поля noCopy в нём нет, и его копирование vet пропустит.

atomic.Value: горячая перезагрузка конфига

var cfg atomic.Pointer[Config]

func reload() {                     // писатель: раз в минуту
    c := loadFromFile()
    cfg.Store(c)                    // подменяем указатель целиком
}

func handle(r *http.Request) {      // читатели: тысячи в секунду
    c := cfg.Load()                 // ~1 нс, ноль контенции
    use(c.Timeout)
}

Это copy-on-write: конфиг не мутируют на месте, а собирают новый объект и атомарно подменяют указатель. Читатели вообще не синхронизируются между собой, старый объект живёт, пока на него ссылается хоть один обработчик, и потом его забирает GC. Для read-heavy данных это быстрее любого RWMutex.

Ложное чувство безопасности
// 1) Проверка и действие - две операции. Между ними всё может измениться.
if counter.Load() < limit {
    counter.Add(1)                 // лимит будет превышен под нагрузкой
}
// Правильно: цикл CAS
for {
    old := counter.Load()
    if old >= limit { return errLimit }
    if counter.CompareAndSwap(old, old+1) { return nil }
}

// 2) Два атомарных поля - не атомарная пара
balance.Store(newBalance)
version.Store(newVersion)          // между строками кто-то прочитает несогласованное состояние
// Правильно: один atomic.Pointer на неизменяемую структуру со ВСЕМИ полями

// 3) atomic.Value с разными типами
var v atomic.Value
v.Store(&ConfigA{})
v.Store(&ConfigB{})                // panic: sync/atomic: store of inconsistently typed value
// И Store(nil) тоже паникует - в отличие от atomic.Pointer[T]
Формулировка про производительность

«Атомик быстрее не потому, что “без лока”, а потому что вся синхронизация - это одна инструкция вроде LOCK XADD, которая удерживает кэш-линию на время своего выполнения. Мьютекс в лучшем случае делает тот же CAS плюс проверки, а в худшем - парковку горутины и переключение. Но под высокой конкуренцией атомик тоже упирается в шину: десять ядер, инкрементящие одну переменную, гоняют кэш-линию между собой, и это может быть медленнее, чем шардированный счётчик или локальные счётчики с периодическим сведением.»

Суть: буферизированный канал ёмкости N - это готовый счётный семафор: отправка = Acquire, приём = Release. Для взвешенных захватов и отмены по контексту есть semaphore.Weighted.

Семафор на канале

sem := make(chan struct{}, 10)          // не больше 10 одновременно

for _, task := range tasks {
    sem <- struct{}{}                   // Acquire: блокируется, если 10 уже работают
    go func() {
        defer func() { <-sem }()        // Release только через defer
        process(task)
    }()
}
// дождаться остатка: заполнить семафор целиком
for i := 0; i < cap(sem); i++ { sem <- struct{}{} }

Ёмкость канала и есть лимит. Плюсы: ноль зависимостей, работает в select, значит легко добавить таймаут и отмену:

select {
case sem <- struct{}{}:                 // получили слот
    defer func() { <-sem }()
case <-ctx.Done():
    return ctx.Err()                    // не дождались слота, уходим
}

Важная деталь: где ставить Acquire

// Плохо: горутина создаётся сразу, лимит ограничивает только «работающих»
for _, t := range tasks {
    go func() {
        sem <- struct{}{}               // миллион горутин уже создан и спит здесь
        defer func() { <-sem }()
        process(t)
    }()
}

// Хорошо: Acquire до go, горутину не создаём, пока нет слота
for _, t := range tasks {
    sem <- struct{}{}
    go func() { defer func() { <-sem }(); process(t) }()
}

semaphore.Weighted

import "golang.org/x/sync/semaphore"

sem := semaphore.NewWeighted(100)       // 100 условных единиц ресурса

func handle(ctx context.Context, req Request) error {
    w := int64(req.SizeMB)              // тяжёлый запрос занимает больше
    if err := sem.Acquire(ctx, w); err != nil {
        return err                      // контекст отменён или истёк
    }
    defer sem.Release(w)
    return process(req)
}
Канал-семафорsemaphore.Weighted
Вес захватавсегда 1любой int64
Отмена по контекстувручную через selectвстроена в Acquire(ctx, n)
Неблокирующая попыткаselect/defaultTryAcquire
СправедливостьFIFO очереди каналаFIFO, но большой вес ждёт накопления слотов
Зависимостинетx/sync
На чём ловят
  • Release без defer. Паника или ранний return - и слот потерян навсегда; после N таких случаев семафор закрыт наглухо.
  • Release чужого веса в Weighted - паника semaphore: released more than held.
  • Путают семафор и worker pool. Семафор ограничивает одновременность, но горутина на каждую задачу всё равно создаётся. Worker pool ограничивает и одновременность, и число горутин. Для миллиона мелких задач нужен пул, для сотни тяжёлых - семафор проще.
  • Забывают, что errgroup.SetLimit - это тот же семафор на канале, уже написанный за тебя.
Суть: канал - когда данные передаются между горутинами; мьютекс - когда данные охраняются на месте; atomic - когда это одно число или указатель. Мантра «share memory by communicating» - про проектирование, а не про запрет мьютексов.

Критерии

Признак задачиВыбор
Передача владения данными, конвейер, очередь задачканал
Сигнал отмены/завершения, broadcastканал (через close)
Ожидание нескольких событий одновременноканал + select
Ограничение одновременностиканал-семафор
Защита состояния структуры (кэш, мапа, счётчики полей)мьютекс
Короткая критическая секция в горячем путимьютекс
Один счётчик, флаг, версия, указатель на конфигatomic
Дождаться группы горутинWaitGroup / errgroup

Что говорит официальный Go

Слоган Роба Пайка «Do not communicate by sharing memory; instead, share memory by communicating» - про то, что владение данными в каждый момент должно принадлежать одной горутине, а передавать его надо явной операцией. Но в Go Wiki (страница MutexOrChannel) прямо написано: берите то, что выразительнее и проще, а для кэшей и состояния - мьютекс. Комментарий к пакету sync при этом строже: его примитивы, кроме Once и WaitGroup, он отводит низкоуровневым библиотекам, а синхронизацию верхнего уровня советует делать каналами. Стандартная библиотека полна мьютексов (net/http, database/sql, runtime).

Что типично в реальных проектах

  • Мьютекс/atomic - внутри типов: кэши, пулы, метрики, реестры, состояние соединения. Их больше по количеству.
  • Канал - на границах между компонентами: очередь задач воркерам, конвейер обработки, сигналы завершения, события.
  • Гибрид - норма. Worker pool: задачи через канал, счётчики результатов через atomic, общая мапа результатов под мьютексом.
  • Цена. Канал ~25–80 нс на операцию, мьютекс и атомик без конкуренции - единицы наносекунд (около 2 нс на M4 Pro, go1.27), а под конкуренцией атомик в разы дешевле мьютекса. Разница важна только в горячих путях, но там она решает.
Как формулировать на собесе

«Я задаю себе один вопрос: данные едут или лежат? Если едут от горутины к горутине - канал: он переносит и данные, и синхронизацию, и сигнал конца одной конструкцией. Если лежат в структуре и к ним ходят - мьютекс, он дешевле и понятнее. Канал вокруг обычной мапы - это переусложнение: получится актор с единственной горутиной-владельцем, который упрётся в один поток и будет медленнее мьютекса.»

Антипаттерн, который любят показывать
// Счётчик «по-гошному» через канал: в разы медленнее и в разы сложнее
type Counter struct {
    ops chan func(*int)
}
func (c *Counter) Inc() { c.ops <- func(n *int) { *n++ } }

// То же самое, как надо
type Counter struct {
    n atomic.Int64
}
func (c *Counter) Inc() { c.n.Add(1) }
Суть: условная переменная - очередь горутин, ждущих изменения состояния, привязанная к мьютексу. Wait отпускает лок и засыпает, Signal/Broadcast будят одного или всех. В Go используется редко, потому что не дружит с select и context.

API и обязательный шаблон

c := sync.NewCond(&mu)

// Ждущая сторона проверяет в цикле, не через if
mu.Lock()
for !condition() {          // проверять условие в цикле обязательно
    c.Wait()                // атомарно отпускает mu и засыпает; проснувшись, снова берёт mu
}
useState()
mu.Unlock()

// Сигналящая сторона
mu.Lock()
changeState()
mu.Unlock()
c.Signal()                  // будит одного; Broadcast будит всех

Цикл вместо if нужен потому, что между пробуждением и повторным захватом мьютекса другая горутина может снова изменить состояние - проснувшийся обязан перепроверить условие сам.

Где Cond всё-таки уместен

  • Ограниченная очередь с двумя разными условиями («не пуста» для читателей, «не полна» для писателей) и множеством ждущих на каждом - каналами это выражается хуже.
  • Пул ресурсов: ждём освобождения любого из N объектов. Именно так Cond используется внутри некоторых драйверов БД.
  • Broadcast по изменению состояния, когда состояние сложное и его нельзя «передать» значением, - например, «конфигурация перезагружена».
Почему в 95% случаев берут не Cond
  • Не работает в select. Нельзя ждать «Cond или ctx.Done()» - а без отмены в проде нельзя. Обходной путь уродлив: отдельная горутина, которая по отмене контекста дёргает Broadcast, плюс проверка ctx.Err() в цикле условия.
  • Легко ошибиться: Wait без цикла, Signal без изменения состояния, Signal вместо Broadcast при нескольких разных условиях (разбудишь не того, и никто не проснётся никогда).
  • Копировать нельзя, go vet ругается.
  • Есть готовые замены: канал (сигнал), закрытие канала (broadcast), WaitGroup (дождаться всех), semaphore.Weighted (ждать ресурс с отменой), errgroup.
Как ответить, если спросят «использовал ли?»

Рабочий ответ: «В продакшн-коде - ни разу, и это нормально: практически любая задача, где хочется Cond, в Go решается каналом с отменой. Понимаю, как он работает и почему Wait обязан быть в цикле, но выбрал бы его только там, где нужно много ждущих на разных условиях под одним состоянием - например, в собственной ограниченной очереди.»

Суть: без синхронизации нет гарантии, что одна горутина вообще увидит записи другой и увидит их в том порядке, в котором они написаны. Порядок задают только рёбра happens-before: канал, мьютекс, Once, WaitGroup, atomic.

Что такое happens-before

Это частичный порядок между событиями программы. Если запись W happens-before чтение R и между ними нет других записей в ту же переменную, то R гарантированно видит W. Если такого ребра нет - R может увидеть что угодно: старое значение, новое, а на некоторых архитектурах и разорванное.

Откуда берутся рёбра

  • Внутри одной горутины - порядок исходного кода.
  • go f(): всё, что было до запуска, видно внутри новой горутины.
  • Отправка в канал happens-before завершения соответствующего приёма.
  • close(ch) happens-before приёма нулевого значения из-за закрытия.
  • n-й Unlock happens-before (n+1)-го Lock.
  • Once.Do(f): завершение f happens-before возврата из любого другого Do.
  • WaitGroup: все Done happens-before возврата Wait.
  • С Go 1.19 операции sync/atomic явно объявлены sequentially consistent - раньше их семантика была описана только «по факту реализации».

Почему bool-флаг не работает

var data int
var ready bool

// горутина A
data = 42
ready = true          // компилятор вправе поменять эти строки местами:
                      // зависимости по данным между ними нет

// горутина B
for !ready {}         // может крутиться вечно: компилятор имеет право
                      // прочитать ready один раз и закешировать в регистре
use(data)             // даже увидев ready==true, может прочитать data == 0

Ломается в трёх независимых местах: компилятор переставляет и кеширует чтения, процессор выполняет записи не в порядке программы и держит их в store buffer, и кэши ядер не обязаны синхронизироваться немедленно. Ни одна из них не «баг» - все они законны для программы без гонок, а программа с гонкой по определению находится вне контракта.

// Правильно: канал
data = 42
close(ready)      // ребро happens-before

<-ready
use(data)         // гарантированно 42
// Правильно: atomic
data = 42
flag.Store(true)

for !flag.Load() {}
use(data)         // гарантированно 42
Одной фразой на собесе

Развести горутины по времени - только половина дела. Вторая половина: вообще увидеть чужие записи и увидеть их согласованно. Именно поэтому «у меня записывает только одна горутина, а читают остальные - гонки нет» - неверное рассуждение: гонка есть, и она может проявиться как «читатель никогда не видит новое значение» или «видит новый флаг и старые данные».

Что Go гарантирует даже при гонке

В отличие от C++, где data race - undefined behavior и компилятор вправе сделать буквально что угодно, Go даёт ограниченную гарантию: чтение машинного слова при гонке вернёт значение из какой-то реальной записи, а не «из воздуха». Но интерфейс, слайс, строка и int64 на 32-битной платформе состоят из нескольких слов, и прочитать их можно разорванными: тип от одного значения, указатель от другого. На первом же вызове метода это уронит программу, а модель памяти прямо предупреждает, что такие гонки могут привести к произвольной порче памяти. Так что практически разница с UB невелика: гонки надо чинить, а не оценивать по степени опасности.

Что стоит запомнить

  • Mutex защищает всю критическую секцию.
  • WaitGroup сигнализирует завершение, но не переносит результаты.
  • Atomic годится для отдельных атомарных значений.

Частые ошибки

  • Mutex нельзя копировать после первого использования.
  • Atomic не защищает инвариант из нескольких операций.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи выполнение

WaitGroup используется для ожидания двух worker. Что напечатает программа и почему Add расположен до запуска?

var wg sync.WaitGroup
for i := 0; i < 2; i++ { wg.Add(1); go func() { defer wg.Done(); fmt.Println("done") }() }
wg.Wait(); fmt.Println("all")
Ответ и разбор

Появятся две строки done в любом взаимном порядке, затем all. Add выполняется до запуска worker, поэтому Wait не может увидеть нулевой счётчик прежде, чем задачи будут учтены.

Код-ревью

2. Найди проблему и исправь

Два worker конкурентно меняют total без синхронизации. Исправь data race и дождись обоих worker перед чтением.

var total int64
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        total++
    }()
}
wg.Wait()
fmt.Println(total)
Ответ и разбор

total++ состоит из чтения, сложения и записи, поэтому конкурентные инкременты образуют data race. Используй var total atomic.Int64, внутри worker вызывай total.Add(1), после wg.Wait() читай через total.Load(). Ожидание нужно для завершения worker; atomic защищает счётчик, но не составной инвариант из нескольких значений.

Применение

3. Разбери рабочий сценарий

Несколько goroutine резервируют места в счётчике с одним общим неизменным limit; освобождения мест нет. Исправь гонку между проверкой и резервированием, чтобы count никогда не превысил limit.

var count atomic.Int64
func reserve(limit int64) bool {
    if count.Load() >= limit {
        return false
    }
    count.Add(1)
    return true
}
Ответ и разбор

Load и Add отдельны: несколько вызовов могут одновременно увидеть значение ниже limit и все увеличить его, превысив границу. Сделай цикл CAS: for { old := count.Load(); if old >= limit { return false }; if count.CompareAndSwap(old, old+1) { return true } }. Каждый успешный вызов меняет именно то значение, которое проверил; при конкуренции проигравший перечитает count.

Применение

4. Разбери граничный сценарий

Под mutex обновляются два поля, которые должны всегда удовлетворять available + reserved == total. Почему atomic на каждом поле недостаточно?

Ответ и разбор

Читатель может увидеть промежуточное состояние между двумя atomic операциями. Защити изменение и чтение пары одним Mutex, чтобы инвариант наблюдался целиком.