23. sync.Pool под капотом

В предыдущих статьях (21. Аллокатор памяти Go. mcache, mcentral, mheap и 22. Tiny Allocator и маленькие объекты) мы подробно убедились, что рантайм Go делает всё возможное, чтобы выделение памяти работало предельно быстро и без межпоточных блокировок. Но какой бы изощренной ни была внутренняя архитектура аллокатора, фундаментальная истина компьютерных систем неизменна: самая быстрая аллокация в куче — это та, которой удалось избежать.

Если ваш высоконагруженный сетевой HTTP/gRPC сервис на каждый входящий запрос выделяет буфер bytes.Buffer размером 10 КБ для парсинга JSON, Escape Analysis гарантированно отправит этот объект в кучу. При нагрузке в 10 000 RPS сервис начнет генерировать 100 Мегабайт мусора в секунду! Сборщик мусора (GC) неизбежно начнет захлебываться, а ядра процессора будут тратить до четверти своей вычислительной мощности не на обработку бизнес-логики, а на непрерывную разметку и очистку памяти.

Чтобы разорвать этот порочный круг, создатели Go добавили в стандартную библиотеку пакет sync. В нем содержится структура данных, позволяющая многократно переиспользовать уже выделенную память в обход регулярного цикла аллокатора кучи, действуя в идеальной гармонии с планировщиком горутин — sync.Pool.


Главное заблуждение: Это не кэш

Первое и главное правило системного инженера: sync.Pool — это пул временных объектов, а вовсе не долговременный кэш (Cache).

В классическом прикладном кэше (будь то внутрипроцессный LRU-кэш или распределенный Redis) вы помещаете объект и вправе рассчитывать, что сможете прочитать его спустя секунды или минуты.
В sync.Pool рантайм оставляет за собой абсолютное право безвозвратно уничтожить любые накопленные объекты в любой момент времени (и, как мы увидим ниже, сборщик мусора регулярно делает это).

В sync.Pool категорически нельзя хранить сетевые соединения с базой данных (для этого предназначен пул соединений sql.DB), открытые TCP-сокеты, дескрипторы файлов или данные пользовательских сессий. sync.Pool спроектирован ровно для одной задачи: разгрузить аллокатор памяти и снизить давление на Garbage Collector за счет рециркуляции дорогостоящих структур данных.


Архитектура под капотом

Как спроектировать универсальный пул объектов, к которому одновременно обращаются десятки тысяч параллельных горутин, и при этом не превратить его в узкое горлышко (bottleneck) на глобальном мьютексе?

Создатели Go вновь применили проверенный принцип изоляции и локальности ресурсов (аналогично архитектуре mcache).

Если заглянуть в исходный код рантайма в файле src/sync/pool.go, мы обнаружим, что sync.Pool — это не банальный кольцевой буфер или стек под мьютексом. Это распределенная иерархическая структура, жестко привязанная к логическим процессорам рантайма P (см. 9. Scheduler Go. G, M, P и work stealing).

Внутри структуры sync.Pool содержится массив local (указатель на непрерывную область памяти). Размер этого массива строго равен значению GOMAXPROCS. Каждому логическому процессору P в системе принадлежит ровно один локальный сегмент этого массива — структура poolLocal.

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 POOL ["sync.Pool"]
        subgraph P1_LOCAL ["poolLocal для P1"]
            PRIV1["private<br>Быстрый слот - 1 объект"]:::process
            SHARED1["shared<br>Lock-free очередь"]:::data
        end

        subgraph P2_LOCAL ["poolLocal для P2"]
            PRIV2["private<br>Быстрый слот - 1 объект"]:::process
            SHARED2["shared<br>Lock-free очередь"]:::data
        end

        P1_LOCAL ~~~ P2_LOCAL
    end

    G1["Горутина на P1"] -->|"1. Прямой доступ"| PRIV1
    G1 -->|"2. Берет из головы"| SHARED1

    G2["Горутина на P2"] -.->|"3. Work Stealing<br>Крадет с хвоста"| SHARED1
  

Структура poolLocal

Каждый локальный сегмент poolLocal разделен на два принципиально разных уровня хранения:

  1. private: Одиночный слот (указатель на ровно один объект). Это самый быстрый и желанный путь (Fast Path). Поскольку доступ к этому слоту разрешен исключительно горутине, исполняющейся на текущем логическом процессоре P, работа с private не требует никаких мьютексов и даже атомарных инструкций процессора.
  2. shared: Двусвязная lock-free очередь (структура poolChain, состоящая из кольцевых буферов poolDequeue). Сюда перемещаются объекты, если слот private уже занят. Текущий процессор P добавляет (push) и извлекает (pop) объекты из «головы» своей очереди, используя дешевые локальные операции.

Механика работы: Get()

