golang

Три способа оптимизировать работу с памятью на Go с помощью memory pools

  • четверг, 12 сентября 2024 г. в 00:00:07
https://habr.com/ru/companies/yadro/articles/842314/

Привет, Хабр! Меня зовут Александр Иванов, я разрабатываю средства управления сетевыми элементами сотовой связи и пишу на языке Go в YADRO. Однажды я работал над приложением, которое испытывало пиковые нагрузки каждые 10 минут, но выполнить обработку памяти быстро мешал Garbage Collector. Чтобы решить эту проблему, я изучил несколько способов реализации memory pool и провел испытания скорости работы. 

В этой статье расскажу, как уменьшить влияние GC на выполнение программы и покажу бенчмарки для разных memory pools. Объясню, какая из реализаций подойдет вашему проекту, а когда лучше не использовать memory pools и полагаться на встроенные механизмы рантайм-окружения Go.

В чем проблема

В разработку на Go я пришел после 20 лет программирования на С++ и Assembler, где моя работа была тесно связана с мультимедиа. В этой сфере данные должны обрабатываться быстро. Представьте, сколько понадобится вычислений, чтобы проигрывать видео в разрешении 4К, декодировать и показывать 60 кадров в секунду.

Поскольку я на практике знал, как такая оптимизация работает на низком уровне в программах на C/C++, мне предложили оптимизировать программу на Go. 

Однако в процессе выяснилось, что оптимизация на Go сильно отличается от оптимизации на С++. В случае с С++ я мог полагаться на то, что операционная система управляет памятью и доступом к железу. В Go это работает не совсем так.

Особенность приложения, которое мне предстояло оптимизировать, заключалась в том, что 90% времени оно не требовало больших ресурсов системы, но раз в 10 минут выдавало пиковую нагрузку на подсистему памяти. Чтобы обработать приходящие по сети мегабайты, требовалось распределить в оперативной памяти много структур данных. В этот момент и происходило то, к чему бывают не готовы большинство программистов.

Известно, что Garbage Collector срабатывает в следующих сценариях:

  • Регулярно — например, раз в две минуты — GC «подбирает» память, которая не используется.

  • Не по расписанию, а когда потребление памяти достигает максимального предела.

  • По запросу разработчика.

На графике видите пиковые нагрузки
На графике видите пиковые нагрузки

В моем случае, когда по сети приходило много данных на обработку, GC фиксировал пиковое потребление памяти, останавливал программу, освобождал память и продолжал выполнять программу как ни в чем не бывало.

Я еще не понимал, что происходит, и первым делом я запустил профилировщик — благо, Go предоставляет прекрасные инструменты наряду с компиляторами. Так я узнал, что дело не в какой-то медленной time-critical функции, которая потребляет много CPU и прочих ресурсов. Профилировщик показал, что вся программа останавливается на срабатывании GC.

С этим нужно было что-то делать. Бесполезно было ускорять вычисления, переписывая часть кода на SIMD-инструкции и снимая нагрузку с CPU. Основным бутылочным горлышком были особенности Go-рантайма по очистке неиспользуемой памяти. Не хотелось поднимать порог срабатывания GC и настраивать рантайм не по расписанию или заставлять GC запускаться чаще или реже, чем каждые две минуты. Так можно было еще больше навредить работе программы. 

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

В основе этой статьи — доклад с Go-митапа. 25 сентября YADRO проводит очередную встречу Go-разработчиков в Санкт-Петербурге. Инженеры расскажут, как выбрать тестовый фреймворк, упростить работу платформенной команды и отладить сервис на проде. Присоединиться можно офлайн и онлайн, участие бесплатное, но нужно зарегистрироваться.

Как Go работает с памятью и почему оптимизация все-таки нужна

Перед тем, как перейти к первому способу, рассмотрим, как Go и Garbage Collector распоряжаются памятью. Взгляните на примитивные функции GetBytes и PutBytes. Через GetBytes будем получать у рантайма некоторый слайс байт, а через PutBytes — возвращать.

func (p *NoPool) GetBytes() *[]byte {

	b := make([]byte, 0, ContentCap)

	return &b

}

func (p *NoPool) PutBytes(b *[]byte) {

	// just do nothing

}

