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

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 в проде.


  1. Базовая концепция (кратко)
  2. Под капотом
  3. Gotchas
  4. Производительность
  5. Когда использовать / альтернативы
  6. Вопросы на собесе
  7. Practice
  8. Источники

Пакет sync — низкоуровневые примитивы синхронизации поверх рантайма:

ТипНазначение
sync.MutexЭксклюзивный lock
sync.RWMutexReader/writer lock
sync.WaitGroup”Подождать N горутин”
sync.OnceВыполнить раз
sync.OnceFunc/Value/ValuesLazy init (Go 1.21+)
sync.PoolReuse временных объектов
sync.MapConcurrent map для read-heavy
sync.CondУсловная переменная

Плюс пакет sync/atomic:

ТипНазначение
atomic.Int32/64, Uint32/64, Bool, Pointer[T] (Go 1.19+)Lock-free числа и указатели
atomic.ValueLock-free сохранение произвольного значения

Все zero-value примитивы готовы к использованию: var m sync.Mutex работает.


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
)
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, и часто выигрывает. Старый снова паркуется.

Если 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 → wins
G4 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.

type RWMutex struct {
w Mutex // эксклюзивный lock для writers и для "write вход"
writerSem uint32
readerSem uint32
readerCount atomic.Int32 // активные читатели; отрицательное = есть writer
readerWait atomic.Int32 // сколько читателей writer ждёт
}
const rwmutexMaxReaders = 1 << 30
n := readerCount.Add(1)
если n < 0 — writer уже захватил или ждёт: semacquire(readerSem) — паркуемся.
иначе — мы читатель, идём дальше.
n := readerCount.Add(-1)
если n < 0 — есть writer:
если readerWait.Add(-1) == 0 — последний read закончился, sempost(writerSem).
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()

В 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' нет).

Под низкой contention — да. У RWMutex более тяжёлый Lock/Unlock (нужны 2 atomic + semaphore). Mutex же — один CAS в hot path.

Правило: используй RWMutex, когда:

  • Чтений сильно больше записей (10:1+).
  • Чтения длинные (не milliseconds — в худшем случае стоит профилить).

Для коротких чтений (например map lookup ~50ns) Mutex выигрывает.

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.

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 бессмысленно.

Удобные обёртки:

init := sync.OnceFunc(func() {
fmt.Println("init")
})
init() // печатает
init() // ничего не делает
getCfg := sync.OnceValue(func() *Config {
return loadConfig()
})
cfg := getCfg() // первый вызов — loadConfig
cfg = getCfg() // второй — кешированное значение
split := sync.OnceValues(func() (int, error) {
return 42, nil
})
v, err := split()

Под капотом — sync.Once + замыкание для результата. Эквивалентно ручному варианту, но компактнее и без race на захват результата.

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
}
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().
1. pin P.
2. if local.private == nil — local.private = x.
3. else — local.shared.pushHead(x).
GC цикл:
1. victim = local (старый local становится victim).
2. local = nil (allocate новый при первом Get).
Следующий GC:
victim очищается.

Это даёт объекту “пережить” один GC: если он не был переиспользован в первом цикле, у него есть второй шанс.

runtime_registerPoolCleanup(poolCleanup) — рантайм вызывает cleanup каждый GC. Без victim cache (до Go 1.13) был полный сброс, и pool становился бесполезен на нерегулярной нагрузке.

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 ...
}
// АНТИПАТТЕРН
var dbPool = sync.Pool{
New: func() any { return openDB() },
}

Проблемы:

  1. GC очищает половину — соединения утекают.
  2. Pool не ограничен в количестве.
  3. Pool не управляет жизненным циклом.

Для connection pool — buffered channel или database/sql.DB (у него свой пул).

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.

Soft delete: e.value = nil. Реально удаляется при promote.

  1. Append-only / rarely-deleted keys.
  2. Read >> write (например, кеш конфига).
  3. Discrete keys для разных горутин (каждая горутина — свой key).

Всегда, если:

  • Записи сравнимы с чтениями. Sync.Map медленнее обычного map+Mutex для write.
  • Нужен range с гарантией.
  • Map маленький (< 100 элементов) — overhead не окупается.
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() — будит одного 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) при изменении сложного состояния.

