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 использует гибридный подход.

У мьютекса есть два пути захвата:

  1. Fast Path (Быстрый путь): Горутина пытается атомарно (с помощью процессорной инструкции Compare-And-Swap, CAS) изменить состояние мьютекса в User Space. Если мьютекс был свободен, операция занимает около 20 тактов CPU. Никаких обращений к ОС не происходит.
  2. 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
  

Итог

  1. WaitGroup: Используйте для ожидания завершения пула фоновых задач. Вызывайте Add строго до старта горутины.
  2. Mutex: Защищает критические секции. Использует гибридный подход (Спиннинг + Парковка). Имеет режим защиты от голодания (Starvation Mode).
  3. RWMutex: Оптимизирует Read-Heavy нагрузки. Но берегитесь аппаратного ограничения — инвалидации кэш-линий при огромном количестве одновременных RLock().
  4. Не копируйте блокировки: Примитивы из пакета sync всегда должны передаваться и использоваться по указателю.

Базовые примитивы покрывают 90% задач синхронизации. Но пакет sync таит в себе еще несколько продвинутых инструментов для хардкорной оптимизации и работы с памятью, которые отличают Senior разработчика от Middle. В следующей статье мы разберем паттерны синглтона, широковещательных сигналов, переиспользования аллокаций и работу без мьютексов вообще: 41. sync.Once, Cond, Pool и atomic.