Go & Rust
July 1

Zero Allocations в Go с использованием sync.Pool: преимущества, выгоды и бенчмарки

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

Что такое sync.Pool?

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

Использование sync.Pool снижает нагрузку на сборщик мусора. При частом создании и уничтожении объектов сборщик мусора может испытывать высокую нагрузку, что приводит к паузам и снижению производительности. Переиспользование объектов через sync.Pool уменьшает количество операций сборки мусора. Это также повышает производительность за счет уменьшения числа аллокаций памяти в куче, что ускоряет выполнение программы. Переиспользование объектов может значительно сократить объем используемой памяти, особенно в приложениях с высокой интенсивностью создания объектов.

Внутреннее устройство sync.Pool

sync.Pool состоит из нескольких ключевых компонентов для обеспечения высокой производительности и безопасности.

Основные компоненты

type Pool struct {
    noCopy noCopy

    local     unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
    localSize uintptr        // size of the local array

    victim     unsafe.Pointer // previously used pool for once-only cleanup
    victimSize uintptr        // size of victim array

    New func() interface{} // функция, создающая новый объект, если пул пуст
}

local и localSize используются для хранения локальных пулов, привязанных к конкретным процессорам (P). victim и victimSize используются для хранения старых локальных пулов, которые могут быть очищены сборщиком мусора. New — это функция, которую пользователь может определить для создания новых объектов, если пул пуст.

type poolLocalInternal struct {
    private interface{} // Can be used only by the respective P.
    shared  []interface{} // Can be used by any P.
}

type poolLocal struct {
    poolLocalInternal

    Pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte // предотвращение ложного разделения кэша
}

Каждый процессор (P) в Go имеет свой локальный пул для минимизации синхронизации между потоками. private используется для хранения единственного объекта, доступного только для текущего P. shared содержит массив объектов, которые могут быть использованы любым P. Pad используется для выравнивания данных и предотвращения ложного разделения кэша.

Получение и возврат объектов

Методы Get и Put обеспечивают получение и возврат объектов в пул.

func (p *Pool) Get() interface{} {
    if race.Enabled {
        race.Disable()
    }
    l, pid := p.pin()
    x := l.private
    l.private = nil
    if x == nil {
        l, _ = p.pin()
        for i := 0; i < 100; i++ {
            if len(l.shared) == 0 {
                break
            }
            x, l.shared = l.shared[len(l.shared)-1], l.shared[:len(l.shared)-1]
            if x != nil {
                break
            }
        }
        if x == nil && p.New != nil {
            x = p.New()
        }
    }
    runtime_procUnpin()
    if race.Enabled {
        race.Enable()
    }
    return x
}

Метод пытается получить объект из private текущего P. Если private пуст, метод пытается получить объект из shared. Если shared также пуст, создает новый объект с помощью p.New, если эта функция определена.

func (p *Pool) Put(x interface{}) {
    if x == nil {
        return
    }
    if race.Enabled {
        race.Disable()
    }
    l, pid := p.pin()
    if l.private == nil {
        l.private = x
        runtime_procUnpin()
        if race.Enabled {
            race.Enable()
        }
        return
    }
    l.shared = append(l.shared, x)
    runtime_procUnpin()
    if race.Enabled {
        race.Enable()
    }
}

Метод пытается поместить объект в private текущего P. Если private уже занят, помещает объект в shared.

Пример использования sync.Pool с очисткой структуры

Когда вы используете sync.Pool для переиспользования объектов, важно очищать их перед возвратом в пул, чтобы избежать неожиданных состояний при следующем извлечении объекта. Это особенно важно, если объект содержит данные, которые не должны быть переиспользованы. Рассмотрим пример использования sync.Pool для переиспользования структур, моделируя сценарий обработки сетевых соединений:

package main

import (
    "sync"
    "testing"
)

type Connection struct {
    ID   int
    Data []byte
}

var connPool = sync.Pool{
    New: func() interface{} {
        return &Connection{
            Data: make([]byte, 4096),
        }
    },
}

func handleRequest(conn *Connection) {
    // Обработка данных соединения
    for i := 0; i < len(conn.Data); i++ {
        conn.Data[i] = byte(i % 256)
    }
}

func resetConnection(conn *Connection) {
    conn.ID = 0
    for i := range conn.Data {
        conn.Data[i] = 0
    }
}

func BenchmarkWithSyncPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        conn := connPool.Get().(*Connection)
        handleRequest(conn)
        resetConnection(conn) // Очистка структуры перед возвратом в пул
        connPool.Put(conn)
    }
}

func BenchmarkWithoutSyncPool(b *testing.B) {
    for i := 0; i < b.N; i++ {
        conn := &Connection{
            Data: make([]byte, 4096),
        }
        handleRequest(conn)
    }
}

Запуск и результаты бенчмарков

go test -bench=. -benchmem

Результаты могут выглядеть следующим образом:

goos: darwin
goarch: amd64
pkg: yourpackage
BenchmarkWithSyncPool-8          1000000          210 ns/op        0 B/op        0 allocs/op
BenchmarkWithoutSyncPool-8       1000000          350 ns/op     4096 B/op        1 allocs/op

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