Когда хочется распределить память в программе на Go, разработчики вводят переменную, а потом «забывают» про нее в расчете на то, что запустится GC и все почистит. Все с этим живут и радуются, пока не возникает необычных ситуаций, как, например, пиковые нагрузки в моем случае.

В коде GetBytes по-настоящему инициализирует слайс байт и возвращает указатель на него в вызывающий код, тогда как PutBytes лишь делает вид, что куда-то что-то возвращает. Такой подход полностью полагается на запуск GC, который определит, что на выделенный слайс больше никто не ссылается, и вернет память слайса в состояние «свободен для дальнейшего использования при распределении памяти».

Ниже видим нормальное поведение Go-программы, без оптимизаций.

Как используется память по умолчанию в промежутках между запусками GC
Как используется память по умолчанию в промежутках между запусками GC


Проблема в том, что в промежуток времени между запусками GC может потребоваться столько памяти, что занятый объем приблизится к пределу, заданному константой GOMEMLIMIT. 

Мы видим, как приложение забирает память, но GC еще не пришел, чтобы ее освободить. Память, которая освободится при очередном запуске GC, еще не доступна для переиспользования. То есть, несмотря на то, что она помечена как свободная, до запуска GC мы ее использовать не можем. Придется ждать, пока запустится GC по расписанию или по достижению GOMEMLIMIT, и вернет освобожденные участки памяти обратно в список доступных для использования.

Это нормально для нетребовательных программ, но, если нужно активно работать с памятью, способ без оптимизации не подходит. Единственный плюс такого решения — разработчику не нужно беспокоиться о памяти, ведь управление полностью лежит на GC. 

Как правило, оптимизация работы с памятью сводится к тому, чтобы выделить определенный объем, а затем собственными средствами распределять и освобождать фрагменты под переменные. Так снимается нагрузка с GC, и он не срабатывает по достижению предела заданного константой GOMEMLIMIT. Рассмотрим некоторые способы, как это сделать.

Первый способ: заводим Channel Pool 

Первый способ, который я нашел на просторах сети и успешно протестировал, заключается в том, чтобы использовать буферизованный канал для хранения распределенных фрагментов памяти. Назовем этот способ организации пула памяти Channel Pool. При такой реализации GetBytes будет дожидаться появления в канале освободившегося буфера, а PutBytes будет возвращать в канал буфер, который больше не используется:

var chanPool = make(chan *[]byte, BuffCount)

func (p *ChanPool) GetBytes() *[]byte {
	select {
	case b, ok := <-chanPool:
		if ok {
			return b
		}
	default:
	}
	b := make([]byte, 0, ContentCap)
	return &b
}

func (p *ChanPool) PutBytes(b *[]byte) {
	if cap(*b) > ContentCap {
		return
	}
	*b = (*b)[:0]
	chanPool <- b
	return
}

Учитывая, что размер буферизованного канала нельзя поменять после инициализации, нужно попытаться предугадать, сколько буферов памяти будет достаточно для работы программы. Если при создании канала не удалось угадать количество требуемых буферов, то рано или поздно программа будет испытывать голод, и pool, организованный на каналах, окажется тонким местом в алгоритме.

Как работает pool, организованный на каналах
Как работает pool, организованный на каналах

 Способ работает хорошо, но не без недостатков. 

Не всегда возможно заранее угадать, сколько потребуется буферов. Можно предположить, что 10 000 будет достаточно, но рано или поздно их не хватит, а ожидание 10 001 буфера приведет к остановке алгоритма до момента, пока в канал не вернется пустой фрагмент для дальнейшего переиспользования. 

Учитывая вышеперечисленное, я продолжил поиски более универсального способа, который бы не вынуждал разработчиков угадывать размер буферизированного канала.

Второй способ: храним память в sync.Pool

Я продолжал искать и обнаружил структуру Pool в пакете sync. Это список, который увеличивается динамически и может хранить любую сущность, в том числе, память, выделенную в heap.

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

type SyncPool struct{}

var heapBuffersPool = &sync.Pool{
	New: func() interface{} {
		b := make([]byte, 0, ContentCap)
		return &b
	},
}

func (p *SyncPool) GetBytes() *[]byte {
	return heapBuffersPool.Get().(*[]byte)
}

