Память освобождает сборщик мусора: в Go нет free и ручного освобождения (встроенный delete лишь удаляет ключ из map), а где живёт значение (на стеке или в куче), решает компилятор. Утечка в Go всё равно возможна: достаточно держать достижимым то, что больше не нужно.
new(T), &T{}, make и append выделяют память; всё, на что больше никто не ссылается, освободит один из следующих циклов GC.
runtime.GC() запускает полную сборку и блокирует вызывающего до её конца. Ему место в тестах и бенчмарках; в проде рантайм сам решает, когда собирать, а принудительный цикл только жжёт CPU.
debug.FreeOSMemory() собирает мусор и возвращает свободные страницы ОС; это полезно один раз после известного всплеска, но не по таймеру.
Типичные утечки: горутины, навсегда заблокированные на канале, map в роли кэша без вытеснения, маленький подслайс, держащий огромный базовый массив, глобальный реестр, который никто не чистит.
GoSenior
Рантайм, планировщик и GC
G-M-P, вытеснение, escape-анализ, GOGC, GOMEMLIMIT, pprof
Закрыто вопросов
0 / 25
- 0Повторить
- 6Часто спрашивают
Вопросы
Рантайм выполняет множество горутин (G) на нескольких потоках ОС (M). Поток может исполнять код Go, только пока держит P (один из GOMAXPROCS слотов планировщика со своей очередью), поэтому P ограничивает параллелизм и делает планирование почти без блокировок.
Составные части
G: горутина: её стек, сохранённые регистры и состояние (выполняется, готова, ждёт).
M: поток ОС. M может быть гораздо больше, чем P: потоки, заблокированные в системных вызовах или cgo, существуют, но P не держат.
P: логический процессор: локальная очередь до 256 горутин, слот runnext для только что разбуженной горутины и свой кэш аллокаций (mcache).
Как выбирается следующая горутина
M с P берёт runnext, потом свою локальную очередь. Каждый 61-й раз он сначала заглядывает в глобальную очередь, чтобы там никто не голодал.
Если локальная очередь пуста, он пробует глобальную, затем сетевой поллер, затем кражу работы (work stealing): забирает половину очереди случайного другого P.
Переполненная локальная очередь отправляет половину горутин в глобальную.
Зачем нужен P
До Go 1.1 была одна глобальная очередь под одной блокировкой, и все потоки за неё дрались. Когда очереди и кэши живут внутри P, большая часть планирования трогает только локальные данные, а поток, заблокированный в системном вызове, отдаёт свой P (вместе с очередью и кэшем) другому потоку, и работа продолжается.
runtime.Gosched() кладёт текущую горутину в глобальную очередь и даёт поработать другой; реальному коду он почти никогда не нужен.
Вытесняющий, начиная с Go 1.14. Раньше горутину можно было остановить только на вызове функции, поэтому этот цикл навсегда занимал единственный P и горутина main так и не просыпалась; теперь рантайм прерывает цикл сигналом, и main печатает done.
Кооперативно, до 1.14: проверка жила в прологе функции, там, где Go смотрит, не пора ли расти стеку. Рантайм просил горутину уступить, вызывая срабатывание этой проверки, так что цикл без вызовов просьбу никогда не видел. Такой цикл мог ещё и навсегда задержать фазу GC с остановкой мира.
Асинхронное вытеснение, 1.14+: sysmon замечает горутину, которая работает дольше 10 мс, и шлёт её потоку SIGURG; обработчик сигнала сохраняет состояние и уходит в планировщик.
По-прежнему не вытесняется: горутина внутри вызова cgo или блокирующего системного вызова (её P вместо этого передают другому потоку) и короткие внутренние участки рантайма.
GODEBUG=asyncpreemptoff=1 отключает сигнал; так старое зависание и воспроизводят.
Вытеснение делает рантайм честным, но не снижает ваши задержки: горутина, нагружающая CPU, всё равно отрабатывает свои 10 мс целиком, прежде чем очередь за ней на этом P получит ход.
sysmon представляет собой служебный поток рантайма, который крутится в цикле без P и следит за планировщиком снаружи: забирает P у потоков, застрявших в системных вызовах, вытесняет слишком долго работающие горутины и не даёт остановиться сетевому поллеру и GC.
Забирает P: поток, заблокированный в системном вызове или cgo дольше одного тика sysmon (20 мкс), теряет свой P, если другие горутины ждут; если очередь пуста, то через 10 мс. Его получает другой поток, и остальные горутины продолжают работать.
Вытесняет: горутину, которая занимает CPU дольше 10 мс, просят уступить, а начиная с Go 1.14 прерывают сигналом.
Опрашивает сеть: если 10 мс никто не проверял epoll, он делает это сам и возвращает в очередь горутины, чьи сокеты готовы.
Запускает GC: если сборки не было две минуты, он её начинает.
Между проверками он спит 20 мкс, пока есть работа, и до 10 мс, когда процесс простаивает.
Без P он работает намеренно: он должен действовать именно тогда, когда все P заняты или застряли, а поскольку пользовательский код Go он не выполняет, ему не нужны ни очередь, ни кэш аллокаций.
Она напечатает runtime: goroutine stack exceeds 1000000000-byte limit и fatal error: stack overflow и завершится с кодом 2. Стек удваивался до 512 МиБ; следующее удвоение, до 1 ГиБ, превысило бы лимит в 1 ГБ, а фатальная ошибка не является паникой, поэтому отложенные функции не выполняются.
Рост: пролог каждой функции проверяет, сколько осталось места. Когда оно кончается, рантайм выделяет стек вдвое больше, копирует кадры и переписывает все указатели на старый. Поэтому стек горутины может вырасти намного больше 8 МБ стека потока.
Сжатие: во время GC горутина, использующая меньше четверти стека, получает стек вдвое меньше, так что один глубокий всплеск не держит память вечно.
Предел: 1 000 000 000 байт на 64-битных системах (250 000 000 на 32-битных), меняется через debug.SetMaxStack. Стеки удваиваются, поэтому наибольший, которого достигает горутина, составляет 512 МиБ; при копировании нужны одновременно старый и новый стек. За пределом рантайм не может безопасно продолжать и завершает весь процесс.
recover останавливает только паники. Переполнение стека, concurrent map writes и нехватка памяти относятся к фатальным ошибкам, которые никто не перехватывает.
Исправление: базовый случай или явный стек (слайс) для входных данных, глубину которых вы не контролируете.
GOMAXPROCS задаёт число P, то есть сколько потоков могут одновременно выполнять код Go. До Go 1.24 включительно по умолчанию бралось число ядер машины, а лимит CPU контейнера игнорировался; начиная с Go 1.25 значение по умолчанию учитывает лимит cgroup в Linux.
Старая проблема: под с лимитом 2 CPU на 64-ядерной ноде работал с 64 P. Вместе они выбирали квоту CFS в начале каждого 100-миллисекундного периода, и ядро тормозило процесс до следующего: всплески p99 при нормальной средней загрузке CPU.
Старое решение: пустой импорт go.uber.org/automaxprocs, который на старте читает квоту cgroup и вызывает runtime.GOMAXPROCS.
Go 1.25+: по умолчанию берётся меньшее из числа ядер и лимита CPU cgroup, и рантайм обновляет значение, когда лимит меняется. Для этого нужен go 1.25 или новее в go.mod и не заданный явно GOMAXPROCS. Requests по CPU не учитываются, только limits.
GC отдаёт четверть GOMAXPROCS под фоновую разметку, так что неверное значение искажает и поведение GC.
Это не лимит потоков и не ограничение одновременной работы: горутины, заблокированные в системных вызовах, работают на дополнительных потоках, а для ограничения параллелизма нужен семафор или пул воркеров.
Подключите net/http/pprof на закрытом порту, снимите под реальной нагрузкой go tool pprof -http=:8081 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30' и читайте top и flame graph по накопленному времени.
Включение: пустой импорт регистрирует /debug/pprof/* в http.DefaultServeMux; отдавайте его на 127.0.0.1 или внутреннем порту, но не на публичном слушателе.
Flat и cum: flat показывает время в самой функции, cum включает всё, что она вызывает. Начните с наибольшего cum в вашем коде, затем найдите под ним листовую функцию с большим flat; на flame graph широкий блок означает горячий путь.
Цена: профайлер снимает стек примерно 100 раз в секунду, так что накладные расходы составляют несколько процентов CPU; эндпоинт можно оставить включённым и снимать профиль только по необходимости.
Чего он не видит: CPU-профиль видит только время на процессоре. Горутины, которые ждут блокировки, каналы или ввод-вывод, в нём не видны; для них есть профили block и mutex и trace.
До и после: -base before.pb.gz after.pb.gz показывает только разницу, так вы подтверждаете, что исправление помогло.
Другие профили лежат на том же эндпоинте: heap, goroutine, allocs, block, mutex.
Escape analysis (анализ побега) представляет собой решение компилятора для каждого значения: может ли оно пережить функцию, которая его создала. Если нет, оно остаётся на стеке и ничего не стоит при освобождении; если может, то уходит в кучу. Здесь на стеке остаётся только local.
local: &Point{} не покидает функцию, и -m сообщает &Point{...} does not escape. Взятие адреса само по себе кучу не означает.
returned: указатель переживает кадр (moved to heap: p).
stored: переменную уровня пакета могут прочитать когда угодно позже (&Point{...} escapes to heap).
printed: fmt.Println принимает ...any, и компилятор не может доказать, что значение интерфейса остаётся локальным (n escapes to heap). Вот почему логирование на горячем пути аллоцирует.
Другие частые причины: переменная, захваченная замыканием, которое выполняет горутина, указатель, отправленный в канал, make с неконстантным размером (с Go 1.25 маленькие остаются на стеке), значение, слишком большое для кадра стека.
Проверяйте через go build -gcflags=-m; -m=2 объясняет каждое решение. Этот вывод остаётся утверждением компилятора, а доказательством служит allocs/op из go test -bench . -benchmem.
В Go конкурентный, неперемещающий трёхцветный mark-and-sweep: он размечает достижимые объекты, пока ваши горутины работают, останавливает мир только дважды за цикл, обычно заметно меньше чем на миллисекунду, и лениво подметает (sweep) память по мере её повторного использования.
Цикл
Завершение подметания (остановка мира): доделать прошлое подметание, включить барьер записи.
Разметка (конкурентно): начать с корней (глобальные переменные и стеки горутин) и идти по указателям. Фоновые воркеры занимают четверть GOMAXPROCS; горутины, которые в это время аллоцируют, могут заставить помогать (mark assist).
Завершение разметки (остановка мира): доделать разметку, выключить барьер.
Подметание (конкурентно): неразмеченные объекты становятся свободными слотами, которые освобождаются до повторного использования их спана.
Три цвета и барьер
Белый объект ещё не найден, серый найден, но его указатели не просмотрены, чёрный просмотрен. Разметка вычерпывает серое множество; всё, что в конце осталось белым, и есть мусор.
Ваш код меняет указатели, пока идёт разметка. Он мог бы спрятать единственный указатель на белый объект внутрь уже чёрного, и живой объект был бы освобождён. Барьер записи сообщает о записях указателей во время разметки, чтобы этого не случилось; с Go 1.8 его гибридная форма ещё и избавляет от повторного просмотра стеков в конце.
Запуск и устройство
Цикл начинается, когда куча доходит до цели, заданной GOGC и GOMEMLIMIT, после двух минут без сборки или по runtime.GC().
Он не поколенческий и не уплотняющий: escape analysis держит многие короткоживущие значения вне кучи, аллокатор с классами размеров ограничивает фрагментацию без перемещения объектов, а поколенческой схеме барьер записи был бы нужен постоянно.
GOGC задаёт, насколько куча может вырасти сверх живых данных до следующего цикла: цель составляет примерно живая куча + (живая куча + стеки + глобальные переменные) × GOGC/100. При стандартных 100 и 100 МБ живых данных следующий цикл начнётся около 200 МБ.
Поднять (GOGC=200, 400): меньше циклов и меньше CPU на GC, но за счёт памяти; пик кучи около трёх или пяти размеров живого набора.
Опустить: меньше памяти, чаще циклы, больше CPU.
**GOGC=off**: сборка по отношению не запускается вообще; имеет смысл только вместе с GOMEMLIMIT.
Риск: отношение ничего не знает о вашем контейнере. Всплеск, удваивающий живую кучу, удваивает и цель, и процесс может получить OOM-kill раньше следующего цикла. GOMEMLIMIT и есть тот потолок, который на это реагирует.
Из кода это debug.SetGCPercent, который возвращает предыдущее значение.
Когда GC ест много CPU, сначала смотрите на темп аллокаций (-alloc_space в pprof): настройка GOGC меняет память на CPU, а меньшее число аллокаций экономит и то и другое.
gctrace=1 печатает в stderr строку на каждый цикл GC (время пауз и разметки, размеры кучи и долю CPU на GC); schedtrace=1000 раз в секунду печатает состояние планировщика. Вместе они отличают проблему GC от проблемы планирования вообще без профайлера.
Как читать строки из примера:
gc 42 @12.513s 4%: 42-й цикл, 12,5 с после старта; GC потратил 4% CPU с момента запуска. Как правило, доля, которая держится выше 10%, указывает на большой объём аллокаций.
0.031+3.1+0.052 ms clock: завершение подметания с остановкой мира, конкурентная разметка, завершение разметки с остановкой мира. Горутины замирают только на первой и последней.
0.25+0.40/6.1/9.0+0.42 ms cpu: средняя тройка показывает mark assist, фоновую и простаивающую разметку. Большой assist означает, что обработчики запросов платят за сборщик.
118->121->62 MB, 124 MB goal: куча на старте цикла, в его конце и объём живого после разметки; затем цель этого цикла.
Строки SCHED показывают gomaxprocs, idleprocs, threads и глобальную и локальные очереди. Очереди, которые не рассасываются, значат «готовые горутины есть, свободного CPU нет»; постоянно растущий threads указывает на блокирующие системные вызовы или cgo.
scheddetail=1 включайте ненадолго: он печатает каждый G, M и P на каждом тике.
data[:256] создаёт лишь новый заголовок над многомегабайтным массивом, который вернул io.ReadAll, поэтому каждое 256-байтовое превью держит достижимой всю загрузку: map растёт на размер каждого файла, а не на 256 байт.
Почему: переслайсинг копирует заголовок среза (указатель, длину, ёмкость), но не байты. GC освобождает аллокацию целиком, поэтому один живой указатель внутрь неё держит её всю, включая запас ёмкости, который нарастил ReadAll.
Исправление: bytes.Clone копирует 256 байт в новый маленький массив, и data становится мусором, как только saveUpload возвращается. На 100 загрузках по 2 МиБ живая куча до исправления около 200 МиБ, а после меньше 1 МиБ.
Та же ловушка со строками: s[:8] от большой строки тоже разделяет её байты; копию делает strings.Clone.
Как найти: heap-профиль inuse_space винит io.ReadAll, место аллокации, а не map, которая держит память. Если в живой куче доминирует место, чьи данные должны жить недолго, ищите, кто хранит их подслайс.
Как проверить тестом: HeapAlloc после runtime.GC() не растёт на множестве загрузок.
Поток держат только последние два случая. Горутина, ждущая канал или сокет, паркуется, и её поток выполняет другие горутины; блокирующий системный вызов или вызов cgo держит поток всё время ожидания, а рантайм передаёт P этого потока другому потоку.
**Канал, мьютекс, time.Sleep: чистые события рантайма: горутину помечают как ждущую и снимают с потока, системного вызова нет вообще.
Сокет: сетевые дескрипторы неблокирующие и зарегистрированы в netpoller** (epoll в Linux, kqueue в macOS, IOCP в Windows). Чтение, которое заблокировалось бы, паркует горутину; когда приходят данные, поллер снова делает её готовой. 20 000 простаивающих соединений означают 20 000 припаркованных горутин и ни одного лишнего потока.
**Обычный файл, cgo**: дисковые файлы в Linux нельзя опрашивать, поэтому поток стоит внутри read(2). Если вызов не вернулся за несколько десятков микросекунд и другие горутины ждут, sysmon отдаёт его P другому потоку, чтобы остальная программа продолжала работать.
Цена: каждый одновременный блокирующий вызов занимает ещё один поток. Простаивающие потоки сохраняются для повторного использования, а не уничтожаются, а после 10 000 потоков процесс падает (debug.SetMaxThreads меняет этот лимит).
Значит, 20 000 горутин, читающих файлы с медленного диска, могут дать 20 000 потоков. Ограничивайте блокирующие системные вызовы и вызовы cgo семафором по тому, сколько диск или библиотека реально тянут параллельно.
Компилятор встраивает функцию, тело которой стоит не более 80 узлов синтаксического дерева. Выигрыш не только в сэкономленном вызове: встроенное тело заново анализируется внутри вызывающего кода, поэтому указатель, убегающий из маленького конструктора, может остаться на стеке вызывающего.
Что мешает: тело сверх бюджета, defer, оператор go, recover или //go:noinline; -gcflags=-m=2 печатает причину, например function too complex: cost 106 exceeds budget 80.
Встраивание не только листовых функций (mid-stack inlining): с Go 1.12 встраиваются и функции, которые сами вызывают другие функции, так что мелкие обёртки и конструкторы исчезают.
Связь с побегом: NewPoint, возвращающий &Point{}, внутри себя даёт escapes to heap, а в месте вызова, где его встроили, даёт does not escape. Поэтому крошечные конструкторы ничего не стоят, а //go:noinline в бенчмарке может добавить аллокацию.
PGO: с CPU-профилем, сохранённым как default.pgo в пакете main (подхватывается автоматически с Go 1.21), горячие места вызова получают гораздо больший бюджет, а горячие вызовы через интерфейс девиртуализируются; обычный выигрыш составляет несколько процентов CPU.
Другие проходы: свёртка констант, удаление мёртвого кода, устранение проверок границ (-gcflags=-d=ssa/check_bce перечисляет оставшиеся проверки) и распределение регистров в SSA-бэкенде.
Встраивание увеличивает бинарник; бюджет отражает догадку компилятора об этом компромиссе. -gcflags=-m показывает can inline NewPoint и inlining call to NewPoint.
Аллокатор Go, построенный по образцу TCMalloc, округляет каждый маленький объект до одного из примерно 68 классов размеров и выдаёт его из кэша своего P без блокировок. Только когда этот кэш пустеет, он обращается к общим структурам, а объекты больше 32 КБ берутся прямо из кучи страниц.
Уровни
**mcache**: по одному на P, со спаном для каждого класса спанов. Большинство аллокаций просто берут следующий свободный слот в этом спане: без блокировки и без атомиков.
**mcentral**: по одному на класс спанов (класс размера, с указателями или без), общий: выдаёт спан со свободными слотами тому mcache, у которого они кончились.
**mheap: куча страниц (страницы по 8 КБ): нарезает новые спаны из памяти, полученной от ОС, и напрямую обслуживает большие объекты.
Tiny-аллокатор**: объекты без указателей меньше 16 байт упаковываются вместе в один 16-байтный блок.
Фрагментация
Каждый спан хранит объекты одного размера, поэтому освободившийся слот ровно подходит под следующую аллокацию этого класса. Внешняя фрагментация остаётся низкой без уплотнения; платой служат потери на округление внутри объекта, которые выбором классов держат около 12,5%.
Спаны ещё и разделены по тому, есть ли в типе указатели, поэтому GC пропускает память без указателей целиком.
Пустые страницы возвращает ОС фоновый scavenger.
На практике: аллокация дешёвая, но не бесплатная (десятки наносекунд плюс работа GC потом), структура на байт больше класса размера теряет разницу, а данные без указателей дешевле для сборщика.
Стоимость GC зависит от темпа аллокаций, а не от размера кучи, поэтому лечится это сокращением аллокаций: найдите главные места через -alloc_space, исправьте их и докажите каждую правку цифрой allocs/op до и после.
Выделяйте заранее то, что можно посчитать: make([]T, 0, n) и make(map[K]V, n). Слайс, дорастающий через append до 10 000 элементов, по дороге перевыделяется и копируется около двадцати раз.
Избегайте скрытых копий и упаковки: string(b) и []byte(s) копируют; fmt.Sprintf превращает каждый аргумент в интерфейс. Используйте strconv.AppendInt, strings.Builder или bytes.Buffer.
Следите за побегом: интерфейсы и замыкания на горячем пути часто выталкивают значения в кучу; -gcflags=-m покажет какие.
Переиспользуйте черновые буферы через sync.Pool: взяли, Reset, вернули. Не возвращайте буфер, раздутый одним огромным запросом, иначе пул будет держать эту память.
Выбирайте раскладку без указателей: []Point вместо []*Point означает одну аллокацию вместо n, и сборщику нечего сканировать.
Доказывайте дважды: go test -bench . -benchmem в несколько прогонов со сравнением через benchstat, затем -alloc_space и CPU на GC в проде. Если аллокации ниже не опустить, более высокий GOGC купит CPU за счёт памяти.
buf.Bytes() возвращает срез над собственной памятью буфера из пула, а отложенный Put отдаёт этот буфер следующему вызывающему, пока текущий ещё держит срез. Reset и Encode другого запроса перезаписывают байты, и в ответ уходит чужой JSON.
Почему: Bytes() не копирует: срез действителен только до следующего изменения буфера. defer срабатывает при возврате из encode, так что буфер уже лежит в пуле, и следующий Get на том же P сразу его выдаёт.
Почему баг прячется: с одним вызывающим никто не зовёт Get в промежутке, результат верный, и юнит-тесты проходят. Проявляется только под конкурентной нагрузкой: чаще как перемешанные или обрезанные тела ответов и редко как падение.
Исправление: скопировать данные до возврата буфера: bytes.Clone(buf.Bytes()) (или []byte(buf.String())), одна копия. Это одна аллокация точного размера; пул по-прежнему экономит на повторном росте буфера. Ещё лучше кодировать сразу в io.Writer и ничего не возвращать.
Как поймать: go test -race с несколькими горутинами, которые вызывают encode и сохраняют результаты, сообщает о гонке данных (data race) между Reset или Write и чтением у вызывающего. Правило для ревью: ничто, взятое из объекта пула (Bytes(), подслайс, указатель на поле), не должно жить дольше Put.
RSS не совпадает с кучей. В него входят ещё стеки горутин, служебные структуры рантайма и свободные страницы кучи, которые аллокатор оставил себе, а не вернул ОС, тогда как профиль кучи показывает только выборку живых объектов на момент последней сборки.
**runtime/metrics раскладывает по полкам**: /memory/classes/heap/objects:bytes (живые объекты плюс ещё не подметённый мусор), /memory/classes/heap/free:bytes (свободные, но всё ещё отображённые страницы), /memory/classes/heap/stacks:bytes (стеки горутин).
Свободные страницы: после всплеска куча сжимается, но страницы остаются у процесса. Фоновый scavenger возвращает их постепенно; debug.FreeOSMemory() делает это сразу, что уместно после известного разового всплеска и расточительно по таймеру.
Стеки: десятки тысяч горутин или несколько, когда-то ушедших в глубокую рекурсию, держат память стеков, которой нет ни в одном профиле кучи.
Вообще не память Go: аллокации cgo и файлы, отображённые в память, входят в RSS, но ни в одно число рантайма. Если /memory/classes/total:bytes сильно меньше RSS, ищите там.
**GOMEMLIMIT** заставляет scavenger активнее возвращать память по мере приближения к лимиту.
Обычно это пик, а не утечка: один огромный запрос раздул кучу, и процесс оставил страницы себе. Ограничьте размер тела запроса и обрабатывайте большие данные потоком, а не читайте целиком.
Убедитесь, что живая куча растёт от цикла к циклу GC, затем сравните два профиля **inuse_space, снятых с разницей в несколько минут: то, что выросло, и есть утечка. alloc_space считает всё выделенное с запуска, поэтому он объясняет нагрузку GC на CPU, а не утечки.
Подтвердить**: живая куча после GC (третье число кучи в gctrace или метрика /gc/heap/live:bytes) растёт при ровной нагрузке. Высокий, но ровный RSS указывает на другую проблему.
**inuse_space**: байты, живые на момент последней сборки, по местам аллокации; вид по умолчанию. Добавьте ?gc=1 к URL, чтобы сначала прошла сборка.
**alloc_space: всё выделенное с запуска. Сервис может перемалывать гигабайты в секунду при ровных 30 МБ живой кучи; этот вид нужен, когда GC жжёт CPU.
Проверьте и горутины**: число горутин, растущее вместе с трафиком, выдаёт самую частую утечку: каждая застрявшая горутина держит свой стек и всё, на что ссылается. Начиная с Go 1.27 есть профиль goroutineleak (/debug/pprof/goroutineleak; в 1.26 он был экспериментальным под GOEXPERIMENT=goroutineleakprofile): он перечисляет горутины, заблокированные на том, до чего больше никто не дотянется; утечки через глобальные переменные или готовые к запуску горутины он может не заметить.
Обычные причины: map в роли кэша без вытеснения (map никогда не сжимается), подслайс, держащий большой базовый массив, горутины, заблокированные навсегда.
Профиль кучи показывает, где память выделили, а не кто её держит; от этого места идите по ссылкам к владельцу.
Поставьте **GOMEMLIMIT** чуть ниже лимита контейнера (около 1,8 ГиБ для пода на 2 ГиБ) и оставьте GOGC=100. Это мягкий потолок: по мере приближения общей памяти Go к нему рантайм собирает чаще, вместо того чтобы дать куче дорасти до OOM-killer.
Что считает: всё, что отображает рантайм Go: кучу, стеки горутин и служебные структуры (/memory/classes/total:bytes минус возвращённая память). Не считает аллокации cgo, бинарник и page cache; отсюда и запас.
Мягкий: если живая куча действительно больше лимита, Go никого не убивает: рантайм собирает почти непрерывно, GC ограничен примерно 50% CPU, и сервис еле ползёт. Алертите по CPU на GC и не считайте лимит запасом мощности.
**GOGC=off плюс лимит: куча свободно растёт до лимита и собирается только там. Хорошо для пропускной способности батч-задачи, а для сервиса это вечная работа на грани.
Не автоматически**: в отличие от GOMAXPROCS начиная с Go 1.25, рантайм сам не читает лимит памяти cgroup. Задайте переменную в деплойменте или вызовите debug.SetMemoryLimit на старте.
Балласт был приёмом времён до 1.19: большая аллокация, которую никто не трогал, раздувала живую кучу, чтобы GOGC вычислил цель побольше. Он поднимал пол и никогда не был потолком, так что всплеск всё равно шёл прямо в OOM.
Mark assist заставляет горутину, которая аллоцирует во время разметки, сделать долю разметки, прежде чем её аллокация вернётся. Когда темп аллокаций обгоняет фоновых воркеров, обработчики запросов тратят время на разметку вместо обслуживания: пропускная способность падает, задержки взлетают.
Долг: рантайм распределяет оставшуюся разметку по байтам, которые программа ещё может выделить до цели кучи. Если выделили n байт, то должны пропорциональный объём сканирования, и платите сразу.
Где это видно: в CPU-тройке gctrace, 0.5+900/400/0+0.8 ms cpu, 900 мс приходится на assist против 400 мс фоновой разметки. Есть и метрика /cpu/classes/gc/mark/assist:cpu-seconds; go tool trace показывает отрезки assist прямо внутри ваших обработчиков.
Почему это спираль: assist тормозит обработчики, запросы копятся в очереди, больше запросов в работе держат больше живой памяти, и следующий цикл начинается раньше. Ограничитель CPU для GC держит сборщик около 50% CPU, что спасает процесс, но не задержки.
Выход: снизить темп аллокаций на горячем пути, ведь долг линеен по байтам, выделенным за цикл. Затем дать куче запас (выше GOGC или GOMEMLIMIT, который ещё не упёрся) и проверить, что циклы никто не форсирует: runtime.GC() в обработчике или GOMEMLIMIT ниже живой кучи.
Ограничение параллелизма на входе тоже разрывает спираль: чем меньше запросов в работе, тем меньше живая куча.
Никто не обходит кучу с остановленным миром: разметка конкурентная, а две паузы за цикл делают небольшую фиксированную работу, обычно заметно меньше миллисекунды. С кучей растут объём разметки, то есть CPU на GC, и запас памяти, которого требует GOGC.
Стоимость разметки зависит от числа указателей, по которым надо пройти, а не от байтов: 40 ГБ map[string]*Entry означают сотни миллионов объектов на каждый цикл, а 40 ГБ []byte лишь горстку.
Запас: при GOGC=100 цель примерно вдвое больше живой кучи, 80 ГБ. Для большого кэша ставьте GOMEMLIMIT около объёма памяти машины и поднимайте GOGC, чтобы циклы были редкими.
Память без указателей не сканируется никогда: спаны типов без указателей пропускаются, как и map, у которых ни в ключах, ни в значениях нет указателей. На этом построены bigcache и freecache: одна большая байтовая арена плюс map из хэшей ключей в смещения.
Запись во время разметки стоит assist, поэтому путь записи должен аллоцировать мало.
Так что Go будет разумным выбором для большого кэша, если данные хранятся без указателей; больно становится на миллионах мелких объектов, набитых указателями.
Go 1.26 сделал Green Tea алгоритмом разметки по умолчанию: он ставит в очередь целые спаны (страницы по 8 КБ) и сканирует размеченные объекты страницы вместе, а не по одному. Настройки те же; CPU на GC обычно падает на 10–40%.
Почему быстрее: классическая разметка прыгает между объектами в порядке указателей, и большая часть её времени уходит на промахи кэша. Работа по страницам даёт локальность памяти, а на новых x86-процессорах сканирование ещё и использует векторные инструкции.
Что затрагивает: маленькие объекты, из которых состоит большая часть кучи; большие размечаются по-старому.
Что осталось прежним: конкурентная трёхцветная разметка, барьер записи, GOGC, GOMEMLIMIT, две короткие паузы. Код менять не нужно.
Обновление: сравните CPU на GC (/cpu/classes/gc/total:cpu-seconds), размер кучи и задержки до и после. Если что-то ухудшилось, соберите с GOEXPERIMENT=nogreenteagc и сообщите о проблеме: по заметкам к Go 1.26 этот отказ планируют убрать в 1.27, так что проверьте, принимает ли его ваш тулчейн.
В Go 1.25 Green Tea был доступен как эксперимент (GOEXPERIMENT=greenteagc). Советы он не меняет: меньше аллокаций и меньше указателей по-прежнему важнее любых улучшений сборщика.
CPU-профиль снимает только выполняющиеся горутины, поэтому ожидание в него не попадает никогда. Ожидание видно в профилях block и mutex, оба выключены по умолчанию, и в профиле горутин; время «готова, но не запущена» видно только в трассе выполнения.
Профиль block: runtime.SetBlockProfileRate(rate): где горутины ждали каналы, select, sync.Cond и блокировки. Значение 1_000_000 нс даёт примерно одно событие на миллисекунду ожидания.
Профиль mutex: runtime.SetMutexProfileFraction(5): конкуренция за блокировки, приписанная коду, который держал блокировку, а это и есть код, который придётся менять.
Профиль горутин: /debug/pprof/goroutine?debug=2 не требует настройки: каждый стек с состоянием и временем ожидания. Четыре тысячи горутин в одном и том же acquire пула означают очередь, а не проблему CPU.
Трасса выполнения: /debug/pprof/trace?seconds=5, открывается через go tool trace: шкала времени каждой горутины, задержки планировщика, системные вызовы, фазы GC и assist. Только она показывает горутину, готовую к работе, когда свободного P нет, или запрос, застрявший за GC.
Flight recorder (Go 1.25): trace.FlightRecorder держит последние секунды трассы в памяти, так что её можно сохранить в момент, когда замечен медленный запрос, а не пытаться поймать его вживую.
Оба режима профилирования чего-то стоят на горячем пути: берите выборку, а не ставьте 1 везде.
Вызов cgo держит свой поток ОС всё время выполнения и стоит десятки наносекунд против примерно одной для вызова Go. GC не видит, что сохранил C, поэтому Go-память, за которую держится C, могут освободить или переиспользовать, а значение на стеке горутины переезжает при росте стека.
Цена и планирование
Каждый вызов переключается на системный стек и помечает P как находящийся в системном вызове; медленный вызов теряет P в пользу другого потока через sysmon, так что 1 000 одновременных медленных вызовов C дают 1 000 потоков.
Вызовы из C обратно в Go ещё намного дороже.
Платит и сборка: для кросс-компиляции нужен C-тулчейн, бинарник линкуется динамически, если не задан CGO_ENABLED=0, а ошибки памяти в C не видны ни race detector, ни GC.
Правила для указателей
Go-память, переданная в C, действительна только на время вызова; сохранять её дольше C может, только если объект закреплён через runtime.Pinner (Go 1.21+). Проверка cgocheck по умолчанию ловит часть нарушений во время выполнения.
uintptr хранит просто целое число, а не ссылку: GC может освободить объект и переиспользовать его память, а значение на стеке горутины переезжает, когда стек растёт и копируется.
Тесты проходят, потому что между сохранением адреса и чтением ничего не происходит. В проде между ними случается цикл GC или рост стека, и C пишет в память, которая уже принадлежит чему-то другому.
Если C должен долго владеть буфером, выделите его через C.malloc и освобождайте явно.