Сборщик мусора Go: как работает и как его настраивать
Сервис на Go начал держать 4 ГБ RSS вместо ожидаемого гигабайта, а latency подскочила из-за пауз GC. Разбираем, как работает сборщик, за что отвечают GOGC и GOMEMLIMIT и когда их трогать.

Типичная картина: сервис на Go обрабатывает поток запросов, потребление памяти по метрикам держится в районе гигабайта по факту живых объектов, а RSS процесса стабильно висит на трёх-четырёх гигабайтах. Одновременно в трейсах видны периодические всплески latency на 5–20 мс, совпадающие с циклами сборки мусора. Оба симптома лечатся не переписыванием кода, а пониманием того, как устроен GC в Go и какими двумя-тремя ручками он управляется.
Ниже разбираем механизм сборщика в Go 1.22 (поведение GOMEMLIMIT актуально с 1.19), что реально означают паузы, и как крутить GOGC и GOMEMLIMIT под свою нагрузку. Замеры на вашем железе будут отличаться — по ходу указываю, что именно мерить.
Как работает сборщик Go
GC в Go — конкурентный трёхцветный mark-and-sweep со вставочным write barrier. Ключевое слово — конкурентный: основная работа по обходу графа объектов идёт параллельно с работой вашего приложения, а не в отдельной длинной stop-the-world паузе.
Упрощённо цикл состоит из фаз:
- Sweep termination — короткий STW, завершается зачистка предыдущего цикла.
- Mark — конкурентная маркировка живых объектов. Приложение работает, но часть процессорного времени уходит на assist: горутины, которые аллоцируют быстрее, чем GC успевает маркировать, помогают ему сами.
- Mark termination — второй короткий STW, фиксация результатов маркировки.
- Sweep — освобождение памяти, идёт лениво и конкурентно по мере новых аллокаций.
Две паузы STW в современном Go — это обычно десятки-сотни микросекунд, а не миллисекунды. Если вы видите паузы в единицы миллисекунд в трейсе — это чаще всего не STW, а assist: горутина встала помогать сборщику вместо того, чтобы делать полезную работу.
Память в Go освобождается не мгновенно и не обязательно возвращается операционной системе сразу. Высокий RSS при низком объёме живых объектов — нормальное поведение, а не утечка.
Почему RSS не падает
После sweep рантайм держит освобождённые страницы, чтобы переиспользовать их под будущие аллокации, а не отдавать ядру и запрашивать заново. Возврат в ОС происходит лениво (через madvise с MADV_DONTNEED или MADV_FREE в зависимости от платформы и настроек рантайма). Поэтому смотреть на пик RSS как на «сколько нужно памяти» — ошибка: он отражает исторический максимум аллокаций, а не текущую потребность.
Две ручки, которые реально работают
Настроек у GC по сути две, и они закрывают почти все сценарии.
GOGC — компромисс между CPU и памятью
Переменная GOGC (по умолчанию 100) задаёт, насколько может вырасти куча относительно объёма живых объектов после предыдущей сборки, прежде чем запустится следующий цикл. При GOGC=100 куча растёт вдвое: если после сборки осталось 500 МБ живых объектов, следующий цикл стартует примерно на 1 ГБ.
Логика простая:
- Больше GOGC (например 200, 400) — реже сборки, меньше нагрузка на CPU, но больше пиковая память.
- Меньше GOGC (например 50) — чаще сборки, ниже пиковая память, но больше CPU уходит на GC.
Задать можно через окружение или из кода:
// Go 1.22
import "runtime/debug"
// Вернуть новое значение GOGC в процентах, получить предыдущее.
// 200 означает: расти вдвое дольше обычного до следующей сборки.
previous := debug.SetGCPercent(200)
_ = previous
// Отключить триггер по росту кучи целиком (обычно нужно только
// в связке с GOMEMLIMIT, см. ниже).
debug.SetGCPercent(-1)GOMEMLIMIT — мягкий потолок памяти
Появился в Go 1.19. Задаёт мягкий лимит на общий объём памяти, которым управляет рантайм. По мере приближения к лимиту GC начинает запускаться чаще, независимо от GOGC, чтобы удержать потребление под потолком.
Слово «мягкий» важно: рантайм не гарантирует, что не превысит лимит, если живых объектов физически больше. Он не заменяет OOM-killer, а старается его не допустить, жертвуя процессорным временем на более частые сборки.
// Go 1.22
import "runtime/debug"
// 900 МБ в байтах. math.MaxInt64 (значение по умолчанию) снимает лимит.
debug.SetMemoryLimit(900 << 20)Через окружение то же самое: GOMEMLIMIT=900MiB. Поддерживаются суффиксы B, KiB, MiB, GiB.
Рабочий рецепт для контейнера
Классический сценарий — сервис в контейнере с лимитом памяти, скажем 1 ГБ. Без настройки Go не знает про cgroup-лимит и может разогнать кучу под пик нагрузки так, что ядро прибьёт процесс по OOM раньше, чем GC решит сработать.
- Выставите
GOMEMLIMITчуть ниже лимита контейнера — оставьте запас на стеки горутин, буферы и не-Go память. Для лимита 1 ГБ разумная отправная точка — 750–900 МБ. - Оставьте
GOGCпо умолчанию (100). Связка «GOMEMLIMIT + дефолтный GOGC» покрывает большинство случаев: пока памяти много, GC редкий; при приближении к потолку — учащается. - Отключать
GOGC(SetGCPercent(-1)) стоит только тогда, когда вы сознательно управляете памятью через один лишь лимит и понимаете риски. Для обычного веб-сервиса это не нужно.
Проверка результата: под нагрузкой RSS должен стабилизироваться ниже лимита контейнера, а частота циклов GC — вырасти при приближении к потолку. Смотрите на метрики, а не на догадки.
Что и чем мерить
Настраивать GC вслепую бессмысленно. Минимальный набор инструментов на Go 1.22:
GODEBUG=gctrace=1— печатает в stderr строку на каждый цикл GC: объём кучи до и после, длительность, доля CPU на GC. Быстрый способ увидеть частоту и стоимость сборок без внешних систем.runtime/metrics— программный доступ к точным метрикам рантайма, включая паузы и объёмы. Предпочтительнее устаревающегоruntime.ReadMemStatsдля новых интеграций.- runtime trace (
runtime/trace+go tool trace) — покажет, где именно горутины стоят в assist и как STW-паузы ложатся на запросы.
Ключевые величины, за которыми стоит следить в проде:
| Показатель | Откуда | О чём говорит |
|---|---|---|
| Частота циклов GC | gctrace / metrics | Слишком часто — мало памяти или низкий GOGC |
| Доля CPU на GC | gctrace | Выше ~10–15% — стоит поднять GOGC или память |
| Время в assist | runtime trace | Всплески latency от аллокаций в горутинах |
| Живые объекты после sweep | gctrace / metrics | База для расчёта GOMEMLIMIT и GOGC |
Где подход ломается
Тюнинг ручек не спасает в двух случаях, и важно уметь их распознавать.
Death spiral при жёстком GOMEMLIMIT. Если живых объектов почти столько же, сколько лимит, GC запускается почти непрерывно, чтобы удержать потолок, и сжигает CPU впустую, почти ничего не освобождая. Приложение стоит в сборке вместо работы. Признак — доля CPU на GC уходит за 30–50%. Лечение не в лимите, а в снижении объёма живых данных или увеличении памяти.
Давление аллокаций. Если код создаёт миллионы короткоживущих объектов в горячем пути, никакой GOGC не спасёт: сборщик просто не успевает, горутины уходят в assist. Здесь помогает не настройка рантайма, а sync.Pool, переиспользование буферов, предаллокация слайсов через make с capacity и профилирование через pprof с флагом -alloc_space. Это отдельная тема — здесь мы её только обозначаем.
Что делать дальше
Начните с наблюдения, а не с ручек. Включите GODEBUG=gctrace=1 на стейджинге под реалистичной нагрузкой и посмотрите на частоту сборок и долю CPU на GC. Если сервис в контейнере — выставьте GOMEMLIMIT ниже лимита пода и оставьте GOGC дефолтным. GOGC трогайте только когда gctrace показывает конкретную проблему: высокий CPU на GC при запасе памяти (поднять GOGC) или регулярные OOM (снизить). Следующий шаг после этого — профилирование аллокаций через pprof, потому что самый дешёвый GC-цикл тот, которого не было.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: A Guide to the Go Garbage Collector — go.dev, Package runtime/debug — go.dev
Частые вопросы
У меня Go 1.18, где GOMEMLIMIT?
Я выставил GOMEMLIMIT, а процесс всё равно упал по OOM. Почему?
Стоит ли отключать GC через SetGCPercent(-1)?
Я поднял GOGC, а latency не улучшилась. Что не так?
Как понять, что RSS высокий из-за GC, а не из-за утечки?
GOGC и GOMEMLIMIT конфликтуют?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.