41. sync.Once, Cond, Pool и atomic

«Управление сложностью — вот суть программирования».
— Брайан Керниган

В арсенале бэкенд-разработчика мьютексы и WaitGroup решают подавляющее большинство типовых задач синхронизации. Однако, когда вы начинаете проектировать инфраструктурный код, высоконагруженные in-memory кэши, сериализаторы или сетевые фреймворки (вроде fasthttp или ядра gin), базовых примитивов становится недостаточно. Вам требуются специализированные инструменты: бескомпромиссная оптимизация аллокаций памяти в куче, широковещательные сигналы и аппаратные инструкции процессора, работающие вообще без обращений к ядру операционной системы.

В этой статье мы разберем продвинутую часть пакета sync и низкоуровневый пакет sync/atomic — инструменты, владение которыми отличает глубокого инженера от обычного кодера.


1. sync.Once: Ленивая инициализация и Синглтоны

Классическая проблема: у вас есть ресурсоемкая операция (например, чтение и парсинг конфигурационного файла с диска, прогрев кэша или установка TCP-пула к базе данных), которую нужно выполнить строго один раз, даже если тысячи горутин одновременно попытаются её вызвать при старте приложения.

Использование обычного Mutex здесь избыточно: после первой инициализации все последующие чтения будут неоправданно бороться за блокировку, создавая лишний Lock Contention. sync.Once решает эту задачу с максимальной аппаратной эффективностью.

Go
package main

import (
    "fmt"
    "sync"
)

var (
    once sync.Once
    cfg  *Config
)

type Config struct { /* ... */ }

func loadConfig() {
    fmt.Println("Инициализация конфига... (вызовется лишь однажды)")
    cfg = &Config{}
}

func GetConfig() *Config {
    // Допустимо вызывать из тысяч горутин одновременно
    once.Do(loadConfig)
    return cfg
}

Под капотом: Fast Path и Slow Path

Как sync.Once умудряется быть потокобезопасным и при этом не тормозить последующие вызовы?
Внутри структуры есть флаг done (атомарный счетчик) и m (мьютекс).

  1. Fast Path (Быстрый путь): При вызове Do(), рантайм атомарно читает флаг done. Если он равен 1, функция мгновенно возвращает управление (операция занимает доли наносекунд).
  2. Slow Path (Медленный путь): Если флаг равен 0, горутина захватывает мьютекс. Внутри критической секции она еще раз проверяет флаг (Double-Checked Locking). Если он все еще 0, выполняется переданная функция, и флаг атомарно выставляется в 1.
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
    Start["Вызов once.Do(f)"]:::success --> CheckDone{"atomic.LoadUint32(&done) == 1?"}
    CheckDone -->|"Да (Fast Path)"| DoneFast["Мгновенный возврат (0.5 нс)"]
    CheckDone -->|"Нет (Slow Path)"| Lock["Захват m.Lock()"]
    Lock --> Recheck{"done == 0?"}
    Recheck -->|"Да"| Exec["Вызов f()<br>atomic.StoreUint32(&done, 1)"]
    Recheck -->|"Нет"| Unlock["m.Unlock()"]
    Exec --> Unlock
    Unlock --> DoneSlow["Возврат управления"]
  

2. sync.Cond: Сигналы и широковещание

sync.Cond (Condition Variable) — это примитив синхронизации, позволяющий горутине заснуть (отвязаться от процессора) до тех пор, пока не наступит определенное условие, о котором ей сообщит другая горутина.

Во многих случаях Cond можно заменить каналами (и в Idiomatic Go так и принято). Но у Cond есть одна уникальная суперспособность — метод Broadcast(), который может одновременно разбудить сотни спящих горутин, ничего им не передавая. С каналами для этого пришлось бы закрыть канал (но тогда его нельзя использовать повторно) или отправлять N сообщений.

Как это работает

Go
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    mu := &sync.Mutex{}
    cond := sync.NewCond(mu)
    ready := false

    for i := 0; i < 3; i++ {
        go func(id int) {
            mu.Lock()
            // ВАЖНО: всегда проверяем условие в цикле for!
            for !ready {
                // Wait атомарно отпускает мьютекс и усыпляет горутину.
                // При пробуждении - снова захватывает мьютекс.
                cond.Wait()
            }
            fmt.Printf("Горутина %d начала работу\n", id)
            mu.Unlock()
        }(i)
    }

    time.Sleep(1 * time.Second)

    mu.Lock()
    ready = true
    mu.Unlock()

    fmt.Println("Рассылаем сигнал всем!")
    cond.Broadcast() // Будит все горутины, зависшие на Wait()

    time.Sleep(1 * time.Second) // Даем горутинам время на вывод
}

3. sync.Pool: Уничтожение работы для Garbage Collector

В высоконагруженном бэкенде мы часто аллоцируем краткосрочные объекты: буферы для JSON, структуры контекстов запросов, слайсы байт. Постоянное выделение памяти в куче (Heap Allocation) приводит к двум проблемам:

  1. Затраты CPU на саму аллокацию.
  2. Взрывной рост пауз сборщика мусора (GC), которому нужно сканировать и удалять этот мусор.

sync.Pool — это потокобезопасный пул объектов. Вместо создания нового объекта мы просим его у пула (Get). Поработали — вернули обратно (Put). Если пул пуст, он сам создаст новый объект через функцию New.

Go
var bufferPool = sync.Pool{
    New: func() any {
        b := make([]byte, 0, 4096) // Выделяем 4KB памяти
        return &b // Храним указатель: он кладётся в any без аллокации
    },
}