Новый стиль — типизированные обёртки:

var counter atomic.Int64
counter.Add(1)
v := counter.Load()
counter.Store(0)
ok := counter.CompareAndSwap(1, 2) // CAS
var b atomic.Bool
b.Store(true)
var ptr atomic.Pointer[Config]
ptr.Store(&Config{...})
cfg := ptr.Load()

Старый стиль — функции (по-прежнему работает, но стилистически устарел):

var x int64
atomic.AddInt64(&x, 1)
atomic.LoadInt64(&x)

Новый стиль:

  • Безопаснее (нельзя случайно прочитать неатомарно).
  • Типобезопасный (atomic.Pointer[T]).
  • Чище API.

Для произвольных типов:

var cfg atomic.Value
cfg.Store(&Config{...}) // Тип фиксируется на первом Store!
c := cfg.Load().(*Config)

⚠️ Все Store должны быть одного типа, иначе panic. Используй atomic.Pointer[T] если хочешь type safety.

В Go спека гарантирует sequentially consistent для atomic операций. То есть для всех горутин видится один порядок всех atomic операций.

Это сильнее, чем у C/Rust (где есть Relaxed/Acquire/Release/SeqCst). Go выбирает простоту в обмен на минимальный overhead.

Практически: не нужно думать о memory barriers, просто используй atomic — порядок будет правильным.

Атомарное “если значение == old, установить new, вернуть true”.

var version atomic.Int64
for {
cur := version.Load()
if version.CompareAndSwap(cur, cur+1) {
break
}
// race — попробовать снова
}

CAS используется для lock-free структур. На amd64 — это инструкция LOCK CMPXCHG.

  • 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 был бы не нужен.

Операцияhappens-before
Unlockследующего Lock того же mutex
RUnlockследующего Lock writer-а
WaitGroup.DoneWaitGroup.Wait возврата
Once.Do(f) f завершиласьвозврата следующего Do
sync.Cond.Signalвозврата соответствующего Wait
atomic.Storeatomic.Load, увидевший значение
Канал — см. файл 05

Все стандартные примитивы дают happens-before — можно не добавлять лишний mutex для чтения побочного состояния.


type Bad struct {
mu sync.Mutex
}
b := Bad{}
b2 := b // КОПИЯ mutex — race detection словит!

go vet это ловит. Решение: всегда передавать указатель *Bad, или embed Mutex в указатель struct.

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 безопаснее.

Если CritSection занимает < 100 ns, RWMutex проигрывает Mutex по latency. Профилируй.

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // ← может произойти после Wait
defer wg.Done()
// ...
}()
}
wg.Wait()

Правильно: wg.Add(1) ДО go func(), или wg.Add(N) перед циклом.

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
}()

Pool рассчитан на временные объекты (например, buffer на time of request). Объекты в пуле очищаются GC. Не клади туда то, что должно жить долго.

buf := pool.Get().(*bytes.Buffer)
// ... используем ...
buf.Reset() // ← обязательно, иначе следующий получит грязный
pool.Put(buf)

Иначе следующий пользователь получит данные предыдущего.

В коде сторонних библиотек встречается переход на sync.Map “для производительности” — но если write-heavy, это потеря. Бенчмарк перед заменой.

var v atomic.Value
v.Store(int(1))
v.Store(int64(2)) // panic: store of inconsistently typed value

Решение: atomic.Pointer[T] (Go 1.19+).

type Point struct {
X, Y atomic.Int64
}
p.X.Add(1)
p.Y.Add(1) // другая горутина может прочитать X uno X+1, Y == прежний

Если нужна согласованность пары — Mutex.

// ПЛОХО
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 *Config
var once sync.Once
func getCfg() *Config {
once.Do(func() { cfg = load() })
return cfg
}

Шарёная переменная cfg неприятна. Go 1.21+: sync.OnceValue(load) — чище.


