19. sync. Mutex, RWMutex, WaitGroup, Once
Философия синхронизации состояния
«Не общайтесь через разделяемую память — делитесь памятью через общение. Но если вам необходимо защитить состояние в оперативной памяти, делайте это с железной дисциплиной: берите мьютекс, держите критическую секцию микроскопической и никогда не удерживайте блокировку во время операций ввода-вывода».
— Брайан Керниган
В Go существует широко известное золотое правило: «Не общайтесь, разделяя память. Делитесь памятью, общаясь». Это превосходный совет для архитектуры потоков данных и координации независимых процессов. Однако когда речь заходит о локальном мутабельном состоянии, разделяемом между сотнями параллельных горутин (in-memory кэши, счетчики запросов, таблицы маршрутизации, пулы буферов), использование каналов может оказаться неэффективным или избыточно сложным. Пакет sync предоставляет низкоуровневые фундаментальные примитивы для безопасного конкурентного доступа к общим ячейкам памяти.
Ключевое отличие sync от мьютексов в C++ или классической Java — это продуманная гибридная модель. Блокировка в Go не падает в ядро операционной системы сразу. Сначала предпринимается сверхбыстрая попытка захвата в пространстве пользователя (User Space) с помощью атомарных процессорных инструкций. И только при обнаружении реальной конкуренции горутина передается планировщику Go и ядру ОС. Это минимизирует количество дорогостоящих переключений контекста и сохраняет колоссальную пропускную способность.
sync.Mutex: Гибридная блокировка и машинные инструкции
sync.Mutex — это не просто тривиальная обертка над системным вызовом futex в Linux или функцией WaitForSingleObject в Windows. Это сложный конечный автомат с двумя режимами работы: Normal (нормальный) и Starvation (режим голодания).
Внутреннее состояние мьютекса упаковано всего в два поля:
type Mutex struct {
state int32 // Биты: 1=Locked, 2=Woken, 4=Starving, 8..=WaiterCount
sema uint32 // Семафор для park/unpark
}
Алгоритм захвата (Fast Path -> Slow Path)
- Атомарный CAS (Fast Path): Горутина пытается установить бит
Lockedчерез атомарную операциюatomic.CompareAndSwapInt32. Если мьютекс был свободен — захват происходит мгновенно за ~5 тактов CPU без единого перехода в ядро ОС. - Аппаратный спиннинг (Spinning): Если мьютекс занят, но на машине есть свободные процессоры P (логические процессоры рантайма) и очередь горутин коротка, горутина выполняет до 4 итераций активного спиннинга с инструкциями
PAUSE(procyield(30)). Это позволяет удерживать поток на ядре CPU в надежде на скорую разблокировку владельцем, избегая дорогостоящего контекстного переключения. - Парковка (Parking / Slow Path): Если спиннинг не привел к успеху, мьютекс увеличивает счетчик ожидающих
WaiterCount, помечает себя какWokenи вызывает рантайм-функциюruntime_SemacquireMutex. Это транслируется в системный вызов ядраfutex(SYS_futex, FUTEX_WAIT)на Linux. Горутина переходит в состояние снаGwaiting, а тред операционной системы (M) немедленно освобождается для полезной работы других горутин.
flowchart TD
A["Вызов Lock"] --> B{"Атомарный CAS<br>state |= Locked"}
B -->|"Успех"| C["Критическая секция<br>Без syscall"]
B -->|"Занят"| D{"Можно спиннить?"}
D -->|"Да, есть P"| E["4 такта PAUSE<br>Попытка CAS снова"]
E --> F{"Успех?"}
F -->|"Да"| C
F -->|"Нет"| G["Увеличить Waiters<br>Вызвать runtime_Semacquire"]
D -->|"Нет"| G
G --> H["Syscall futex<br>Горутина в Gwaiting"]
H --> I["Ожидание Unlock"]
classDef fast fill:#064e3b,stroke:#10b981,color:#d1fae5;
classDef check fill:#1e3a8a,stroke:#3b82f6,color:#dbeafe;
classDef slow fill:#7f1d1d,stroke:#f87171,color:#fee2e2;
class C fast;
class A,B,D,E,F check;
class G,H,I slow;
sync.RWMutex: Приоритет писателей и балансировка
Структура sync.RWMutex (Read-Write Mutex) позволяет сотням читателей работать параллельно, но полностью блокирует доступ при запросе со стороны писателя. Внутри используется комбинация int32 счетчиков и обычного Mutex.
Логика работы
- Читатели: Инкрементируют счетчик
readerCount. ЕслиreaderCount>= 0, вход разрешен. - Писатели: Пытаются захватить внутренний
Mutex. При успехе устанавливаютreaderCountв отрицательное значение, блокируя новых читателей. Затем ждут, пока активные читатели (readerWait) завершат работу. - Разблокировка писателя: Сбрасывает
readerCount, будит всех ожидающих писателей или читателей.
sync.WaitGroup: Координация без каналов
WaitGroup решает задачу ожидания завершения группы параллельных горутин. Это не канал, а оптимизированный атомарный счетчик со встроенным системным семафором.
Внутренняя структура использует поле state1 [3]uint32 (или uint64 на 64-битных системах), разделенное на две логические части:
- Counter: Число активных работающих горутин (
Add(1)->counter++). - Waiters: Число горутин, заблокированных в вызове
Wait().
Когда Counter падает до нуля, WaitGroup вызывает runtime_Semrelease ровно Waiters раз, одновременно пробуждая все ожидающие горутины.
func processBatch(items []Item) {
var wg sync.WaitGroup
// ❌ ОШИБКА: Add внутри горутины создает гонку!
// for _, item := range items {
// go func() {
// wg.Add(1) // Может выполниться после wg.Wait()!
// ...
// }()
// }
// ✅ ИДИОМАТИЧНО: Add до запуска
wg.Add(len(items))
for _, item := range items {
go func(i Item) {
defer wg.Done() // Атомарный counter--
process(i)
}(item)
}
wg.Wait() // Блокирует до counter == 0
}
sync.Once: Ленивая инициализация и Double-Checked Locking
sync.Once гарантирует однократное выполнение переданной функции f, сколько бы сотен параллельных горутин ни вызывали метод Do(f). Используется для инициализации синглетонов, загрузки конфигурационных файлов, настройки кэшей.
Реализация использует классический паттерн Double-Checked Locking:
- Быстрая атомарная проверка флага
done == 0без блокировок. - Если
done == 0, происходит захват внутреннего мьютекса. - Повторная проверка
doneпод защитой мьютекса (защита от гонки, пока горутина ждала освобождения). - Выполнение пользовательской функции, атомарная установка
done = 1, освобождение мьютекса.
var config *Config
var once sync.Once
func GetConfig() *Config {
once.Do(func() {
config = loadFromDisk() // Выполнится строго один раз
})
return config
}
В Go 1.21+ появились удобные функции-обертки:
sync.OnceFunc(f): возвращает функцию, выполняющуюfровно один раз.sync.OnceValue(f): возвращает функцию, вычисляющую значение и кэширующую результат.sync.OnceValues(f): аналогично для функций, возвращающих пару(value, error).
Mechanical Sympathy: Кэш-линии, False Sharing и планировщик
1. False Sharing (Ложное разделение)
Если два независимых экземпляра sync.Mutex случайно оказываются в пределах одной 64-байтовой кэш-линии процессора, изменение состояния одного мьютекса ядром CPU инвалидирует всю кэш-линию для соседнего ядра через протокол когерентности MESI. Это порождает паразитный cache coherence traffic и падение производительности на 10–30%.
Решение: Принудительное выравнивание структуры до размера строки кэша CPU (64 байта):
type alignedMutex struct {
mu sync.Mutex
_ [56]byte // Паддинг до 64 байт на 64-битной системе
}
2. Конкуренция и рост M-тредов
Когда sync.Mutex переводит горутину в режим сна Gwaiting, тред операционной системы (M) освобождается. Если блокировка критической секции длительна, планировщик Go для поддержания параллелизма может порождать новые системные потоки M. Это приводит к явлению Thread Thrashing: операционная система тратит львиную долю времени на переключение контекстов между десятками тяжелых ядерных потоков, а не на выполнение полезного кода.
Правило: Держите критические секции микроскопическими! Никогда не делайте дисковый или сетевой I/O под mu.Lock(). Захватили -> быстро скопировали/обновили указатель -> немедленно отпустили через mu.Unlock().
Ловушки и вопросы с собеседований
| Ловушка | Описание | Решение |
|---|---|---|
defer mu.Unlock() в начале функции |
Разблокировка происходит при return, а не сразу после секции |
Вызывайте defer непосредственно перед mu.Lock(), но только если вся функция является критической секцией. |
Передача sync.Mutex по значению |
Функция получает копию, мьютекс не синхронизирует | Передавайте только указатели *sync.Mutex. |
WaitGroup.Add() в конце горутины |
wg.Wait() может сработать до инкремента |
Всегда wg.Add() до go func(). |
Once для периодических задач |
Once выполняется один раз за всё время жизни процесса |
Для периодической перезагрузки используйте context + таймер или sync.RWMutex с версионированием. |
Сравнение с экосистемами
| Язык | Примитив | Особенности в сравнении с Go |
|---|---|---|
| C++ | std::mutex, std::shared_mutex |
Прямая обертка над pthread_mutex или OS primitives. Нет встроенного спиннинга/режима starvation. Рекурсивность настраивается. |
| Java | synchronized, ReentrantLock |
synchronized использует Object Monitor (JVM internal). ReentrantLock дает fair/unfair modes, аналогично Go, но требует явного lock()/unlock() в finally. |
| PHP | Отсутствует | PHP не имеет общей памяти для процессов. Синхронизация происходит через внешние хранилища (Redis SETNX, Memcached, файлы). |
| Go | sync.Mutex, sync.RWMutex |
Гибридный User/Kernel режим, автоматический starvation mode, интеграция с планировщиком G-M-P, нулевая магия. |
Итог
sync.Mutexиспользует быстрый CAS в User Space и переходит вfutexтолько при длительном ожидании.sync.RWMutexблокирует новых читателей при запросе писателя. Избегайте дедлоков через рекурсию.sync.WaitGroupтребует вызоваAdd()строго до запуска горутины. Копирование структуры после первого использования категорически запрещено.sync.Onceреализует идиому Double-Checked Locking. Паника внутриfпомечает выполнение как завершенное.- Избегайте
False Sharingвыравниванием структур и держите критические секции предельно короткими, чтобы не провоцировать Thread Thrashing.
Понимание блокировок открывает путь к изучению их «атомарной» альтернативы. Когда мьютекс избыточен, а производительность критична, на сцену выходят машинные инструкции. В следующей статье: 20. sync/atomic. Атомарные операции.