Перейти к содержимому

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.

  1. sync.Pool: архитектура и применение
  2. sync.Map: read/dirty split
  3. sync.Cond: classic condition variable
  4. sync.Once и Go 1.21+ OnceFunc/OnceValue
  5. sync.Mutex/RWMutex deep dive
  6. Gotchas (15)
  7. Производительность
  8. Вопросы на собесе (30)
  9. Practice (8)
  10. Источники

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
}
┌────────────────────────────────────────────────────────────┐
│ 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.
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).

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.13 GC просто очищал весь Pool — все объекты выбрасывались. После — двухуровневая схема:

GC tick 1: ┌─────────┐ ┌──────────┐
│ current │ │ victim │ ← null
└─────────┘ └──────────┘
GC tick 2: ┌─────────┐ ┌──────────┐
│ current │ │ victim │ ← move current here
└─────────┘ └──────────┘
←────empty ← drop victim
↓ new puts here

При GC:

  1. Старый victim выбрасывается (отдаётся GC).
  2. current становится новым victim.
  3. current сбрасывается в пустое состояние.

Это значит — объект переживает один GC цикл. Если приложение интенсивно использует Pool, между GC объекты не теряются.

Сценарий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 всё равно

⚠️ Если pooled объект держит ссылку на большие данные, эти данные не освобождаются до выбрасывания Pool:

type Request struct {
body []byte // 10 MB
}
var reqPool = sync.Pool{New: func() any { return &Request{} }}
// Bad: Put не очищает body
func handle(r *Request) {
reqPool.Put(r) // body всё ещё 10MB, ждёт GC
}
// Good: clear перед Put
func handle(r *Request) {
r.body = r.body[:0] // или nil
reqPool.Put(r)
}

Чтобы 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())
}

Для 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.


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.
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.

Каждая запись — *entry, который содержит atomic.Pointer[any]:

type entry struct {
p atomic.Pointer[any]
}

Это позволяет:

  • Удалять запись через e.p.Store(nil) без блокировки.
  • Обновлять через CAS.
  • Marked for delete через специальный sentinel (expunged).
m.Store(key, value) // запись
v, ok := m.Load(key) // чтение
v, loaded := m.LoadOrStore(k, v) // atomic load-or-store
m.LoadAndDelete(key) // atomic load-and-delete
m.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+
Сценарийsync.Map?
Read-heavy, очень редкие writesДА
Append-only cacheДА
Каждый ключ пишется один раз, читается многоДА
Set of disjoint keys (writer per key)ДА
Update existing keys частоНЕТ
Read == WriteНЕТ
Iteration в hot pathНЕТ (Range дорогой)
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/op
BenchmarkRWMutexMap-8 20_000_000 60 ns/op
BenchmarkMutexMap-8 8_000_000 150 ns/op

С 50% writes (того же ключа):

BenchmarkSyncMap-8 2_000_000 600 ns/op ← медленнее!
BenchmarkRWMutexMap-8 5_000_000 250 ns/op
BenchmarkMutexMap-8 5_000_000 240 ns/op

⚠️ sync.Map проигрывает под равной read/write нагрузкой.

m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // continue
})
  • Snapshot — может содержать ключи, добавленные/удалённые во время итерации.
  • НЕ гарантирует иммутабельность (другие goroutines могут модифицировать).
  • В пессимистичном случае promote’ит dirty → read, что блокирует mutex.

sync.Cond — это classical condition variable в стиле pthread_cond. Сценарий: один goroutine ждёт условия, другой его уведомляет (Signal/Broadcast).

c := sync.NewCond(&sync.Mutex{})
ready := false
// waiter
go func() {
c.L.Lock()
for !ready {
c.Wait() // atomically: unlock + sleep; on wake: relock
}
c.L.Unlock()
fmt.Println("ready!")
}()
// signaler
c.L.Lock()
ready = true
c.L.Unlock()
c.Signal() // или c.Broadcast()
  • Wait()должен вызываться под locked mutex. Атомарно: unlock + park. При wake: relock.
  • Signal() — будит одного waiter’а (если есть). Не обязан держать lock, но обычно держит.
  • Broadcast() — будит всех waiter’ов.
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
}

В POSIX pthread_cond_wait может вернуться без Signal (spurious wakeup). Поэтому проверка условия всегда в for, не if.

