Язык Go — leaky buffer: пул буферов без мьютексов
Паттерн leaky buffer в Go — пул переиспользуемых буферов через буферизованный канал и select с default. Никаких мьютексов, никакого явного учёта.
Содержание
Иногда инструменты конкурентного программирования упрощают задачи, которые к конкурентности напрямую не относятся. 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 buffer | sync.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— общий паттерн: попробовать операцию с каналом без риска заблокироваться