Когда пользовательский код вызывает метод pool.Get(), рантайм проходит строгий многоступенчатый алгоритм — от самого дешевого локального доступа до глобальных операций:

  1. Pin (Привязка): Вызывающая горутина временно «прикалывается» к текущему логическому процессору P (системный вызов runtime_procPin). На время выполнения критической секции планировщик рантайма блокирует вытеснение (preemption) горутины на другие ядра, что гарантирует целостность локального указателя P.
  2. Fast Path (Private Slot): Рантайм проверяет поле private текущего P. Если там присутствует объект, мы забираем его, обнуляем слот private, вызываем runtime_procUnpin и немедленно возвращаем объект. Затраты: буквально пара инструкций без единого атомарного барьера.
  3. Shared Head: Если слот private пуст, горутина обращается к голове своей локальной очереди shared. Здесь применяется атомарный CAS, защищающий структуру от гонок со сторонними ворами.
  4. Work Stealing (Кража у соседей): Если локальная очередь shared текущего процессора также пуста, горутина не спешит аллоцировать память. Она поочередно обходит структуры poolLocal всех остальных процессоров P в системе и пытается украсть готовый свободный объект с «хвоста» (tail) их shared-очередей. Это выравнивает нагрузку между ядрами.
  5. Victim Cache: Если кража объектов у соседей не увенчалась успехом, рантайм обращается к специальному Жертвенному кэшу (о нем ниже).
  6. New(): Если все пулы во всей системе абсолютно пусты, вызывается зарегистрированная пользователем функция pool.New(), выполняющая честную аллокацию нового объекта в куче.

Механизм метода Put() зеркален: сначала мы пытаемся сохранить возвращаемый объект в свободный слот private текущего процессора P. Если он уже занят — объект проталкивается в голову локальной очереди shared.


Жертвенный кэш (Victim Cache) и сборка мусора

До релиза Go 1.13 поведение sync.Pool вызывало справедливую критику системных программистов: при каждом цикле Сборщика мусора (GC) все накопленные объекты во всех пулах на всех процессорах безусловно зачищались.
Если под высокой нагрузкой GC запускался несколько раз в секунду, sync.Pool оказывался перманентно опустошенным, и метод Get() превращался в конвейер аллокаций через New(), полностью обесценивая идею пула.

Начиная с Go 1.13 разработчики внедрили элегантный механизм Victim Cache (Жертвенный кэш).
Теперь каждый экземпляр sync.Pool оперирует двумя независимыми наборами массивов:

  • local: активный рабочий пул для текущего поколения.
  • victim: жертвенный теневой пул предыдущего поколения.

Что происходит в момент наступления фазы Stop-The-World сборщика мусора?

  1. GC не уничтожает объекты из sync.Pool напрямую.
  2. Вместо этого рантайм безжалостно очищает старый массив victim (все невостребованные объекты окончательно отдаются сборщику).
  3. Затем весь текущий массив local целиком переносится на место victim (victim = local).
  4. Сам массив local обнуляется и становится чистым листом для нового поколения.
sequenceDiagram
    autonumber
    participant App as Приложение
    participant Local as local (Активный)
    participant Victim as victim (Жертвенный)
    participant GC as Garbage Collector

    App->>Local: Put(obj)
    Note over Local: Объекты активно циркулируют
    GC->>Victim: 1. Очистка старого victim
    GC->>Victim: 2. Ротация: victim = local
    GC->>Local: 3. Обнуление: local = nil
    Note over Local,Victim: local пуст, объекты лежат в victim
    App->>Local: Get() -> пусто!
    App->>Victim: Get() из victim -> УСПЕХ!
    Note over Local: Объект возвращается в активный local
  

Что это дает на практике?
Если после сборки мусора сервис продолжает активно запрашивать буферы через pool.Get(), рантайм обнаруживает пустой local, но тут же находит теплый объект в victim. Он извлекает его оттуда и возвращает в активный local!

Объекты, находящиеся в постоянном обращении, мигрируют между local и victim, безопасно переживая сборки мусора и оставаясь в памяти неограниченно долго. Те же объекты, которые перестали запрашиваться программой (например, после спада пиковой нагрузки), тихо и экологично утилизируются сборщиком мусора на следующем цикле.


Mechanical Sympathy. Правила безопасного использования

Поскольку интерфейс sync.Pool оперирует абстрактным типом any, мы обязаны помнить о скрытых рисках Interface Boxing (см. 36. Interface Boxing и hidden allocation) и соблюдать строгую культуру обращения с памятью.


Итог

  1. sync.Pool — высокопроизводительный рантайм-инструмент повторного использования памяти, нивелирующий затраты на аллокации в куче.
  2. Он обеспечивает масштабируемость без глобальных блокировок за счет разделения данных (poolLocal) по логическим процессорам P.
  3. Для балансировки применяется алгоритм Work Stealing: незанятые ядра процессора забирают объекты из очередей соседних P.
  4. Двухуровневая архитектура Victim Cache гарантирует, что активно востребованные объекты живут неограниченно долго, не очищаясь при первом же цикле сборки мусора.
  5. Работа с пулом требует безупречной дисциплины: обязательного сброса состояния (Reset) и жесткого контроля предельной емкости (Capacity Limit).

В ходе обсуждения пулов мы неоднократно упоминали Сборщик Мусора (GC) как некую неумолимую силу, периодически очищающую память кучи. Мы научились предотвращать аллокации (Escape Analysis) и прятать буферы от утилизации (sync.Pool).

Но пришло время заглянуть в самое сердце управления памятью: как именно устроен Сборщик Мусора в Go? Каким образом он сканирует гигабайты памяти в реальном времени, не замораживая отзывчивость бэкенда на сотни миллисекунд?

В следующей главе мы откроем архитектурный фундамент подсистемы GC:
24. Сборщик мусора Go. Общая архитектура