sync пакет: продвинутое использование
Зачем знать на Middle 1: Пакет
sync— это не “пара примитивов на запас”. Внутри Mutex есть starvation mode, у RWMutex есть свой fairness, sync.Pool имеет victim cache, а atomic в Go 1.19+ полностью переехал на типизированные обёртки. Middle-разработчик должен выбирать примитив осознанно: где Mutex, где atomic, где sync.Pool, где Once. Без этого — упрёшься в performance ceiling или, хуже, словишь race condition в проде.
Содержание
Заголовок раздела «Содержание»- Базовая концепция (кратко)
- Под капотом
- Gotchas
- Производительность
- Когда использовать / альтернативы
- Вопросы на собесе
- Practice
- Источники
1. Базовая концепция (кратко)
Заголовок раздела «1. Базовая концепция (кратко)»Пакет sync — низкоуровневые примитивы синхронизации поверх рантайма:
| Тип | Назначение |
|---|---|
sync.Mutex | Эксклюзивный lock |
sync.RWMutex | Reader/writer lock |
sync.WaitGroup | ”Подождать N горутин” |
sync.Once | Выполнить раз |
sync.OnceFunc/Value/Values | Lazy init (Go 1.21+) |
sync.Pool | Reuse временных объектов |
sync.Map | Concurrent map для read-heavy |
sync.Cond | Условная переменная |
Плюс пакет sync/atomic:
| Тип | Назначение |
|---|---|
atomic.Int32/64, Uint32/64, Bool, Pointer[T] (Go 1.19+) | Lock-free числа и указатели |
atomic.Value | Lock-free сохранение произвольного значения |
Все zero-value примитивы готовы к использованию: var m sync.Mutex работает.
2. Под капотом (детально)
Заголовок раздела «2. Под капотом (детально)»2.1. sync.Mutex: normal vs starvation mode
Заголовок раздела «2.1. sync.Mutex: normal vs starvation mode»sync.Mutex — это futex-based mutex с спин-фазой. Структура:
// sync/mutex.go (Go 1.22)type Mutex struct { state int32 // битовое поле sema uint32 // semaphore handle для парковки}
const ( mutexLocked = 1 << iota // bit 0: захвачен mutexWoken // bit 1: уже разбужен один waiter mutexStarving // bit 2: режим starvation mutexWaiterShift = iota // 3, остальное — счётчик waiters)Normal mode (по умолчанию)
Заголовок раздела «Normal mode (по умолчанию)»Lock: CAS попытка: 0 → mutexLocked. Если успех — выходим. Иначе — spin (если SMP, разумно spin'ить): runtime_doSpin() — 30 PAUSE. Если не получилось — увеличить waiters счётчик, runtime_SemacquireMutex (futex_wait). Когда semaphore сигналит — пытаемся снова захватить (с новыми waiters).
Unlock: Atomic Add(-mutexLocked). Если есть waiters → runtime_Semrelease (futex_wake one).В normal mode разбуженный waiter и новый pretender соревнуются за lock. Новый — преимущественно с CPU cache hot, и часто выигрывает. Старый снова паркуется.
Starvation mode (Go 1.9+)
Заголовок раздела «Starvation mode (Go 1.9+)»Если waiter ждал > 1 ms, mutex переходит в starvation mode. В этом режиме:
- Unlock передаёт ownership напрямую разбуженному waiter (не возвращает в pool).
- Новый pretender НЕ спинит — сразу в очередь.
- Выходим из starvation, когда последний waiter получил lock или ждал < 1 ms.
Это решает проблему “богатого богаче” (rich-get-richer): без этого долго ждущая горутина может буквально никогда не получить lock на высококонкурентном mutex.
Time →G1 [hold]──┐G2 park│ ────────────────── starves (1ms+)G3 park│ tries spin → winsG4 try → wins (G2 ждёт навсегда в normal mode)
Со starvation mode: после 1ms G2 переключает mutex,unlock передаст напрямую G2, спин G3/G4 запрещён.Спин-фаза детали
Заголовок раздела «Спин-фаза детали»runtime_canSpin:
- GOMAXPROCS > 1 (на single-core spin бесполезен).
- iter < 4 (не более 4 итераций).
- Есть другие runnable Ps.
Каждая итерация = 30 PAUSE инструкций. Это amd64-специфично; на ARM — yield.
2.2. sync.RWMutex
Заголовок раздела «2.2. sync.RWMutex»type RWMutex struct { w Mutex // эксклюзивный lock для writers и для "write вход" writerSem uint32 readerSem uint32 readerCount atomic.Int32 // активные читатели; отрицательное = есть writer readerWait atomic.Int32 // сколько читателей writer ждёт}
const rwmutexMaxReaders = 1 << 30n := readerCount.Add(1)если n < 0 — writer уже захватил или ждёт: semacquire(readerSem) — паркуемся.иначе — мы читатель, идём дальше.RUnlock
Заголовок раздела «RUnlock»n := readerCount.Add(-1)если n < 0 — есть writer: если readerWait.Add(-1) == 0 — последний read закончился, sempost(writerSem).Lock (writer)
Заголовок раздела «Lock (writer)»w.Lock() // эксклюзив над "writer-входом", чтобы один writer писалr := readerCount.Add(-rwmutexMaxReaders) // блокируем новых читателейесли есть активные читатели (r != -rwmutexMaxReaders): readerWait.Store(r + rwmutexMaxReaders) semacquire(writerSem) // ждём пока активные читатели уйдутr := readerCount.Add(rwmutexMaxReaders)будим всех ожидающих читателей: r sempost(readerSem)w.Unlock()Reader vs writer fairness
Заголовок раздела «Reader vs writer fairness»В Go RWMutex — writer preference: новые читатели не пускаются, пока есть ожидающий writer. Это защищает от writer starvation.
Time →Readers: R1 R2 R3 R4 R5 R6 R7 ... W приходит, делает Lock — readerCount += -MaxReadersНовые R8 R9 видят n<0, паркуются.W ждёт R1..R7 finish.W захватывает, Unlock — будит R8 R9 (но не больше, пока W' нет).Когда RWMutex медленнее Mutex
Заголовок раздела «Когда RWMutex медленнее Mutex»Под низкой contention — да. У RWMutex более тяжёлый Lock/Unlock (нужны 2 atomic + semaphore). Mutex же — один CAS в hot path.
Правило: используй RWMutex, когда:
- Чтений сильно больше записей (10:1+).
- Чтения длинные (не milliseconds — в худшем случае стоит профилить).
Для коротких чтений (например map lookup ~50ns) Mutex выигрывает.
2.3. sync.WaitGroup internals
Заголовок раздела «2.3. sync.WaitGroup internals»type WaitGroup struct { noCopy noCopy state atomic.Uint64 // upper 32 bits = counter, lower 32 = waiters count sema uint32}Add(delta):
state += delta << 32 (atomic)если counter < 0 — panic.если counter == 0 и есть waiters — sempost для всех waiters; reset state.Wait():
if counter == 0 — return сразу.иначе CAS: waiters++ → semacquire(sema).Done() — это Add(-1).
Ловушка: Add нельзя вызывать из горутины, которая ждёт Wait — race. Add должен быть до Wait.
2.4. sync.Once internals
Заголовок раздела «2.4. sync.Once internals»type Once struct { done atomic.Uint32 // 0 или 1 m Mutex}
func (o *Once) Do(f func()) { if o.done.Load() == 0 { o.doSlow(f) }}
func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done.Load() == 0 { // double-check defer o.done.Store(1) f() }}Fast path: атомарный Load done. Если 1 — return мгновенно (~1ns). Если 0 — берём mutex, double-check, выполняем f.
Защита от паники: если f() panic’ит — done.Store(1) всё равно срабатывает через defer. Так задумано: повторно вызвать “сломанный” once бессмысленно.
2.5. sync.OnceFunc / OnceValue / OnceValues (Go 1.21+)
Заголовок раздела «2.5. sync.OnceFunc / OnceValue / OnceValues (Go 1.21+)»Удобные обёртки:
init := sync.OnceFunc(func() { fmt.Println("init")})init() // печатаетinit() // ничего не делает
getCfg := sync.OnceValue(func() *Config { return loadConfig()})cfg := getCfg() // первый вызов — loadConfigcfg = getCfg() // второй — кешированное значение
split := sync.OnceValues(func() (int, error) { return 42, nil})v, err := split()Под капотом — sync.Once + замыкание для результата. Эквивалентно ручному варианту, но компактнее и без race на захват результата.
2.6. sync.Pool deep
Заголовок раздела «2.6. sync.Pool deep»type Pool struct { noCopy noCopy local unsafe.Pointer // [P]poolLocal localSize uintptr // size of the local array victim unsafe.Pointer // previous local victimSize uintptr New func() any}
type poolLocal struct { poolLocalInternal pad [128]byte // false-sharing padding}
type poolLocalInternal struct { private any // только эта P может использовать shared poolChain // lock-free deque для work-stealing}Алгоритм Get
Заголовок раздела «Алгоритм Get»1. pin P (отключить preemption).2. local := localPool[P.id]3. if local.private != nil — забрать, выключить, return.4. else — local.shared.popHead() (с конца, lock-free).5. else — попробовать stealing: пройти по другим P, popTail (с начала).6. else — попробовать victim cache.7. else — New().Алгоритм Put
Заголовок раздела «Алгоритм Put»1. pin P.2. if local.private == nil — local.private = x.3. else — local.shared.pushHead(x).Victim cache (Go 1.13+)
Заголовок раздела «Victim cache (Go 1.13+)»GC цикл: 1. victim = local (старый local становится victim). 2. local = nil (allocate новый при первом Get).
Следующий GC: victim очищается.Это даёт объекту “пережить” один GC: если он не был переиспользован в первом цикле, у него есть второй шанс.
GC behavior
Заголовок раздела «GC behavior»runtime_registerPoolCleanup(poolCleanup) — рантайм вызывает cleanup каждый GC. Без victim cache (до Go 1.13) был полный сброс, и pool становился бесполезен на нерегулярной нагрузке.
Use case
Заголовок раздела «Use case»var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) },}
func handler(w http.ResponseWriter, r *http.Request) { buf := bufPool.Get().(*bytes.Buffer) defer func() { buf.Reset() bufPool.Put(buf) }() // ... используем buf ...}⚠️ Pool не для connection pool!
Заголовок раздела «⚠️ Pool не для connection pool!»// АНТИПАТТЕРНvar dbPool = sync.Pool{ New: func() any { return openDB() },}Проблемы:
- GC очищает половину — соединения утекают.
- Pool не ограничен в количестве.
- Pool не управляет жизненным циклом.
Для connection pool — buffered channel или database/sql.DB (у него свой пул).
2.7. sync.Map deep
Заголовок раздела «2.7. sync.Map deep»type Map struct { mu Mutex read atomic.Pointer[readOnly] // hot path read dirty map[any]*entry // обычный map под mutex misses int // когда мисс → promote}
type readOnly struct { m map[any]*entry amended bool // dirty содержит ключи, не в read}read := m.read.Load()if e, ok := read.m[key]; ok — atomic load e.value, return.если amended: lock. re-check read. e, ok := dirty[key] m.misses++ if misses >= len(dirty) — promote: read = readOnly{m: dirty, amended: false}; dirty = nil. unlock.read := m.read.Load()если e, ok := read.m[key]; ok и e не expunged — atomic store.иначе lock: если в read — undelete (expunged → nil). иначе если в dirty — atomic store. иначе — добавить в dirty (создать dirty из read если nil, mark amended).unlock.Delete (через LoadAndDelete и т.д.)
Заголовок раздела «Delete (через LoadAndDelete и т.д.)»Soft delete: e.value = nil. Реально удаляется при promote.
Когда use sync.Map
Заголовок раздела «Когда use sync.Map»- Append-only / rarely-deleted keys.
- Read >> write (например, кеш конфига).
- Discrete keys для разных горутин (каждая горутина — свой key).
Когда use Mutex + map
Заголовок раздела «Когда use Mutex + map»Всегда, если:
- Записи сравнимы с чтениями. Sync.Map медленнее обычного map+Mutex для write.
- Нужен range с гарантией.
- Map маленький (< 100 элементов) — overhead не окупается.
2.8. sync.Cond
Заголовок раздела «2.8. sync.Cond»type Cond struct { noCopy noCopy L Locker // обычно Mutex/RWMutex notify notifyList checker copyChecker}Использование:
type Queue[T any] struct { mu sync.Mutex cond *sync.Cond items []T}
func (q *Queue[T]) Push(v T) { q.mu.Lock() defer q.mu.Unlock() q.items = append(q.items, v) q.cond.Signal() // разбудить одного waiter}
func (q *Queue[T]) Pop() T { q.mu.Lock() defer q.mu.Unlock() for len(q.items) == 0 { q.cond.Wait() // unlock + sleep + lock } v := q.items[0] q.items = q.items[1:] return v}Ключевое: cond.Wait() отпускает lock на время сна. После пробуждения lock снова захвачен. Поэтому проверка — в цикле (защита от spurious wakeups — хотя в Go их нет, но защита от race с Signal).
Signal vs Broadcast
Заголовок раздела «Signal vs Broadcast»Signal()— будит одного waiter (FIFO).Broadcast()— будит всех. Использовать когда меняется состояние, релевантное всем.
Альтернатива: канал
Заголовок раздела «Альтернатива: канал»sync.Cond редко нужен. Канал даёт то же самое чище:
type Queue[T any] struct { ch chan T}func (q *Queue[T]) Push(v T) { q.ch <- v }func (q *Queue[T]) Pop() T { return <-q.ch }Cond полезен в редких случаях:
- Несколько разных условий на одном lock.
- Нужно явное “обнови всех” (Broadcast) при изменении сложного состояния.
2.9. atomic пакет (Go 1.19+)
Заголовок раздела «2.9. atomic пакет (Go 1.19+)»Новый стиль — типизированные обёртки:
var counter atomic.Int64counter.Add(1)v := counter.Load()counter.Store(0)
ok := counter.CompareAndSwap(1, 2) // CAS
var b atomic.Boolb.Store(true)
var ptr atomic.Pointer[Config]ptr.Store(&Config{...})cfg := ptr.Load()Старый стиль — функции (по-прежнему работает, но стилистически устарел):
var x int64atomic.AddInt64(&x, 1)atomic.LoadInt64(&x)Новый стиль:
- Безопаснее (нельзя случайно прочитать неатомарно).
- Типобезопасный (atomic.Pointer[T]).
- Чище API.
atomic.Value
Заголовок раздела «atomic.Value»Для произвольных типов:
var cfg atomic.Valuecfg.Store(&Config{...}) // Тип фиксируется на первом Store!c := cfg.Load().(*Config)⚠️ Все Store должны быть одного типа, иначе panic. Используй atomic.Pointer[T] если хочешь type safety.
Memory ordering
Заголовок раздела «Memory ordering»В Go спека гарантирует sequentially consistent для atomic операций. То есть для всех горутин видится один порядок всех atomic операций.
Это сильнее, чем у C/Rust (где есть Relaxed/Acquire/Release/SeqCst). Go выбирает простоту в обмен на минимальный overhead.
Практически: не нужно думать о memory barriers, просто используй atomic — порядок будет правильным.
CompareAndSwap (CAS)
Заголовок раздела «CompareAndSwap (CAS)»Атомарное “если значение == old, установить new, вернуть true”.
var version atomic.Int64for { cur := version.Load() if version.CompareAndSwap(cur, cur+1) { break } // race — попробовать снова}CAS используется для lock-free структур. На amd64 — это инструкция LOCK CMPXCHG.
atomic vs Mutex
Заголовок раздела «atomic vs Mutex»- atomic для одного значения (int, pointer, bool).
- Mutex для группы значений / структур.
Пример: atomic.Pointer[Config] для горячей перезагрузки конфига:
var cfg atomic.Pointer[Config]
// Hot path — чтениеc := cfg.Load()useConfig(c)
// Reload — атомарная заменаnewCfg := loadConfig()cfg.Store(newCfg)Это работает, потому что Config иммутабелен после Store. Mutex был бы не нужен.
2.10. Memory ordering и happens-before в sync
Заголовок раздела «2.10. Memory ordering и happens-before в sync»| Операция | happens-before |
|---|---|
| Unlock | следующего Lock того же mutex |
| RUnlock | следующего Lock writer-а |
| WaitGroup.Done | WaitGroup.Wait возврата |
| Once.Do(f) f завершилась | возврата следующего Do |
| sync.Cond.Signal | возврата соответствующего Wait |
| atomic.Store | atomic.Load, увидевший значение |
| Канал — см. файл 05 |
Все стандартные примитивы дают happens-before — можно не добавлять лишний mutex для чтения побочного состояния.
3. Gotchas
Заголовок раздела «3. Gotchas»3.1. ⚠️ Mutex нельзя копировать
Заголовок раздела «3.1. ⚠️ Mutex нельзя копировать»type Bad struct { mu sync.Mutex}b := Bad{}b2 := b // КОПИЯ mutex — race detection словит!go vet это ловит. Решение: всегда передавать указатель *Bad, или embed Mutex в указатель struct.
3.2. ⚠️ defer Unlock vs handwritten
Заголовок раздела «3.2. ⚠️ defer Unlock vs handwritten»func (c *Cache) Get(k string) int { c.mu.Lock() defer c.mu.Unlock() return c.m[k]}defer стоит ~30-50 ns в Go 1.22 (после оптимизаций). Для hot path можно вручную:
func (c *Cache) Get(k string) int { c.mu.Lock() v := c.m[k] c.mu.Unlock() return v}Но осторожно: panic между Lock и Unlock → leak. defer безопаснее.
3.3. ⚠️ RWMutex не для коротких чтений
Заголовок раздела «3.3. ⚠️ RWMutex не для коротких чтений»Если CritSection занимает < 100 ns, RWMutex проигрывает Mutex по latency. Профилируй.
3.4. ⚠️ WaitGroup.Add внутри goroutine — race
Заголовок раздела «3.4. ⚠️ WaitGroup.Add внутри goroutine — race»var wg sync.WaitGroupfor i := 0; i < 10; i++ { go func() { wg.Add(1) // ← может произойти после Wait defer wg.Done() // ... }()}wg.Wait()Правильно: wg.Add(1) ДО go func(), или wg.Add(N) перед циклом.
3.5. ⚠️ WaitGroup нельзя reuse до Wait
Заголовок раздела «3.5. ⚠️ WaitGroup нельзя reuse до Wait»wg.Add(1); go f1(); wg.Wait()wg.Add(1); go f2(); wg.Wait() // OKНо не:
wg.Add(1)go func() { wg.Add(1) // gauge подскочил wg.Done() wg.Wait() // panic: race}()3.6. ⚠️ sync.Pool не для long-lived объектов
Заголовок раздела «3.6. ⚠️ sync.Pool не для long-lived объектов»Pool рассчитан на временные объекты (например, buffer на time of request). Объекты в пуле очищаются GC. Не клади туда то, что должно жить долго.
3.7. ⚠️ sync.Pool put должен reset объект
Заголовок раздела «3.7. ⚠️ sync.Pool put должен reset объект»buf := pool.Get().(*bytes.Buffer)// ... используем ...buf.Reset() // ← обязательно, иначе следующий получит грязныйpool.Put(buf)Иначе следующий пользователь получит данные предыдущего.
3.8. ⚠️ sync.Map медленнее обычного для write
Заголовок раздела «3.8. ⚠️ sync.Map медленнее обычного для write»В коде сторонних библиотек встречается переход на sync.Map “для производительности” — но если write-heavy, это потеря. Бенчмарк перед заменой.
3.9. ⚠️ atomic.Value: тип фиксируется первым Store
Заголовок раздела «3.9. ⚠️ atomic.Value: тип фиксируется первым Store»var v atomic.Valuev.Store(int(1))v.Store(int64(2)) // panic: store of inconsistently typed valueРешение: atomic.Pointer[T] (Go 1.19+).
3.10. ⚠️ atomic не защищает группу полей
Заголовок раздела «3.10. ⚠️ atomic не защищает группу полей»type Point struct { X, Y atomic.Int64}p.X.Add(1)p.Y.Add(1) // другая горутина может прочитать X uno X+1, Y == прежнийЕсли нужна согласованность пары — Mutex.
3.11. ⚠️ Cond.Wait должен быть в for, не if
Заголовок раздела «3.11. ⚠️ Cond.Wait должен быть в for, не if»// ПЛОХОif len(q.items) == 0 { q.cond.Wait()}v := q.items[0]Между Signal и захватом lock другой waiter может забрать items. Всегда — for, проверяй заново.
3.12. ⚠️ Once.Do не возвращает значение (до Go 1.21)
Заголовок раздела «3.12. ⚠️ Once.Do не возвращает значение (до Go 1.21)»Старый паттерн:
var cfg *Configvar once sync.Oncefunc getCfg() *Config { once.Do(func() { cfg = load() }) return cfg}Шарёная переменная cfg неприятна. Go 1.21+: sync.OnceValue(load) — чище.
4. Производительность
Заголовок раздела «4. Производительность»4.1. Бенчмарки (Go 1.22, M1 Pro, 8 ядер)
Заголовок раздела «4.1. Бенчмарки (Go 1.22, M1 Pro, 8 ядер)»| Операция | Single-thread | High contention |
|---|---|---|
atomic.Int64.Add | 5 ns | 50-200 ns (cache pingpong) |
Mutex.Lock+Unlock | 18 ns | 200-2000 ns |
RWMutex.RLock+RUnlock | 30 ns | 100-500 ns (read-heavy) |
RWMutex.Lock+Unlock | 40 ns | 1000-5000 ns |
chan int send+recv (unbuffered) | 100 ns | 500-2000 ns |
sync.Map.Load (hit) | 15 ns | 30-50 ns (linear scaling!) |
sync.Map.Store | 100 ns | 1000-3000 ns |
map + Mutex.Load | 25 ns | 200-1000 ns |
sync.Pool.Get/Put | 20 ns | 40-100 ns |
sync.Once.Do (after) | 1 ns | 1 ns |
4.2. Mutex profile
Заголовок раздела «4.2. Mutex profile»go test -mutexprofile=mu.out -mutexprofilefraction=1go tool pprof mu.out(pprof) top(pprof) list YourFuncmutexprofilefraction=1 означает сэмплировать каждый contention event. Для prod — выше число (10, 100), чтобы не убить performance.
4.3. Block profile
Заголовок раздела «4.3. Block profile»runtime.SetBlockProfileRate(1) — сэмплирует, когда горутина блокирует > 1 ns.
go tool pprof http://localhost:6060/debug/pprof/blockShow’ит блокировки на чём угодно (mutex, channel, semaphore).
4.4. False sharing
Заголовок раздела «4.4. False sharing»type Stats struct { Hits atomic.Int64 Miss atomic.Int64}Hits и Miss лежат в одной cache line (64 байта). При обновлении Hits с одного ядра, кеш-линия инвалидируется для всех — Miss обновление с другого ядра становится дорогим.
Решение — padding:
type Stats struct { Hits atomic.Int64 _ [56]byte // pad до 64 байт Miss atomic.Int64}Или per-CPU counters (atomic.Int64 на каждый GOMAXPROCS, агрегируется при чтении).
4.5. sync.Pool оптимизация
Заголовок раздела «4.5. sync.Pool оптимизация»// ПЛОХО: каждый запрос аллоцируетbuf := make([]byte, 4096)
// ХОРОШО: poolvar bufPool = sync.Pool{New: func() any { b := make([]byte, 4096); return &b }}
p := bufPool.Get().(*[]byte)defer bufPool.Put(p)buf := (*p)[:0]Под загрузкой 100к req/s pool снижает GC pressure в разы.
5. Когда использовать / альтернативы
Заголовок раздела «5. Когда использовать / альтернативы»5.1. Decision matrix
Заголовок раздела «5.1. Decision matrix»| Задача | Примитив |
|---|---|
| Один int counter | atomic.Int64 |
| Один bool flag | atomic.Bool |
| Один pointer (config reload) | atomic.Pointer[T] |
| Защита структуры из 2+ полей | sync.Mutex |
| Read-heavy кеш | sync.Map или RWMutex + map (бенчмарк!) |
| Конфиг (write rare) | atomic.Pointer[Config] |
| Lazy init | sync.OnceValue |
| Ожидание N горутин | sync.WaitGroup |
| Bounded queue | chan T, N |
| Сложная синхронизация состояния | sync.Cond или chan |
| Pool временных буферов | sync.Pool |
| Connection pool | chan T, N или библиотека (database/sql) |
5.2. Альтернативы из x/sync
Заголовок раздела «5.2. Альтернативы из x/sync»errgroup.Group— WaitGroup + ошибка + context. Чаще используется чем WaitGroup в новых проектах.semaphore.Weighted— semaphore с весами.singleflight.Group— дедупликация одинаковых запросов (cache stampede).
(Подробно — в файле 07.)
5.3. Когда channel лучше sync
Заголовок раздела «5.3. Когда channel лучше sync»- Pipeline-style data transfer.
- Уведомление “событие произошло” (close as broadcast).
- Сложный select с timeout/cancel.
5.4. Когда sync лучше channel
Заголовок раздела «5.4. Когда sync лучше channel»- Простой shared state — Mutex дешевле (~5x).
- Один счётчик — atomic дешевле (~20x).
- Lazy init — Once.
6. Вопросы на собесе
Заголовок раздела «6. Вопросы на собесе»1. Чем отличается normal от starvation mode у Mutex? В normal новый pretender соревнуется с разбуженным waiter (часто выигрывает из-за cache hot). Starvation — waiter ждал >1ms; unlock передаёт lock напрямую ему, новые не спинят.
2. Зачем спин-фаза в Mutex? Если критическая секция короткая, спинить дешевле, чем park/unpark (≈микросекунда vs наносекунды). Active только при GOMAXPROCS > 1.
3. Когда RWMutex медленнее Mutex? При коротких чтениях и низкой contention. RWMutex имеет более тяжёлый RLock (atomic + проверка writer). Mutex — один CAS.
4. Reader или writer приоритет у RWMutex? Writer-preference: новые читатели блокируются, пока ждёт writer. Защита от writer starvation.
5. Можно ли RUnlock из другой горутины? Можно (нет проверки). Но это плохой стиль. Lock/RLock хорошо парятся в пределах функции.
6. Что такое poolLocal в sync.Pool? Per-P структура с private (один объект для P) и shared (deque для work-stealing).
7. Что такое victim cache? После GC старый local становится victim (живёт ещё один GC цикл). Защищает от деградации Pool при нерегулярной нагрузке.
8. Почему sync.Pool не для connection pool? GC очищает половину объектов каждый цикл — соединения утекают. Нет capacity limit. Не управляет lifecycle.
9. Как реализован sync.Map? read (atomic, read-only) + dirty (map + Mutex). Load из read без lock. Misses на read promote dirty → read через N мисс.
10. Когда sync.Map хуже Mutex + map? Write-heavy. Store идёт через mutex + копирование read когда promote. Mutex + обычный map в этом случае быстрее.
11. Что делает Cond.Wait? Атомарно: отпускает Locker, паркует, при пробуждении захватывает Locker обратно. Между ними другие горутины могут зайти.
12. Signal vs Broadcast? Signal — будит одного (FIFO). Broadcast — всех. Broadcast когда состояние релевантно всем.
13. Зачем noCopy в sync примитивах?
Защита от копирования — копия Mutex имеет свой state, в результате две разные Mutex думают, что разные горутины их держат → race. go vet ловит.
14. Расскажи про OnceFunc / OnceValue. Go 1.21+. Обёртки над sync.Once, возвращают functions: OnceFunc(f) — функцию без значения, OnceValue(f) — с возвратом, OnceValues — два возврата.
15. Что произойдёт, если f в Once.Do panic’ит? Once помечается как done (defer Store). Следующий Do не вызовет f. Это намеренно — повторно вызывать сломанную инициализацию опасно.
16. atomic.Pointer[T] vs atomic.Value? Pointer[T] — type-safe, нельзя положить другой тип. Value — type erased, тип фиксируется первым Store, потом panic при несовпадении.
17. Какие гарантии у Go memory model для atomic? Sequentially consistent: для всех горутин виден один порядок atomic операций. Сильнее, чем acquire/release в C++.
18. Что такое CompareAndSwap (CAS)? Атомарное “если значение == old, поставь new”. Возвращает true если успешно. Основа lock-free алгоритмов.
19. Можно ли заменить Mutex на atomic для счётчика? Да, для счётчика лучше atomic.Int64 — в 3-5x быстрее. Mutex нужен только если защищаешь группу полей.
20. Что такое false sharing и как его избегать? Две переменные в одной cache line (64 байта) обновляются с разных ядер → постоянная инвалидация cache. Решение: padding до 64 байт между ними.
21. Что делает go vet с sync?
- Ловит копирование Mutex/WaitGroup и т.д. (
copylockschecker). - Ловит missing Lock/Unlock pair (внутри
lostcancel).
22. WaitGroup.Add(-1) можно? Можно, эквивалентно Done. Но WaitGroup.Add делает counter += delta, и если counter < 0 — panic.
23. Что вернёт sync.Map.Range? Применяет f к каждому ключу. Не блокирует другие операции. Может пропустить элементы, добавленные во время Range.
24. Можно ли передать sync.Mutex в функцию? По значению — НЕТ (копия). По указателю — да. Лучше: embed в struct, передавать struct по указателю.
25. Зачем mutex profile?
Профилирование блокировок: где waitsumtime большой — там contention bottleneck. go test -mutexprofile=mu.out.
26. Можно ли использовать sync.Pool для slice?
Да, но Put — указатель: pool.Put(&buf). Иначе sync.Pool требует interface{}, slice — header — boxed, growth не сохранится.
27. Что произойдёт при unlock не-locked Mutex? Panic: “sync: unlock of unlocked mutex”.
28. Можно ли locker в Cond поменять между Wait’ами? Нет. Сond.Wait использует Cond.L; смена приведёт к race.
29. Когда atomic недостаточно?
- Несколько полей должны меняться вместе.
- Нужен явный sequence (transaction).
- Нужно “поменять и узнать предыдущее значение И что-то ещё” — CAS только над одним значением.
30. Какие атомарные типы добавил Go 1.19?
atomic.Int32, Int64, Uint32, Uint64, Uintptr, Bool, Pointer[T]. Старые функции (atomic.AddInt64) теперь только для совместимости.
7. Practice
Заголовок раздела «7. Practice»Задача 1. Lock-free counter
Заголовок раздела «Задача 1. Lock-free counter»Реализовать структуру, которая:
- Поддерживает Inc, Dec, Get без mutex.
- Имеет per-CPU shards для уменьшения contention.
package counter
import ( "runtime" "sync/atomic")
type Counter struct { shards []paddedInt64}
type paddedInt64 struct { v atomic.Int64 _ [56]byte // padding до 64 байт}
func New() *Counter { n := runtime.GOMAXPROCS(0) return &Counter{shards: make([]paddedInt64, n)}}
func (c *Counter) Inc() { // pseudo-random shard, можно через goroutine id, но проще: idx := fastrandn(uint32(len(c.shards))) c.shards[idx].v.Add(1)}
func (c *Counter) Get() int64 { var total int64 for i := range c.shards { total += c.shards[i].v.Load() } return total}
//go:linkname fastrandn runtime.fastrandnfunc fastrandn(n uint32) uint32Бенчмарк vs обычный atomic.Int64 — на 8 ядрах разница может быть 3-5x.
Задача 2. SingleFlight (вариант)
Заголовок раздела «Задача 2. SingleFlight (вариант)»Уже есть в golang.org/x/sync/singleflight, но как упражнение реализуй:
type Group struct { mu sync.Mutex m map[string]*call}
type call struct { wg sync.WaitGroup val interface{} err error}
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() if g.m == nil { g.m = make(map[string]*call) } if c, ok := g.m[key]; ok { g.mu.Unlock() c.wg.Wait() return c.val, c.err } c := &call{} c.wg.Add(1) g.m[key] = c g.mu.Unlock()
c.val, c.err = fn() c.wg.Done()
g.mu.Lock() delete(g.m, key) g.mu.Unlock() return c.val, c.err}Применение — cache stampede protection: 100 запросов одновременно за одним ключом → один реальный downstream call.
Задача 3. RWMutex vs Mutex bench
Заголовок раздела «Задача 3. RWMutex vs Mutex bench»Напиши бенчмарк:
func BenchmarkMutexRead(b *testing.B) { var mu sync.Mutex var v int = 42 b.RunParallel(func(pb *testing.PB) { for pb.Next() { mu.Lock() _ = v mu.Unlock() } })}
func BenchmarkRWMutexRead(b *testing.B) { var mu sync.RWMutex var v int = 42 b.RunParallel(func(pb *testing.PB) { for pb.Next() { mu.RLock() _ = v mu.RUnlock() } })}Запусти на разном GOMAXPROCS. RWMutex побеждает только при large GOMAXPROCS и долгих read crit sections.
Задача 4. Lazy singleton через OnceValue
Заголовок раздела «Задача 4. Lazy singleton через OnceValue»type DBClient struct{ /* ... */ }
func newClient() *DBClient { // дорогая инициализация return &DBClient{}}
var getClient = sync.OnceValue(newClient)
func Handler() { db := getClient() db.Query()}Сравни с глобальным var + init() — OnceValue lazy, init() eager.
Задача 5. Atomic config reload
Заголовок раздела «Задача 5. Atomic config reload»type Config struct { Endpoint string Timeout time.Duration}
var cfg atomic.Pointer[Config]
func init() { cfg.Store(loadConfig())}
func GetConfig() *Config { return cfg.Load()}
func Reload() { new := loadConfig() cfg.Store(new)}Hot path — Load (≈1ns). Reload не блокирует hot path. Это лучше Mutex + Config.
Задача 6. sync.Pool для bytes.Buffer
Заголовок раздела «Задача 6. sync.Pool для bytes.Buffer»var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) },}
func GetBuf() *bytes.Buffer { return bufPool.Get().(*bytes.Buffer)}
func PutBuf(b *bytes.Buffer) { b.Reset() if b.Cap() > 64*1024 { return // отбросить слишком большие } bufPool.Put(b)}Отбрасывание больших buffer’ов — защита от memory bloat (один edge case с 100MB не должен застрять в пуле).
Задача 7. Cond-based bounded queue
Заголовок раздела «Задача 7. Cond-based bounded queue»См. задачу 2 из файла 05 — там полная реализация. Сравни с make(chan T, N). Для пользователя — то же. Внутри — больше кода. Это упражнение чтобы понять Cond.
Задача 8. Race condition detection — пример
Заголовок раздела «Задача 8. Race condition detection — пример»func TestRaceMap(t *testing.T) { m := map[int]int{} var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(i int) { defer wg.Done() m[i] = i // RACE! }(i) } wg.Wait()}Запусти go test -race. Race detector тебе всё расскажет. (Подробно — файл 08.)
8. Источники
Заголовок раздела «8. Источники»- Go source:
sync/mutex.go— реализация. https://github.com/golang/go/blob/master/src/sync/mutex.go - Go source:
sync/rwmutex.go— RWMutex internals. https://github.com/golang/go/blob/master/src/sync/rwmutex.go - Go source:
sync/pool.go— Pool и victim cache. https://github.com/golang/go/blob/master/src/sync/pool.go - Go source:
sync/map.go— sync.Map. https://github.com/golang/go/blob/master/src/sync/map.go - Russ Cox — “Mutex Implementation” — https://research.swtch.com/mutex (общая теория mutex’ов).
- Dmitry Vyukov — “Go scheduler: implementing language with lightweight concurrency” — https://www.youtube.com/watch?v=-K11rY57K7k.
- Brad Fitzpatrick — “Profiling Go programs” — https://go.dev/blog/pprof.
- Go Memory Model — https://go.dev/ref/mem.
- Bryan C. Mills — “Rethinking Classical Concurrency Patterns” (GopherCon 2018) — https://www.youtube.com/watch?v=5zXAHh5tJqQ.
- The Go Programming Language Spec — Concurrency — https://go.dev/ref/spec.