func ProcessRequest() {
    // Берем буфер из пула (без аллокации, если пул не пуст)
    bp := bufferPool.Get().(*[]byte)

    // Важно! Сбрасываем длину перед использованием, но сохраняем capacity
    // (старые данные при этом НЕ стираются!)
    buf := (*bp)[:0]

    // ...работаем с buf...

    // Возвращаем в пул (указатель на, возможно, выросший буфер)
    *bp = buf
    bufferPool.Put(bp)
}

Под капотом и Mechanical Sympathy

Архитектура sync.Pool тесно связана с планировщиком Go (моделью G-M-P).
Если бы Pool был единым списком объектов с глобальным мьютексом, на 64-ядерном сервере горутины бы убили производительность, стоя в очереди за буфером.

Вместо этого рантайм создает свой собственный локальный пул для каждого логического процессора (P) (poolLocal).
Когда горутина вызывает Get(), она привязывается к текущему P (вытеснение горутины отключается) и берет объект из приватной переменной своего P вообще без блокировок! Если там пусто, она берет из общего lock-free списка этого P (poolChain). И только если и там пусто, она ворует объекты у других P (Work Stealing).

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
    G["Горутина G вызывает Get()"]:::process --> P1["Текущий логический процессор P"]
    subgraph LocalPool["Локальный пул P (poolLocal)"]
        Priv["private (1 элемент, без блокировок)"]
        Shared["shared (lock-free deque)"]
    end
    P1 --> Priv
    Priv -->|"Если пусто"| Shared
    Shared -->|"Если пусто"| Steal["Work Stealing: Украсть из shared другого P"]
    Steal -->|"Если везде пусто"| NewAlloc["Вызов функции New()"]
  

4. sync/atomic: Железобетонная синхронизация

Иногда нам нужно просто увеличить счетчик просмотров или атомарно обновить указатель на кэш. Захватывать ради этого sync.Mutex — значит заставлять горутину проходить сложный путь (Fast Path + потенциальная парковка в ядре).

Пакет sync/atomic предоставляет доступ к низкоуровневым аппаратным инструкциям процессора.

На архитектуре x86-64 операция вроде atomic.AddUint64 компилируется в одну-единственную ассемблерную инструкцию с префиксом LOCK (например, LOCK XADD).

Как это работает на уровне железа:
Префикс LOCK заставляет процессор захватить кэш-линию L1 эксклюзивно для себя (используя протокол когерентности кэшей MESI) или заблокировать системную шину памяти на долю наносекунды. Другие ядра процессора аппаратно не смогут прочитать или изменить эту ячейку памяти, пока операция не завершится. Никаких планировщиков, никаких очередей — чистая физика.

Новые типы в Go 1.19+

Исторически атомарные операции в Go делались через функции (например, atomic.AddInt64(&counter, 1)). Это было чревато ошибками: программист мог случайно прочитать counter напрямую (вызвав Data Race).

В Go 1.19 завезли строго типизированные атомики:

Go
package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var views atomic.Int64 // Безопасная структура

    views.Add(1) // Увеличиваем
    views.Store(100) // Явно устанавливаем

    // Читаем безопасно
    fmt.Printf("Просмотры: %d\n", views.Load()) 
}

Compare-And-Swap (CAS)

Основа Lock-Free программирования — операция CompareAndSwap (CAS).
Она делает следующее: "Запиши в переменную значение new, только если прямо сейчас там лежит значение old".

Это позволяет строить сложные неблокирующие алгоритмы:

Go
var state atomic.Uint32

// Пытаемся перевести статус из 0 в 1.
// Вернет true, если мы были первыми, и false, если кто-то другой уже изменил состояние.
if state.CompareAndSwap(0, 1) {
    fmt.Println("Я захватил ресурс Lock-Free способом!")
}
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 CAS["Аппаратная инструкция CAS (LOCK CMPXCHG)"]
        Start["Ячейка памяти M"]:::warning --> Cmp{"Текущее значение == old?"}
        Cmp -->|"Да"| Set["M = new<br>Возврат true"]
        Cmp -->|"Нет"| Ret["Возврат false (значение M не изменилось)"]
    end
  

Итог

  1. sync.Once: Используем для синглтонов. Супер-быстро благодаря атомарной проверке флага перед мьютексом. Остерегайтесь рекурсивных вызовов (Deadlock).
  2. sync.Cond: Мощный механизм Broadcast() для пробуждения сразу всех ждущих горутин. Метод Wait всегда оборачиваем в цикл for: условие могло измениться до повторного захвата мьютекса.
  3. sync.Pool: Маст-хэв для высоконагруженных серверов (API, парсеры). Убирает мусор в куче, спасает от долгих пауз GC. Не забывайте очищать данные (сбрасывать длину слайсов) перед возвратом в пул.
  4. sync/atomic: Работает на уровне инструкций CPU. Быстрее мьютексов, но подходит только для простых примитивов (числа, указатели, флаги).

Мы погрузились в низкоуровневую синхронизацию, обсудили атомики, мьютексы и кеши логических процессоров. Но как формально язык программирования гарантирует, что одно ядро процессора увидит изменения в памяти, сделанные другим ядром? Почему процессор или компилятор имеют право переставлять наши строки кода местами, и как с этим жить?

В следующей статье мы разберем самый сложный академический концепт языка: 42. Race Condition и Go Memory Model.