Память и сборщик мусора в Go: как не сойти с ума от GC
Вы когда-нибудь задумывались, куда уходит память в вашем Go-приложении? Особенно когда оно начинает жрать гигабайты, а вы всего лишь парсите JSON? Знакомо? Добро пожаловать в мир сборщика мусора (GC) в Go — таинственного механизма, который либо спасает ваш проект, либо превращает его в тормозную платформу. Сегодня мы разберем, как работает память в Go, как GC решает, что пора убирать, и как не попасть в ловушку "ожидания STW".
Если вы пишете на Go хотя бы пару месяцев, вы уже сталкивались с этим: внезапные паузы, рост потребления памяти, загадочные gc pause в профилях. Давайте заглянем под капот и разберемся, как управлять памятью в Go осознанно.
Как устроена память в Go: куча и стек
Прежде чем говорить о сборщике мусора, давайте поймем, где вообще живут данные в Go. В отличие от языков вроде C, где вы сами отвечаете за malloc/free, Go использует автоматическое управление памятью. Но это не значит, что всё валится в одну кучу.
В Go есть два основных региона памяти:
- Стек (stack) — быстрая, автоматически управляемая память для локальных переменных. Каждая горутина имеет свой стек (начинается с 2 КБ и динамически растет).
- Куча (heap) — медленная, но гибкая память для данных, которые "переживают" вызов функции.
Главный вопрос: что попадает в кучу, а что остается на стеке? За это отвечает escape analysis — умный механизм компилятора Go. Он анализирует, "убегает" ли переменная за пределы функции. Если да — она едет в кучу. Если нет — остается на стеке, и GC о ней даже не узнает.
Пример:
package main
func createPointer() *int {
x := 42
return &x // x "убегает" — попадает в кучу
}
func noEscape() int {
x := 42
return x // x остается на стеке
}
В первом случае компилятор видит, что указатель на x возвращается наружу, и размещает переменную в куче. Во втором — всё просто и быстро. Это и есть управление памятью в Go на микроуровне.
Сборщик мусора в Go: как он работает
Теперь о главном герое — сборщике мусора (GC). В Go используется параллельный, триколорный, неинкрементный сборщик с алгоритмом "mark-and-sweep". Звучит страшно? На самом деле всё логично.
GC в Go проходит три фазы:
- Mark Setup (STW) — короткая пауза для включения write barrier. Обычно занимает 10-30 микросекунд.
- Marking (Concurrent) — основная работа: GC обходит граф объектов, помечая живые. Выполняется параллельно с вашим кодом.
- Sweep (Concurrent + STW) — очистка мертвых объектов. Частично делается параллельно, но финальная синхронизация может вызвать короткую паузу.
Ключевая особенность GC в Go — он не делает дефрагментацию. Это значит, что после сборки память может фрагментироваться, но Go использует битовые карты для отслеживания свободных блоков, что делает выделение памяти быстрым.
Пример работы GC можно увидеть в логах:
GODEBUG=gctrace=1 go run main.go
gc 1 @0.013s 4%: 0.031+0.42+0.017 ms clock, 0.25+0.10/0.42/0.0+0.13 ms cpu, 4->4->2 MB, 5 MB goal, 8 P
Здесь: 0.031+0.42+0.017 ms — это STW + Mark + STW. 4->4->2 MB — размер кучи до, после mark и после sweep.
Почему GC тормозит: STW и паузы
Главный враг производительности — STW (Stop The World). Хотя Go старается минимизировать паузы, они всё равно случаются. Особенно если:
- У вас много указателей (GC должен обойти все объекты)
- Куча большая (больше объектов — дольше marking)
- Вы создаете много мусора (high allocation rate)
В Go 1.19+ паузы STW обычно не превышают 100-500 микросекунд, но если у вас гигабайты данных, может быть и больше. Как проверить? Используйте pprof или trace:
import (
"runtime/pprof"
"os"
)
func main() {
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// ваш код
}
Анализируйте: если видите много времени в runtime.gcAssistAlloc или runtime.mallocgc — ваш GC под нагрузкой.
Настройка GC: GOGC и память
Сборщик мусора в Go настраивается через переменную окружения GOGC. Она определяет, насколько сильно может вырасти куча перед следующей сборкой. По умолчанию GOGC=100 — это значит, что GC запустится, когда размер кучи увеличится на 100% относительно предыдущего размера после сборки.
Примеры настройки:
GOGC=200— реже сборки, но больше пиковое потребление памятиGOGC=50— чаще сборки, меньше память, но больше CPUGOGC=off— отключить GC (только для тестов!)
Как выбрать? Если ваше приложение чувствительно к задержкам (например, API), лучше уменьшить GOGC (50-75). Если память критична, а CPU не жалко — оставить 100 или увеличить до 150.
Но есть нюанс: управление памятью в Go не ограничивается GOGC. Вы можете влиять на аллокации прямо в коде.
Практические советы: как уменьшить нагрузку на GC
Хватит теории. Вот 5 конкретных приемов, которые помогут вашему приложению меньше грузить сборщик мусора:
- Используйте sync.Pool — переиспользуйте объекты вместо создания новых.
- Избегайте указателей в горячих путях — передавайте структуры по значению, если они маленькие.
- Предпочитайте массивы и слайсы с фиксированной длиной — меньше реаллокаций.
- Не смешивайте маленькие и большие объекты —