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

Пакет sync и atomic

Если канал — это коммуникация между горутинами, то sync и sync/atomic — это синхронизация доступа к разделяемой памяти. Mutex, RWMutex, WaitGroup, Once, Pool, atomic — обязательные инструменты. Без них вы получите race conditions, которые проявятся в проде раз в неделю и будут отлаживаться месяц. На собеседовании спросят: про reentrant Mutex (нет!), про zero-value sync.Mutex (usable), про race detector, про разницу atomic/mutex, про sync.Pool.

  1. Базовое API
  2. Под капотом
  3. Тонкие моменты / Gotchas
  4. Производительность
  5. Race detector
  6. Типичные вопросы на собесе
  7. Practice
  8. Источники

Mutex (Mutual Exclusion) — взаимное исключение. Только одна горутина одновременно может держать lock.

import "sync"
var (
mu sync.Mutex
counter int
)
func inc() {
mu.Lock()
defer mu.Unlock()
counter++
}

Ключевые свойства:

  • Zero value usablevar mu sync.Mutex уже готов к работе, без new() и инициализации.
  • Не recursive — повторный Lock() той же горутиной = deadlock (не panic, просто зависание!).
  • Не привязан к горутине — формально можно Unlock() из другой горутины (UB по Go Memory Model, но компилится; в runtime будет panic при попытке Unlock не залоченного mutex).
  • Не копируется — после первого использования копирование Mutex — бага (vet поймает: “passes lock by value”).

Best practice:

mu.Lock()
defer mu.Unlock()
// ... critical section

defer гарантирует Unlock даже при panic.

Read-Write Mutex — допускает много читателей или одного писателя.

var (
rw sync.RWMutex
cache = make(map[string]string)
)
func get(key string) string {
rw.RLock()
defer rw.RUnlock()
return cache[key]
}
func set(key, val string) {
rw.Lock()
defer rw.Unlock()
cache[key] = val
}
  • RLock() / RUnlock() — для чтения (много одновременных).
  • Lock() / Unlock() — для записи (эксклюзив).

Когда использовать?

  • Чтений в 10+ раз больше, чем записей.
  • Critical section короткий → накладные расходы RWMutex окупаются.

⚠️ Если writes часто блокируют reads (writer-starvation prevention) — может быть медленнее обычного Mutex. Замеряйте!

WaitGroup — счётчик “сколько горутин ещё работает”. Wait() блокирует, пока счётчик не станет 0.

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // ВАЖНО: до go!
go func(i int) {
defer wg.Done() // = Add(-1)
time.Sleep(time.Duration(i) * time.Second)
fmt.Println(i)
}(i)
}
wg.Wait() // блок, пока все 5 не вызовут Done
fmt.Println("all done")

Правила:

  • Add(n) до запуска горутины (если в горутине — race с Wait).
  • Done() — обычно через defer для гарантии.
  • Counter не должен уйти в минус (Done без Add → panic).
  • Не переиспользуйте WaitGroup между раундами Wait без аккуратности (можно, но Add в момент Wait — race).

Once — гарантирует один вызов функции, даже при concurrent invocations.

var (
once sync.Once
instance *Singleton
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{...}
})
return instance
}
  • Do(f) — выполнит f ровно один раз за время жизни Once. Все остальные вызовы (даже из других горутин) дождутся завершения первого f и ничего больше не сделают.
  • Если f паникует — Once считает себя “выполненной” (повторных вызовов не будет).
  • Идеально для lazy initialization singleton-ов.

С Go 1.21 добавлены sync.OnceFunc, sync.OnceValue, sync.OnceValues — удобные обёртки:

init := sync.OnceFunc(func() {
fmt.Println("init once")
})
init() // печатает
init() // ничего
getCfg := sync.OnceValue(func() *Config {
return loadConfig() // вызовется один раз
})
cfg := getCfg() // load
cfg = getCfg() // тот же объект

Conditional variable — горутины могут “ждать” события и быть разбуженными.

var (
mu sync.Mutex
cond = sync.NewCond(&mu)
queue []Item
)
func consumer() {
mu.Lock()
for len(queue) == 0 {
cond.Wait() // отпускает mu, паркуется, пробудившись снова берёт mu
}
item := queue[0]
queue = queue[1:]
mu.Unlock()
process(item)
}
func producer(item Item) {
mu.Lock()
queue = append(queue, item)
cond.Signal() // или cond.Broadcast() для всех
mu.Unlock()
}
  • Wait() — отпускает mutex, паркует горутину. Пробуждение — снова берёт mutex.
  • Signal() — будит одну ждущую горутину.
  • Broadcast() — будит всех.