В Go sync.Cond.Wait НЕ имеет spurious wakeup. Но всё равно — пишите for, потому что:

  • Между Signal и нашим relock другой waiter мог изменить состояние.
  • Будущая совместимость.

В 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.

var once sync.Once
var 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.

init := sync.OnceFunc(func() {
expensive()
})
// init() can be called many times, expensive() runs only once
init()
init()
loadConfig := sync.OnceValue(func() *Config {
return parseFromDisk()
})
cfg := loadConfig() // первый вызов — реально читает
cfg2 := loadConfig() // возвращает кеш

Типобезопасный wrapper, без manual var и cast’ов.

loadConfigOrErr := sync.OnceValues(func() (*Config, error) {
return parseFromDisk()
})
cfg, err := loadConfigOrErr() // вычисляется один раз
  • Package init: init() сам по себе уже однократен, но если init не подходит (нужно поздняя инициализация) — Once.
  • Lazy globals: connection, expensive computation.
  • Cleanup-once (вместо sync.Once можно использовать context cancel).

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.

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 normal
type RWMutex struct {
w Mutex // защищает writers
writerSem uint32 // semaphore для writers
readerSem uint32 // semaphore для readers
readerCount atomic.Int32
readerWait atomic.Int32
}

Writer preference: когда writer хочет lock:

  1. Берёт w mutex (блокирует других writers).
  2. Делает readerCount.Add(-rwmutexMaxReaders) — будущие RLock’и увидят отрицательное значение и сразу пойдут спать.
  3. Ждёт активных readers (readerWait).
  4. Получает lock.

Это означает: readers, пришедшие после writer’а, ждут writer’а. Это правильно для fairness, но плохо если readers — hot path и нужна полная параллельность.

var rw sync.RWMutex
var 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.

T=0 mu.Lock() ← gA holds
T=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.

Без изменений — модель та же. Внутренние оптимизации (cas spin), но публичная семантика стабильна.

Если данные immutable после первоначальной загрузки (или редкие изменения через RCU) — atomic.Pointer[T] быстрее RWMutex:

var cfg atomic.Pointer[Config]
// Read: ~1 ns
c := cfg.Load()
// vs RWMutex
rw.RLock()
c := cfgMap["key"]
rw.RUnlock()
// ~30 ns с contention

Pool выбрасывает объекты после GC. Если нужен реальный cache (с TTL, размером) — используйте отдельную структуру (e.g., freelru, ristretto).

Если в Pool кладут []byte разного размера, получите fragmentation: маленькие Put’ы вытеснят большие. Используйте bucketed pools.

Возвращение грязного объекта в Pool → данные «утекут» в другой goroutine. Всегда Reset (buf.Reset(), []byte = buf[:0]).

buf := pool.Get().(*Buffer)
defer pool.Put(buf) // ⚠️ defer => Put выполнится ПОСЛЕ всего блока
// если есть return raw bytes from buf — другая goroutine может перезаписать

Возвращайте копию, не указатель.

Под read==write нагрузкой sync.Map проигрывает mutex+map. Бенчмаркните для своего use-case.

Нужен подсчёт через Range — O(N). Если важно — используйте mutex+map+atomic counter.

c := sync.NewCond(&sync.Mutex{})
c.Wait() // panic: sync: unlock of unlocked mutex

В Go нет spurious wakeups, но другой waiter мог изменить состояние. Всегда for !condition, не if.

var once sync.Once
var err error
once.Do(func() { err = init() }) // err виден после, но Do не возвращает

В Go 1.21+ используйте sync.OnceValues.

Если f паникует — Once считается завершённым (done=1). Следующие вызовы не повторят. Это иногда не то, что нужно.

В Go нельзя RLock → Lock без полного разблокирования. Если нужно — RLock, проверка, RUnlock, Lock, перепроверка, Unlock. Double-checked locking.

type S struct {
mu sync.Mutex
}
s := S{}
s2 := s // ⚠️ копирование mutex — теперь два состояния

go vet это ловит. Используйте *S.

Если 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’ом, если нужно конситентное представление.

Если goroutine паникует между Get и Put — объект потерян (попадает в GC, но Pool не знает). При высокой панике частоте Pool деградирует.


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/op
BenchmarkMake-8 30_000_000 45 ns/op 64 B/op 1 allocs/op

