40. sync.WaitGroup, Mutex, RWMutex
«Управление сложностью — вот суть программирования».
— Брайан Керниган
В предыдущих статьях мы возносили дифирамбы каналам и парадигме CSP. Из-за этого у начинающих разработчиков иногда складывается ложное впечатление, будто в идиоматичном Go нет места классическим примитивам синхронизации и мьютексам. Это опасное заблуждение.
Создатели языка, и в частности Роб Пайк, всегда подчеркивали: «Используйте то, что проще и выразительнее».
Каналы идеально подходят для передачи владения данными, конвейерной обработки (pipelines) и оркестрации жизненного цикла. Но если вам нужно просто защитить in-memory кэш (например, map) от одновременной записи или подождать завершения пула воркеров, использование каналов сделает код переусложненным, медленным и многословным. В таких сценариях на сцену выходит пакет sync.
1. sync.WaitGroup: Оркестрация горутин
Когда вы запускаете фоновые горутины, функция main (или родительская горутина) не будет их ждать. Если main завершится, рантайм Go жестко убьет все фоновые процессы процесса ОС. sync.WaitGroup — это потокобезопасный счетчик, который решает проблему барьерной синхронизации.
Основных методов у него три:
Add(int)— увеличивает счетчик (сколько горутин мы ждем).Done()— уменьшает счетчик на 1 (вызывается внутри горутины при завершении).Wait()— блокирует текущую горутину, пока счетчик не станет равен 0.
А с Go 1.25 есть ещё Go(f func()): он сам делает Add(1), запускает f в новой горутине и вызывает Done() по её завершении — так ошибиться с порядком Add (см. ловушку ниже) уже нельзя.
Под капотом: Атомики и Семафоры
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph State64["Поле state (atomic.Uint64)"]
High["Верхние 32 бита:<br>Счетчик горутин (Add/Done)"]
Low["Нижние 32 бита:<br>Счетчик ждущих waiters (Wait)"]
end
High -->|"При равенстве 0"| Sema["Семафор sema:<br>runtime_Semrelease"]
2. sync.Mutex: Эксклюзивная блокировка
Mutex (Mutual Exclusion) гарантирует, что только одна горутина может находиться в критической секции (например, при записи в map, которая в Go не является потокобезопасной).
Mechanical Sympathy: Fast Path против Slow Path
Системные вызовы для блокировки потоков ОС (futex в Linux) — это очень дорого. Поэтому sync.Mutex в Go использует гибридный подход.
У мьютекса есть два пути захвата:
- Fast Path (Быстрый путь): Горутина пытается атомарно (с помощью процессорной инструкции Compare-And-Swap, CAS) изменить состояние мьютекса в User Space. Если мьютекс был свободен, операция занимает около 20 тактов CPU. Никаких обращений к ОС не происходит.
- Slow Path (Медленный путь): Если мьютекс занят, горутина не засыпает сразу. Она делает Spinning (Активное ожидание) — несколько раз крутит пустой цикл процессора (инструкция
PAUSE), надеясь, что мьютекс вот-вот освободится. И только если спиннинг не помог, горутина заносится в очередь ожидания семафора и паркуется (runtime.gopark), освобождая системный тред (M) для других горутин.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Захват Mutex"]:::warning --> B{"Свободен? CAS-операция"}
B -->|"Да"| C["Fast Path<br>Захват за 20 тактов CPU"]
B -->|"Нет"| D["Slow Path<br>Spinning - активное ожидание"]
D --> E{"Удалось захватить?"}
E -->|"Да"| C
E -->|"Нет"| F["Парковка горутины<br>Очередь семафора"]
Starvation Mode (Режим голодания)
3. sync.RWMutex: Разделение чтения и записи
Если ваши данные обновляются редко (например, конфиги или In-Memory словарь), но читаются тысячами горутин одновременно, использование обычного Mutex убьет пропускную способность: читатели будут выстраиваться в очередь друг за другом.
Для этого существует sync.RWMutex (Read-Write Mutex):
RLock()/RUnlock()— захват для чтения. Множество горутин могут владеть им одновременно.Lock()/Unlock()— эксклюзивный захват для записи. Блокирует и новых читателей, и новых писателей.
Механика работы под капотом
Как Lock() предотвращает появление новых читателей, пока ждет завершения старых?
В RWMutex есть счетчик readerCount (атомарный int32).
Когда приходит писатель, он делает хитрый трюк: вычитает из readerCount константу rwmutexMaxReaders (равную $2^{30}$).
Счетчик становится отрицательным!
Новые читатели видят отрицательное число и сразу уходят в очередь ожидания. При этом писатель точно знает количество старых читателей (прибавив константу обратно) и ждет, пока они не вызовут RUnlock().
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph RWMutexState["Состояние RWMutex"]
W["Писатель вызывает Lock()"]:::process --> Sub["readerCount -= rwmutexMaxReaders (2^30)"]
Sub --> Neg{"readerCount < 0"}
Neg -->|"Новые читатели"| WaitQueue["Уходят в очередь ожидания"]
Neg -->|"Старые читатели"| Finish["Завершают работу через RUnlock()"]
Finish --> UnlockWriter["Писатель получает эксклюзивный доступ"]
end
Итог
WaitGroup: Используйте для ожидания завершения пула фоновых задач. ВызывайтеAddстрого до старта горутины.Mutex: Защищает критические секции. Использует гибридный подход (Спиннинг + Парковка). Имеет режим защиты от голодания (Starvation Mode).RWMutex: Оптимизирует Read-Heavy нагрузки. Но берегитесь аппаратного ограничения — инвалидации кэш-линий при огромном количестве одновременныхRLock().- Не копируйте блокировки: Примитивы из пакета
syncвсегда должны передаваться и использоваться по указателю.
Базовые примитивы покрывают 90% задач синхронизации. Но пакет sync таит в себе еще несколько продвинутых инструментов для хардкорной оптимизации и работы с памятью, которые отличают Senior разработчика от Middle. В следующей статье мы разберем паттерны синглтона, широковещательных сигналов, переиспользования аллокаций и работу без мьютексов вообще: 41. sync.Once, Cond, Pool и atomic.