⚠️ В 99% случаев джуну Cond не нужен — каналы решают то же самое идиоматичнее. Знайте, что он существует, но избегайте.

Pool — пул объектов для переиспользования. Снижает нагрузку на GC и аллокатор.

var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handle() {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // ВАЖНО: очистить
bufPool.Put(buf)
}()
// ... используем buf
}
  • Get() — берёт объект из пула. Если пул пуст — вызывает New().
  • Put(x) — возвращает объект в пул.
  • New — фабрика для создания, если пул пуст.

Use cases: буферы (bytes.Buffer, []byte), reusable parsers, request-scoped objects.

⚠️ Подвохи:

  • GC чистит пул: с Go 1.13 при сборе GC удаляет половину объектов из пула. Поэтому Pool — только для временных объектов, не для долгоживущих singleton-ов.
  • Не для типов с состоянием, которое нельзя обнулить (Reset).
  • Не для пула соединений (используйте database/sql или специализированные библиотеки).

Map с поддержкой concurrent доступа. Альтернатива map + sync.RWMutex.

var m sync.Map
m.Store("k", "v")
v, ok := m.Load("k")
m.Delete("k")
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // продолжать итерацию
})

Когда использовать?

  • Запись редкая, чтения частые на разных ключах.
  • Append-only кеш.

⚠️ Когда не использовать?

  • Любые сценарии с частой записью одного ключа → обычный map + RWMutex быстрее.
  • Если нужны range, len, типобезопасность → обычный map лучше.

sync.Map использует нетипизированный интерфейс (any/interface{}). С Go 1.18+ можно сделать обёртку с генериками самому.

Атомарные операции — без блокировок, на уровне CPU инструкций.

import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1)
v := atomic.LoadInt64(&counter)
atomic.StoreInt64(&counter, 0)
swapped := atomic.CompareAndSwapInt64(&counter, 0, 100)
var counter atomic.Int64
counter.Add(1)
v := counter.Load()
counter.Store(0)
swapped := counter.CompareAndSwap(0, 100)

Доступные типы: atomic.Bool, atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64, atomic.Uintptr, atomic.Pointer[T] (Go 1.19+).

ЗадачаЛучше
Счётчикatomic
Read-modify-write одного значенияatomic CAS
Защита нескольких полей одновременноMutex
Защита коллекции (map, slice)Mutex / sync.Map
Защита invariant между полямиMutex

Правило: atomic — для одного значения. Если нужно атомарно изменить два числа — нужен Mutex (или CAS на одну структуру через unsafe.Pointer).

Все atomic-операции в Go — sequentially consistent (SC). Это самая сильная гарантия: все горутины видят операции в одном глобальном порядке.

Это удобно (нет случайных race), но дороже более слабых моделей (acquire/release, relaxed) — например, в C++/Rust.


Mutex в Go (src/sync/mutex.go) — это структура:

type Mutex struct {
state int32 // битовое поле: locked, woken, starving, waitersCount
sema uint32 // семафор для парковки/пробуждения горутин
}

Fast path — uncontended Lock через одну CAS-операцию:

// Псевдокод:
if atomic.CompareAndSwap(&m.state, 0, locked) {
return // успех, lock взят
}

Slow path — если CAS не сработал, идём в medium-path (active spinning несколько раз), потом в slow-path (паркуемся на семафоре).

С Go 1.9 у Mutex два режима:

  • Normal — fast. Новые горутины могут “обогнать” уже ждущих в очереди (lock прыгает к “горячей” горутине). Это хорошо для throughput, но плохо для fairness.
  • Starvation — если горутина ждёт lock больше 1 ms, Mutex переходит в режим, где lock гарантированно передаётся первой в очереди. Это снижает throughput, но обеспечивает fairness.

После того как goroutine получает lock и waitersCount = 0 (или прошло <1 ms), mutex возвращается в normal mode.

type RWMutex struct {
w Mutex // mutex для writers
writerSem uint32
readerSem uint32
readerCount int32 // число активных reader-ов
readerWait int32 // сколько readers ждут writer
}
  • Lock() — берёт w, ставит readerCount в negative число, ждёт пока активные reader-ы не закончат.
  • RLock() — атомарно инкрементит readerCount. Если оно negative (writer ждёт) — паркуется.
  • Unlock() — возвращает readerCount назад в positive, пробуждает reader-ов.

⚠️ RWMutex предпочитает writer-ов (no writer starvation), что иногда удивляет.