5× ускорение и 0 allocations — главная ценность Pool.

func BenchmarkSyncMap_Read(b *testing.B) { ... }
func BenchmarkRWMutex_Read(b *testing.B) { ... }

100% reads, 1000 keys, 8 goroutines:

SyncMap: 20 ns/op
RWMutex: 50 ns/op

50% writes:

SyncMap: 600 ns/op
RWMutex: 250 ns/op
Mutex: 240 ns/op

Чем выше доля writes — тем хуже sync.Map.

var cfg atomic.Pointer[Config]
var rw sync.RWMutex
var cfgMap *Config

Pure read:

atomic.Pointer.Load: 1.5 ns/op
RWMutex.RLock+read: 30 ns/op

atomic.Pointer в 20× быстрее. Для конфигов это всегда правильный выбор.

sync.Mutex+int64++ : 120 ns/op (under contention)
atomic.Int64.Add : 35 ns/op (under contention)

Caddy/HTTP middleware pool’ит Request objects, bytes.Buffer для логирования, *regexp.Regexp capture’ы. Без Pool на 100k RPS приложение делает 100k+ allocs/sec → GC pressure доминирует.

google.golang.org/protobuf использует sync.Pool для encoder/decoder state. Без — 50% времени уходит на allocations.

  • Маленькие объекты (< 32 байт): cost of Get/Put сравним с make.
  • Long-lived объекты: их не нужно reuse, они и так живут.
  • Rare allocations: < 1k/sec — нет пользы.
go test -run=^$ -bench=. -benchmem -memprofile=mem.prof
go tool pprof -alloc_objects mem.prof

Если allocs/op остался > 0 — Pool не работает (escape analysis, retry, и т.д.).


Пул переиспользуемых объектов. Хранит объекты per-P (по логическим процессорам), даёт lock-free Get/Put в hot path. Не cache — GC очищает Pool. Используется для bytes.Buffer, json.Encoder и других объектов, allocated 100k+ раз/сек.

  • local: *poolLocal[P] — per-P slots.
  • Каждый slot: private (lock-free single object) + shared (lock-free deque).
  • victim — предыдущая generation, переживает 1 GC цикл.
  • GC очищает не-victim каждый цикл; current → victim.

Дополнительный уровень — старая generation. При GC current → victim, victim → drop. Это даёт объектам пережить один GC цикл (без victim 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’ов с фиксированными размерами).

Read-only atomic map + dirty mutex-protected map. Reads из read lock-free. Writes идут в dirty. После N misses dirty промотится в read.

Read-heavy с очень редкими writes (например, append-only registry). Disjoint key sets между writers. Не подходит для read==write или частых updates существующих ключей.

O(1) average (один atomic Load + map lookup). Под нагрузкой с миссами — может пойти под mutex и amortized O(N) (когда происходит promote).

Зависит. Read-heavy: sync.Map в 2-3× быстрее. Read==write: RWMutex+map в 2× быстрее. Бенчмаркните своё приложение.

Condition variable: Wait паркует goroutine с разблокировкой mutex; Signal/Broadcast будит. Использование: bounded buffer, producer-consumer с сложным условием.

Формально нет, но всё равно пишите for condition, а не if condition. Между Signal и нашим relock условие могло измениться (другой waiter изменил).

Signal — один waiter. Broadcast — все. Используйте Broadcast, если все ждут одно и то же событие; Signal — если за раз может прогрессировать только один.

Channel — почти всегда. Cond — если: сложное условие, нужен Broadcast эффективно, портируется код из других языков. В Go идиоматично channels.

После 1ms ожидания waiter переключает Mutex в starving mode: lock передаётся напрямую waiter’у при Unlock, новые goroutines не могут «обогнать». Возвращается в normal mode после короткого ожидания.

Потому что FIFO дорогой: каждый Unlock паркует один goroutine, шедулер тратит время на context switch. Normal mode — отдаёт lock тому, кто уже на CPU, throughput выше. Starvation mode включается, когда становится нечестно.

Writers конкурируют через embedded Mutex. Каждый writer atomically декрементит readerCount на huge value → новые readers видят отрицательное значение и паркуются. Затем ждёт активных readers через readerWait counter.