ОперацияSingle-threadHigh contention
atomic.Int64.Add5 ns50-200 ns (cache pingpong)
Mutex.Lock+Unlock18 ns200-2000 ns
RWMutex.RLock+RUnlock30 ns100-500 ns (read-heavy)
RWMutex.Lock+Unlock40 ns1000-5000 ns
chan int send+recv (unbuffered)100 ns500-2000 ns
sync.Map.Load (hit)15 ns30-50 ns (linear scaling!)
sync.Map.Store100 ns1000-3000 ns
map + Mutex.Load25 ns200-1000 ns
sync.Pool.Get/Put20 ns40-100 ns
sync.Once.Do (after)1 ns1 ns
Окно терминала
go test -mutexprofile=mu.out -mutexprofilefraction=1
go tool pprof mu.out
(pprof) top
(pprof) list YourFunc

mutexprofilefraction=1 означает сэмплировать каждый contention event. Для prod — выше число (10, 100), чтобы не убить performance.

runtime.SetBlockProfileRate(1) — сэмплирует, когда горутина блокирует > 1 ns.

Окно терминала
go tool pprof http://localhost:6060/debug/pprof/block

Show’ит блокировки на чём угодно (mutex, channel, semaphore).

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, агрегируется при чтении).

// ПЛОХО: каждый запрос аллоцирует
buf := make([]byte, 4096)
// ХОРОШО: pool
var 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 в разы.


ЗадачаПримитив
Один int counteratomic.Int64
Один bool flagatomic.Bool
Один pointer (config reload)atomic.Pointer[T]
Защита структуры из 2+ полейsync.Mutex
Read-heavy кешsync.Map или RWMutex + map (бенчмарк!)
Конфиг (write rare)atomic.Pointer[Config]
Lazy initsync.OnceValue
Ожидание N горутинsync.WaitGroup
Bounded queuechan T, N
Сложная синхронизация состоянияsync.Cond или chan
Pool временных буферовsync.Pool
Connection poolchan T, N или библиотека (database/sql)
  • errgroup.Group — WaitGroup + ошибка + context. Чаще используется чем WaitGroup в новых проектах.
  • semaphore.Weighted — semaphore с весами.
  • singleflight.Group — дедупликация одинаковых запросов (cache stampede).

(Подробно — в файле 07.)

  • Pipeline-style data transfer.
  • Уведомление “событие произошло” (close as broadcast).
  • Сложный select с timeout/cancel.
  • Простой shared state — Mutex дешевле (~5x).
  • Один счётчик — atomic дешевле (~20x).
  • Lazy init — Once.

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 и т.д. (copylocks checker).
  • Ловит 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) теперь только для совместимости.


Реализовать структуру, которая:

  1. Поддерживает Inc, Dec, Get без mutex.
  2. Имеет 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.fastrandn
func fastrandn(n uint32) uint32

Бенчмарк vs обычный atomic.Int64 — на 8 ядрах разница может быть 3-5x.

Уже есть в 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.

Напиши бенчмарк:

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.

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.

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.

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 не должен застрять в пуле).

См. задачу 2 из файла 05 — там полная реализация. Сравни с make(chan T, N). Для пользователя — то же. Внутри — больше кода. Это упражнение чтобы понять Cond.

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


  1. Go source: sync/mutex.go — реализация. https://github.com/golang/go/blob/master/src/sync/mutex.go
  2. Go source: sync/rwmutex.go — RWMutex internals. https://github.com/golang/go/blob/master/src/sync/rwmutex.go
  3. Go source: sync/pool.go — Pool и victim cache. https://github.com/golang/go/blob/master/src/sync/pool.go
  4. Go source: sync/map.go — sync.Map. https://github.com/golang/go/blob/master/src/sync/map.go
  5. Russ Cox — “Mutex Implementation”https://research.swtch.com/mutex (общая теория mutex’ов).
  6. Dmitry Vyukov — “Go scheduler: implementing language with lightweight concurrency”https://www.youtube.com/watch?v=-K11rY57K7k.
  7. Brad Fitzpatrick — “Profiling Go programs”https://go.dev/blog/pprof.
  8. Go Memory Modelhttps://go.dev/ref/mem.
  9. Bryan C. Mills — “Rethinking Classical Concurrency Patterns” (GopherCon 2018) — https://www.youtube.com/watch?v=5zXAHh5tJqQ.
  10. The Go Programming Language Spec — Concurrencyhttps://go.dev/ref/spec.