sync.Pool, sync.Map, sync.Cond — продвинутая синхронизация
Зачем знать на Middle 2: пакет
sync— фундамент конкурентного Go. На уровне Middle 2 нужно знать внутренности sync.Pool (per-P storage, victim cache, GC integration), архитектуру sync.Map (read/dirty split), starvation mode у Mutex, fairness у RWMutex, новыеsync.OnceFunc/OnceValue(Go 1.21+). Без понимания этих вещей не получится объяснить, почемуsync.Mapиногда медленнееmutex+map, почемуsync.Pool«терят» объекты после GC, почемуsync.Condведёт себя какpthread_cond, и как написать эффективный bounded buffer. Это знания, которые отличают senior от middle.
Содержание
Заголовок раздела «Содержание»- sync.Pool: архитектура и применение
- sync.Map: read/dirty split
- sync.Cond: classic condition variable
- sync.Once и Go 1.21+ OnceFunc/OnceValue
- sync.Mutex/RWMutex deep dive
- Gotchas (15)
- Производительность
- Вопросы на собесе (30)
- Practice (8)
- Источники
1. sync.Pool
Заголовок раздела «1. sync.Pool»1.1. Что это и для чего
Заголовок раздела «1.1. Что это и для чего»sync.Pool — это временное хранилище для объектов, которые можно безопасно переиспользовать. Не cache. Не connection pool. Освобождается GC: содержимое может исчезнуть в любой момент после следующего GC цикла.
Канонический use-case — переиспользование bytes.Buffer/[]byte/json.Encoder в горячих местах:
var bufPool = sync.Pool{ New: func() any { return &bytes.Buffer{} },}
func Marshal(v any) ([]byte, error) { buf := bufPool.Get().(*bytes.Buffer) buf.Reset() defer bufPool.Put(buf)
if err := json.NewEncoder(buf).Encode(v); err != nil { return nil, err } // ⚠️ ВАЖНО: возвращаем КОПИЮ, потому что после Put // buffer может быть переиспользован другим goroutine out := make([]byte, buf.Len()) copy(out, buf.Bytes()) return out, nil}1.2. Архитектура (Go 1.13+)
Заголовок раздела «1.2. Архитектура (Go 1.13+)»┌────────────────────────────────────────────────────────────┐│ sync.Pool ││ ││ local: *poolLocal ← array indexed by P (=GOMAXPROCS) ││ localSize: uintptr ││ victim: *poolLocal ← previous generation ││ victimSize: uintptr ││ New: func() any │└────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────┐ │ poolLocal[P0] │ │ ┌─────────────────────────────────────────────────┐ │ │ │ private: any ← lock-free │ │ │ │ shared: poolChain (lock-free deque) │ │ │ │ pad: [128]byte ← false sharing │ │ │ └─────────────────────────────────────────────────┘ │ ├───────────────────────────────────────────────────────┤ │ poolLocal[P1]: ... │ │ poolLocal[P2]: ... │ │ ... │ └───────────────────────────────────────────────────────┘Каждое P (логический процессор Go-шедулера) имеет:
private— слот для одного объекта, доступен только из этого P, без локов. Hot path.shared— двусторонняя lock-free очередь (poolChain), куда можно складывать дополнительные объекты.- 128 байт padding — защита от false sharing.
1.3. Get hot path
Заголовок раздела «1.3. Get hot path»func (p *Pool) Get() any { // 1. Pin P (запрет preemption на этом P) l, pid := p.pin()
// 2. Попытка взять private (no locks) x := l.private l.private = nil if x != nil { runtime_procUnpin() return x }
// 3. Попытка взять из shared (head, lock-free) x, _ = l.shared.popHead() if x != nil { runtime_procUnpin() return x }
// 4. Steal from other P's shared (tail) или victim return p.getSlow(pid)}99% Get’ов попадают в private — практически бесплатно (один store).
1.4. Put hot path
Заголовок раздела «1.4. Put hot path»func (p *Pool) Put(x any) { if x == nil { return } l, _ := p.pin() if l.private == nil { l.private = x // hot path: один store runtime_procUnpin() return } l.shared.pushHead(x) // в локальный deque runtime_procUnpin()}1.5. Victim cache (Go 1.13+)
Заголовок раздела «1.5. Victim cache (Go 1.13+)»До 1.13 GC просто очищал весь Pool — все объекты выбрасывались. После — двухуровневая схема:
GC tick 1: ┌─────────┐ ┌──────────┐ │ current │ │ victim │ ← null └─────────┘ └──────────┘
GC tick 2: ┌─────────┐ ┌──────────┐ │ current │ │ victim │ ← move current here └─────────┘ └──────────┘ ←────empty ← drop victim ↓ new puts hereПри GC:
- Старый
victimвыбрасывается (отдаётся GC). currentстановится новымvictim.currentсбрасывается в пустое состояние.
Это значит — объект переживает один GC цикл. Если приложение интенсивно использует Pool, между GC объекты не теряются.
1.6. Когда оправдан Pool
Заголовок раздела «1.6. Когда оправдан Pool»| Сценарий | Pool? |
|---|---|
bytes.Buffer в hot encoder | ДА |
*json.Encoder reuse | ДА |
[]byte фиксированного размера | ДА (но осторожно с size variance) |
gzip.Writer reuse | ДА (Reset поддерживается) |
| HTTP connection pool | НЕТ → используйте Transport.MaxIdleConns |
| DB connection pool | НЕТ → database/sql |
| Worker goroutines | НЕТ → используйте semaphore/errgroup |
| Большие объекты ($> 64KB$) | ОСТОРОЖНО — keep alive памяти, GC pressure всё равно |
1.7. Memory leak через Pool
Заголовок раздела «1.7. Memory leak через Pool»⚠️ Если pooled объект держит ссылку на большие данные, эти данные не освобождаются до выбрасывания Pool:
type Request struct { body []byte // 10 MB}
var reqPool = sync.Pool{New: func() any { return &Request{} }}
// Bad: Put не очищает bodyfunc handle(r *Request) { reqPool.Put(r) // body всё ещё 10MB, ждёт GC}
// Good: clear перед Putfunc handle(r *Request) { r.body = r.body[:0] // или nil reqPool.Put(r)}1.8. Pool в noescape pattern
Заголовок раздела «1.8. Pool в noescape pattern»Чтобы Pool работал, объект должен уходить в heap (escape). Если компилятор stack-allocate’ит — Pool бесполезен:
// Эта функция НЕ заполняет Pool — buffer на стекеfunc noescape() { buf := bytes.Buffer{} buf.WriteString("hello") fmt.Println(buf.String())}
// А эта — заполняетfunc escape() { buf := bufPool.Get().(*bytes.Buffer) // pointer escape defer bufPool.Put(buf) buf.WriteString("hello") fmt.Println(buf.String())}1.9. Pool с size buckets
Заголовок раздела «1.9. Pool с size buckets»Для variable-size []byte лучше иметь несколько pool’ов разных размеров:
var pools = [...]sync.Pool{ {New: func() any { return make([]byte, 0, 256) }}, {New: func() any { return make([]byte, 0, 1024) }}, {New: func() any { return make([]byte, 0, 4096) }}, {New: func() any { return make([]byte, 0, 16384) }},}
func getBuf(size int) []byte { for i, capSize := range []int{256, 1024, 4096, 16384} { if size <= capSize { return pools[i].Get().([]byte)[:0] } } return make([]byte, 0, size)}
func putBuf(b []byte) { c := cap(b) for i, capSize := range []int{256, 1024, 4096, 16384} { if c == capSize { pools[i].Put(b[:0]) return } } // else drop}Используется в fasthttp, valyala/bytebufferpool.
2. sync.Map
Заголовок раздела «2. sync.Map»2.1. Архитектура
Заголовок раздела «2.1. Архитектура»sync.Map — это специализированная конкурентная map для read-heavy workload’ов. Не замена map+mutex.
┌────────────────────────────────────────────────┐│ sync.Map ││ ││ read: atomic.Pointer[readOnly] ││ │ ││ ▼ ││ ┌────────────────────────┐ ││ │ readOnly │ ││ │ m: map[K]*entry│ ││ │ amended: bool │ ││ └────────────────────────┘ ││ ││ mu: sync.Mutex ││ dirty: map[K]*entry ││ misses: int │└────────────────────────────────────────────────┘- read — atomic snapshot, immutable map (только чтение, lock-free).
- dirty — обычная map под mutex, содержит новые ключи.
- misses — счётчик: когда read даёт промах и приходится читать dirty.
2.2. Жизненный цикл записи
Заголовок раздела «2.2. Жизненный цикл записи»Initial: Load(k1) hit → no miss read: {k1: v1} dirty: nil
Store(k2, v2): dirty создаётся, k2 туда read: {k1: v1}, amended=true dirty: {k1, k2}
Load(k2): read miss → ищем в dirty → misses++ misses: 1
После N misses: promote dirty → read read: {k1, k2}, amended=false dirty: nilКогда misses >= len(dirty), dirty промотится в read. Это точка дороговизны — копирование всей dirty в read.
2.3. entry pointer trick
Заголовок раздела «2.3. entry pointer trick»Каждая запись — *entry, который содержит atomic.Pointer[any]:
type entry struct { p atomic.Pointer[any]}Это позволяет:
- Удалять запись через
e.p.Store(nil)без блокировки. - Обновлять через CAS.
- Marked for delete через специальный sentinel (
expunged).
2.4. API
Заголовок раздела «2.4. API»m.Store(key, value) // записьv, ok := m.Load(key) // чтениеv, loaded := m.LoadOrStore(k, v) // atomic load-or-storem.LoadAndDelete(key) // atomic load-and-deletem.Delete(key) // удалениеm.Range(func(k, v any) bool {...}) // итерация (snapshot)m.CompareAndSwap(k, old, new) // Go 1.20+m.CompareAndDelete(k, old) // Go 1.20+m.Swap(k, v) // Go 1.20+2.5. Когда sync.Map оправдан
Заголовок раздела «2.5. Когда sync.Map оправдан»| Сценарий | sync.Map? |
|---|---|
| Read-heavy, очень редкие writes | ДА |
| Append-only cache | ДА |
| Каждый ключ пишется один раз, читается много | ДА |
| Set of disjoint keys (writer per key) | ДА |
| Update existing keys часто | НЕТ |
| Read == Write | НЕТ |
| Iteration в hot path | НЕТ (Range дорогой) |
2.6. Bench: sync.Map vs RWMutex+map vs Mutex+map
Заголовок раздела «2.6. Bench: sync.Map vs RWMutex+map vs Mutex+map»func BenchmarkSyncMap(b *testing.B) { var m sync.Map for i := 0; i < 1000; i++ { m.Store(i, i) } b.ResetTimer() b.RunParallel(func(pb *testing.PB) { for pb.Next() { m.Load(rand.Intn(1000)) } })}Результаты (M1, Go 1.22, 8 goroutines, 1000 keys, 100% reads):
BenchmarkSyncMap-8 50_000_000 25 ns/opBenchmarkRWMutexMap-8 20_000_000 60 ns/opBenchmarkMutexMap-8 8_000_000 150 ns/opС 50% writes (того же ключа):
BenchmarkSyncMap-8 2_000_000 600 ns/op ← медленнее!BenchmarkRWMutexMap-8 5_000_000 250 ns/opBenchmarkMutexMap-8 5_000_000 240 ns/op⚠️ sync.Map проигрывает под равной read/write нагрузкой.
2.7. Range — особенности
Заголовок раздела «2.7. Range — особенности»m.Range(func(key, value any) bool { fmt.Println(key, value) return true // continue})- Snapshot — может содержать ключи, добавленные/удалённые во время итерации.
- НЕ гарантирует иммутабельность (другие goroutines могут модифицировать).
- В пессимистичном случае promote’ит dirty → read, что блокирует mutex.
3. sync.Cond
Заголовок раздела «3. sync.Cond»3.1. Что это
Заголовок раздела «3.1. Что это»sync.Cond — это classical condition variable в стиле pthread_cond. Сценарий: один goroutine ждёт условия, другой его уведомляет (Signal/Broadcast).
c := sync.NewCond(&sync.Mutex{})ready := false
// waitergo func() { c.L.Lock() for !ready { c.Wait() // atomically: unlock + sleep; on wake: relock } c.L.Unlock() fmt.Println("ready!")}()
// signalerc.L.Lock()ready = truec.L.Unlock()c.Signal() // или c.Broadcast()3.2. Semantics
Заголовок раздела «3.2. Semantics»Wait()— должен вызываться под locked mutex. Атомарно: unlock + park. При wake: relock.Signal()— будит одного waiter’а (если есть). Не обязан держать lock, но обычно держит.Broadcast()— будит всех waiter’ов.
3.3. Bounded buffer пример
Заголовок раздела «3.3. Bounded buffer пример»type BoundedBuffer struct { mu sync.Mutex notFull *sync.Cond notEmpty *sync.Cond buf []int cap int}
func New(cap int) *BoundedBuffer { b := &BoundedBuffer{cap: cap} b.notFull = sync.NewCond(&b.mu) b.notEmpty = sync.NewCond(&b.mu) return b}
func (b *BoundedBuffer) Put(x int) { b.mu.Lock() for len(b.buf) == b.cap { b.notFull.Wait() } b.buf = append(b.buf, x) b.notEmpty.Signal() b.mu.Unlock()}
func (b *BoundedBuffer) Take() int { b.mu.Lock() for len(b.buf) == 0 { b.notEmpty.Wait() } x := b.buf[0] b.buf = b.buf[1:] b.notFull.Signal() b.mu.Unlock() return x}3.4. Spurious wakeups
Заголовок раздела «3.4. Spurious wakeups»В POSIX pthread_cond_wait может вернуться без Signal (spurious wakeup). Поэтому проверка условия всегда в for, не if.
В Go sync.Cond.Wait НЕ имеет spurious wakeup. Но всё равно — пишите for, потому что:
- Между Signal и нашим relock другой waiter мог изменить состояние.
- Будущая совместимость.
3.5. Альтернатива — channels
Заголовок раздела «3.5. Альтернатива — channels»В Go идиоматично использовать channels вместо Cond:
type BoundedBuf struct { ch chan int}
func New(cap int) *BoundedBuf { return &BoundedBuf{ch: make(chan int, cap)}}
func (b *BoundedBuf) Put(x int) { b.ch <- x }func (b *BoundedBuf) Take() int { return <-b.ch }chan + buffered = bounded buffer бесплатно. Когда выбирать Cond:
- Сложное условие, неудобно через channels.
- Нужен Broadcast (channels можно:
close(ch)). - Перенос алгоритма из C++/Java.
4. sync.Once и newer API
Заголовок раздела «4. sync.Once и newer API»4.1. sync.Once
Заголовок раздела «4.1. sync.Once»var once sync.Oncevar conn *Conn
func GetConn() *Conn { once.Do(func() { conn = dial() }) return conn}Реализация (упрощённо):
type Once struct { done atomic.Uint32 m sync.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 { defer o.done.Store(1) f() }}Hot path — один atomic load. Slow path — mutex + recheck.
4.2. Go 1.21+: sync.OnceFunc
Заголовок раздела «4.2. Go 1.21+: sync.OnceFunc»init := sync.OnceFunc(func() { expensive()})// init() can be called many times, expensive() runs only onceinit()init()4.3. sync.OnceValue
Заголовок раздела «4.3. sync.OnceValue»loadConfig := sync.OnceValue(func() *Config { return parseFromDisk()})
cfg := loadConfig() // первый вызов — реально читаетcfg2 := loadConfig() // возвращает кешТипобезопасный wrapper, без manual var и cast’ов.
4.4. sync.OnceValues — два возвращаемых значения
Заголовок раздела «4.4. sync.OnceValues — два возвращаемых значения»loadConfigOrErr := sync.OnceValues(func() (*Config, error) { return parseFromDisk()})
cfg, err := loadConfigOrErr() // вычисляется один раз4.5. Когда использовать
Заголовок раздела «4.5. Когда использовать»- Package init:
init()сам по себе уже однократен, но если init не подходит (нужно поздняя инициализация) — Once. - Lazy globals: connection, expensive computation.
- Cleanup-once (вместо sync.Once можно использовать context cancel).
5. Mutex/RWMutex deep
Заголовок раздела «5. Mutex/RWMutex deep»5.1. sync.Mutex internal state
Заголовок раздела «5.1. sync.Mutex internal state»type Mutex struct { state int32 // bitfield: locked, woken, starving, waiterShift sema uint32}Биты:
mutexLocked(bit 0): занят.mutexWoken(bit 1): один waiter уже разбужен и идёт за lock.mutexStarving(bit 2): в starvation mode.mutexWaiterShift = 3: остальные биты — счётчик waiters.
5.2. Normal vs starvation mode
Заголовок раздела «5.2. Normal vs starvation mode»Normal mode (по умолчанию):
- Waiter может «обогнать» очередь (lock не FIFO).
- Новый goroutine может схватить lock раньше тех, кто уже ждёт.
- Это даёт throughput (новый goroutine уже на CPU, не нужно park/unpark).
- Но может вызвать starvation старых waiters.
Starvation mode (Go 1.9+):
- Триггерится, если waiter ждал > 1ms.
- Lock передаётся напрямую следующему в очереди.
- Новые goroutines не пытаются schwa lock — встают в очередь.
- Выход из режима — когда waiter получил lock < 1ms или очередь пуста.
state transitions:
normal: ─── lock acquired by new ───> new lock holder ─── waiter wakes up ───> wait 1ms → starving
starving: ─── lock release ───> pass to head of queue ─── queue empty ───> back to normal5.3. RWMutex internals
Заголовок раздела «5.3. RWMutex internals»type RWMutex struct { w Mutex // защищает writers writerSem uint32 // semaphore для writers readerSem uint32 // semaphore для readers readerCount atomic.Int32 readerWait atomic.Int32}Writer preference: когда writer хочет lock:
- Берёт
wmutex (блокирует других writers). - Делает
readerCount.Add(-rwmutexMaxReaders)— будущие RLock’и увидят отрицательное значение и сразу пойдут спать. - Ждёт активных readers (
readerWait). - Получает lock.
Это означает: readers, пришедшие после writer’а, ждут writer’а. Это правильно для fairness, но плохо если readers — hot path и нужна полная параллельность.
5.4. RWMutex anti-pattern: blocking under RLock
Заголовок раздела «5.4. RWMutex anti-pattern: blocking under RLock»var rw sync.RWMutexvar data map[string]string
func process(key string) { rw.RLock() defer rw.RUnlock() val := data[key] // ⚠️ Не делайте I/O или time.Sleep под RLock! // Любой Lock() заблокирует все будущие RLock'и до конца этого. expensiveCall(val)}Лечение: копировать данные под RLock, делать I/O без lock.
5.5. sync.Mutex starvation deep
Заголовок раздела «5.5. sync.Mutex starvation deep»T=0 mu.Lock() ← gA holdsT=10ms 100 goroutines call mu.Lock(), all park
T=11ms gA does mu.Unlock() → нормальный режим: один из 100 waiter'ов wake'нулся → но новый g_new (только что появился) тоже пытается Lock() → g_new может «обогнать»! → старый waiter снова паркуется
T=12ms waiter ждёт > 1ms → starvation mode → lock передаётся напрямуюВ CPU-bound коде с большим contention starvation mode — частое явление. Это намеренно, чтобы избежать catastrophic latency.
5.6. sync.Mutex fairness в Go 1.21+
Заголовок раздела «5.6. sync.Mutex fairness в Go 1.21+»Без изменений — модель та же. Внутренние оптимизации (cas spin), но публичная семантика стабильна.
5.7. RWMutex против atomic для read-heavy
Заголовок раздела «5.7. RWMutex против atomic для read-heavy»Если данные immutable после первоначальной загрузки (или редкие изменения через RCU) — atomic.Pointer[T] быстрее RWMutex:
var cfg atomic.Pointer[Config]
// Read: ~1 nsc := cfg.Load()
// vs RWMutexrw.RLock()c := cfgMap["key"]rw.RUnlock()// ~30 ns с contention6. Gotchas
Заголовок раздела «6. Gotchas»⚠️ 6.1. Pool — не cache
Заголовок раздела «⚠️ 6.1. Pool — не cache»Pool выбрасывает объекты после GC. Если нужен реальный cache (с TTL, размером) — используйте отдельную структуру (e.g., freelru, ristretto).
⚠️ 6.2. Pool с разными размерами объектов
Заголовок раздела «⚠️ 6.2. Pool с разными размерами объектов»Если в Pool кладут []byte разного размера, получите fragmentation: маленькие Put’ы вытеснят большие. Используйте bucketed pools.
⚠️ 6.3. Забыли Reset() перед Put
Заголовок раздела «⚠️ 6.3. Забыли Reset() перед Put»Возвращение грязного объекта в Pool → данные «утекут» в другой goroutine. Всегда Reset (buf.Reset(), []byte = buf[:0]).
⚠️ 6.4. Не Put до использования
Заголовок раздела «⚠️ 6.4. Не Put до использования»buf := pool.Get().(*Buffer)defer pool.Put(buf) // ⚠️ defer => Put выполнится ПОСЛЕ всего блока// если есть return raw bytes from buf — другая goroutine может перезаписатьВозвращайте копию, не указатель.
⚠️ 6.5. sync.Map — не magic bullet
Заголовок раздела «⚠️ 6.5. sync.Map — не magic bullet»Под read==write нагрузкой sync.Map проигрывает mutex+map. Бенчмаркните для своего use-case.
⚠️ 6.6. sync.Map не имеет Len()
Заголовок раздела «⚠️ 6.6. sync.Map не имеет Len()»Нужен подсчёт через Range — O(N). Если важно — используйте mutex+map+atomic counter.
⚠️ 6.7. sync.Cond.Wait без Lock — panic
Заголовок раздела «⚠️ 6.7. sync.Cond.Wait без Lock — panic»c := sync.NewCond(&sync.Mutex{})c.Wait() // panic: sync: unlock of unlocked mutex⚠️ 6.8. Cond.Wait с if вместо for
Заголовок раздела «⚠️ 6.8. Cond.Wait с if вместо for»В Go нет spurious wakeups, но другой waiter мог изменить состояние. Всегда for !condition, не if.
⚠️ 6.9. sync.Once.Do не возвращает результат f
Заголовок раздела «⚠️ 6.9. sync.Once.Do не возвращает результат f»var once sync.Oncevar err erroronce.Do(func() { err = init() }) // err виден после, но Do не возвращаетВ Go 1.21+ используйте sync.OnceValues.
⚠️ 6.10. Once.Do c panic в f
Заголовок раздела «⚠️ 6.10. Once.Do c panic в f»Если f паникует — Once считается завершённым (done=1). Следующие вызовы не повторят. Это иногда не то, что нужно.
⚠️ 6.11. RWMutex — Upgrade невозможен
Заголовок раздела «⚠️ 6.11. RWMutex — Upgrade невозможен»В Go нельзя RLock → Lock без полного разблокирования. Если нужно — RLock, проверка, RUnlock, Lock, перепроверка, Unlock. Double-checked locking.
⚠️ 6.12. Mutex copy через value — sealed bug
Заголовок раздела «⚠️ 6.12. Mutex copy через value — sealed bug»type S struct { mu sync.Mutex}s := S{}s2 := s // ⚠️ копирование mutex — теперь два состоянияgo vet это ловит. Используйте *S.
⚠️ 6.13. RWMutex starvation для writers под heavy read load
Заголовок раздела «⚠️ 6.13. RWMutex starvation для writers под heavy read load»Если 100 readers постоянно держат RLock — writer может ждать вечно. Go RWMutex имеет writer preference (см. §5.3), но только когда writer сделает первый шаг. Если у вас бесконечный поток readers — нужны другие паттерны (e.g., periodic write window).
⚠️ 6.14. sync.Map.Range не покажет все ключи под нагрузкой
Заголовок раздела «⚠️ 6.14. sync.Map.Range не покажет все ключи под нагрузкой»Range — snapshot read map, dirty не сканируется атомарно. Используйте под mutex’ом, если нужно конситентное представление.
⚠️ 6.15. Pool в коде с panic
Заголовок раздела «⚠️ 6.15. Pool в коде с panic»Если goroutine паникует между Get и Put — объект потерян (попадает в GC, но Pool не знает). При высокой панике частоте Pool деградирует.
7. Производительность
Заголовок раздела «7. Производительность»7.1. Pool vs make() для buffer
Заголовок раздела «7.1. Pool vs make() для buffer»func BenchmarkPool(b *testing.B) { for i := 0; i < b.N; i++ { buf := bufPool.Get().(*bytes.Buffer) buf.WriteString("hello world") bufPool.Put(buf) buf.Reset() }}
func BenchmarkMake(b *testing.B) { for i := 0; i < b.N; i++ { buf := &bytes.Buffer{} buf.WriteString("hello world") }}Результаты (M1, Go 1.22):
BenchmarkPool-8 200_000_000 8 ns/op 0 B/op 0 allocs/opBenchmarkMake-8 30_000_000 45 ns/op 64 B/op 1 allocs/op5× ускорение и 0 allocations — главная ценность Pool.
7.2. sync.Map vs map+RWMutex (read-only)
Заголовок раздела «7.2. sync.Map vs map+RWMutex (read-only)»func BenchmarkSyncMap_Read(b *testing.B) { ... }func BenchmarkRWMutex_Read(b *testing.B) { ... }100% reads, 1000 keys, 8 goroutines:
SyncMap: 20 ns/opRWMutex: 50 ns/op50% writes:
SyncMap: 600 ns/opRWMutex: 250 ns/opMutex: 240 ns/opЧем выше доля writes — тем хуже sync.Map.
7.3. atomic.Pointer vs RWMutex для config
Заголовок раздела «7.3. atomic.Pointer vs RWMutex для config»var cfg atomic.Pointer[Config]var rw sync.RWMutexvar cfgMap *ConfigPure read:
atomic.Pointer.Load: 1.5 ns/opRWMutex.RLock+read: 30 ns/opatomic.Pointer в 20× быстрее. Для конфигов это всегда правильный выбор.
7.4. Mutex vs CAS для счётчика
Заголовок раздела «7.4. Mutex vs CAS для счётчика»sync.Mutex+int64++ : 120 ns/op (under contention)atomic.Int64.Add : 35 ns/op (under contention)7.5. Real case: HTTP middleware pooling
Заголовок раздела «7.5. Real case: HTTP middleware pooling»Caddy/HTTP middleware pool’ит Request objects, bytes.Buffer для логирования, *regexp.Regexp capture’ы. Без Pool на 100k RPS приложение делает 100k+ allocs/sec → GC pressure доминирует.
7.6. Real case: protobuf encoding
Заголовок раздела «7.6. Real case: protobuf encoding»google.golang.org/protobuf использует sync.Pool для encoder/decoder state. Без — 50% времени уходит на allocations.
7.7. Когда Pool не помогает
Заголовок раздела «7.7. Когда Pool не помогает»- Маленькие объекты (< 32 байт): cost of Get/Put сравним с make.
- Long-lived объекты: их не нужно reuse, они и так живут.
- Rare allocations: < 1k/sec — нет пользы.
7.8. Профилирование Pool effectiveness
Заголовок раздела «7.8. Профилирование Pool effectiveness»go test -run=^$ -bench=. -benchmem -memprofile=mem.profgo tool pprof -alloc_objects mem.profЕсли allocs/op остался > 0 — Pool не работает (escape analysis, retry, и т.д.).
8. Вопросы на собесе
Заголовок раздела «8. Вопросы на собесе»8.1. Что такое sync.Pool и для чего он?
Заголовок раздела «8.1. Что такое sync.Pool и для чего он?»Пул переиспользуемых объектов. Хранит объекты per-P (по логическим процессорам), даёт lock-free Get/Put в hot path. Не cache — GC очищает Pool. Используется для bytes.Buffer, json.Encoder и других объектов, allocated 100k+ раз/сек.
8.2. Опишите архитектуру sync.Pool.
Заголовок раздела «8.2. Опишите архитектуру sync.Pool.»local: *poolLocal[P]— per-P slots.- Каждый slot:
private(lock-free single object) +shared(lock-free deque). victim— предыдущая generation, переживает 1 GC цикл.- GC очищает не-victim каждый цикл; current → victim.
8.3. Что такое victim cache в Pool?
Заголовок раздела «8.3. Что такое victim cache в Pool?»Дополнительный уровень — старая generation. При GC current → victim, victim → drop. Это даёт объектам пережить один GC цикл (без victim Pool полностью обнулялся, что катастрофично для интенсивных приложений).
8.4. Когда НЕ использовать sync.Pool?
Заголовок раздела «8.4. Когда НЕ использовать sync.Pool?»- Connection pool (используйте channel/semaphore).
- Объекты с complex lifecycle (Close, references).
- Big objects (> 64KB) — заняли память, GC всё равно сильнее.
- Объекты, требующие longevity guarantee.
8.5. Что произойдёт, если положить грязный объект в Pool?
Заголовок раздела «8.5. Что произойдёт, если положить грязный объект в Pool?»Утечка данных в другой goroutine, потенциально security bug (например, leak token). Всегда Reset перед Put.
8.6. Pool с разными размерами []byte — какие проблемы?
Заголовок раздела «8.6. Pool с разными размерами []byte — какие проблемы?»Fragmentation: маленькие Put’ы заполнят шарду, большие — выбросятся. Решение — bucketed pools (несколько Pool’ов с фиксированными размерами).
8.7. Архитектура sync.Map?
Заголовок раздела «8.7. Архитектура sync.Map?»Read-only atomic map + dirty mutex-protected map. Reads из read lock-free. Writes идут в dirty. После N misses dirty промотится в read.
8.8. Когда sync.Map оправдан?
Заголовок раздела «8.8. Когда sync.Map оправдан?»Read-heavy с очень редкими writes (например, append-only registry). Disjoint key sets между writers. Не подходит для read==write или частых updates существующих ключей.
8.9. sync.Map.Load complexity?
Заголовок раздела «8.9. sync.Map.Load complexity?»O(1) average (один atomic Load + map lookup). Под нагрузкой с миссами — может пойти под mutex и amortized O(N) (когда происходит promote).
8.10. sync.Map vs Map+RWMutex — что быстрее?
Заголовок раздела «8.10. sync.Map vs Map+RWMutex — что быстрее?»Зависит. Read-heavy: sync.Map в 2-3× быстрее. Read==write: RWMutex+map в 2× быстрее. Бенчмаркните своё приложение.
8.11. Что такое sync.Cond?
Заголовок раздела «8.11. Что такое sync.Cond?»Condition variable: Wait паркует goroutine с разблокировкой mutex; Signal/Broadcast будит. Использование: bounded buffer, producer-consumer с сложным условием.
8.12. Spurious wakeups в Go sync.Cond?
Заголовок раздела «8.12. Spurious wakeups в Go sync.Cond?»Формально нет, но всё равно пишите for condition, а не if condition. Между Signal и нашим relock условие могло измениться (другой waiter изменил).
8.13. Cond.Signal vs Broadcast?
Заголовок раздела «8.13. Cond.Signal vs Broadcast?»Signal — один waiter. Broadcast — все. Используйте Broadcast, если все ждут одно и то же событие; Signal — если за раз может прогрессировать только один.
8.14. Когда Cond, а когда channel?
Заголовок раздела «8.14. Когда Cond, а когда channel?»Channel — почти всегда. Cond — если: сложное условие, нужен Broadcast эффективно, портируется код из других языков. В Go идиоматично channels.
8.15. Что такое starvation mode у sync.Mutex?
Заголовок раздела «8.15. Что такое starvation mode у sync.Mutex?»После 1ms ожидания waiter переключает Mutex в starving mode: lock передаётся напрямую waiter’у при Unlock, новые goroutines не могут «обогнать». Возвращается в normal mode после короткого ожидания.
8.16. Почему Mutex по умолчанию не FIFO?
Заголовок раздела «8.16. Почему Mutex по умолчанию не FIFO?»Потому что FIFO дорогой: каждый Unlock паркует один goroutine, шедулер тратит время на context switch. Normal mode — отдаёт lock тому, кто уже на CPU, throughput выше. Starvation mode включается, когда становится нечестно.
8.17. RWMutex internals?
Заголовок раздела «8.17. RWMutex internals?»Writers конкурируют через embedded Mutex. Каждый writer atomically декрементит readerCount на huge value → новые readers видят отрицательное значение и паркуются. Затем ждёт активных readers через readerWait counter.
8.18. RWMutex writer preference?
Заголовок раздела «8.18. RWMutex writer preference?»Когда writer хочет lock, новые readers паркуются, не могут получить RLock. Это предотвращает writer starvation, но снижает read throughput.
8.19. Можно ли RLock upgrade’ить в Lock?
Заголовок раздела «8.19. Можно ли RLock upgrade’ить в Lock?»Нет. Нужно RUnlock → Lock, и перепроверить состояние (double-checked locking).
8.20. Что произойдёт при sync.Mutex copy?
Заголовок раздела «8.20. Что произойдёт при sync.Mutex copy?»Скопируется state, появится «параллельный» mutex с не-определённым состоянием. go vet это поймает. Никогда не передавайте Mutex by value.
8.21. sync.Once internals?
Заголовок раздела «8.21. sync.Once internals?»atomic.Uint32 done + sync.Mutex. Hot path — done.Load() != 0 → return. Slow path — Lock, recheck, выполнить f, set done=1.
8.22. Что нового в Go 1.21+ для once-семантики?
Заголовок раздела «8.22. Что нового в Go 1.21+ для once-семантики?»sync.OnceFunc(func()) — возвращает функцию, которую можно звать много раз, но f выполнится один раз. sync.OnceValue[T](func() T) — type-safe lazy. sync.OnceValues[T1,T2] — для (T1, T2) пар.
8.23. Что произойдёт, если f в Once.Do паникует?
Заголовок раздела «8.23. Что произойдёт, если f в Once.Do паникует?»Once считается завершённым (done=1), следующие вызовы не повторят f. Это иногда нежелательно (хотите retry) — но часто разумно (init failure — fatal).
8.24. atomic.Pointer[T] vs sync.Map для конфига?
Заголовок раздела «8.24. atomic.Pointer[T] vs sync.Map для конфига?»atomic.Pointer[T] (1.5 ns/Load) на порядок быстрее sync.Map (~25 ns/Load), если конфиг — single value. sync.Map хорош для множества disjoint keys.
8.25. Sync.Map.LoadOrStore vs LoadAndDelete — когда что?
Заголовок раздела «8.25. Sync.Map.LoadOrStore vs LoadAndDelete — когда что?»LoadOrStore — idempotent insert (interner pattern). LoadAndDelete — atomic remove с получением старого значения (например, для one-shot tasks).
8.26. Можно ли использовать defer для Pool.Put?
Заголовок раздела «8.26. Можно ли использовать defer для Pool.Put?»Можно, но осторожно. Если f возвращает данные из pooled объекта — defer Put выполнится после return, и данные могут быть переписаны другим goroutine. Возвращайте копии.
8.27. Bounded buffer через Cond vs через chan?
Заголовок раздела «8.27. Bounded buffer через Cond vs через chan?»Chan — короче, идиоматичнее, performant. Cond — если нужен сложный invariant (например, buffer не пуст AND priority > X). Predocate с Cond Wait сильнее, чем <-ch.
8.28. Mutex sema (semaphore) — что это?
Заголовок раздела «8.28. Mutex sema (semaphore) — что это?»Внутри Mutex есть sema uint32 — счётчик для блокировки/разблокировки goroutines через runtime.semacquire/semrelease. Это связка с шедулером Go, через которую park’аются waiters.
8.29. Какие inappropriate uses sync.Map?
Заголовок раздела «8.29. Какие inappropriate uses sync.Map?»- Iterate-heavy workload (Range дорогой).
- Need Len() — нет.
- Updates of existing keys — медленнее RWMutex.
- Need atomic transaction across keys — sync.Map не предоставляет.
8.30. sync.WaitGroup race conditions?
Заголовок раздела «8.30. sync.WaitGroup race conditions?»Add должен вызываться до возможного Wait. Если goroutine стартует и сразу вызывает Wait — race. Идиоматически: wg.Add(1) в parent, defer wg.Done() в child, wg.Wait() в parent.
9. Practice
Заголовок раздела «9. Practice»9.1. Pool с size buckets
Заголовок раздела «9.1. Pool с size buckets»Реализовать BufferPool с 4 buckets (256, 1024, 4096, 16384 байт). API: Get(size int) []byte, Put([]byte). Бенчмарк vs make.
9.2. sync.Map обёртка с Len()
Заголовок раздела «9.2. sync.Map обёртка с Len()»Обернуть sync.Map в структуру с дополнительным atomic.Int64 count. Поддержать Store/Delete/LoadOrStore/LoadAndDelete с корректным подсчётом.
9.3. Bounded buffer через Cond
Заголовок раздела «9.3. Bounded buffer через Cond»Реализовать generic BoundedBuffer[T] с Put/Take через sync.Cond. Сравнить с реализацией через chan T.
9.4. sync.OnceValue vs sync.Once
Заголовок раздела «9.4. sync.OnceValue vs sync.Once»Переписать legacy code, использующий sync.Once + var, на sync.OnceValue. Сделать diff в LOC.
9.5. Двухслойный кеш
Заголовок раздела «9.5. Двухслойный кеш»Cache с promotion: hot (sync.Map) + cold (LRU под mutex). Hits в cold промотят в hot.
9.6. Pool с custom victim
Заголовок раздела «9.6. Pool с custom victim»Реализовать собственный pool, который удерживает объекты дольше 1 GC (например, до 5 GC) через slice victim chain.
9.7. RWMutex stress test
Заголовок раздела «9.7. RWMutex stress test»Запустить 100 readers + 1 writer, замерить writer latency. Затем заменить на atomic.Pointer (RCU pattern), сравнить.
9.8. sync.Cond — Producer/Consumer с приоритетом
Заголовок раздела «9.8. sync.Cond — Producer/Consumer с приоритетом»Bounded buffer с приоритетными уровнями (high/normal). High priority items вытесняются раньше. Реализация через Cond + 2 очереди.
10. Источники
Заголовок раздела «10. Источники»- sync package documentation — https://pkg.go.dev/sync (полный API, включая 1.21+).
- sync.Pool source — https://github.com/golang/go/blob/master/src/sync/pool.go.
- sync.Map source — https://github.com/golang/go/blob/master/src/sync/map.go.
- Go 1.13 Pool changes — https://go.dev/blog/go1.13-pool (victim cache rationale).
- Russ Cox, Go Memory Model — https://go.dev/ref/mem.
- Bryan Mills (Go team), “Rethinking Classical Concurrency” — GopherCon 2018 talk.
- valyala/bytebufferpool — https://github.com/valyala/bytebufferpool (production-grade bucketed pool).
- Dmitry Vyukov, “Scalable Go Scheduler” — design doc, объясняет per-P storage idea.
- Go 1.21 release notes — раздел
sync.OnceFunc/OnceValue/OnceValues. - The Go Programming Language (Donovan, Kernighan), глава 9 — sync примитивы с примерами.
Итог Middle 2:
sync.Pool— для escape allocs в hot path;sync.Map— только для read-heavy disjoint keys;sync.Cond— почти всегда заменим channels; новыеsync.OnceFunc/Value/Values(Go 1.21+) — must-have для lazy globals;sync.Mutexимеет starvation mode (1ms threshold);sync.RWMutexимеет writer preference, но не upgrade. Бенчмаркните под свой профиль нагрузки, не на синтетике.