Когда writer хочет lock, новые readers паркуются, не могут получить RLock. Это предотвращает writer starvation, но снижает read throughput.

Нет. Нужно RUnlock → Lock, и перепроверить состояние (double-checked locking).

Скопируется state, появится «параллельный» mutex с не-определённым состоянием. go vet это поймает. Никогда не передавайте Mutex by value.

atomic.Uint32 done + sync.Mutex. Hot path — done.Load() != 0 → return. Slow path — Lock, recheck, выполнить f, set done=1.

sync.OnceFunc(func()) — возвращает функцию, которую можно звать много раз, но f выполнится один раз. sync.OnceValue[T](func() T) — type-safe lazy. sync.OnceValues[T1,T2] — для (T1, T2) пар.

Once считается завершённым (done=1), следующие вызовы не повторят f. Это иногда нежелательно (хотите retry) — но часто разумно (init failure — fatal).

atomic.Pointer[T] (1.5 ns/Load) на порядок быстрее sync.Map (~25 ns/Load), если конфиг — single value. sync.Map хорош для множества disjoint keys.

LoadOrStore — idempotent insert (interner pattern). LoadAndDelete — atomic remove с получением старого значения (например, для one-shot tasks).

Можно, но осторожно. Если f возвращает данные из pooled объекта — defer Put выполнится после return, и данные могут быть переписаны другим goroutine. Возвращайте копии.

Chan — короче, идиоматичнее, performant. Cond — если нужен сложный invariant (например, buffer не пуст AND priority > X). Predocate с Cond Wait сильнее, чем <-ch.

Внутри Mutex есть sema uint32 — счётчик для блокировки/разблокировки goroutines через runtime.semacquire/semrelease. Это связка с шедулером Go, через которую park’аются waiters.

  • Iterate-heavy workload (Range дорогой).
  • Need Len() — нет.
  • Updates of existing keys — медленнее RWMutex.
  • Need atomic transaction across keys — sync.Map не предоставляет.

Add должен вызываться до возможного Wait. Если goroutine стартует и сразу вызывает Wait — race. Идиоматически: wg.Add(1) в parent, defer wg.Done() в child, wg.Wait() в parent.


Реализовать BufferPool с 4 buckets (256, 1024, 4096, 16384 байт). API: Get(size int) []byte, Put([]byte). Бенчмарк vs make.

Обернуть sync.Map в структуру с дополнительным atomic.Int64 count. Поддержать Store/Delete/LoadOrStore/LoadAndDelete с корректным подсчётом.

Реализовать generic BoundedBuffer[T] с Put/Take через sync.Cond. Сравнить с реализацией через chan T.

Переписать legacy code, использующий sync.Once + var, на sync.OnceValue. Сделать diff в LOC.

Cache с promotion: hot (sync.Map) + cold (LRU под mutex). Hits в cold промотят в hot.

Реализовать собственный pool, который удерживает объекты дольше 1 GC (например, до 5 GC) через slice victim chain.

Запустить 100 readers + 1 writer, замерить writer latency. Затем заменить на atomic.Pointer (RCU pattern), сравнить.

Bounded buffer с приоритетными уровнями (high/normal). High priority items вытесняются раньше. Реализация через Cond + 2 очереди.


  1. sync package documentationhttps://pkg.go.dev/sync (полный API, включая 1.21+).
  2. sync.Pool sourcehttps://github.com/golang/go/blob/master/src/sync/pool.go.
  3. sync.Map sourcehttps://github.com/golang/go/blob/master/src/sync/map.go.
  4. Go 1.13 Pool changeshttps://go.dev/blog/go1.13-pool (victim cache rationale).
  5. Russ Cox, Go Memory Modelhttps://go.dev/ref/mem.
  6. Bryan Mills (Go team), “Rethinking Classical Concurrency” — GopherCon 2018 talk.
  7. valyala/bytebufferpoolhttps://github.com/valyala/bytebufferpool (production-grade bucketed pool).
  8. Dmitry Vyukov, “Scalable Go Scheduler” — design doc, объясняет per-P storage idea.
  9. Go 1.21 release notes — раздел sync.OnceFunc/OnceValue/OnceValues.
  10. 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. Бенчмаркните под свой профиль нагрузки, не на синтетике.