func (p *SyncPool) PutBytes(b *[]byte) {
	if cap(*b) > ContentCap {
		return
	}
	*b = (*b)[:0]
	heapBuffersPool.Put(b)
}

Фактически победили проблему, с которой столкнулись в реализации memory pool на Channel. 

Однако не забывайте: если возвращенная в sync.Pool память не используется в данный момент, то GC придет по расписанию и эту память подберет. Не стоит рассчитывать, что помещенный в sync.Pool кусок памяти будет там всегда. 

Поэтому не нужно хранить там данные. Некоторые разработчики попадают в эту ловушку. Они думают, что это ведь список, а раз так, то я туда сейчас положу память, а потом рано или поздно ее заберу, и все будет хорошо. Нет, так не будет.  Использовать sync.Pool как кэш нельзя!

На GIF-изображении вы видите метод new, который распределяет память. Он будет вызываться каждый раз, когда в sync.Pool не хватает элементов по вашему запросу. Как мы видим, одно победили, напоролись на другое. GС все-таки «обращает внимание» на эту память.

Третий способ: создаем memory arena

После знакомства с sync.Pool мы перевели приложение на него. Оно заработало лучше: мы больше не упирались в предел памяти, но нет предела совершенству. Go развивается, и в языке появилась экспериментальная возможность — memory arena. 

Memory arena — это кусок памяти, на который GC вообще не обращает внимания. Когда переходили на sync.Pool с памятью в heap, проблема была в том, что приходил GC и что-то подчищал. С sync.Pool с memory arena в качестве базы для памяти мы решили эту проблему, но есть нюансы.

Дело в том, что, если вы не угадали размер буфера, который запросили у memory arena, не получится просто взять и увеличить его. Вам нужно сразу загадать размер буфера, который вы будете запрашивать, чтобы любое ваше желание увеличить память вписывалось в этот размер.

type ArenaPool struct{}

var a = arena.NewArena()

var arenaBuffersPool = &sync.Pool{
	New: func() interface{} {
		b := arena.MakeSlice[byte](a, 0, ContentCap)
		return &b
	},
}

func (p *ArenaPool) GetBytes() *[]byte {
	return arenaBuffersPool.Get().(*[]byte)
}

func (p *ArenaPool) PutBytes(b *[]byte) {
	if cap(*b) > ContentCap {
		return
	}
	*b = (*b)[:0]
	arenaBuffersPool.Put(b)
}

Смотрите, как все просто. В принципе, никакой разницы между GetBytes и PutBytes для случаев sync.Pool в heap и в memory arena нет. Разница лишь в том, как мы реализуем метод new.

Бенчмарки для всех способов 

Для бенчмарков я запрашивал выделение памяти, но чтобы компилятор излишне не оптимизировал, выполнял рандомное изменение этого куска памяти, затем освобождал память в случае без оптимизации или возвращал эту память назад в memory pool. 

Видим, что обычная работа с памятью без экспериментов с memory pools на порядок медленнее, чем остальные способы. Memory pool, организованный на channels, чуть-чуть медленнее, чем два других. А sync.Pool на heap и sync.Pool на memory arena показали приблизительно одинаковые результаты. На скриншоте видно, что memory arena немного быстрее, но я бы списал это на погрешность измерений. 

Если посмотреть на flame-граф ниже, увидим, что способ без оптимизации съедает огромное количество памяти и многократно вызывает системные функции. Другие способы — sync.Pool на heap, sync.Pool на memory arena и channel-буферы — показывают схожие результаты по количеству вызовов функций и выделению памяти. 

Flame-граф
Flame-граф

Как вернуть память в pool

Если хранить в pool память как слайс, а во время использования, чтобы не выйти за его границы, менять размер слайса, в итоге слайсы могут оказаться разного размера или будут стремиться занять максимум пространства. Это касается всех memory pools, на чем бы они ни были организованы. И со временем вместо 100 буферов по 10 килобайт у вас может получиться pool из 100 буферов по 10 гигабайт. 

Это касается всех рассмотренных memory pools, на чем бы они ни были организованы. Об этом тоже всегда следует помнить, чтобы оптимизация использования памяти невольно не превратилась в ее жадное использование. 

