· 9 мин 👁 1.5k Средний

Язык Go — leaky buffer: пул буферов без мьютексов

Паттерн leaky buffer в Go — пул переиспользуемых буферов через буферизованный канал и select с default. Никаких мьютексов, никакого явного учёта.

каналыselectпул объектовбуфероптимизация памяти
Содержание

Иногда инструменты конкурентного программирования упрощают задачи, которые к конкурентности напрямую не относятся. Leaky buffer — хороший пример: пул переиспользуемых буферов без единого мьютекса.

Проблема: частое выделение памяти

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

Стандартное решение — пул буферов: берём из пула, используем, возвращаем. В большинстве языков пул требует мьютекса. В Go — буферизованного канала и двух select.

Реализация: клиент

var freeList = make(chan *Buffer, 100)  // пул: максимум 100 буферов в резерве
var serverChan = make(chan *Buffer)     // очередь для сервера

func client() {
    for {
        var b *Buffer

        select {
        case b = <-freeList:
            // в пуле есть готовый буфер — берём его, аллокации нет
        default:
            // пул пуст — выделяем новый буфер
            b = new(Buffer)
        }

        load(b)         // читаем следующее сообщение из сети в буфер
        serverChan <- b // отправляем серверу
    }
}

select с default — неблокирующий: если freeList пуст, мы не ждём, а сразу переходим к default и выделяем новый буфер. Клиент никогда не блокируется на пуле.

Реализация: сервер

func server() {
    for {
        b := <-serverChan // ждём следующего буфера от клиента

        process(b) // обрабатываем сообщение

        select {
        case freeList <- b:
            // вернули буфер в пул — будет переиспользован
        default:
            // пул заполнен (100 буферов) — отпускаем, сборщик мусора подберёт
        }
    }
}

Сервер тоже не блокируется: если пул полон — просто не возвращает буфер. Отсюда и название: буферы «утекают» в GC когда пул насыщен.

Почему это работает без мьютексов

Буферизованный канал сам по себе потокобезопасен — это его фундаментальное свойство. Он обеспечивает:

  • взаимное исключение: одновременно забрать один буфер может только одна горутина
  • ограничение размера: канал вместимостью 100 не позволит хранить больше 100 буферов

select с default добавляет неблокирующее поведение: операция либо выполняется сразу, либо пропускается. Никакого ожидания, никаких блокировок.

Сравнение с sync.Pool

В стандартной библиотеке есть sync.Pool — официальный пул объектов. Сравним подходы:

// sync.Pool — стандартный пул
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(Buffer) // вызывается когда пул пуст
    },
}

func clientWithPool() {
    for {
        b := bufPool.Get().(*Buffer) // берём из пула или создаём новый
        load(b)
        serverChan <- b
    }
}

func serverWithPool() {
    for {
        b := <-serverChan
        process(b)
        bufPool.Put(b) // возвращаем в пул
    }
}

Ключевые отличия:

Leaky buffersync.Pool
Максимальный размерограничен ёмкостью каналане ограничен
Поведение при полном пулетихо «утекает» в GCвсегда принимает объект
Поведение при GCбуферы в канале выживаютPool может очиститься GC
Применениекогда важно ограничить памятьобщий случай

Leaky buffer предпочтителен когда нужно жёстко ограничить количество буферов в памяти. sync.Pool — когда важнее переиспользование без ограничений.

Неблокирующий select: паттерн подробнее

select с default появляется в Go-коде часто — стоит разобрать его отдельно:

// неблокирующее чтение
select {
case v := <-ch:
    fmt.Println("получили:", v)
default:
    fmt.Println("канал пуст, не ждём")
}

// неблокирующая запись
select {
case ch <- value:
    fmt.Println("отправили")
default:
    fmt.Println("канал полон или нет читателей, не ждём")
}

// проверка закрытия канала без блокировки
select {
case v, ok := <-ch:
    if !ok {
        fmt.Println("канал закрыт")
    } else {
        fmt.Println("значение:", v)
    }
default:
    fmt.Println("данных пока нет")
}

Без default select блокируется до тех пор пока хотя бы один case не станет готов. С default — выполняется немедленно.

Итоги

  • Leaky buffer — пул буферов на основе буферизованного канала: берём если есть, выделяем если нет, возвращаем если места хватает
  • select с default делает операции с каналом неблокирующими: выполнить сейчас или сразу перейти к default
  • Буферизованный канал потокобезопасен сам по себе — мьютекс для пула не нужен
  • «Утечка» в GC при полном пуле — это фича, а не баг: автоматически регулирует размер пула под нагрузку
  • sync.Pool — стандартная альтернатива: без ограничения размера, но GC может очистить пул между вызовами
  • Неблокирующий select — общий паттерн: попробовать операцию с каналом без риска заблокироваться