Обещание звучит коротко: сборщик мусора в Go останавливает мир редко и ненадолго, типовые паузы укладываются в доли миллисекунды, а остальная работа идёт параллельно с приложением. Обещание волнует всех, кто держит сервис с жёсткими требованиями к задержкам: биржевые шлюзы, игровые серверы и стриминговые бэкенды не прощают рывков времени ответа. Но обещание редко проверяют числом, и напрасно. Средняя пауза и хвост распределения живут в разных мирах, и высоконагруженный сервис ощущает именно хвост. Разберём, что сборщик делает на самом деле, измерим паузы на живой нагрузке и найдём ручки, которые удержат задержки в пределах бюджета.
Устройство сборщика мусора Go и происхождение остановок мира
В основе лежит параллельный трёхцветный маркирующий сборщик с фазами отметки и очистки. Полностью приложение останавливается только дважды за цикл: на мгновение перед стартом маркировки, когда нужно подготовить состояния и корни, и на короткое завершение маркировки. Само маркирование идёт одновременно с работой приложения и забирает по умолчанию 25% ресурсов процессора на время фазы. Очистка свободной памяти вообще не требует остановки.
Увидеть это своими глазами позволяет переменная окружения GODEBUG=gctrace=1. Реальная строка трассировки из запущенного процесса выглядит так:
gc 2 @0.030s 7%: 0.10+1.9+0.005 ms clock, 0.40+1.1/0.47/0.048+0.020 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 4 P
Расшифровка слева направо: второй цикл запущен через 30 миллисекунд после старта, на сборщик ушло 7% процессорного времени. Из трёх чисел первое это остановка перед маркировкой, 0.10 миллисекунды, второе сама параллельная маркировка на 1.9 миллисекунды, третье остановка на завершение, всего 5 микросекунд. Тройка 3->4->1 MB показывает кучу: было 3 мегабайта, в пике 4, живыми остались 1. Целевая куча 4 мегабайта, планировщик работал с 4 процессорами. Строка честно раскладывает цикл на составляющие, и видно главное: остановки мира занимают крошечную долю цикла, остальное приложение продолжает работать.
Замер пауз на лабораторной нагрузке вместо цифр из презентаций
Среднее в брошюре проверяется за десять минут. Нагрузка проста: восемь горутин в цикле выделяют блоки по 32 килобайта и удерживают только последние 64 из них, живое множество остаётся маленьким, темп аллокаций высокий. Код замера пауз читает кольцевой буфер из структуры MemStats:
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
n := int(ms.NumGC)
for i := n - 256; i < n; i++ {
p := ms.PauseNs[i%256]
total += p
if p > maxP {
maxP = p
}
}
Массив PauseNs хранит длительность последних 256 остановок мира, поэтому статистика собирается без внешних утилит. Результат на четырёх ядрах за шесть секунд работы: около 21.5 гигабайта аллокаций, то есть темп примерно 3.6 гигабайта в секунду, 660 циклов сборки. Средняя пауза из последних 256 циклов составила 652 микросекунды, минимум 100 микросекунд, максимум 3.65 миллисекунды. Сумма всех остановок за окно составила 167 миллисекунд на шесть секунд работы.
Картина складывается честная. Средняя пауза действительно держится ниже миллисекунды даже при давлении выше типичного продакшена, а короткие остановки уложились в сотню микросекунд. Но хвост вышел за три с половиной миллисекунды, и пользователь, попавший именно на неё, увидит задержку в разы выше обычной. Один сервис почти не заметит такого выброса, а сервис с бюджетом в 10 миллисекунд на запрос почувствует его сразу. Среднее лжёт меньше, чем о нём говорят, но продаёт не тот товар: в задержках продаётся перцентиль.
Рамку эксперимента стоит проговорить честно: темп в 3.6 гигабайта в секунду на четырёх ядрах выше типичного веб сервиса, где цикл аллокаций измеряется десятками и сотнями мегабайт в секунду. Реальный сервис с умеренным темпом увидит не 110 циклов в секунду, а единицы. Ценность замера в форме распределения и поведении хвоста, а не в абсолютных числах стенда.
Откуда берётся хвост задержек помимо самих остановок мира
Если бы хвост объяснялся только двумя короткими остановками, вопрос был бы закрыт. Источников больше, и официальное руководство называет их прямым текстом:
- короткие остановки мира на переходах между фазами маркировки и очистки;
- задержки планировщика, потому что на фазе маркировки сборщик забирает 25% ресурсов процессора;
- горутины принудительно помогают сборщику, когда темп аллокаций опережает расчётный;
- запись указателя во время маркировки требует дополнительной работы барьера записи;
- работающие горутины кратко приостанавливаются для сканирования их корней.
Третий пункт достоин отдельного внимания. Когда приложение аллоцирует быстрее, чем сборщик успевает отмечать, планировщик отправляет сами горутины помогать маркировке, и время помощи вычитается из полезной работы. На замере выше это особенно заметно: темп в гигабайты в секунду превышает возможности четырёх ядер, и CPU трассировка показывает рост assist времени. К этому добавляются эффекты за пределами сборщика: возврат памяти операционной системе вызывает страничные сбои при следующем обращении, лимиты контейнера режут доступные ядра, а конкуренты за процессор ставят цикл в очередь планировщика.
Ручки управления GOGC и мягкий лимит памяти GOMEMLIMIT
Первая ручка старая и простая. GOGC задаёт, сколько памяти сборщик готов отпустить приложению сверх живого множества перед следующей остановкой. Формула из руководства: целевая куча равна живой куче плюс произведение живой кучи с корнями на GOGC, делённое на 100. Пример оттуда же: живая куча 8 мегабайт, стеки горутин 1 мегабайт, указатели в глобальных переменных 1 мегабайт. При GOGC=100 приложение получит ещё 10 мегабайт до следующего цикла, суммарно 18. При GOGC=200 планка уходит на 20 мегабайт сверху, при GOGC=50 всего на 5.
Ручка управляет частотой, а не длиной отдельной остановки, и замер это подтверждает. На той же лабораторной нагрузке значение GOGC=800 сократило число циклов с 660 до 85 за то же окно, а GOGC=50 подняло до 1048. Средняя пауза при этом почти не сдвинулась: 721 микросекунда против 676 при исходных 652. Максимум тоже остался в одной зоне, от 3.6 до 4.8 миллисекунды. Плата за редкие циклы это память: чем выше планка, тем больше живёт между остановками.
Практика выбора простая. Значение по умолчанию 100 сбалансировано для памяти и процессора, и менять его без замера нет смысла. Сервисам с запасом памяти и чувствительностью к частоте циклов поднимают GOGC до 200 или 400, тяжёлым пакетным задачам с монотонной аллокацией хватает и этого. Тесные контейнеры идут в обратную сторону, до 50 и ниже: частые циклы как цена предсказуемого потребления. Любое изменение проверяется замером перцентиля.
Вторая ручка появилась в версии 1.19 и закрывает обратную сторону. GOMEMLIMIT задаёт мягкий потолок всей памяти рантайма, включая стеки и метаданные, и настраивается через переменную окружения или через debug.SetMemoryLimit в коде. Когда приложение приближается к потолку, сборщик начинает циклы чаще и раньше, чтобы не дать процессу уйти за границы контейнера. Лимит мягкий: при пробитии процесс не падает, но сборщик переходит в непрерывный режим, и если само живое множество превысило порог, настройка уже не спасает, нужен анализ кучи. В контейнерах потолок ставят чуть ниже лимита памяти, чтобы оставить запас на память вне кучи и на файлы mmap.
Снижение темпа аллокаций самое честное ускорение сервиса
Все ручки сборщика бледнеют перед главным рычагом: количеством мусора. Меньше аллокаций значит реже циклы, короче маркировка и меньше работы барьеров. Пул буферов убирает выделения в горячем цикле:
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(w io.Writer, data []byte) (int64, error) {
b := bufPool.Get().(*bytes.Buffer)
defer func() {
b.Reset()
bufPool.Put(b)
}()
b.Write(data)
return b.WriteTo(w)
}
Предвыделение ёмкости убирает цепочку перераспределений слайса, которая под нагрузкой удваивает копирование:
out := make([]Order, 0, len(ids))
for _, id := range ids {
out = append(out, load(id))
}
Поиск виновников начинается с двух инструментов. Анализ убегания значений показывает, какие аллокации ушли в кучу и почему:
go build -gcflags=-m ./...
Профиль аллокаций раскладывает мусор по стекам вызовов и суммам:
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/allocs
Практика из тяжёлых сервисов сходится в коротком списке: строковые конкатенации в горячем пути заменить на strings.Builder, разбор больших ответов перевести на потоки из bytes.Reader, повторяющиеся структуры отправить в sync.Pool, а на fmt.Sprintf в цикле просто закрыть глаза только после замера, доказавшего его невиновность.
Сборка строк в горячем пути собирается без единого выделения под промежуточные строки:
b := make([]byte, 0, 64)
b = strconv.AppendInt(b, userID, 10)
b = append(b, ' ')
b = strconv.AppendQuote(b, name)
w.Write(b)
Функции Append из пакета strconv пишут прямо в готовый буфер, и сборщик вообще не узнаёт об этой работе.
Наблюдение за сборщиком метрики профили и трассировка
Числа, собранные один раз на стенде, быстро устаревают вместе с кодом, поэтому наблюдение встраивается в сам сервис. Пакет runtime/metrics с версии Go 1.16 отдаёт показания без остановки мира, в отличие от тяжёлого ReadMemStats:
samples := []metrics.Sample{
{Name: "/gc/pauses:seconds"},
{Name: "/gc/heap/live:bytes"},
{Name: "/cpu/classes/gc/mark/assist:cpu-seconds"},
{Name: "/gc/cycles/total:gc-cycles"},
}
metrics.Read(samples)
hist := samples[0].Value.Float64Histogram()
liveHeap := samples[1].Value.Uint64()
assistCPU := samples[2].Value.Float64()
Гистограмма /gc/pauses:seconds показывает распределение пауз, из которого берут перцентили. Метка /cpu/classes/gc/mark/assist:cpu-seconds прямо измеряет, сколько процессорного времени приложение отдало на принудительную помощь маркировке: рост этой метрики под нагрузкой означает, что аллокации душат сервис. Число циклов отслеживает частоту, размер живой кучи связывает частоту с формулой цели. Перцентиль из гистограммы достаётся в несколько строк, без внешних конвейеров:
total := uint64(0)
for _, c := range hist.Counts {
total += c
}
limit := total * 99 / 100
seen := uint64(0)
for i, c := range hist.Counts {
seen += c
if total > 0 && seen >= limit && i+1 < len(hist.Buckets) {
upper := hist.Buckets[i+1]
fmt.Printf("p99 пауз ниже %.3f мс\n", upper*1000)
break
}
}
Массив Buckets на один длиннее Counts, поэтому верхняя граница интервала лежит в следующем элементе, а перцентиль получается накоплением счётчиков до порога. Именно p99 или p999 печатается в дежурные отчёты, потому что именно его замечает пользователь.
К ним прикладывается трассировка исполнения go tool trace, где фазы сборщика видны полосами на временной шкале, и профиль кучи /debug/pprof/heap, который отвечает на вопрос, что именно живёт в памяти.
Практические выводы для сервиса под реальной нагрузкой
До версии 1.19 популярным трюком был балласт: приложение при старте выделяло большой пустой массив, раздувая живую кучу и заставляя сборщик ходить реже. Мягкий лимит памяти закрыл потребность в трюке, и сегодня балласт остаётся памятником ушедшей эпохе в чужом коде. Современная последовательность решений выглядит скромнее: сначала замер и профиль, потом снижение аллокаций в горячем пути, потом GOGC под запас памяти, и только затем GOMEMLIMIT как страховка контейнера.
Полезно помнить и про смежное: с версии Go 1.25 рантайм учитывает лимит процессора контейнера и снижает GOMAXPROCS, от которого зависит число процессоров планировщика и работа сборщика в фазе маркировки. Проверки перед релизом сводятся к трём: перцентили задержек под реалистичным трафиком, счётчик циклов в метриках и доля assist-времени в процессорном бюджете. Если все три держатся, сборщик не источник проблем, и копать глубже ради цифр на слайдах не нужно.
Заявленные низкие паузы в Go не маркетинговая фантазия, а рабочий факт, подтверждаемый замером на глазах: сотни микросекунд в среднем и короткие остановки у большинства циклов. Хвост под давлением действительно выходит за рамки, и именно он отделяет демо от продакшена. Разница между ровными и дёргаными задержками лежит не в выборе сборщика, а в дисциплине вокруг него: меньше мусора в горячем пути, осознанные ручки цели и потолка, метрики, которые ловят деградацию раньше пользователя. Сборщик за свою работу отвечает, но рамки этой работы всегда задаёт сам разработчик.