Важно различать, какую память и в каком объеме мы возвращаем в любой memory pool. То есть, если безусловно возвращать разросшийся кусок памяти в memory pool, рано или поздно это приведет к тому, что потребление памяти выйдет за ожидаемые размеры памяти.

Что делать? Нужно чуть поменять PutBytes. Эмпирическим путем выведите предел памяти для вашего проекта, который будет позволено возвращать обратно в pool. И, сравнивая размер возвращаемой памяти с этой константой, просто забывайте про слишком большие буфера, которые пытаются вернуть в pool.

Именно для этого везде в PutBytes добавлена проверка на размер возвращаемого буфера.

if cap(*b) > ContentCap {
		return
	}

Кусок памяти, который не вернулся в pool, рано или поздно будет освобожден GC в общее использование. Ничего страшного не произойдет. Самое главное, что они не попадут в memory pool и не займут в итоге всю доступную память. 

Как выбрать реализацию memory pool

Чтобы выбрать реализацию, которая подойдет вашему проект, оцените, как вы работаете с памятью. Ниже я привел несколько примеров приложений, для которых подойдут разные memory pools. 

Без memory management

Если вы не используете память активно, можно вообще не прибегать к memory management. В таком случае, максимальное, что можно сделать — провести анализ memory escape и попытаться минимизировать их количество. Распределение памяти на стеке в десятки раз быстрее, чем в heap, поэтому такой подход — наиболее эффективный для оптимизации производительности программы. При этом вы избегаете излишних манипуляций с sync.Pool или других аналогичных способов.

Channel Pool 

Если память используется редко, но распоряжаться ей хочется рационально, выбирайте Channel Pool.

Допустим, вы пишете редактор с фреймбуфером видеокадров, который используется для плавной анимации. Для такой программы хорошо подойдет memory pool, организованный на каналах, потому что вы заранее знаете размер хранилища. А мы помним, что важное условие для Channel Pool — знать, сколько буферов распределить для памяти.

Channel Pool можно использовать в программах с фрагментарной нагрузкой на память. Пиковые нагрузки такие программы испытывают, но между ними проходит немало времени. И, чтобы не занимать лишнюю память постоянно, мы просто используем pool на каналах до тех пор, пока требуется память, а затем удаляем ее из канала и удаляем сам канал. Память в таком случае возвращается в ведение Garbage Collector.

sync.Pool + memory в heap

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

sync.Pool + memory arena

Рассмотрим применение этого подхода на примере видеоредактора. Программа показывает пользователю видеоряд, но не в реальном времени. Это происходит, когда пользователь идет покадрово: что-то правит, заполняет timeline спецэффектами и прочее. 

Для таких программ подходит sync.Pool на memory arena. Еще раз подчеркну, что memory arena хороша для случаев, когда мы не хотим, чтобы Garbage Collector подобрал эту память. А sync.Pool нас избавляет от попытки угадать заранее, сколько нам нужно буферов.

Еще memory arena подходит, когда мы точно знаем, какого размера буфер распределить. Допустим, кадр 4К — всегда кадр 4К. Не надо угадывать, распределить ли половину кадра или кадр плюс «хвостик». Поэтому memory arena — самый подходящий вариант.

Индивидуальный подход 

Но есть случаи, которые не попадают ни под один из рассмотренных вариантов — к счастью, их не очень много. Тут включается индивидуальный подход.

Допустим, некоторые разработчики говорят: мы используем memory arena, sync.Pool и Channelpool не подходят. В таком случае можно «попросить» из memory arena 2 ГБ памяти и организовать свой memory-менеджмент — главное подумать, как это сделать. Но, я уверен, результат оптимизации тоже получится хорошим.

Нюансы, которые стоит учесть в разработке своего memory pool

  • Garbage Collector очищает буферы в sync.Pool, не используемые на момент его запуска. 

  • Неконтролируемый рост размера буферов может привести к неэффективному использованию памяти. 

  • Возможна утечка памяти, если хранить не указатели на изменяемые объекты, а их копии. 

  • Буфер можно вернуть, даже если отдали его далеко: нужно просто «завернуть» в структуру еще и указатель на PutBytes.

  • Go-рантайм часто меняется: с каждой новой версией проверяйте выбранный подход бенчмарками.

Репозиторий с кодом, где я сравниваю реализации memory pools →