type Once struct {
done uint32 // флаг "выполнено"
m Mutex // для slow path
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

Fast path — атомарная проверка флага (без mutex), быстро. Slow path — mutex для гарантии единственного выполнения.

⚠️ Подвох: до Go 1.21 был баг — если f() паникует, done всё равно ставится в 1. Поведение интенциональное. С Go 1.21 это явно документировано.

type WaitGroup struct {
noCopy noCopy
state1 uint64 // upper 32 bits: counter, lower 32 bits: waiters
state2 uint32 // semaphore
}
  • Add(n) — атомарно прибавляет к counter-у.
  • Done() = Add(-1).
  • Wait() — атомарно проверяет counter; если 0 — сразу возвращает. Иначе инкрементит waiters, паркуется на семафоре.
  • Когда counter становится 0 — освобождается семафор, все waiters просыпаются.
type Pool struct {
noCopy noCopy
local unsafe.Pointer // per-P структура
localSize uintptr
victim unsafe.Pointer // прошлый цикл GC
victimSize uintptr
New func() any
}

У каждого P (см. GMP в файле 10) есть локальный пул объектов. Get/Put в основном идут без блокировки:

  • Get() — сначала пытается взять из своего P. Если пусто — пытается украсть у других P. Если ни у кого нет — вызывает New.
  • Put(x) — кладёт в свой P.

Локально структура — это shared queue (private + shared). Put идёт в private. Get — сначала private, потом shared head, потом ворует с shared tail других P.

С Go 1.13 при GC:

  1. То, что было в local, перемещается в victim.
  2. Что было в victimвыкидывается.
  3. На следующем GC шаге всё повторяется.

Это значит: объект, помещённый в пул, живёт минимум 1 GC цикл, максимум 2. После — будет собран.

Зачем victim? Чтобы избежать “холодного старта” сразу после GC — некоторые недавние объекты остаются доступными.

Atomic-операции компилируются в специальные CPU инструкции:

Операция в Gox86 инструкция
atomic.LoadInt64MOVQ (aligned 64-bit)
atomic.StoreInt64XCHGQ или MOVQ + MFENCE
atomic.AddInt64LOCK XADDQ
atomic.CompareAndSwapLOCK CMPXCHGQ

LOCK prefix — заставляет CPU гарантировать атомарность операции (cache coherence через MESI протокол).

⚠️ Подвох с alignment: на 32-bit платформах (например, 32-bit ARM) atomic.LoadInt64 требует, чтобы поле было выровнено по 8 байтам. Если структура содержит другие поля до — поле может оказаться не выровнено → panic в runtime.

Best practice: int64/uint64 поле — всегда первым в struct, или используйте atomic.Int64 (с Go 1.19+, который сам обеспечивает alignment).

type Counter struct {
n int64 // первым! иначе на 32-bit может крашить
name string
}

С atomic.Int64:

type Counter struct {
n atomic.Int64 // сам обеспечивает alignment
name string
}

mu.Lock()
mu.Lock() // deadlock! Та же горутина — то же mutex.

⚠️ Это деадлок (горутина залипает навсегда), не паника. В отличие от Java’s ReentrantLock, Go-шный Mutex не reentrant.

Если кажется, что нужен recursive lock — это запах плохой архитектуры. Рефакторите код.

type Counter struct {
mu sync.Mutex
n int
}
func (c Counter) Inc() { // ⚠️ value receiver!
c.mu.Lock()
defer c.mu.Unlock()
c.n++ // мутирует копию, не оригинал
}

⚠️ Передача Counter по значению копирует и mutex. Это race (две горутины с двумя копиями mutex-а — нет защиты).

Правило: value-receiver на типе с sync.Mutexвсегда баг. go vet это поймает: “passes lock by value”.

Фикс — pointer receiver:

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // ⚠️ Race с wg.Wait()!
defer wg.Done()
work()
}()
}
wg.Wait() // может вернуться сразу, если ни одна горутина не успела Add

Фикс: Add до go.

wg.Add(5)
// ... горутины
wg.Wait()
wg.Add(5) // ⚠️ Race! Wait может ещё разблокировать предыдущих waiters

⚠️ Технически можно переиспользовать WaitGroup, но в Go Memory Model Add после Wait — race. Best practice: на каждый “раунд” — новый WaitGroup.

m := sync.Mutex{}
m.Lock()
m2 := m // ⚠️ копирование mutex в залоченном состоянии
m.Unlock()
m2.Unlock() // panic: unlock of unlocked mutex

go vet поймает.

func foo() {
mu.Lock()
defer mu.Unlock()
longComputation() // mu держится всё время
}

В hot path defer стоит ~50ns. Если critical section короткий — может быть существенно. Тогда — explicit:

func foo() {
mu.Lock()
longComputation()
mu.Unlock()
}

⚠️ Но! Если longComputation может паниковать — explicit оставит mutex залоченным навсегда. Используйте defer, если есть риск panic.

На 32-bit платформах (32-bit ARM, 32-bit x86):

type Bad struct {
a int32 // 4 bytes
b int64 // 8 bytes — может быть не выровнен!
}
var x Bad
atomic.AddInt64(&x.b, 1) // panic на 32-bit ARM

Фикс — int64 первым:

type Good struct {
b int64 // выровнен по 8 байт
a int32
}

Или использовать atomic.Int64 (Go 1.19+) — компилятор сам обеспечит.

rw.RLock()
rw.RLock() // ⚠️ может вызвать deadlock!

Если между двумя RLock-ами кто-то вызвал Lock, второй RLock залипнет навсегда (RWMutex даёт приоритет writer-у).

type Buf struct {
data []byte
}
var pool = sync.Pool{
New: func() any {
return &Buf{data: make([]byte, 4096)}
},
}
func use() {
b := pool.Get().(*Buf)
defer pool.Put(b)
// ... используем b.data
// ⚠️ Если data сохранили где-то ещё, после Put — race!
}

Правило: после Putзабудьте про объект, не используйте его ссылку.

Если нужно атомарно подменять значение разных типовatomic.Value:

var v atomic.Value
v.Store(&Config{Version: 1})
// ...
cfg := v.Load().(*Config)

⚠️ Все Store должны быть одного типа. Иначе panic.

С Go 1.19+ для типобезопасности предпочитайте atomic.Pointer[Config].


ОперацияВремя
Mutex Lock/Unlock (uncontended)~15 ns
Mutex Lock/Unlock (contended)200 ns - μs
RWMutex RLock/RUnlock~25 ns
atomic.AddInt64~5 ns
atomic CAS~5-10 ns
channel send/recv30-70 ns

Почти всегда для simple counter / shared state. Канал — overhead на парковку, scheduler, hchan.

  • Coarse locking — один большой mutex на всю структуру. Просто, но scales плохо.
  • Fine-grained locking — много маленьких mutex-ов (например, на bucket в map). Быстрее, но сложнее.

Compromise: sharded map — массив [N]struct{ mu sync.Mutex; data map[K]V }. Каждый ключ попадает в bucket по hash. Параллелизм до N.

⚠️ Подвох на многоядерных: если два mutex/atomic находятся в одной cache line (64 bytes на x86), они мешают друг другу.

Фикс — padding:

type Counter struct {
n int64
_ [56]byte // padding до 64 байт
}
var counters [4]Counter // каждый в своей cache line

Замеряйте! Pool помогает, если:

  • Объект большой (kB+).
  • Создание объекта дорогое.
  • Используется в hot path.

Иначе overhead pool-а > benefit.


Go runtime имеет встроенный race detector, который ловит data races во время выполнения.

Окно терминала
go build -race ./...
go test -race ./...
go run -race main.go

⚠️ Замедляет программу в 5-10x, увеличивает память. Только для dev и CI, не для prod.

Data race — два или более горутины обращаются к одной и той же памяти, минимум одна — на запись, без синхронизации.

var x int
go func() { x = 1 }()
go func() { x = 2 }() // race
fmt.Println(x) // UB

Race detector это поймает. Без него — x может быть 1, 2, частично 1 и частично 2 (на платформах с не-atomic word-size write).

Добавьте в pipeline:

Окно терминала
go test -race -timeout 60s ./...

