GOGC=off. Часть 2, ручной GC и код без аллокацийВ первой части мы разобрали связку
GOGC=off и GOMEMLIMIT. Она подходит большинству сервисов и не требует переписывать код. Но есть задачи, где даже редкие циклы сборки в неудачный момент неприемлемы. Торговый бот в момент котировки, игровой сервер внутри тика, обработчик, у которого хвостовые задержки важнее среднего времени ответа.Для таких случаев есть два более жёстких варианта.
➡️ Вариант первый, ручные циклы в окнах простоя
Идея простая. Вы выключаете автоматический триггер и запускаете сборку сами, но только тогда, когда пауза никому не мешает. Между тиками, между батчами, вне торговой сессии:
debug.SetGCPercent(-1)
for {
batch := queue.Fetch()
process(batch) // на этом участке GC не вмешивается
if idle() {
runtime.GC()
debug.FreeOSMemory() // ещё и вернуть память операционной системе
}
}
runtime.GC() выполняет полный цикл синхронно и возвращает управление, когда он закончен. debug.FreeOSMemory() делает то же самое и дополнительно отдаёт освободившиеся страницы ОС, что полезно, если вы платите за резидентную память, а не за виртуальную.Выигрыш в том, что паузы переезжают туда, где они бесплатны. Хвост распределения задержек становится заметно ровнее, потому что в горячем пути нет ни маркировки, ни барьеров записи.
Риск в том, что окно простоя может не наступить. Всплеск нагрузки, и память растёт без ограничений. Поэтому такую схему почти всегда страхуют. Либо тем же
GOMEMLIMIT как аварийным тормозом, либо явной проверкой порога:var m runtime.MemStats
func maybeCollect(limit uint64) {
runtime.ReadMemStats(&m) // это stop the world, не вызывайте в горячем цикле
if m.HeapAlloc > limit {
runtime.GC()
}
}
runtime.ReadMemStats сам по себе останавливает мир, так что вызывать его надо по таймеру раз в секунду, а не на каждой итерации.➡️ Вариант второй, программа без аллокаций
Теоретически самый чистый путь. Если после инициализации программа не аллоцирует в куче вообще, сборщику нечего собирать и
GOGC=off безопасен буквально навсегда.Это означает выделение всех буферов на старте, переиспользование через
sync.Pool или собственные пулы, отказ от fmt в горячем пути, отказ от интерфейсов там, где значение убегает в кучу, работу со срезами фиксированной ёмкости вместо растущего append.Проверять это надо не на глаз, а инструментами:
go build -gcflags='-m' ./... // что именно убегает в кучу
go test -bench=. -benchmem // столбец allocs/op должен быть нулевым
И регрессионный тест, который падает, как только счётчик аллокаций перестал быть нулём:
func TestNoAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(1000, func() {
process(input) // горячий путь
})
if allocs != 0 {
t.Fatalf("ожидали 0 аллокаций, получили %v", allocs)
}
}Честное предупреждение. Держать такое состояние в живом проекте тяжело. Одна безобидная строка логирования, один
err.Error() в неудачном месте, и аллокации возвращаются. Обычно так пишут узкое ядро системы, а весь остальной сервис живёт с обычным GC.Когда всё это оправдано
Выключать сборщик стоит только после измерений. Профиль показывает заметное время в
runtime.gcBgMarkWorker, трассировка показывает всплески задержек ровно на циклах сборки, или программа настолько короткоживущая, что сборка просто не успевает окупиться.Во всех остальных случаях достаточно поднять
GOGC до 200 или 400. Это даёт большую часть выигрыша и не создаёт риска OOM.Долгоживущая программа с
GOGC=off возможна, если вы не оставляете систему без тормозов совсем. GOMEMLIMIT подходит почти всем и не требует менять код. Ручные вызовы runtime.GC() в окнах простоя дают самые ровные задержки, но нуждаются в страховке по памяти. Код без аллокаций работает вечно, но живёт только под постоянным присмотром бенчмарков.📍 Навигация: Вакансии • Задачи • Собесы
🐸 Библиотека Go-разработчика
#GoDeep