2.4Примитивы синхронизации: sync и atomic
Выберем Mutex, WaitGroup или atomic по тому, какой инвариант нужно сохранить между goroutine.
sync.Mutex изнутри
Структура спартанская: два поля, state int32 и sema uint32.
Ни владельца, ни счётчика рекурсии, ни привязки к горутине. Отсюда и растёт половина граблей.
Что происходит при Lock, по шагам
- Fast path.
CAS(state, 0, mutexLocked). Если получилось - всё, лок наш, стоимость - единицы наносекунд (LockсUnlockоколо 2 нс на M4 Pro, go1.27). Так заканчивается подавляющее большинство вызовов. - Spin. Если лок занят, но мьютекс в normal mode, машина многоядерная, хотя бы ещё
один P сейчас работает (скорее всего, на нём и сидит владелец лока), а в локальной очереди
нашего P никто не ждёт, горутина крутится активно: до 4 итераций по 30 инструкций
PAUSE(procyield). Расчёт простой: владелец отпустит лок через десятки наносекунд, и это дешевле парковки с пробуждением. - Парковка. Спин не помог - увеличиваем счётчик
waitersи вызываемruntime_SemacquireMutex. Это семафор рантайма: горутина уходит в_Gwaiting, поток ОС свободен. Никакогоfutexна уровне потока здесь нет - блокируется именно горутина. - Пробуждение. Проснувшись, горутина смотрит режим: в normal - пробует захватить
снова (и может проиграть новичку), в starvation - получает лок сразу, потому что
Unlockпередал его напрямую. - Переключение режима. Если горутина в момент захвата обнаружила, что ждала дольше
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) и, если значение неотрицательное,
сразу возвращается.
| Сценарий | Mutex | RWMutex | Комментарий |
|---|---|---|---|
| Короткая критическая секция (чтение поля) | быстрее | медленнее | атомарных операций у читателя столько же, сколько у Mutex, но все пишут в один readerCount; выигрыш не окупается |
| Много читателей, редкие записи, секция > ~1 мкс | узкое место | быстрее | читатели идут параллельно |
| 50/50 чтение и запись | быстрее | медленнее | писатель всё равно всех выстраивает в очередь |
| Много ядер, очень много читателей | - | осторожно | readerCount - одна кэш-линия, все ядра дерутся за неё (cache line bouncing) |
Наивный RWMutex пускает читателей всегда, пока их счётчик больше нуля: при плотном потоке чтений писатель не пройдёт никогда. В Go это закрыто: Lock
писателя вычитает из readerCount константу rwmutexMaxReaders
(1<<30), делая счётчик отрицательным. Все новые RLock видят
отрицательное значение и уходят спать. Писатель ждёт только тех читателей, которые уже
внутри. То есть писатель блокирует новых читателей, но не наоборот, и голодания писателя не будет. Зато работает обратное: плотный поток писателей подтормаживает читателей.
- Вложенный
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() }
Анализатор 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()
Addвнутри горутины.go func(){ wg.Add(1); ... }()- гонка:Waitможет увидеть счётчик 0 и вернуться до того, как горутина успеет стартовать.Addстрого доgo.- Копирование
WaitGroup. Передали по значению в функцию - там своя копия счётчика,Waitв оригинале ждёт вечно. Только*sync.WaitGroupили замыкание. - Забытый
Doneпри панике или раннемreturn. Толькоdefer wg.Done()первой строкой горутины. - Переиспользование до завершения
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
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 для быстрых
чтений без лока и dirty под мьютексом для всего остального. Выигрыш есть только
там, где ключи стабильны, а чтений сильно больше записей.| Операция | Что происходит | Стоимость |
|---|---|---|
Load, ключ в read | atomic-загрузка указателя + чтение мапы | очень дёшево, лока нет |
Load, ключа в read нет, amended | лок, поиск в dirty, misses++ | дорого |
Store существующего ключа | CAS прямо в *entry | дёшево, лока нет |
Store нового ключа | лок, при пустой dirty - копирование всей read | очень дорого, O(n) |
Delete | ставит в entry nil, в expunged его переведёт следующее пересоздание dirty; память сразу не освобождается | средне |
Range | при amended сначала промоутит dirty в read | дорого, снимок не консистентен |
- Ключ пишется один раз, читается много раз - кэши только на дозапись.
- Несколько горутин читают, пишут и перезаписывают непересекающиеся наборы ключей - шардирование по горутинам.
- Нет типизации до дженериков:
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 всё
зелено, а в проде на другой архитектуре раз в сутки прилетает ноль вместо значения.
- Внутри одной горутины всё выполняется так, будто по порядку исходника (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 + map | sync.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
}
- Не работает с
select. Нельзя ждать «Cond или отмену контекста» - а это нужно почти всегда. Единственный обходной путь - отдельная горутина, которая по отмене дёргаетBroadcast. - Не отменяем. Горутина в
Waitждёт до победного. - Легко ошибиться:
Waitобязан стоять в цикле проверки условия,Signal/Broadcast- рядом с изменением состояния, копироватьCondнельзя. - Есть замена: канал (для сигнала),
WaitGroup(для «дождаться всех»),errgroup,semaphore.Weighted. Реальные примененияCond- очередь с ограниченной ёмкостью и множеством ждущих разных условий, например внутри пула соединений.
Вопросы
13state int32 с битами
locked/woken/starving/waiters и семафор рантайма. Fast path - один CAS. Мьютекс
не реентерабельный: повторный Lock из той же горутины блокирует её
навсегда.Путь захвата
- CAS(state, 0, locked) - успех, единицы наносекунд, дальше ничего не происходит.
- Спин. Занят, но normal mode, ещё какой-то P работает (там, скорее всего, и
владелец), а локальная очередь нашего P пуста - крутимся до 4 раз по 30
инструкций
PAUSE. Дешевле, чем парковка, если владелец вот-вот отпустит. - Парковка.
runtime_SemacquireMutex: горутина в_Gwaiting, поток ОС свободен и берёт другую работу. Это семафор рантайма, а не futex потока. - Пробуждение. В 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 уходят спать, писатель дожидается только тех, кто уже внутри.
Голодания писателя нет. Обратная сторона: при плотном потоке писателей подтормаживают уже читатели, но это честная очередь.
// 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/syncv0.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второй раз.
Как устроен
У каждого P - свой poolLocal с приватным слотом (доступ вообще без
синхронизации) и локальной очередью, из которой другие P могут воровать. Поэтому
Get/Put почти бесплатны и хорошо масштабируются по ядрам.
Если ничего не нашлось - вызывается New.
Связь с GC - самое важное
- При старте цикла GC хук
poolCleanupпроходит по всем пулам. - Текущие локальные наборы переезжают в victim-кэш, а старые victim выбрасываются насовсем.
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 }}
- Объект грязный.
Getможет вернуть что угодно из прошлой жизни. Не сбросили - утечка данных между запросами, вплоть до чужих персональных данных в ответе. Это реальная уязвимость, а не теория. - Put после того, как отдали наружу. Если
buf.Bytes()уехал в структуру, которая переживётdefer Put, ты вернул в пул память, на которую есть живая ссылка, и следующийGetперезапишет её. - Класть в пул слайс (не указатель) - аллокация.
Put(any)упаковывает значение в интерфейс; для не-указателя это боксинг в кучу, то есть ровно то, от чего мы уходили. - Растущие объекты. Если в пул возвращать буферы после обработки гигантского
запроса, пул начнёт хранить их, и память вырастет. Лечение -
не класть обратно, если
capпревысил порог. - Это не пул соединений. Нет ограничения количества, нет времени жизни,
содержимое исчезает при 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не удаляет сразу: ставит вentrynil. При очередном пересозданииdirtyтакие записи помечаютсяexpungedи в неё не копируются, а физически исчезают, когда этаdirtyстанет новойread.- Обновление существующего ключа идёт через CAS прямо в
*entry, то есть тоже без лока -readиdirtyссылаются на один и тот жеentry.
Два сценария, где она оправдана
- Ключ записывается один раз, читается много раз - кэш, который только растёт: скомпилированные шаблоны, метаданные типов, справочники.
- Разные горутины работают с непересекающимися наборами ключей - тогда промахи
редки, а
readу каждой своя по факту доступа.
Почему обычно хуже mutex+map
| sync.Map | map + RWMutex | шардированная мапа | |
|---|---|---|---|
| Типизация | any, нужен каст, боксинг | полная | полная |
len | нет, только полный Range | есть | сумма по шардам |
| Композитные операции | только LoadOrStore/CAS | любые под локом | любые под локом шарда |
| Много новых ключей | плохо: копирование O(n) | нормально | хорошо |
| Чтение при стабильных ключах | отлично | средне | хорошо |
| Понятность кода | средняя | максимальная | средняя |
Внутренности sync.Map переписали с пары read/dirty на
HashTrieMap - конкурентное хеш-дерево, где узлы обновляются локально и не
требуют копирования всей мапы. Запись новых ключей стала кратно быстрее, а провалы
на смешанной нагрузке ушли. API не изменился, нетипизированность осталась. Хороший
ответ на собесе упоминает и классическое устройство (его спрашивают чаще), и то, что
в актуальных версиях оно уже другое.
«По умолчанию беру map под Mutex - это понятно и типизировано.
Если профиль показывает контенцию и нагрузка read-heavy со стабильным набором ключей,
смотрю на sync.Map. Если ключей много и они постоянно меняются - делаю
шардирование по хешу на 64–256 мап. sync.Map - специализированный
инструмент, а не быстрая мапа на все случаи.»
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 плюс проверки, а в худшем -
парковку горутины и переключение. Но под высокой конкуренцией атомик тоже упирается
в шину: десять ядер, инкрементящие одну переменную, гоняют кэш-линию между собой,
и это может быть медленнее, чем шардированный счётчик или локальные счётчики с
периодическим сведением.»
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/default | TryAcquire |
| Справедливость | FIFO очереди канала | FIFO, но большой вес ждёт накопления слотов |
| Зависимости | нет | x/sync |
- Release без defer. Паника или ранний
return- и слот потерян навсегда; после N таких случаев семафор закрыт наглухо. - Release чужого веса в
Weighted- паникаsemaphore: released more than held. - Путают семафор и worker pool. Семафор ограничивает одновременность, но горутина на каждую задачу всё равно создаётся. Worker pool ограничивает и одновременность, и число горутин. Для миллиона мелких задач нужен пул, для сотни тяжёлых - семафор проще.
- Забывают, что
errgroup.SetLimit- это тот же семафор на канале, уже написанный за тебя.
Критерии
| Признак задачи | Выбор |
|---|---|
| Передача владения данными, конвейер, очередь задач | канал |
| Сигнал отмены/завершения, 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 по изменению состояния, когда состояние сложное и его нельзя «передать» значением, - например, «конфигурация перезагружена».
- Не работает в
select. Нельзя ждать «Cond илиctx.Done()» - а без отмены в проде нельзя. Обходной путь уродлив: отдельная горутина, которая по отмене контекста дёргаетBroadcast, плюс проверкаctx.Err()в цикле условия. - Легко ошибиться:
Waitбез цикла,Signalбез изменения состояния,SignalвместоBroadcastпри нескольких разных условиях (разбудишь не того, и никто не проснётся никогда). - Копировать нельзя,
go vetругается. - Есть готовые замены: канал (сигнал), закрытие канала (broadcast),
WaitGroup(дождаться всех),semaphore.Weighted(ждать ресурс с отменой),errgroup.
Рабочий ответ: «В продакшн-коде - ни разу, и это нормально: практически любая
задача, где хочется Cond, в Go решается каналом с отменой. Понимаю, как он
работает и почему Wait обязан быть в цикле, но выбрал бы его только там,
где нужно много ждущих на разных условиях под одним состоянием - например, в
собственной ограниченной очереди.»
Once, WaitGroup,
atomic.Что такое happens-before
Это частичный порядок между событиями программы. Если запись W happens-before чтение R и между ними нет других записей в ту же переменную, то R гарантированно видит W. Если такого ребра нет - R может увидеть что угодно: старое значение, новое, а на некоторых архитектурах и разорванное.
Откуда берутся рёбра
- Внутри одной горутины - порядок исходного кода.
go f(): всё, что было до запуска, видно внутри новой горутины.- Отправка в канал happens-before завершения соответствующего приёма.
close(ch)happens-before приёма нулевого значения из-за закрытия.- n-й
Unlockhappens-before (n+1)-гоLock. Once.Do(f): завершениеfhappens-before возврата из любого другогоDo.WaitGroup: всеDonehappens-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
Развести горутины по времени - только половина дела. Вторая половина: вообще увидеть чужие записи и увидеть их согласованно. Именно поэтому «у меня записывает только одна горутина, а читают остальные - гонки нет» - неверное рассуждение: гонка есть, и она может проявиться как «читатель никогда не видит новое значение» или «видит новый флаг и старые данные».
В отличие от 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, чтобы инвариант наблюдался целиком.