Если find race — фейл билда. Обязательно для любого Go-проекта.

  • Race, который не случился в данном запуске (test coverage важен!).
  • Logical races (race по бизнес-логике, без data race).
  • Deadlocks (есть отдельный детектор только для тривиальных случаев — когда все горутины заблокированы).

  1. sync.Mutex recursive? Нет. Повторный Lock в той же горутине = deadlock.

  2. Что произойдёт при Unlock без Lock? Panic: “unlock of unlocked mutex”.

  3. Zero value sync.Mutex usable? Да. var mu sync.Mutex готов к работе.

  4. Можно ли копировать sync.Mutex? Нет. go vet поймает. Передавайте по указателю.

  5. Что делает sync.RWMutex? Много reader-ов одновременно или один writer.

  6. Когда RWMutex медленнее обычного Mutex? Когда писем много (writers блокируют reader-ов, есть накладные на trackirovku state).

  7. Что не так с этим?

    wg.Add(1)
    go func() {
    defer wg.Done()
    wg.Add(1) // ⚠️
    }()

    Add после старта горутины — race с Wait.

  8. Что такое sync.Once? Гарантирует одно выполнение функции, даже из разных горутин.

  9. Что произойдёт, если функция в Once.Do паникует? Once считается “выполненной”, повторных вызовов не будет.

  10. Зачем sync.Pool? Переиспользование объектов для снижения нагрузки на GC.

  11. Что делает GC с sync.Pool? Чистит примерно половину объектов каждый GC цикл (через victim cache).

  12. Atomic vs Mutex — когда что? Atomic — для одного значения. Mutex — для нескольких полей / коллекций.

  13. Что такое CAS? Compare-And-Swap. Атомарная операция: if value == old { value = new }. Используется для lock-free алгоритмов.

  14. Memory ordering в Go atomic? Sequentially consistent. Все операции упорядочены глобально.

  15. Зачем atomic.Int64 вместо atomic.AddInt64?

    1. Типобезопасность. 2) Автоматический alignment на 32-bit платформах.
  16. Что такое starvation в Mutex? Когда горутина ждёт lock слишком долго. С Go 1.9 если ждёт >1ms, Mutex переходит в starvation mode и lock гарантированно передаётся следующей в очереди.

  17. Race detector — что ловит? Data race: concurrent access to same memory, at least one write, without synchronization.

  18. Какой overhead у race detector? 5-10x по CPU, 5-10x по памяти.

  19. Можно ли deploy в prod с -race? Нет, slow и потребляет память.

  20. sync.Map vs map+RWMutex? sync.Map — если запись на разные ключи (insert-mostly). map+RWMutex — если частые обновления одних и тех же ключей.

  21. Когда defer mu.Unlock() плох? В hot path (50 ns overhead) и когда critical section короче, чем сам defer.

  22. Как реализовать reentrant mutex? Никак идиоматично. Если очень нужно — отслеживать goroutine ID (но Go не даёт публичного API). Refactor код.

  23. Что произойдёт при двойном wg.Done()? Counter уйдёт ниже нуля → panic.

  24. Как корректно использовать sync.Pool с buffer-ами? Get → use → Reset → Put. После Put не трогать.

  25. Что такое false sharing? Когда разные переменные на разных горутинах попадают в одну cache line → лишний bus traffic. Фикс — padding.


Реализуйте SafeCounter с методами Inc, Dec, Value. Три варианта: с Mutex, с RWMutex (Value через RLock), с atomic. Замерьте бенчмарками.

Реализуйте ShardedMap[K comparable, V any] с 32 shard-ами. Каждый shard — map[K]V + sync.RWMutex. Метод Get(k), Set(k, v), Delete(k).

Используя WaitGroup и канал done, реализуйте worker pool, который завершается, когда все задачи выполнены, и можно остановить через context.

var once sync.Once
go once.Do(func() { time.Sleep(time.Second); fmt.Println("a") })
go once.Do(func() { fmt.Println("b") })
time.Sleep(2 * time.Second)

Что напечатает? Когда напечатает “b”? (Подсказка: вторая горутина дождётся первой.)

var (
cache = map[string]string{}
mu sync.Mutex
)
func Get(key string) string {
if v, ok := cache[key]; ok { // ⚠️ read без lock
return v
}
mu.Lock()
defer mu.Unlock()
if v, ok := cache[key]; ok {
return v
}
v := compute(key)
cache[key] = v
return v
}

Запустите с -race. Поймайте race. Исправьте (через RWMutex.RLock — но тоже надо аккуратно с double-check).

var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func BenchmarkNoPool(b *testing.B) {
for i := 0; i < b.N; i++ {
buf := new(bytes.Buffer)
buf.WriteString("hello")
_ = buf
}
}
func BenchmarkWithPool(b *testing.B) {
for i := 0; i < b.N; i++ {
buf := pool.Get().(*bytes.Buffer)
buf.WriteString("hello")
buf.Reset()
pool.Put(buf)
}
}

Запустите go test -bench=.. Сравните.

Реализуйте lock-free stack через atomic.Pointer[node]. Методы Push(v) и Pop(). Используйте CAS в цикле.


  1. sync пакетhttps://pkg.go.dev/sync — официальная документация.
  2. Go Memory Modelhttps://go.dev/ref/mem — happens-before, atomics, channels.
  3. Dmitry Vyukov, “Go race detector internals”https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm
  4. sync/atomic пакетhttps://pkg.go.dev/sync/atomic
  5. The Go Programming Language, Donovan & Kernighan — chapter 9 (concurrency with shared variables).
  6. sync/mutex.go — исходник: https://github.com/golang/go/blob/master/src/sync/mutex.go