Перейти к содержимому

Память, стек, куча, сборщик мусора

Память Go-процесса делится на несколько принципиально разных областей. Статические сегменты (.text, .rodata, .data, .bss) выделяются загрузчиком при старте и живут всю программу — там лежат код, константы и глобальные переменные. Стеки горутин — по одному на горутину, начинаются с 2 КБ, растут копированием и освобождаются детерминированно: выход из функции — это просто сдвиг указателя стека. Куча — общая область, которой управляет собственный аллокатор рантайма, и именно её обслуживает сборщик мусора. Плюс есть служебная off-heap память самого рантайма (метаданные mspan, битовые карты, профили) и память, выделенная через cgo, — GC про неё ничего не знает.

Аллокатор Go — потомок TCMalloc. Он трёхуровневый: mcache привязан к P и не требует блокировок, mcentral — общий пул спанов на каждый класс размера, mheap — глобальная куча, которая берёт память у ОС кусками (арены по 64 МБ на 64-битных системах) и нарезает её страницами по 8 КБ. Объекты до 32 КБ округляются до одного из ~68 классов размера, объекты меньше 16 байт без указателей склеиваются tiny-аллокатором, всё крупнее 32 КБ выделяется спанами напрямую из mheap. Ключевое следствие: объекты не перемещаются — GC не компактящий, а значит адрес объекта стабилен, зато возможна фрагментация.

Сборщик мусора — конкурентный, трёхцветный mark & sweep, неперемещающий и непоколенческий. Он работает параллельно с программой, поэтому нуждается в барьере записи (с Go 1.8 — гибридный барьер Дейкстры/Юасы), чтобы мутатор не спрятал живой объект от маркера. Останов мира (STW) случается дважды за цикл и длится обычно десятки-сотни микросекунд: на входе в фазу маркировки и в mark termination. Темп задаёт пейсер: по умолчанию GOGC=100, то есть следующий цикл стартует, когда куча вырастет вдвое от объёма живых данных предыдущего цикла; с Go 1.19 есть ещё мягкий потолок GOMEMLIMIT.

Вторая половина модели в голове — escape analysis. Компилятор на этапе компиляции решает, переживёт ли значение свой кадр стека. Если нет — оно на стеке, и GC его вообще не касается. Если да (указатель возвращается, кладётся в глобал, уходит в интерфейс, в канал, в замыкание горутины, или размер неизвестен на этапе компиляции) — значение «убегает» в кучу. Поэтому оптимизация памяти в Go — это в первую очередь не тюнинг GC, а сокращение количества и «указательности» объектов в куче.

Коротко. Память управляется автоматически: компилятор через escape analysis решает, что положить на стек, а что в кучу; кучей заведует собственный аллокатор рантайма (наследник TCMalloc: mcachemcentralmheap), а освобождением — конкурентный трёхцветный mark & sweep сборщик мусора. Стек освобождается сам при возврате из функции, куча — сборщиком.

Глубже. Рантайм запрашивает у ОС большие непрерывные куски (арены по 64 МБ на 64-битных платформах) через mmap, режет их на страницы по 8 КБ, из страниц собирает спаны (mspan), а спан нарезает на слоты фиксированного класса размера. Классов около 68, максимальный малый объект — 32 КБ; всё больше выделяется отдельными спанами. Быстрый путь аллокации идёт из mcache, привязанного к текущему P, и потому не требует блокировок вообще. Возврат памяти ОС делает фоновый scavenger через MADV_DONTNEED/MADV_FREE — RSS падает не мгновенно. Важно, что GC не перемещает объекты: это упрощает interop с unsafe.Pointer и cgo, но исключает компактизацию.

Что такое сборщик мусора? Как он работает?

Заголовок раздела «Что такое сборщик мусора? Как он работает?»

Коротко. Сборщик мусора — часть рантайма, которая находит объекты в куче, до которых больше нельзя дойти по указателям от корней (глобалы, стеки горутин, регистры), и возвращает их память аллокатору. В Go это конкурентный трёхцветный mark & sweep: он размечает живые объекты параллельно с работой программы, останавливая её лишь на два коротких STW, а затем ленивым sweep освобождает мёртвые.

Глубже. Цикл выглядит так. Пейсер решает, что пора (по умолчанию — когда куча выросла до живое * (1 + GOGC/100), то есть вдвое при GOGC=100). Происходит STW: завершается недоделанный sweep прошлого цикла, включается барьер записи, все P переводятся в режим маркировки. Дальше конкурентная фаза: выделенные под GC воркеры (примерно 25% CPU) и mark assist в аллоцирующих горутинах обходят граф от корней, красят объекты. Стек каждой горутины сканируется с её краткой приостановкой, а не общим STW. Когда серая очередь пуста — второй STW, mark termination: выключается барьер, флашатся кэши маркировки, считается новая цель пейсера. Затем sweep — он ленивый: спан подметается в момент, когда из него хотят выделить память. Ключевой инвариант, который держит барьер записи: чёрный объект не должен указывать на белый, до которого больше никто из серых не дойдёт.

Коротко. Да: переменной окружения GOGC=off или вызовом debug.SetGCPercent(-1). Тогда автоматических циклов не будет, куча будет только расти, и освободить память можно только вручную через runtime.GC().

Глубже. Полностью отключить GC как подсистему нельзя — барьеры записи и метаданные остаются, отключается только автоматический запуск по росту кучи. Есть нюанс: с Go 1.19, если задан GOMEMLIMIT, сборщик всё равно будет запускаться при приближении к лимиту даже при GOGC=off. Эта комбинация (GOGC=off + GOMEMLIMIT=...) — рекомендуемый паттерн для латентно-чувствительных сервисов с предсказуемым бюджетом памяти: GC не дёргается на каждом удвоении, но и OOM не случается. Ещё остаётся принудительный запуск раз в 2 минуты (forcegcperiod) — он тоже отключается при GOGC=off. Для короткоживущих CLI-утилит отключение GC — законная оптимизация.

package main
import "runtime/debug"
func main() {
prev := debug.SetGCPercent(-1) // выключить, вернуть прежнее значение
defer debug.SetGCPercent(prev)
// ... горячая фаза без GC
}

Как в Go работает сборщик мусора? Какие фазы есть и как они влияют на производительность?

Заголовок раздела «Как в Go работает сборщик мусора? Какие фазы есть и как они влияют на производительность?»

Коротко. Фаз четыре: sweep termination (STW), конкурентная маркировка, mark termination (STW), конкурентный/ленивый sweep. Две STW-паузы короткие (обычно десятки-сотни микросекунд), а основная цена — не пауза, а отъедаемый на маркировку CPU: рантайм целится примерно в 25% процессорного времени плюс mark assist в горутинах, которые активно аллоцируют.

Глубже. На латентность влияют именно assist’ы: если горутина выделяет быстрее, чем сборщик успевает размечать, ей начисляется «долг», и она обязана сама отмаркировать пропорциональный объём — отсюда всплески p99 у аллокационно-жадного кода. На пропускную способность влияет частота циклов (GOGC) и размер живой кучи: время маркировки примерно пропорционально числу живых объектов и указателей в них, а не общему объёму памяти. Поэтому 1 ГБ в виде []byte почти бесплатен для GC, а 1 ГБ в виде миллионов мелких структур с указателями — дорого. Sweep практически бесплатен, так как размазан по аллокациям.

Какие инструменты вы использовали для поиска утечки памяти в вашем проекте?

Заголовок раздела «Какие инструменты вы использовали для поиска утечки памяти в вашем проекте?»

Коротко. Это вопрос про личный опыт — от вас ждут не список названий, а маршрут расследования: сначала метрики, потом профиль, потом diff профилей, потом конкретный фикс с подтверждением по графику.

Глубже. Каркас хорошего ответа: (1) заметили — по метрикам go_memstats_heap_inuse_bytes/RSS в Prometheus или по рестартам от OOM-killer; (2) отделили утечку кучи от утечки горутин — посмотрели go_goroutines и /debug/pprof/goroutine; (3) сняли heap-профиль через net/http/pprof в двух точках времени и сравнили: go tool pprof -base heap1.pb.gz heap2.pb.gz, смотрели inuse_space, а не alloc_space; (4) нашли, что растёт (у меня это была карта, из которой никто не удалял записи / незакрытый http.Response.Body / time.Ticker без Stop()); (5) починили, добавили регрессионный тест или алерт. Типичные ошибки: назвать только «pprof» без объяснения, что именно смотрели; путать alloc_space (сколько всего аллоцировано за жизнь процесса) с inuse_space (что живо сейчас); считать растущий RSS утечкой, не проверив scavenger и GOGC.

Как работает фаза пометки в сборщике мусора?

Заголовок раздела «Как работает фаза пометки в сборщике мусора?»

Коротко. Маркировка идёт по трёхцветной схеме: все объекты изначально белые, корни (глобалы, стеки горутин, регистры) красятся в серый и кладутся в рабочую очередь; воркер берёт серый объект, сканирует его указатели, красит найденные белые в серый, а сам объект — в чёрный. Когда серых не осталось, всё белое — мусор.

Глубже. Технически «цвет» — это биты в метаданных спана (gcmarkBits) плюс принадлежность рабочей очереди, отдельного поля цвета у объекта нет. Какие слова внутри объекта являются указателями, рантайм знает из карты типа; с Go 1.22 эти метаданные для небольших объектов лежат рядом с самим объектом, что улучшило локальность и дало несколько процентов CPU. Поскольку программа работает параллельно и может переставить указатель из ещё не отсканированного места в уже чёрный объект, включён гибридный барьер записи (Go 1.8, Yuasa + Dijkstra): при записи указателя в кучу он красит и старое, и новое значение в серый. Это и позволило убрать повторное STW-сканирование стеков, которое было до 1.8. Стеки сканируются по одной горутине с микроприостановкой, а не общим STW.

Коротко. Не по таймеру, а по росту кучи: при GOGC=100 цикл стартует, когда объём кучи достигает удвоенного объёма живых данных с прошлого цикла. Плюс есть страховка — если GC не запускался 2 минуты, рантайм форсирует цикл; и ещё сборку можно вызвать вручную runtime.GC().

Глубже. Значит, частота полностью зависит от темпа аллокаций и размера живой кучи: сервис с 10 МБ живых данных и 1 ГБ/с аллокаций будет собирать десятки раз в секунду, а сервис с 10 ГБ кэша и редкими аллокациями — раз в минуты. Отсюда классический трюк «ballast» (искусственный большой неиспользуемый срез, чтобы поднять базу и снизить частоту) — с Go 1.19 он заменяется на честный GOMEMLIMIT. С заданным GOMEMLIMIT появляется второй триггер: приближение суммарной памяти рантайма к лимиту. Наблюдать реальную частоту проще всего через GODEBUG=gctrace=1 — каждая строка это один цикл.

Можно ли управлять работой сборщика мусора?

Заголовок раздела «Можно ли управлять работой сборщика мусора?»

Коротко. Да, но ограниченно: GOGC/debug.SetGCPercent регулируют агрессивность, GOMEMLIMIT/debug.SetMemoryLimit задают мягкий потолок памяти, runtime.GC() запускает цикл принудительно, debug.FreeOSMemory() дополнительно возвращает память ОС. Ручного free, выбора поколений или тюнинга пауз в миллисекундах в Go нет.

Глубже. Полный набор ручек: GOGC (проценты роста кучи или off), GOMEMLIMIT (с 1.19, мягкий лимит на всю память рантайма, включая стеки и метаданные — именно то, что нужно в контейнере с memory.limit), GOMAXPROCS (косвенно — сколько CPU достанется GC-воркерам), GODEBUG=gctrace=1 для диагностики, runtime/debug.SetMaxStack. Разумная практика в Kubernetes: GOMEMLIMIT ≈ 80–90% от лимита пода и GOGC оставить дефолтным (либо off, если нужен минимум циклов). Не стоит звать runtime.GC() в проде «на всякий случай» — это блокирующий полный цикл.

Garbage collection. Algorithms a) Tri-color algorithm (black, gray, white), mark & sweep

Заголовок раздела «Garbage collection. Algorithms a) Tri-color algorithm (black, gray, white), mark & sweep»

Коротко. Трёхцветная абстракция: белый — ещё не достигнутый (кандидат в мусор), серый — достигнутый, но его поля ещё не просканированы, чёрный — достигнутый и полностью просканированный. Алгоритм крутит серую очередь до опустошения, после чего белое считается мусором и подметается (sweep) — память слотов возвращается в спан.

Глубже. Строгий трёхцветный инвариант: не должно существовать указателя из чёрного объекта в белый. Конкурентный мутатор может его нарушить двумя способами — записать указатель на белый объект в чёрный и одновременно удалить последнюю ссылку на него из серого. Отсюда два класса барьеров: insertion barrier Дейкстры (красит записываемое значение) и deletion barrier Юасы (красит затираемое значение). Go с версии 1.8 использует гибрид обоих, что снимает необходимость в повторном STW-сканировании стеков: стеки после первого сканирования считаются чёрными навсегда в рамках цикла. Sweep в Go ленивый: спан подметается перед тем, как из него будут выделять. Компактизации (moving/copying GC) в Go нет.

Что происходит когда мы в коде просим выделить нам 1КБ памяти?

Заголовок раздела «Что происходит когда мы в коде просим выделить нам 1КБ памяти?»

Коротко. Компилятор сначала решает, можно ли обойтись стеком — если да, аллокации в куче не будет вовсе. Если объект убегает, вызывается runtime.mallocgc: 1024 байта попадают ровно в существующий класс размера 1024, слот берётся из mspan в mcache текущего P без блокировок; если свободных слотов нет — спан запрашивается у mcentral, а тот при необходимости у mheap, который при нехватке страниц берёт новую память у ОС через mmap.

Глубже. Дополнительные детали, которые ценят на собеседовании: (1) выбирается разный спан в зависимости от того, содержит ли тип указатели — scan/noscan, для noscan GC вообще не сканирует содержимое; (2) память всегда обнуляется (кроме случаев, когда рантайм знает, что она уже нулевая); (3) при аллокации начисляется mark assist: если GC активен, ваша горутина обязана отмаркировать пропорциональный объём, то есть аллокация может «залипнуть»; (4) аллокация двигает счётчик кучи и может стать триггером нового цикла GC; (5) 1 КБ — это малый объект (<32 КБ), поэтому идёт по быстрому пути; будь это 40 КБ, спан выделялся бы напрямую из mheap. Округление до класса размера даёт внутреннюю фрагментацию, но классы подобраны так, чтобы потери не превышали ~12.5%.

В каком случае объекты создаются на стеке и в куче?

Заголовок раздела «В каком случае объекты создаются на стеке и в куче?»

Коротко. На стеке — если компилятор доказал, что значение не переживёт кадр функции и его размер известен на этапе компиляции. В куче — если указатель на него «убегает»: возвращается наружу, сохраняется в глобале или в поле долгоживущего объекта, уходит в канал, в интерфейс, захватывается замыканием горутины, либо размер слишком велик/неизвестен.

Глубже. Практические пороги компилятора: явно объявленные локальные переменные размером более ~10 МБ и неявные аллокации (new, make, композитные литералы) размером более 64 КБ уезжают в кучу; make([]T, n) с непостоянным n — тоже, потому что размер неизвестен. Частая ловушка: fmt.Println(x) заставляет x убежать, так как значение уходит в ...any. И наоборот: возврат &LocalStruct{} вовсе не обязан аллоцировать в куче — если функция заинлайнилась и указатель не убежал дальше, значение останется на стеке. Проверяется всегда одним способом: go build -gcflags='-m'.

Сколько памяти выделяется под стек в ос и в Go?

Заголовок раздела «Сколько памяти выделяется под стек в ос и в Go?»

Коротко. ОС даёт потоку стек фиксированного размера: на Linux главный поток обычно 8 МБ (ulimit -s), на Windows по умолчанию 1 МБ. Горутина в Go стартует с 2 КБ и растёт динамически копированием, а максимум по умолчанию — 1 ГБ на 64-битных платформах (250 МБ на 32-битных).

Глубже. Разница принципиальная: у ОС стек — это зарезервированный диапазон виртуальной памяти с guard-страницей, он не может вырасти сверх лимита и не может ужаться; у Go стек — обычный кусок памяти из кучи стеков, при нехватке места пролог функции вызывает morestack, рантайм выделяет вдвое больший стек, копирует туда старый и корректирует все указатели внутрь стека (это возможно именно потому, что рантайм знает разметку кадров). Стек может и сжиматься — GC при сканировании ужимает его вдвое, если используется меньше четверти. С Go 1.19 стартовый размер горутины адаптивный: рантайм ориентируется на средний размер стеков в этой программе, чтобы избежать повторных ростов. Именно дешёвые стеки в 2 КБ позволяют держать сотни тысяч горутин, чего нельзя сделать с потоками ОС.

Как сборщик мусора понимает что объект еще используется в программе?

Заголовок раздела «Как сборщик мусора понимает что объект еще используется в программе?»

Коротко. По достижимости: объект жив, если до него есть путь по указателям от корней — глобальных переменных, стеков и регистров всех горутин. Никакого подсчёта ссылок в Go нет.

Глубже. «Достижим» ≠ «нужен»: объект, на который ссылается забытая запись в глобальной карте, достижим и потому бессмертен — это и есть типичная утечка в Go. Чтобы отличать указатели от чисел, рантайм использует карты указателей: для кучи — метаданные типа рядом с объектом, для стеков — таблицы stackmap, сгенерированные компилятором для каждой точки вызова. Именно поэтому Go — точный (precise), а не консервативный сборщик: случайное целое число, похожее на адрес, не удержит объект. unsafe.Pointer учитывается как настоящий указатель, а uintptr — нет, и объект по такому «адресу» может быть собран.

Коротко. Да. GC освобождает недостижимое, но не спасает от логических утечек: заблокированные навсегда горутины, растущие без удаления карты и слайсы, ссылки на большой массив из маленького слайса, незакрытые тела HTTP-ответов, не остановленные тикеры, cgo-память.

Глубже. Самые частые механизмы: (1) утечка горутин — горутина висит на записи в канал, который никто не читает, и удерживает весь свой стек и захваченные объекты; (2) small := big[:10] — маленький слайс держит весь backing array, лечится append([]T(nil), big[:10]...) или slices.Clone; (3) delete из карты не уменьшает число бакетов, память карты не возвращается, пока карта жива — большие карты после чистки надо пересоздавать; (4) time.NewTicker без Stop(), context.WithCancel без cancel(); (5) незакрытый resp.Body держит соединение и буферы; (6) runtime.SetFinalizer на объект, участвующий в цикле, отодвигает освобождение (с Go 1.24 вместо финализаторов рекомендуется runtime.AddCleanup); (7) память, выделенная в C через cgo, GC не видит вообще.

Сборщик мусора: принцип работы. Как в языках без GC?

Заголовок раздела «Сборщик мусора: принцип работы. Как в языках без GC?»

Коротко. В Go — автоматический поиск недостижимого через трёхцветный mark & sweep. В языках без GC (C, C++, Rust) освобождение делает программист или система типов: явные malloc/free, RAII и деструкторы в C++, владение и borrow checker в Rust, плюс подсчёт ссылок (shared_ptr, Rc/Arc) там, где владение неоднозначно.

Глубже. Компромисс такой: без GC вы получаете предсказуемую латентность и отсутствие фонового CPU-налога, но платите классом ошибок — use-after-free, double free, висячие указатели, — которого в Go по построению нет. Подсчёт ссылок (Swift, Python, shared_ptr) — это тоже автоматическое управление, но с двумя минусами: атомарные инкременты стоят дорого и циклы ссылок не собираются без отдельного цикл-детектора. Rust занимает третью позицию: проверки на этапе компиляции, нулевая стоимость в рантайме, но выше входной барьер. Go осознанно выбрал GC ради простоты и безопасности, а латентность компенсировал конкурентностью сборщика.

Коротко. Это ситуация, когда свободной памяти суммарно достаточно, но она разбита на куски, непригодные для запроса: внешняя фрагментация — дыры между занятыми объектами, внутренняя — неиспользуемый хвост внутри выделенного блока из-за округления размера.

Глубже. Go борется с внешней фрагментацией классами размера: каждый спан обслуживает объекты одного класса, поэтому освободившийся слот всегда подходит следующему объекту того же класса. Платой становится внутренняя фрагментация — округление до класса, но классы подобраны так, чтобы потери не превышали примерно 12.5%. Остаточная проблема есть на уровне страниц: поскольку GC не компактящий, спан нельзя освободить, пока в нём жив хоть один объект, и «одинокие» долгоживущие объекты в разных спанах могут удерживать много страниц. Отсюда типичный симптом — RSS не возвращается к базовому уровню после пика нагрузки. Помогают: пулы (sync.Pool), уменьшение разнообразия размеров, debug.FreeOSMemory() как крайняя мера, и понимание, что часть «невозвращённой» памяти — это просто ещё не отработавший фоновый scavenger либо MADV_FREE-страницы, которые ядро заберёт под давлением.

Случается ли при работе сборщика мусора событие stop the world?

Заголовок раздела «Случается ли при работе сборщика мусора событие stop the world?»

Коротко. Да, дважды за цикл: перед началом маркировки (sweep termination и включение барьера записи) и в mark termination. Обе паузы короткие — на здоровом сервисе это обычно десятки-сотни микросекунд, а не миллисекунды.

Глубже. Механика STW: stopTheWorld дожидается, пока все P дойдут до безопасной точки. С Go 1.14 есть асинхронная вытесняемость через сигналы, поэтому горутина в длинном цикле без вызовов функций больше не задерживает STW бесконечно — до 1.14 это была реальная причина «зависших» пауз. Сканирование стеков внутри STW не происходит: оно конкурентное, с приостановкой отдельных горутин. Измерять паузы надо не «на глаз», а через GODEBUG=gctrace=1 (в строке видны обе паузы) или runtime.ReadMemStats().PauseNs / go tool trace.

Реализовать in-memory кэш менеджер для хранения пользователей

Заголовок раздела «Реализовать in-memory кэш менеджер для хранения пользователей»

Коротко. Минимально достаточная реализация — карта под sync.RWMutex с TTL на запись и ленивым удалением просроченных; дальше по требованиям добавляются фоновая чистка, ограничение размера с LRU-вытеснением и шардирование для снижения контенции.

Глубже. На такой задаче смотрят на четыре вещи: корректность конкурентного доступа, отсутствие утечки (записи должны когда-то удаляться), обработка zero-value через (V, bool) и понимание, что delete не возвращает память карты. Базовый вариант на дженериках:

package cache
import (
"sync"
"time"
)
type entry[V any] struct {
val V
expiresAt time.Time // нулевое время — без TTL
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]entry[V]
ttl time.Duration
}
func New[K comparable, V any](ttl time.Duration, capacity int) *Cache[K, V] {
return &Cache[K, V]{
items: make(map[K]entry[V], capacity),
ttl: ttl,
}
}
func (c *Cache[K, V]) Set(k K, v V) {
var exp time.Time
if c.ttl > 0 {
exp = time.Now().Add(c.ttl)
}
c.mu.Lock()
c.items[k] = entry[V]{val: v, expiresAt: exp}
c.mu.Unlock()
}
func (c *Cache[K, V]) Get(k K) (V, bool) {
c.mu.RLock()
e, ok := c.items[k]
c.mu.RUnlock()
var zero V
if !ok {
return zero, false
}
if !e.expiresAt.IsZero() && time.Now().After(e.expiresAt) {
c.mu.Lock()
if cur, still := c.items[k]; still && cur.expiresAt.Equal(e.expiresAt) {
delete(c.items, k) // не перезатираем более свежую запись
}
c.mu.Unlock()
return zero, false
}
return e.val, true
}
func (c *Cache[K, V]) Delete(k K) {
c.mu.Lock()
delete(c.items, k)
c.mu.Unlock()
}

Что стоит проговорить вслух дальше: фоновый воркер с time.Ticker, который проходит по карте и чистит просроченное (иначе никогда не запрашиваемые ключи живут вечно — это утечка); ограничение по числу элементов с LRU на container/list; шардирование по hash(key) % N при высокой конкуренции; golang.org/x/sync/singleflight, чтобы промах кэша не породил лавину одинаковых запросов в БД; и то, что sync.Map тут обычно хуже обычной карты с мьютексом, потому что он оптимизирован под сценарий «пишем редко, читаем много разными горутинами по непересекающимся ключам».

Как распределяется размещение данных в стек и кучу?

Заголовок раздела «Как распределяется размещение данных в стек и кучу?»

Коротко. Решает компилятор по результатам escape analysis, а не программист и не рантайм: не убегающие значения известного размера — на стек кадра функции, всё остальное — в кучу через runtime.newobject/mallocgc.

Глубже. См. выше про случаи «стек vs куча». Отличие этого вопроса — акцент на том, что в Go нет синтаксического различия: new(T), &T{} и var t T могут дать как стековую, так и кучевую переменную, всё зависит от того, куда уходит указатель. Это отличается от C++/Java, где new всегда куча. Компилятор строит граф потока указателей по всей единице компиляции с учётом инлайнинга и параметров, помеченных как «не убегающие» (//go:noescape и выведенные аннотации для функций из других пакетов).

В какой момент определяется это распределение?

Заголовок раздела «В какой момент определяется это распределение?»

Коротко. На этапе компиляции, во время escape analysis — это статический анализ, результат зашит в машинный код. В рантайме решение не пересматривается.

Глубже. Следствие важное: escape analysis работает межпроцедурно, но в пределах доступной компилятору информации, поэтому инлайнинг напрямую улучшает её точность — функция, чей &local уходит в невиданный вызов, вынуждена аллоцировать в куче консервативно. «Консервативно» здесь означает в сторону кучи: компилятор никогда не оставит на стеке значение, для которого не доказал безопасность. Посмотреть решение можно через go build -gcflags='-m -m', а -l отключает инлайнинг, чтобы увидеть «чистую» картину.

Вызывает ли сборщик мусора паузы stop the world при работе?

Заголовок раздела «Вызывает ли сборщик мусора паузы stop the world при работе?»

Коротко. Да — см. выше: две STW-паузы за цикл, sweep termination и mark termination. Отличие от вопроса «случается ли STW» здесь только в акценте: паузы есть всегда, но они не пропорциональны размеру кучи и обычно укладываются в сотни микросекунд.

Глубже. Утверждение «Go GC без пауз» неверно и на собеседовании звучит как красный флаг. Правильная формулировка: паузы есть, но они ограничены сверху и почти не зависят от объёма живых данных, потому что вся тяжёлая работа (обход графа) вынесена в конкурентную фазу. Что реально может раздуть паузу: очень большое число горутин (много стеков для подготовки), долгие невытесняемые участки в cgo, нехватка CPU в контейнере с жёстким CPU-throttling.

Опишите технологический стек проекта: какие инструменты и библиотеки использовались в разработке?

Заголовок раздела «Опишите технологический стек проекта: какие инструменты и библиотеки использовались в разработке?»

Коротко. Вопрос про личный опыт. Интервьюер проверяет не список названий, а то, понимаете ли вы, почему выбрано именно это, и где границы вашей ответственности.

Глубже. Каркас ответа: язык и версия Go, тип сервиса (HTTP/gRPC, воркер, стрим); транспорт и роутинг (net/http + chi/echo, grpc-go, protobuf); хранилища (PostgreSQL через pgx, Redis, Kafka) и почему именно они; миграции (goose/golang-migrate); конфигурация и логирование (log/slog со структурными полями); тестирование (testify, testcontainers-go, go test -race); наблюдаемость (Prometheus, OpenTelemetry, Grafana, Jaeger); CI/CD и линтинг (golangci-lint, go vet, staticcheck); деплой (Docker, Kubernetes, Helm). Дальше обязательно — одна-две истории «мы выбрали X вместо Y, потому что…» и одна «здесь мы ошиблись и потом переехали». Типичные ошибки: перечислить 30 технологий без единого обоснования; приписать себе решения всей команды; не знать деталей того, что назвал (если сказали pgx — будьте готовы к вопросу про пул соединений).

Возможны ли утечки памяти в Go? Сталкивались ли вы с ними?

Заголовок раздела «Возможны ли утечки памяти в Go? Сталкивались ли вы с ними?»

Коротко. Да, возможны — см. выше подробный список механизмов. Во второй части вопроса ждут конкретную историю: симптом, гипотеза, инструмент, находка, фикс, подтверждение.

Глубже. Хороший ответ строится как расследование, а не как определение. Например: «RSS рос линейно ~200 МБ в сутки, поды перезапускались по OOM раз в трое суток. Число горутин росло синхронно с памятью — значит, утечка горутин, а не кучи. /debug/pprof/goroutine?debug=2 показал тысячи горутин, стоящих на select в функции, где мы забыли передать ctx и не было ветки <-ctx.Done(). После фикса график стал плоским». Типичные ошибки кандидатов: рассказывать про утечки в терминах C (висячие указатели) — в Go их нет; утверждать «в Go утечек не бывает, там же GC»; не уметь отличить утечку от нормального роста кучи до цели GOGC.

Что такое глобальная переменная в Go? Где она хранится в памяти?

Заголовок раздела «Что такое глобальная переменная в Go? Где она хранится в памяти?»

Коротко. Это переменная уровня пакета, объявленная вне функций. Она живёт всё время работы программы и хранится не в куче и не в стеке, а в статических сегментах бинаря: инициализированная ненулевым значением — в .data, нулевая — в .bss (место под неё резервируется, но в файл не пишется).

Глубже. Для сборщика мусора глобалы — это корни: рантайм имеет карту указателей для сегментов данных и сканирует их в начале маркировки. Поэтому всё, на что ссылается глобальная переменная, бессмертно, и глобальные кэши/реестры — источник номер один «легальных» утечек. Ещё следствие: присваивание указателя на локальную переменную в глобал заставляет её убежать в кучу. Инициализируются глобалы до main — сначала в порядке зависимостей выражений инициализации, затем выполняются функции init() пакета. Отдельно стоит помнить, что глобальные изменяемые переменные — источник гонок; в конкурентном коде их защищают мьютексом или sync/atomic.

Что такое глобальная переменная в Go? Где они хранятся в памяти?

Заголовок раздела «Что такое глобальная переменная в Go? Где они хранятся в памяти?»

Коротко. См. выше — вопрос-дубликат. Единственное отличие формулировки во множественном числе: все глобалы пакета размещаются в тех же сегментах .data/.bss, компилятор просто раскладывает их подряд с учётом выравнивания.

Глубже. Раз уж вопрос повторяется, стоит добавить деталь, которую редко говорят: константы вообще не занимают места как переменные — они подставляются в код или уезжают в .rodata; строковые литералы тоже лежат в .rodata, и заголовок строки указывает туда, поэтому копирование строки не копирует байты. Посмотреть раскладку можно через go tool nm по бинарю: буквы D/B в выводе — это как раз .data и .bss.

Как вы проверяете наличие утечек памяти в Go-приложении?

Заголовок раздела «Как вы проверяете наличие утечек памяти в Go-приложении?»

Коротко. Вопрос про практику: постоянный мониторинг метрик рантайма, а при подозрении — снятие двух heap-профилей с интервалом и их сравнение через go tool pprof -base, параллельно проверка профиля горутин.

Глубже. Рабочий порядок действий. В проде: экспортировать runtime/metrics или MemStats в Prometheus (heap_inuse, heap_objects, go_goroutines, gc_duration) и держать алерт на монотонный рост. При подозрении: подключить net/http/pprof (на отдельном служебном порту, не наружу), снять curl host/debug/pprof/heap > h1 и через 30–60 минут h2, затем go tool pprof -base h1 h2 ./binary и смотреть inuse_space — в топе будет место аллокации утекающих объектов. Отдельно curl host/debug/pprof/goroutine?debug=2 — если стеки повторяются тысячами, это утечка горутин. В тестах: go test -memprofile, -benchmem, go.uber.org/goleak в TestMain ловит незавершённые горутины автоматически. Полезно также разово запустить с GODEBUG=gctrace=1, чтобы увидеть, растёт ли именно живая куча (первое число после -> в строке трейса) или это просто пилообразный рост до цели GC.

Как работает сборщик мусора в Go? Какие настройки влияют?

Заголовок раздела «Как работает сборщик мусора в Go? Какие настройки влияют?»

Коротко. Механика — конкурентный трёхцветный mark & sweep, см. выше. Влияют: GOGC (порог роста кучи, по умолчанию 100), GOMEMLIMIT (мягкий лимит памяти, с Go 1.19), GOMAXPROCS (сколько CPU достаётся GC-воркерам), плюс программные аналоги debug.SetGCPercent, debug.SetMemoryLimit, runtime.GC, debug.FreeOSMemory.

Глубже. Как выбирать: если сервис живёт в контейнере с лимитом памяти — обязательно ставьте GOMEMLIMIT чуть ниже лимита, иначе GC не знает про потолок и вас убьёт OOM-killer. Если сервис аллокационно-жадный и памяти в избытке — поднимите GOGC до 200–400: меньше циклов, выше пропускная способность, больше RSS. Если латентность важнее памяти — не трогайте GOGC, а уменьшайте сами аллокации. GOMAXPROCS в Kubernetes стоит согласовать с CPU-лимитом (automaxprocs), иначе рантайм считает, что у него все ядра ноды, и GC-воркеров будет слишком много при жёстком throttling.

Коротко. Стек горутин (по одному на горутину, растёт копированием), куча (управляется аллокатором и GC), статические сегменты бинаря (.text, .rodata, .data, .bss — код, константы, глобалы), служебная off-heap память рантайма (метаданные спанов, битовые карты, пулы стеков, профили) и внешняя память, выделенная через cgo или прямой mmap.

Глубже. Полезно различать три вида «объёма»: virtual (сколько адресного пространства зарезервировано — у Go оно велико из-за арен и ни о чём не говорит), RSS (сколько физических страниц реально отображено — это то, что видит OOM-killer), и heap in-use по данным рантайма. Их расхождение — нормальная ситуация: рантайм мог отдать страницы ядру через MADV_FREE, и они остаются в RSS, пока нет давления на память. runtime.MemStats разделяет HeapSys, StackSys, MSpanSys, MCacheSys, GCSys, OtherSys — по этим полям видно, куда именно ушла память.

Коротко. Только с кучей Go. Стеки и статические сегменты он лишь сканирует как корни, но не освобождает; память, выделенная в C через cgo или напрямую через syscall.Mmap, ему вообще не видна.

Глубже. Практические следствия: (1) объект в куче, на который ссылается только C-код, будет собран — поэтому cgo-правила запрещают хранить Go-указатели в C-памяти; (2) утечка в C-библиотеке никак не отражается в heap-профиле Go, её ловят через valgrind/jemalloc-статистику или по расхождению RSS и HeapSys; (3) большие стеки тысяч горутин видны в StackSys, но не в heap-профиле; (4) чтобы освободить память, выделенную через mmap вручную, нужен явный munmap. Также GC освобождает не «переменные», а объекты: слот в спане становится доступен для повторной аллокации, а физические страницы возвращаются ОС только фоновым scavenger’ом.

Что такое Garbage Collector? Для чего нужен и как устроен?

Заголовок раздела «Что такое Garbage Collector? Для чего нужен и как устроен?»

Коротко. Подсистема рантайма, снимающая с программиста ручное управление освобождением памяти и, как следствие, исключающая use-after-free и double free. Устроен как конкурентный неперемещающий трёхцветный mark & sweep с барьером записи и пейсером на основе GOGC — см. подробный разбор выше.

Глубже. Что стоит добавить, чтобы ответ звучал зрелым: Go-шный GC сознательно оптимизирован под низкую латентность, а не под пропускную способность — отсюда отказ от поколений (нет дешёвой minor-сборки, зато нет и длинных major-пауз) и отказ от компактизации (нет дефрагментации, зато адреса стабильны и cgo/unsafe работают предсказуемо). Отсутствие поколений частично компенсируется тем, что большая доля короткоживущих значений в Go вообще не попадает в кучу благодаря escape analysis. В Go 1.25 появился экспериментальный сборщик Green Tea (GOEXPERIMENT=greenteagc), улучшающий локальность маркировки, — упоминать его стоит осторожно, как эксперимент.

Кривой вопрос про то, можно ли предвыделить память при помощи make().

Заголовок раздела «Кривой вопрос про то, можно ли предвыделить память при помощи make().»

Коротко. Да, make — это как раз способ предвыделения: make([]T, 0, n) резервирует ёмкость без изменения длины, make(map[K]V, n) — подсказка размера, чтобы карта сразу выделила достаточно места, make(chan T, n) — буфер канала. Классическая ловушка вопроса — путаница длины и ёмкости.

Глубже. Разбор подводных камней. make([]int, n) создаёт срез длины n, заполненный нулями, и последующий append добавит n+1-й элемент — это не предвыделение, а типичная ошибка; предвыделение это make([]int, 0, n). Для карты второй аргумент — именно подсказка (hint), а не жёсткая ёмкость: карта может вырасти и сверх неё, и понятия «cap» у карты нет. make всегда обнуляет память, поэтому «предвыделить, не обнуляя» в безопасном Go нельзя. Предвыделение имеет смысл, когда финальный размер известен или оценим: оно убирает серию перевыделений с копированием (рост среза идёт примерно вдвое для малых и на ~25% для больших) и, что не менее важно, снижает нагрузку на GC. И ещё: new(T) возвращает *T и работает для любых типов, а make — только для среза, карты и канала и возвращает сам тип, а не указатель, потому что все три содержат внутренний дескриптор, требующий инициализации.

package main
func build(n int) []int {
buf := make([]int, 0, n) // ёмкость n, длина 0 — верное предвыделение
for i := 0; i < n; i++ {
buf = append(buf, i) // ни одного перевыделения
}
return buf
}
func main() { _ = build(1000) }

Коротко. Термин из внутренностей карты: при росте карты рантайм не перехеширует всё сразу, а переносит данные из старого хранилища в новое порциями, по мере операций записи и удаления. В Go до 1.24 это была evacuate() для бакетов (oldbucketsbuckets), в Go 1.24 карты переписаны на Swiss Tables, и инкрементальность достигается тем, что большая карта разбита на независимые таблицы, каждая из которых перехешируется отдельно.

Глубже. Классическая схема (Go ≤ 1.23): при превышении фактора загрузки (~6.5 элементов на бакет) или при слишком большом числе overflow-бакетов выделяется вдвое больший массив бакетов, старый сохраняется в oldbuckets, и каждая операция записи/удаления эвакуирует один-два старых бакета, распределяя элементы по «верхнему» и «нижнему» новым бакетам по очередному биту хеша. Чтение при этом умеет заглядывать в старый бакет, если он ещё не эвакуирован. Есть также «same-size grow» — эвакуация в массив того же размера для схлопывания overflow-цепочек после массовых удалений. В Go 1.24 модель другая: группы по 8 слотов с control-словом, SIMD-подобный поиск, и большая карта представлена директорией независимых таблиц, что даёт похожий эффект «рост по частям» без общего oldbuckets. Важное общее свойство в обеих версиях: адреса элементов карты нестабильны, поэтому взять указатель на элемент карты нельзя. Отдельно словом «эвакуация» иногда называют копирование стека при morestack — там тоже старое содержимое переносится в новый, вдвое больший буфер с корректировкой указателей.

Где и как происходит аллокация памяти? Что приводит к аллокациям?

Заголовок раздела «Где и как происходит аллокация памяти? Что приводит к аллокациям?»

Коротко. Аллокация в куче происходит в runtime.mallocgc (через newobject, makeslice, makemap, makechan, growslice, конвертацию в интерфейс и т.п.) по пути mcachemcentralmheap → ОС. Приводят к ней: убегающие значения, рост срезов и карт, конкатенация и конвертация строк, упаковка в interface{}, замыкания, захваченные горутинами, и defer в некоторых формах.

Глубже. Список типичных источников аллокаций, которые ищут в alloc_space-профиле: append сверх ёмкости; string([]byte) и []byte(string) (кроме случаев, которые компилятор умеет оптимизировать, например for range []byte(s) или поиск по map[string(b)]); конкатенация строк в цикле вместо strings.Builder; форматирование через fmt.Sprintf; помещение значения в интерфейс, если оно не помещается в слово и не является малым целым (для маленьких целых есть кэш staticuint64s); замыкания, захватывающие переменные по ссылке; make внутри горячего цикла; конвертация в error через errors.New внутри цикла. Проверяется всё это бенчмарком с -benchmem (allocs/op) и -gcflags='-m'.

Коротко. Программа аварийно завершится с fatal error: stack overflow — это не паника, recover не помогает. Происходит, когда стек горутины пытается вырасти сверх предела (по умолчанию 1 ГБ на 64-битных платформах), почти всегда из-за бесконечной рекурсии.

Глубже. Механика: пролог каждой функции сравнивает будущий указатель стека с stackguard0; если места не хватает, вызывается morestacknewstack, который выделяет вдвое больший стек и копирует старый. При достижении maxstacksize newstack печатает runtime: goroutine stack exceeds 1000000000-byte limit и вызывает throw("stack overflow") — процесс умирает целиком, дамп стека обрезается. Лимит меняется через debug.SetMaxStack. Классическая причина, помимо явной рекурсии, — метод String(), который внутри вызывает fmt.Sprintf("%v", x) на том же типе: получается бесконечный взаимный вызов.

Как определить, значение будет выделяться на кучу или на стеке?

Заголовок раздела «Как определить, значение будет выделяться на кучу или на стеке?»

Коротко. Спросить компилятор: go build -gcflags='-m' (или -m -m для подробностей) — он печатает escapes to heap и moved to heap. Второй способ — бенчмарк с -benchmem: если allocs/op равно нулю, аллокаций в куче нет.

Глубже. Практика: -l отключает инлайнинг, чтобы видеть решения без его влияния; -m -m показывает цепочку рассуждений («parameter x leaks to result»). Не полагайтесь на интуицию вроде «& значит куча» — в Go это неверно. Для профилирования уже работающего кода помогает go test -memprofile mem.out с последующим go tool pprof -alloc_objects.

package main
type Point struct{ X, Y int }
func onStack() int {
p := Point{1, 2} // не убегает
return p.X + p.Y
}
func onHeap() *Point {
p := Point{1, 2} // moved to heap: p
return &p
}
func main() {
_ = onStack()
_ = onHeap()
}

Коротко. Стек лучше почти всегда, если значение подходит: аллокация и освобождение стоят одну арифметическую операцию, нет нагрузки на GC, отличная локальность кэша. Куча нужна там, где данные должны пережить кадр функции, разделяться между горутинами или иметь неизвестный на этапе компиляции размер.

Глубже. Формулировка «лучше» некорректна без контекста: это не взаимозаменяемые опции, а разные времена жизни. Стек не подходит для больших объектов (компилятор всё равно отправит в кучу >64 КБ для неявных аллокаций), для данных, разделяемых горутинами, и для структур переменного размера. Правильная инженерная позиция: не пытаться «заставить всё лечь на стек», а сокращать количество и время жизни кучевых объектов там, где профиль показывает проблему, — переиспользовать буферы через sync.Pool, предвыделять срезы, передавать структуры по значению, если они небольшие, и избегать лишних интерфейсов в горячем пути.

Какие существуют механизмы очистки кучи в Go?

Заголовок раздела «Какие существуют механизмы очистки кучи в Go?»

Коротко. Один основной — сборщик мусора (mark & sweep). Дополнительно: фоновый scavenger, возвращающий свободные страницы ОС; runtime.GC() для принудительного цикла; debug.FreeOSMemory() — цикл GC плюс агрессивный возврат памяти ядру; runtime.AddCleanup/SetFinalizer как хук на освобождение (не механизм очистки, а уведомление).

Глубже. Важно понимать разницу «освободить в куче Go» и «вернуть ОС». Sweep возвращает слоты в спаны — RSS при этом не меняется. Возврат ОС делает scavenger, который отдаёт незанятые страницы через madvise. С MADV_FREE (использовался по умолчанию на Linux в некоторых версиях, затем Go вернулся к MADV_DONTNEED) RSS может не уменьшиться сразу, что регулярно порождает ложные баг-репорты об «утечке». Ручного освобождения объекта в Go нет и быть не может — единственный способ «освободить» объект — перестать на него ссылаться (обнулить указатель, вызвать delete, обрезать слайс с занулением хвоста).

Коротко. Обычно не вызывается вручную — рантайм запускает его сам по достижении цели пейсера, либо через 2 минуты простоя, либо при приближении к GOMEMLIMIT. Явно можно позвать runtime.GC() (блокирующий полный цикл) или debug.FreeOSMemory().

Глубже. Технически триггер живёт в mallocgc: после аллокации проверяется, не превышен ли gcTrigger, и при необходимости стартует gcStart. Форсированный периодический запуск делает sysmon. runtime.GC() блокирует вызывающую горутину до завершения цикла и, если он был вызван из нескольких горутин, они могут разделить один цикл. Легитимные случаи ручного вызова: перед снятием heap-профиля (inuse станет честным), в бенчмарках между итерациями, после разовой загрузки большого объёма данных, когда хочется отдать пик памяти ОС. В обычном сервисном коде вызов runtime.GC() в цикле или по таймеру — антипаттерн.

Почему считается, что надо как можно реже вызывать Garbage Collector? В чем минус?

Заголовок раздела «Почему считается, что надо как можно реже вызывать Garbage Collector? В чем минус?»

Коротко. Потому что каждый цикл — это две STW-паузы плюс отъедаемый на маркировку CPU (порядка 25% на время фазы) и mark assist, тормозящий аллоцирующие горутины. То есть частый GC режет и латентность, и пропускную способность.

Глубже. Но «как можно реже» — не абсолютное правило, а компромисс с памятью: реже собирать значит держать более высокий RSS, а на пределе — OOM. Правильная постановка: минимизировать не число вызовов, а произведение «частота × стоимость цикла». Частоту снижают через GOGC/GOMEMLIMIT, стоимость цикла — через уменьшение количества живых объектов и указателей в них (массив структур вместо среза указателей, индексы вместо указателей, []byte вместо множества мелких строк). Отдельный минус ручного runtime.GC(): он игнорирует пейсер, поэтому «полезная» работа сборщика делается впустую, а следующая цель кучи пересчитывается от неудачного момента.

Коротко. На двух: (1) в начале цикла — sweep termination, где добивается недоделанный sweep, включается барьер записи и все P переводятся в режим маркировки; (2) в конце — mark termination, где выключается барьер, флашатся локальные буферы маркировки и пересчитывается цель пейсера.

Глубже. Помимо GC, STW используется и в других местах рантайма: при runtime.GOMAXPROCS() со сменой значения, при старте профилирования кучи с полным дампом, при runtime.Stack(buf, true) для всех горутин, при GODEBUG дампах. Знать это полезно, чтобы не приписывать все паузы сборщику. С Go 1.8 сканирование стеков вынесено из STW; до этого mark termination включала повторное сканирование стеков и была на порядок дольше.

Коротко. См. выше: fatal error: stack overflow, процесс завершается, recover не спасает. Отличие от предыдущей формулировки — только в акценте на «что происходит»: рантайм пытается вырастить стек, упирается в лимит (1 ГБ по умолчанию на 64 бита) и делает throw.

Глубже. Стоит отдельно отметить контраст со стеком потока в ОС: там переполнение — это обращение в guard-страницу, то есть SIGSEGV, и в C программа обычно падает без внятного сообщения. Go даёт понятную диагностику именно потому, что проверка идёт программно в прологе каждой функции. Ещё нюанс: если рекурсия происходит в коде под cgo (на системном стеке ОС), защита Go не работает и получите классический сегфолт.

Какие способы можно применить, чтобы разгрузить память и избежать переполнения?

Заголовок раздела «Какие способы можно применить, чтобы разгрузить память и избежать переполнения?»

Коротко. Сократить аллокации (предвыделение, sync.Pool, strings.Builder, переиспользование буферов), уменьшить время жизни объектов, поставить GOMEMLIMIT, стримить данные вместо загрузки целиком, а для стека — заменить глубокую рекурсию на итерацию с явным стеком.

Глубже. Конкретный чек-лист. По куче: предвыделять срезы и карты; передавать []byte в переиспользуемый буфер вместо возврата новых строк; хранить структуры значениями в срезе, а не указателями (меньше объектов — меньше работы GC); заменять map[string]*T на map[string]int + []T, если ключей миллионы; выносить редко используемые поля в отдельную структуру; использовать sync.Pool для крупных короткоживущих буферов (с оглядкой: пул очищается при GC и не подходит для объектов с состоянием); обрабатывать большие тела запросов потоково через io.Reader/json.Decoder; ставить лимиты (http.MaxBytesReader, размеры очередей и батчей). По стеку: избегать рекурсии на данных неизвестной глубины (парсеры JSON/XML — классический вектор атаки), не объявлять огромные локальные массивы. По конфигурации: GOMEMLIMIT под лимит контейнера, GOGC по профилю нагрузки, automaxprocs для корректного GOMAXPROCS.

Как определить, на стек или на кучу ложится переменная?

Заголовок раздела «Как определить, на стек или на кучу ложится переменная?»

Коротко. См. выше: go build -gcflags='-m' для решения компилятора и go test -bench . -benchmem для проверки по allocs/op. Отличий от предыдущей формулировки нет.

Глубже. Добавлю единственную новую деталь: если нужно понять именно место аллокации в работающем сервисе, а не в сборке, используйте heap-профиль с -alloc_objects и опцией -lines — pprof покажет конкретные строки. И помните, что вывод -m относится к конкретной версии компилятора: между релизами Go решения escape analysis меняются, поэтому «эта функция не аллоцирует» — утверждение с привязкой к версии.

Почему предпочтительнее использовать стек, а не кучу?

Заголовок раздела «Почему предпочтительнее использовать стек, а не кучу?»

Коротко. Стековая аллокация — это сдвиг указателя стека в прологе функции, освобождение — его восстановление при возврате; никакой синхронизации, никаких метаданных, ноль работы для GC и почти гарантированное попадание в L1-кэш. Кучевая аллокация дороже сама по себе и, главное, создаёт постоянный налог в виде маркировки.

Глубже. Численно разница в самой аллокации не так драматична (быстрый путь mcache — это десятки наносекунд), основной выигрыш — отложенный: объект в куче потом надо просканировать в каждом цикле GC, пока он жив, и он ухудшает локальность соседних данных. Поэтому правило звучит так: избегайте не «указателей», а лишних долгоживущих объектов с указателями внутри. При этом не стоит превращать это в культ — читаемость важнее, оптимизировать надо по профилю.

Коротко. Физически это та же RAM — «медленная» она из-за накладных расходов управления: поиск слота в аллокаторе, обнуление, возможный уход в mcentral/mheap с блокировками, mark assist, последующее сканирование сборщиком и худшая локальность кэша по сравнению с плотным кадром стека.

Глубже. Разложим по компонентам: (1) сама аллокация — быстрый путь дёшев, но медленный (новый спан, новая страница, mmap, page fault при первом касании) стоит на порядки дороже; (2) обнуление памяти пропорционально размеру; (3) GC-налог — время маркировки растёт с числом живых объектов и указателей; (4) кэш — объекты кучи разбросаны, обход связной структуры даёт промахи в L1/L2 и TLB, тогда как локальные переменные лежат в уже горячих строках кэша; (5) при первом обращении к свежей странице происходит page fault и работа ядра. Стек всего этого лишён, потому что он линеен, локален для горутины и переиспользуется постоянно.

Коротко. Двухфазный алгоритм сборки: mark — обход графа объектов от корней с пометкой достижимых; sweep — проход по куче с освобождением всего непомеченного. В Go обе фазы конкурентные с программой, а sweep ещё и ленивый — спан подметается перед повторным использованием.

Глубже. Классический наивный mark & sweep требует полной остановки на всё время обхода и оставляет фрагментированную кучу — отсюда варианты mark-compact и copying-collectors, которые дополнительно уплотняют память. Go выбрал неперемещающий вариант ради стабильных адресов (важно для unsafe, cgo, указателей внутрь объекта) и решил проблему фрагментации на уровне аллокатора через классы размера. Sweep в Go не проходит по всей куче единым махом: mspan помечается как требующий подметания, и sweepone обрабатывает его в момент попытки аллокации из него либо в фоне, поэтому стоимость sweep размазана и почти не видна в профиле.

Что такое Stop the World и на каком этапе срабатывает?

Заголовок раздела «Что такое Stop the World и на каком этапе срабатывает?»

Коротко. Stop the World — состояние, когда рантайм приостанавливает выполнение всего пользовательского кода: все P останавливаются в безопасных точках. В GC срабатывает дважды — sweep termination в начале цикла и mark termination в конце (см. выше).

Глубже. Отличие от предыдущего вопроса про этапы: здесь стоит проговорить, как именно достигается остановка. stopTheWorldWithSema выставляет флаг и ждёт, пока каждый P перейдёт в состояние _Pgcstop; горутина попадает в безопасную точку при вызове функции (проверка stackguard0 = stackPreempt в прологе), при системном вызове или, начиная с Go 1.14, принудительно через сигнал SIGURG — асинхронная вытесняемость. Именно поэтому «горячий цикл без вызовов» больше не блокирует STW, как это было в старых версиях. Если процесс сидит в долгом cgo-вызове, его P освобождается и STW не ждёт возврата.

Как можно уменьшить негативное влияние Stop the World?

Заголовок раздела «Как можно уменьшить негативное влияние Stop the World?»

Коротко. Не тюнингом пауз (их длительность не настраивается), а сокращением количества циклов GC и объёма работы в каждом: меньше аллокаций, меньше живых объектов с указателями, разумный GOGC/GOMEMLIMIT, sync.Pool для крупных буферов и достаточный запас CPU, чтобы GC-воркеры не конкурировали с обработкой запросов.

Глубже. Что реально помогает в проде: (1) GOMEMLIMIT вместо ballast — стабилизирует частоту циклов; (2) снижение числа объектов — []Item вместо []*Item, строки-интернированные ключи, представление больших кэшей в виде off-heap-подобных структур без указателей (например, []byte + индексы), потому что noscan-объекты не сканируются вообще; (3) убрать per-request аллокации в горячем пути (JSON-декодер с переиспользованием, bytes.Buffer из пула); (4) в Kubernetes выставить GOMAXPROCS по CPU-лимиту, иначе GC-воркеры провоцируют throttling и паузы растягиваются; (5) не держать сотни тысяч живых горутин без нужды — их стеки надо готовить к сканированию. И измерять: GODEBUG=gctrace=1, go tool trace, гистограмма /gc/pauses:seconds из runtime/metrics.

Коротко. Обрывок исходника, вопрос не восстанавливается.

Глубже. Похоже на вариант ответа из теста с выбором — вероятно, к вопросу вида «что делает функция выделения памяти / сборщик мусора». Если так, то этот вариант неверный: ни аллокатор, ни GC в Go не «исправляют неверные ссылки». GC лишь определяет достижимость; висячих указателей в безопасном Go не бывает по построению, а если вы сломали типобезопасность через unsafe/uintptr, никто это не починит.

Выделяет память и возвращает указатель на участок памяти;

Заголовок раздела «Выделяет память и возвращает указатель на участок памяти;»

Коротко. Обрывок исходника, вопрос не восстанавливается.

Глубже. По формулировке — правильный вариант ответа на вопрос «что делает new()»: new(T) выделяет обнулённую память под значение типа T и возвращает *T. В отличие от C malloc, память гарантированно занулена, а размер выводится из типа, а не задаётся в байтах.

Коротко. Обрывок исходника, вопрос не восстанавливается.

Глубже. Также похоже на неверный вариант ответа теста. Замечу лишь, что рядом есть реальная тема: аллокатор Go группирует объекты по классам размера, и это иногда ошибочно называют «сортировкой по размеру». Ещё близкое явление — выравнивание и порядок полей в структуре: компилятор Go поля не переставляет, поэтому размер структуры зависит от порядка объявления, и ручная перестановка от больших к меньшим экономит память на padding.

Какой функцией мы можем выделить память в Go?

Заголовок раздела «Какой функцией мы можем выделить память в Go?»

Коротко. Встроенными new и make: new(T) возвращает *T с обнулённым значением, make инициализирует срез, карту или канал и возвращает сам тип. Плюс память выделяется неявно — композитными литералами (&T{}), append при росте, конвертациями строк, упаковкой в интерфейс.

Глубже. Под капотом все пути ведут в runtime.mallocgc через newobject, makeslice, makemap, makechan, growslice. make нельзя применить к другим типам именно потому, что только срез, карта и канал имеют внутренний дескриптор, который надо инициализировать (указатель на массив/бакеты/буфер, длина, ёмкость). Ручного освобождения нет — соответствующей функции в языке не существует. Для аллокации вне контроля GC есть syscall.Mmap или cgo C.malloc, но это редкие сценарии, требующие ручного освобождения.

Коротко. Обрывок исходника, вопрос не восстанавливается.

Глубже. По виду — вариант ответа к вопросу вроде «как сравниваются указатели/интерфейсы/значения». Смежный факт, который реально спрашивают: два указателя равны, если указывают на один адрес или оба nil; при этом указатели на разные переменные нулевого размера (struct{}) могут оказаться равными, так как компилятор вправе разместить их по одному адресу. Интерфейсные значения равны, если совпадают динамический тип и динамическое значение, а не адреса.

Коротко. Страница — минимальная единица, которой операционная система управляет виртуальной памятью и отображает её на физическую; на x86-64 и arm64 обычно 4 КБ (плюс huge pages 2 МБ). Рантайм Go оперирует своими «страницами» по 8 КБ внутри арен по 64 МБ.

Глубже. Практические следствия для Go-разработчика: RSS растёт постранично, при первом обращении к новой странице происходит page fault и ядро выделяет физический фрейм — поэтому пик аллокаций сопровождается всплеском системного времени; освобождение памяти в ОС тоже постраничное (madvise на диапазон), и слот, освобождённый GC внутри частично занятой страницы, ОС не вернётся; трансляция адресов кэшируется в TLB, и разбросанные по памяти данные дают TLB-промахи, что отчасти объясняет, почему указательные структуры медленнее плотных массивов. Transparent Huge Pages могут снизить TLB-давление, но исторически провоцировали раздувание RSS у Go-процессов, поэтому в рантайме есть логика, ограничивающая их применение.

На голом сервере запущен какой-то сервис и в нем есть утечка памяти. Что произойдет, когда память закончится? Речь не про кубер и ограничения контейнеров, а про голый сервер

Заголовок раздела «На голом сервере запущен какой-то сервис и в нем есть утечка памяти. Что произойдет, когда память закончится? Речь не про кубер и ограничения контейнеров, а про голый сервер»

Коротко. Сначала система вытеснит page cache и начнёт свопить (если своп есть) — производительность всего сервера упадёт из-за трэшинга. Когда и своп исчерпан, сработает OOM-killer ядра: он выберет процесс с наибольшим oom_score (как правило, именно жирный сервис) и убьёт его SIGKILL — без шанса на graceful shutdown, дефер не выполнится.

Глубже. Детали, которые отличают сильный ответ. Linux по умолчанию использует overcommit, поэтому mmap в Go долго возвращает успех даже когда физической памяти уже нет — падение случится позже и не в точке аллокации. Возможен и второй сценарий: если mmap всё же вернёт ошибку, рантайм Go напечатает fatal error: runtime: out of memory и завершится сам. Убитый OOM-killer процесс оставляет запись в dmesg/journalctl -k («Out of memory: Killed process …»), и это первое, что надо проверить при загадочных рестартах. Пострадать может не только виновник: если сервис вытеснил page cache, замедлится вся система, а OOM-killer иногда выбирает соседа. Смягчается это oom_score_adj, systemd-настройками (MemoryMax, Restart=always), мониторингом и — на стороне Go — выставлением GOMEMLIMIT, который заставит GC работать агрессивнее вместо роста до потолка.

На физическом сервере без контейнеризации запущен процесс с утечкой памяти. Что произойдет, когда он исчерпает доступную память?

Заголовок раздела «На физическом сервере без контейнеризации запущен процесс с утечкой памяти. Что произойдет, когда он исчерпает доступную память?»

Коротко. См. выше — тот же сценарий: своп и трэшинг, затем OOM-killer с SIGKILL, либо fatal error: runtime: out of memory от самого рантайма, если системный вызов выделения памяти вернёт ошибку.

Глубже. Раз вопрос дублируется, добавлю то, чего не было выше: разница между «памятью закончилась» и «адресное пространство закончилось». На 64-битной системе адресное пространство практически бесконечно, поэтому упирается всё именно в физическую память плюс своп (MemAvailable). Также стоит упомянуть, что Go-процесс в момент нехватки часто ведёт себя особенно плохо: GC пытается работать чаще, чтобы уложиться, растёт доля CPU на маркировку, латентность деградирует ещё до смерти процесса — эта «GC-спираль смерти» видна на графиках как одновременный рост CPU и памяти. GOMEMLIMIT ситуацию не спасает при настоящей утечке, но делает деградацию более предсказуемой.

Что такое стек и куча? Какая между ними разница? Когда данные попадают в стек, а когда - в кучу?

Заголовок раздела «Что такое стек и куча? Какая между ними разница? Когда данные попадают в стек, а когда - в кучу?»

Коротко. Стек — LIFO-область на горутину, где живут кадры вызовов и локальные переменные; освобождается автоматически при возврате, дёшев, но ограничен временем жизни функции. Куча — общая область с произвольным временем жизни объектов, управляется аллокатором и GC. Данные попадают в кучу, когда компилятор доказал, что они переживают кадр или их размер неизвестен, иначе — в стек.

Глубже. Сводка отличий, которую удобно проговорить: время жизни (кадр функции против произвольного), стоимость выделения (сдвиг SP против пути в аллокаторе), освобождение (автоматически при возврате против GC), доступность (стек приватен для горутины, куча общая), локальность (высокая против низкой), размер (2 КБ на старте, растёт до 1 ГБ против ограничения памятью машины), участие GC (только как корни против полного управления). В Go, в отличие от Java и C#, размещение не диктуется видом типа: структура может быть и на стеке, и в куче в зависимости от использования.

Как должно работать, если все данные хранятся в памяти? Кодить не нужно - достаточно рассказать словами.

Заголовок раздела «Как должно работать, если все данные хранятся в памяти? Кодить не нужно - достаточно рассказать словами.»

Коротко. Нужно описать in-memory хранилище: структура данных под требуемые операции (обычно карта плюс индексы), защита конкурентного доступа (RWMutex или шардирование), политика вытеснения и TTL, ограничение по объёму, и — главное — стратегия durability: WAL или периодические снапшоты с восстановлением при старте, иначе рестарт потеряет всё.

Глубже. Каркас развёрнутого ответа. (1) Модель доступа: какие операции и с какой долей чтений/записей — от этого зависит выбор между map + RWMutex, шардированной картой и lock-free структурами. (2) Память: оценить объём (число записей × размер записи + накладные расходы карты), поставить лимит и вытеснение (LRU/LFU), иначе — OOM; помнить, что миллионы мелких объектов с указателями дорого обходятся GC, поэтому для больших объёмов выгоднее плотные представления ([]byte-арена + офсеты). (3) Долговечность: append-only WAL с fsync-политикой, периодические снапшоты, восстановление при старте, компакция лога. (4) Доступность: репликация или хотя бы возможность прогреть кэш из основной БД. (5) Консистентность: атомарность многоключевых операций, версии/CAS. (6) Наблюдаемость: метрики hit ratio, размера, времени восстановления. (7) Явно назвать, когда своё писать не надо, — Redis/Memcached уже решают эти задачи. Типичная ошибка кандидата: описать map под мьютексом и остановиться, не сказав ни про лимит памяти, ни про восстановление после рестарта.

Коротко. Возвращает размер в байтах, который занимает значение указанного типа само по себе, не считая памяти, на которую оно ссылается. Это константа времени компиляции типа uintptr, аргумент не вычисляется.

Глубже. Ключевой нюанс — «shallow»: для среза это 24 байта заголовка (указатель, длина, ёмкость) на 64-битной платформе независимо от числа элементов; для строки 16 байт; для карты, канала и указателя — 8; для пустого интерфейса и любого интерфейса — 16 (тип + данные). Для структуры результат учитывает выравнивание и padding, поэтому зависит от порядка полей. Рядом живут unsafe.Alignof и unsafe.Offsetof. Для «глубокого» размера готовой функции в стандартной библиотеке нет — считают вручную или через сторонние библиотеки/heap-профиль.

package main
import (
"fmt"
"unsafe"
)
type S struct {
a bool // 1 байт + 7 padding
b int64 // 8
c bool // 1 + 7 padding
}
func main() {
var sl []int
var st string
var i any
fmt.Println(unsafe.Sizeof(sl)) // 24 на 64-битной платформе
fmt.Println(unsafe.Sizeof(st)) // 16
fmt.Println(unsafe.Sizeof(i)) // 16
fmt.Println(unsafe.Sizeof(S{}))
}

Коротко. См. выше: конкурентный трёхцветный mark & sweep без перемещения объектов, с гибридным барьером записи, двумя короткими STW и пейсером на базе GOGC.

Глубже. Если вопрос задан коротко и повторно, интервьюер обычно ждёт, что вы сами выберете глубину. Хороший ход — за 30 секунд дать скелет (корни → маркировка → sweep, две паузы, конкурентность, барьер записи, GOGC), а потом спросить, какую часть развернуть: пейсер, барьер, аллокатор или тюнинг. Это лучше, чем сразу вываливать всё подряд.

Коротко. Вопрос про личный опыт, повтор предыдущих про инструменты. Отвечать историей: симптом → разделение «куча или горутины» → два heap-профиля и pprof -base → найденное место → фикс → подтверждение по графику.

Глубже. Что усилит ответ: назвать конкретные метрики, по которым заметили; сказать, что смотрели inuse_space, а не alloc_space; упомянуть go.uber.org/goleak в тестах как профилактику; описать, как убедились, что починили (метрика вышла на плато за неделю наблюдения). Что ослабит: «включил pprof и посмотрел», без деталей; рассказ, в котором проблема нашлась «сама» после апгрейда версии.

Какие инструменты для observability вы использовали? Какой стек?

Заголовок раздела «Какие инструменты для observability вы использовали? Какой стек?»

Коротко. Вопрос про опыт. Нужно покрыть три столпа — метрики, логи, трейсы — и показать, что вы понимаете, какой сигнал для какой задачи.

Глубже. Каркас: метрики — Prometheus + Grafana, клиент prometheus/client_golang, обязательные go_* метрики рантайма (горутины, куча, паузы GC) плюс RED/USE по эндпоинтам; логи — структурные через log/slog (или zap/zerolog), сбор в Loki/ELK, обязательно с trace_id в каждой записи; трассировка — OpenTelemetry SDK, экспорт в Jaeger/Tempo, инструментирование HTTP/gRPC/БД; профилирование — net/http/pprof, при наличии continuous profiling (Pyroscope/Parca); алертинг — Alertmanager с SLO-based правилами; для инцидентов — дашборд «золотые сигналы» плюс runbook. Сильный ход — рассказать один случай, когда трассировка или профиль реально сократили время расследования. Ошибки: перечислить продукты без объяснения, зачем каждый; сказать «логировали всё» (стоимость и шум); не знать, чем метрика отличается от трейса.

Коротко. Стек — LIFO-область памяти, в которой при каждом вызове функции создаётся кадр с аргументами, локальными переменными и адресом возврата; при возврате кадр «снимается» простым восстановлением указателя стека. В Go у каждой горутины свой стек, стартующий с 2 КБ и растущий копированием.

Глубже. Механика в Go: компилятор вставляет в пролог функции проверку остатка места относительно stackguard0; при нехватке вызывается morestacknewstack, который аллоцирует вдвое больший сегмент, копирует содержимое старого и корректирует все указатели, которые указывали внутрь стека, — это возможно благодаря точным картам указателей для каждого кадра. Обратная операция — сжатие стека вдвое во время сканирования GC, если занято меньше четверти. Освобождённые стеки не отдаются сразу ОС, а попадают в пулы (stackpool для мелких, stackLarge для крупных) и переиспользуются. Тот же механизм карт кадров используется для точного сканирования стеков сборщиком и для построения трассировок паники.

Коротко. См. выше: конкурентный трёхцветный mark & sweep, неперемещающий и непоколенческий, две короткие STW-паузы, барьер записи, темп задаётся GOGC и (с 1.19) GOMEMLIMIT.

Глубже. Отличие этой формулировки от предыдущих дубликатов — она максимально короткая, и обычно за ней следуют уточняющие вопросы. Держите наготове четыре «ветки»: чем отличается от поколенческого GC в JVM (нет поколений, зато escape analysis снимает нагрузку короткоживущих объектов); почему нет компактизации (стабильные адреса, cgo, unsafe); что делает барьер записи (гибрид Дейкстры и Юасы с Go 1.8); как считается момент запуска (пейсер, цель живое × (1 + GOGC/100)).

Коротко. Принцип — достижимость: живо то, до чего можно дойти по указателям от корней (глобалы, стеки, регистры); всё остальное освобождается. Реализация в Go — конкурентная трёхцветная маркировка с барьером записи и последующим ленивым sweep.

Глубже. Полезно проговорить, что именно этот принцип отличает GC от подсчёта ссылок: достижимость корректно обрабатывает циклы (два объекта, ссылающиеся друг на друга, но недостижимые от корней, будут собраны), тогда как наивный refcounting их бы не собрал. Обратная сторона — недетерминированность: момент освобождения неизвестен, поэтому нельзя привязывать к нему освобождение внешних ресурсов; для файлов и соединений в Go используют defer Close(), а не финализаторы.

Почему сборщик останнавливает программу?

Заголовок раздела «Почему сборщик останнавливает программу?»

Коротко. Потому что часть операций требует глобально согласованного состояния всех процессоров: включить и выключить барьер записи, зафиксировать набор корней, добить недоделанный sweep, слить локальные буферы маркировки. Сделать это, пока горутины продолжают менять граф объектов, нельзя без риска потерять живой объект.

Глубже. Обратите внимание: остановка нужна не для самого обхода графа — он как раз конкурентный. Она нужна для «переключения режима». В момент включения барьера записи все P должны увидеть новое состояние одновременно, иначе часть записей пройдёт без барьера и инвариант сломается — живой объект останется белым и будет ошибочно освобождён. Аналогично mark termination: надо убедиться, что ни у кого не осталось необработанной серой работы, а это глобальное свойство. До Go 1.8 второй STW был длиннее, потому что включал повторное сканирование стеков; гибридный барьер записи позволил от этого избавиться.

Коротко. Только кучу Go — объекты, выделенные mallocgc. Стеки, статические сегменты и память, выделенная вне рантайма (cgo, ручной mmap), сборщиком не освобождаются; стеки и глобалы он лишь сканирует как корни.

Глубже. Дополнительно: GC освобождает слоты внутри спанов, а не страницы ОС — возврат страниц ядру делает отдельный фоновый scavenger. Память под сами метаданные рантайма (mspan, mcache, битовые карты, профили) выделяется вне кучи и в heap-профиле не видна, но входит в GOMEMLIMIT и в MemStats.Sys. Стеки горутин освобождаются при завершении горутины (уходят в стек-пулы), а также ужимаются во время GC-сканирования, если используются меньше чем на четверть.

Коротко. Потому что стеку не нужен сборщик: время жизни кадра точно известно на этапе компиляции, и возврат из функции освобождает всю его память одним восстановлением указателя стека. Собирать нечего — там по определению нет «мусора неизвестного возраста».

Глубже. Стек и GC всё-таки взаимодействуют, но иначе: сборщик сканирует стеки как корни (по картам указателей для каждой точки вызова), чтобы найти живые ссылки в кучу, и заодно может ужать стек вдвое, если он избыточно велик. Сам буфер стека возвращается в пул только при завершении горутины. Ещё нюанс: локальные переменные, чей адрес убежал, физически лежат в куче — на стеке остаётся лишь указатель, — так что «стековые» данные с точки зрения программиста могут оказаться кучевыми с точки зрения рантайма.

Коротко. См. выше про «что лучше» — стек выгоднее там, где он применим, но это не выбор программиста, а следствие времени жизни данных, которое выводит компилятор. Правильная цель — не «загнать всё на стек», а сократить лишние долгоживущие объекты в куче по данным профиля.

Глубже. Единственное практическое, что вы контролируете: не заставляйте значения убегать без нужды. Типичные приёмы — возвращать значение вместо указателя для небольших структур, не оборачивать в interface{} в горячем пути, не логировать через fmt.Sprintf внутри цикла, переиспользовать буферы. И обратный совет: не копируйте по значению большие структуры ради «стековости» — копирование 1 КБ на каждый вызов может стоить дороже одной аллокации.

Коротко. Автоматически и детерминированно: при возврате из функции указатель стека восстанавливается, и весь кадр разом становится недоступен — никакого прохода по объектам нет. Дополнительно рантайм может ужать стек горутины вдвое во время GC-сканирования, а при завершении горутины стек целиком возвращается в пул стеков.

Глубже. Память кадра при этом не обнуляется — она просто перестаёт использоваться и будет перезаписана следующим вызовом. Компилятор, впрочем, вставляет обнуление тех локальных переменных, которые содержат указатели и должны быть корректно интерпретированы сборщиком в точках, где кадр ещё жив, — иначе на стеке остались бы «мусорные» значения, похожие на указатели. Три уровня «очистки» стека полезно назвать явно: (1) на каждом возврате — сдвиг SP; (2) во время GC — shrinkstack, копирование в вдвое меньший буфер, если занято меньше четверти; (3) при завершении горутины — stackfree в stackpool/stackLarge, откуда буферы переиспользуются другими горутинами, а излишки в итоге возвращаются в mheap.

Мы как-то можем оптимизировать работу со сборщиком мусора?

Заголовок раздела «Мы как-то можем оптимизировать работу со сборщиком мусора?»

Коротко. Да, двумя способами: снизить нагрузку на GC (меньше аллокаций, меньше живых объектов с указателями) и настроить его частоту через GOGC и GOMEMLIMIT. Первое даёт кратно больше эффекта, чем второе.

Глубже. Со стороны кода работают: переиспользование буферов (sync.Pool, bytes.Buffer, append в заранее выделенный слайс), преаллокация через make([]T, 0, n) и make(map[K]V, n), хранение значений вместо указателей ([]Item вместо []*Item — меньше объектов и меньше указателей для сканирования), замена map[string]*T на структуры с индексами, отказ от лишних конверсий []bytestring (там, где можно — strings.Builder, unsafe.String/unsafe.Slice в горячем коде), отказ от передачи значений в interface{} в горячем пути (это классическая причина escape). Важно: GC сканирует только объекты, содержащие указатели, — большой []byte на 100 МБ для маркировки почти бесплатен, а миллион мелких структур с указателями дорог.

Со стороны настроек: GOGC (по умолчанию 100) задаёт, на сколько процентов может вырасти куча относительно живого объёма после прошлой сборки; GOGC=400 уменьшит число циклов GC в разы ценой роста RSS. GOMEMLIMIT (Go 1.19+) задаёт мягкий лимит на общий объём памяти рантайма — правильный инструмент для контейнеров: ставим лимит чуть ниже cgroup-лимита, и GC начинает работать чаще при приближении к нему, вместо того чтобы приложение убил OOM killer. Частый продовый рецепт — GOGC=off (или большое значение) плюс GOMEMLIMIT: GC срабатывает только по границе памяти. Программно то же самое: debug.SetGCPercent, debug.SetMemoryLimit. Перед любой из этих ручек нужно снять профиль: go test -bench . -benchmem, pprof по allocs/heap, GODEBUG=gctrace=1.

В каких случаях происходит вызов сборщика мусора?

Заголовок раздела «В каких случаях происходит вызов сборщика мусора?»

Коротко. Три триггера: рост кучи до целевого размера, рассчитанного пейсером из GOGC/GOMEMLIMIT; принудительный запуск раз в 2 минуты, если сборки давно не было; явный вызов runtime.GC() (а также debug.FreeOSMemory()).

Глубже. Основной триггер — heap goal: после каждой сборки рантайм знает объём живых данных и ставит цель live * (1 + GOGC/100); когда выделенная куча дорастает до цели, стартует новый цикл. Причём стартует он заранее — пейсер оценивает скорость аллокации и время маркировки так, чтобы завершить цикл примерно к достижению цели, а горутина, которая аллоцирует слишком быстро, штрафуется mark assist: её заставляют выполнить часть работы по разметке. Второй триггер — sysmon: если с последней сборки прошло больше forcegcperiod (2 минуты), GC запускается принудительно, чтобы вернуть память ОС даже в простаивающем приложении. Третий — ручной runtime.GC(), который блокирует вызывающую горутину до завершения цикла; в проде он почти всегда не нужен, уместен разве что в тестах и бенчмарках. При GOGC=off без GOMEMLIMIT остаются только ручной вызов и периодический форс.

Расскажи про про иерархию компьютерной памяти. Какие виды памяти просто есть в компе и как они между собой взаимодействуют, в каком порядке?

Заголовок раздела «Расскажи про про иерархию компьютерной памяти. Какие виды памяти просто есть в компе и как они между собой взаимодействуют, в каком порядке?»

Коротко. От быстрого и маленького к медленному и большому: регистры CPU → кэши L1/L2/L3 → оперативная память (DRAM) → SSD/HDD (в т. ч. своп) → сеть/архивное хранилище. Каждый уровень выступает кэшем для следующего, обмен идёт блоками фиксированного размера.

Глубже. Порядок величин полезно помнить: регистр — доли наносекунды, L1 — ~1 нс (десятки КБ, на ядро), L2 — ~4 нс (сотни КБ — единицы МБ), L3 — ~15–40 нс (десятки МБ, общий на сокет), DRAM — ~60–100 нс (гигабайты), NVMe SSD — десятки микросекунд, HDD — миллисекунды. Между кэшами и памятью данные ходят кэш-линиями по 64 байта — отсюда практические следствия: последовательный обход массива на порядок быстрее обхода списка указателей, а false sharing (две горутины пишут в разные поля одной кэш-линии) убивает масштабирование, лечится паддингом.

Отдельный слой — виртуальная память: процесс видит непрерывное виртуальное адресное пространство, MMU транслирует адреса в физические по таблицам страниц, а недавние трансляции кэшируются в TLB. Страница обычно 4 КБ (плюс huge pages 2 МБ). Обращение к неотображённой странице даёт page fault: ядро либо подставляет физическую страницу (minor fault, в т. ч. при первом касании свежевыделенной памяти), либо читает её из свопа/файла (major fault, дорого). Поэтому «выделить» и «фактически занять» — разные вещи: Go-рантайм резервирует адресное пространство щедро, а RSS растёт только по мере реального касания страниц. На многосокетных машинах добавляется NUMA: доступ к памяти чужого сокета заметно дороже.

Что такое сборщик мусора, для чего нужен и по какому алгоритму работает?

Заголовок раздела «Что такое сборщик мусора, для чего нужен и по какому алгоритму работает?»

Коротко. GC — часть рантайма, которая автоматически освобождает память в куче, недостижимую из корней программы. В Go это конкурентный, неперемещающий mark & sweep с трёхцветной маркировкой и барьером записи; на каждый цикл приходятся две короткие паузы stop-the-world, обычно доли миллисекунды.

Глубже. Нужен он, чтобы снять с программиста ручное free и связанный с ним класс ошибок: use-after-free, double free, висячие указатели. Цена — фоновый расход CPU (Go целится примерно в 25% CPU на маркировку) и небольшие паузы.

Алгоритм по фазам: sweep termination (короткий STW, завершение подметания прошлого цикла и включение барьера записи), конкурентная маркировка (фоновые mark-воркеры плюс mark assist на аллоцирующих горутинах обходят граф объектов от корней — глобалов и стеков горутин), mark termination (короткий STW, доразметка и выключение барьера), конкурентный sweep (спаны с мёртвыми объектами возвращаются в свободные списки лениво, по мере запросов на аллокацию). Трёхцветная абстракция: белые — ещё не обработанные (кандидаты в мусор), серые — найденные, но не просканированные, чёрные — просканированные. Инвариант — чёрный объект не должен ссылаться на белый; его поддерживает гибридный барьер записи (Dijkstra + удаляющий барьер Юзы, с Go 1.8), благодаря которому не нужно перезапускать сканирование стеков и STW-паузы стали субмиллисекундными. Go GC не уплотняет кучу и не является поколенческим: вместо поколений ставка сделана на escape-анализ, который и так оставляет короткоживущие объекты на стеке.

Как работает garbage collector? Что такое escape анализ? Можно ли как-то посмотреть, убегает переменная в хип или нет?

Заголовок раздела «Как работает garbage collector? Что такое escape анализ? Можно ли как-то посмотреть, убегает переменная в хип или нет?»

Коротко. Про GC — см. выше: конкурентный трёхцветный mark & sweep. Escape-анализ — статический анализ компилятора, который решает, переживёт ли значение свой кадр стека; если нет, оно размещается на стеке. Посмотреть решение можно так: go build -gcflags='-m' ./....

Глубже. Escape-анализ строит граф «утечек» указателей по функциям: если адрес значения записывается в глобал, в поле объекта, уже находящегося в куче, возвращается наружу и не может быть проинлайнен, передаётся в интерфейс или в функцию, чей параметр помечен как escaping, — значение уезжает в кучу. Анализ межпроцедурный и работает вместе с инлайнингом: часто «return &x» остаётся на стеке именно потому, что функция заинлайнилась в вызывающую.

package main
import "fmt"
type point struct{ x, y int }
func stackAlloc() int {
p := point{1, 2} // не убегает
return p.x + p.y
}
func heapAlloc() *point {
p := point{1, 2} // &p escapes to heap, если вызывающий сохранит указатель
return &p
}
func main() {
fmt.Println(stackAlloc(), heapAlloc().y)
}

Диагностика: go build -gcflags='-m' печатает строки вида ./main.go:12:2: moved to heap: p и escapes to heap; -m -m (или -m=2) добавит цепочку рассуждений. Полезны также -gcflags='-m -l' (отключить инлайнинг, чтобы увидеть «честное» решение) и подтверждение фактом: go test -bench . -benchmem покажет allocs/op, а testing.AllocsPerRun — точное число аллокаций. Дизассемблер go build -gcflags=-S покажет вызовы runtime.newobject/runtime.makeslice.

Коротко. Стек — область с дисциплиной LIFO, приватная для горутины, хранит кадры вызовов и локальные переменные; выделение и освобождение — сдвиг указателя, GC её не чистит. Куча — общая область для объектов, время жизни которых не привязано к кадру; там работает аллокатор и GC.

Глубже. В Go у каждой горутины свой стек, который начинается с 2 КБ и растёт копированием; стеки лежат в памяти, полученной из кучи рантайма, а не в стеке потока ОС. Данные на стеке дешевле по всем статьям: аллокация почти бесплатна, освобождение бесплатно, локальность отличная (горячие страницы уже в кэше), GC их не считает мусором. Куча дороже: нужен поиск свободного слота в спане, объект участвует в маркировке, возможна фрагментация — но только куча позволяет объекту пережить функцию и быть разделённым между горутинами.

Ключевое отличие Go от C++/Rust: разработчик не выбирает область размещения явно (new в Go не означает «в куче»). Выбор делает компилятор escape-анализом, и семантика программы от него не зависит — только производительность. Полезно также помнить, что «на стеке» и «в куче» — это про backing storage: например, у слайса заголовок (указатель, len, cap) может лежать на стеке, а массив под ним — в куче, и наоборот.

Когда Go выделяет память в куче, а когда в стеке?

Заголовок раздела «Когда Go выделяет память в куче, а когда в стеке?»

Коротко. На стеке — если escape-анализ доказал, что значение не переживёт кадр функции и его размер известен и не слишком велик. В куче — во всех остальных случаях: значение «убегает», размер неизвестен на этапе компиляции или он слишком большой.

Глубже. Типичные причины попадания в кучу: возврат указателя, который вызывающий сохраняет; сохранение указателя в глобал, в поле уже «кучного» объекта, в канал, в замыкание, которое живёт дольше функции; передача значения в interface{}/any (в том числе через fmt.Println — параметры ...any практически всегда escape); make([]T, n) c переменным n; слишком большой объект. Пороги в компиляторе: явная локальная переменная размещается на стеке, если она меньше 10 МБ (maxStackVarSize), а неявное выделение (new, make, композитный литерал, у которого берут адрес) — если меньше 64 КБ (maxImplicitStackVarSize). Это детали реализации cmd/compile, но порядок величин на собеседовании назвать полезно.

Коротко. Обычно это либо реальная утечка (ссылки на данные никто не отпускает), либо «не утечка, а поведение»: GOGC=100 держит кучу вдвое больше живых данных, плюс фрагментация и отложенный возврат страниц ОС.

Глубже. Частые причины по убыванию встречаемости: утечка горутин — заблокированные на канале горутины держат свои стеки и всё, на что ссылаются (проверяется по runtime.NumGoroutine() и профилю goroutine); подслайсы и подстроки — small := big[:10] держит весь backing-массив big, лечится копированием; карты, которые не отдают память: delete не уменьшает число бакетов, и после пика в миллион ключей карта останется большой — нужно пересоздавать её; в Go 1.24 реализация карт переехала на Swiss Tables (быстрее и компактнее), но автоматического «усыхания» это не даёт; кэши без TTL и без ограничения размера; хранение больших объектов через указатели в долгоживущих структурах, time.Timer/Ticker без Stop, незакрытые тела HTTP-ответов; cgo-память и mmap, которые GC вообще не видит; sync.Pool под пиковой нагрузкой (пул раздувается до пика, очищается только на GC); GOGC по умолчанию при большом живом объёме; фрагментация кучи из-за смеси размеров; разница между heap in-use и RSS: рантайм возвращает страницы ОС не мгновенно (фоновый scavenger), поэтому RSS падает с задержкой. Инструменты: pprof (heap для живых, allocs для суммарных), GODEBUG=gctrace=1, runtime.ReadMemStats, runtime/metrics, для контейнеров — GOMEMLIMIT.

Коротко. Стеки горутин выделяет рантайм Go из собственной кучи: мелкие — из per-P кэшей stackpool, крупные — из stackLarge/mheap. Стек ОС-потока используется только для системных стеков (g0) и кода на C.

Глубже. Это принципиально: горутина — не поток, её стек не создаётся ядром и не резервирует гигабайты виртуального адресного пространства, поэтому миллион горутин по 2 КБ реально помещается в память. Рантайм держит свободные стековые блоки размеров 2/4/8/16 КБ в кэшах, привязанных к P, и переиспользует их при создании новых горутин — создание горутины поэтому дешёвое. Стек главной горутины и стеки потоков ОС (m0/g0) — обычные потоковые стеки, выделенные ядром (типично 8 МБ по ulimit -s в Linux); на g0 рантайм выполняет планировщик, обработку сигналов и часть GC-работы.

Коротко. За счёт копирования: пролог функции проверяет, хватает ли места, при нехватке вызывает morestackruntime.newstack, тот выделяет новый блок вдвое большего размера, копирует туда старый стек и корректирует все указатели внутрь стека.

Глубже. Такой подход называется contiguous (copying) stacks и появился в Go 1.3 вместо сегментированных стеков, которые страдали от «hot split» — многократного перескока туда-сюда на границе сегмента в цикле. Корректность копирования обеспечивается точной информацией об указателях: компилятор генерирует stack maps, так что рантайм знает, какие слова в кадрах — указатели, и может переписать их на новые адреса. Именно поэтому нельзя хранить адрес стековой переменной в uintptr через границу вызова — при росте стека адрес станет невалидным.

Ограничение сверху — 1 ГБ на 64-битных платформах (debug.SetMaxStack меняет лимит); при превышении программа падает с fatal error: stack overflow, и это не паника, recover тут не поможет. Обратный процесс тоже есть: во время сканирования стеков GC может сжать стек вдвое, если используется меньше четверти. Ещё нюанс: в функции, которая вызывает morestack, адреса локальных переменных меняются, поэтому «дешёвая» с виду рекурсия может стоить нескольких копирований стека — в горячем коде это видно в профиле как runtime.morestack.

Коротко. Куча взаимодействует с ОС (получает и возвращает страницы через mmap/madvise), с аллокатором рантайма (mcache/mcentral/mheap), со стеками горутин (указатели со стека в кучу — корни для GC) и с самим GC, а через cgo — с памятью, выделенной malloc’ом.

Глубже. Если вопрос читать как «что может ссылаться на объекты в куче», ответ такой: корни — глобальные переменные (сегмент данных/BSS), стеки всех горутин, регистры, а также внутренние структуры рантайма (например, буферы каналов и finalizer-очереди). Объекты в куче ссылаются друг на друга — это и есть граф, который обходит маркировка. Отдельно стоит cgo: указатели на Go-память, переданные в C, действительны только на время вызова, а Go-объекты, на которые ссылается только C-память, GC не видит — за это отвечают правила cgo pointer passing и runtime/cgo.Handle. Стеки горутин, что важно, физически тоже выделены из памяти кучи рантайма, но управляются отдельно от объектов и не собираются GC. Возврат памяти ОС делает фоновый scavenger: с Go 1.16 по умолчанию используется MADV_DONTNEED, поэтому RSS уменьшается заметно, а debug.FreeOSMemory() форсирует этот процесс.

Как garbage collector обрабатывает переменные, выделенные на стеке?

Заголовок раздела «Как garbage collector обрабатывает переменные, выделенные на стеке?»

Коротко. Он их не освобождает — стековые переменные исчезают при возврате из функции. GC лишь сканирует стеки горутин как корни, чтобы найти в них указатели на объекты в куче.

Глубже. Сканирование стека точное (precise), а не консервативное: по stack maps, сгенерированным компилятором для каждой точки вызова, рантайм знает, какие слоты кадра содержат указатели. Сканирование стека конкретной горутины требует её приостановки на очень короткое время, но не общего STW; после сканирования горутина помечается как «просканированная», и гибридный барьер записи гарантирует, что повторно возвращаться к ней не нужно. Побочный эффект стековой аллокации — она бесплатна для GC не только по освобождению, но и по маркировке: чем больше данных остаётся на стеке, тем меньше живых объектов в куче и тем реже и дешевле циклы GC. Также при сканировании стека может произойти его сжатие (shrinkstack), если горутина использует меньше четверти выделенного блока.

Когда память выделяется на стеке, а когда - в куче? Какие правила использует компилятор Go?

Заголовок раздела «Когда память выделяется на стеке, а когда - в куче? Какие правила использует компилятор Go?»

Коротко. См. выше про escape-анализ: правило одно — «если компилятор не может доказать, что значение не переживёт кадр, оно едет в кучу». Здесь имеет смысл перечислить конкретные правила компилятора.

Глубже. Практический чек-лист причин escape: (1) адрес значения возвращается наружу и функция не инлайнится; (2) адрес записывается в глобальную переменную, в объект, уже находящийся в куче, или в поле структуры, которая сама убегает; (3) значение отправляется в канал или захватывается замыканием, которое убегает; (4) значение преобразуется в интерфейс, и метод/функция, принимающая интерфейс, не девиртуализуется (fmt.Println(x) — канонический пример); (5) размер неизвестен на компиляции: make([]T, n) с переменным n, make(map[K]V, n); (6) размер известен, но превышает пороги (10 МБ для явных локальных, 64 КБ для неявных); (7) значение живёт в функции, использующей recover, или иным образом мешает анализу; (8) unsafe.Pointer-манипуляции, скрывающие поток указателей. Обратное правило тоже важно: «leaking param» — если функция только читает через указатель и не сохраняет его, компилятор пометит параметр как does not escape, и аргумент останется на стеке вызывающего.

Всегда ли возвращаемое значение функции попадает в кучу? Приведите примеры.

Заголовок раздела «Всегда ли возвращаемое значение функции попадает в кучу? Приведите примеры.»

Коротко. Нет. Возврат по значению копируется в кадр вызывающего и в куче не оказывается; даже возврат указателя может остаться на стеке, если после инлайнинга компилятор видит, что указатель никуда не утекает.

Глубже.

package main
type big struct{ buf [128]byte }
//go:noinline
func byValue() big { return big{} } // копия в кадр вызывающего, аллокации нет
//go:noinline
func byPointer() *big { return &big{} } // moved to heap: функция не инлайнится
func inlinedPtr() *big { return &big{} } // может остаться на стеке вызывающего
func main() {
v := byValue()
p := byPointer()
q := inlinedPtr()
_ = v.buf[0] + p.buf[0] + q.buf[0]
}

На Go 1.24 go build -gcflags='-m' для этого файла печатает &big{} escapes to heap для byPointer и &big{} does not escape для заинлайненного вызова inlinedPtr — то есть q остаётся на стеке main. Обратный случай: даже возврат по значению приведёт к аллокации, если вызывающий тут же кладёт результат в интерфейс, в слайс, живущий в куче, или отправляет в канал. Отдельно отмечу именованные возвращаемые значения — они сами по себе escape не вызывают; миф «named return всегда в куче» неверен.

Какие типы значений можно возвращать из функции без аллокации в куче?

Заголовок раздела «Какие типы значений можно возвращать из функции без аллокации в куче?»

Коротко. Любые значения фиксированного размера, возвращаемые по значению и не «убегающие» дальше: числа, bool, строки (заголовок), указатели на уже существующие объекты, структуры, массивы, а также заголовки слайсов и интерфейсные значения — вопрос не в типе, а в том, куда результат попадает потом.

Глубже. Точнее говорить о том, что не требует новой аллокации: возврат скаляров и структур по значению; возврат подстроки или подслайса существующих данных (создаётся только новый заголовок, backing-массив общий); возврат указателя на данные, уже находящиеся в куче (например, поле объекта); возврат ошибки-синглтона вроде io.EOF или предсозданного errors.New в переменной пакета. Аллокацию гарантированно дадут: fmt.Errorf и errors.New в момент вызова, конкатенация строк с неизвестными на компиляции частями, make с переменным размером, упаковка значения в интерфейс, если оно не является маленьким предвыделенным целым (рантайм держит статический массив для однобайтовых значений staticuint64s, поэтому var x any = byte(7) не аллоцирует). Проверять всё это надо не рассуждением, а testing.AllocsPerRun или -benchmem.

Как новый механизм arena влияет на управление памятью? В каких задачах может быть полезен?

Заголовок раздела «Как новый механизм arena влияет на управление памятью? В каких задачах может быть полезен?»

Коротко. Арена — это регион, в котором объекты выделяются последовательно и освобождаются все разом одним вызовом arena.Free, минуя GC. В Go это экспериментальная возможность за GOEXPERIMENT=arenas, не входящая в стандарт языка; официальный процесс по предложению приостановлен, в проде на неё закладываться нельзя.

Глубже. Идея классическая (region-based memory management): вместо того чтобы отслеживать достижимость каждого объекта, мы явно говорим «вся эта пачка живёт до конца запроса». Выигрыш — почти нулевая стоимость аллокации (bump pointer) и полное исключение этих объектов из работы GC, что заметно на нагрузках вида «десериализовали большой protobuf/JSON, обработали, выбросили». Профиль задач: обработчик RPC с большим количеством короткоживущих структур, парсеры, батчевые пайплайны.

Цена — безопасность. arena.Free делает все указатели в арену невалидными; рантайм в экспериментальной реализации защищается тем, что помечает страницы арены как недоступные (fault) вместо немедленного переиспользования, так что use-after-free даёт падение, а не тихую порчу данных. Тем не менее это уход от гарантий memory safety, ради которых Go и выбирают, — именно поэтому команда Go поставила предложение на паузу и ищет вариант, не требующий небезопасного API. На собеседовании корректный ответ: «знаю, что это, знаю, зачем, знаю, что это эксперимент вне Go 1 compatibility promise, и в проде вместо арен использую sync.Pool и переиспользование буферов».

Коротко. Это директива компилятора, которую ставят перед объявлением функции без тела (реализованной на ассемблере). Она утверждает, что указатели, переданные в аргументах, не «убегают» — не сохраняются нигде за пределами вызова, — и позволяет escape-анализу оставить аргументы на стеке.

Глубже. Без тела функции компилятор ничего не знает про её поведение и вынужден консервативно считать, что все указательные аргументы escape, из-за чего каждый вызов ассемблерной функции порождает аллокации. //go:noescape снимает это ограничение. Применяется в рантайме и стандартной библиотеке: криптография (crypto/sha256, crypto/aes), math, runtime — там, где горячая функция написана на ассемблере ради SIMD.

//go:noescape
func blockAVX2(dig *digest, p []byte)

Ограничения и риски: директива работает только для объявлений без Go-тела; в обычном Go-коде она игнорируется. Это обещание компилятору, которое компилятор не проверяет — если ассемблерная функция всё-таки сохранит переданный указатель в глобальную структуру или в кучу, GC может освободить или переместить (при росте стека — скопировать) объект, и получится порча памяти, которую крайне тяжело отлаживать. Правильный ответ на собеседовании: «нужна для zero-alloc вызовов ассемблерных функций, писать её в прикладном коде почти никогда не приходится».

Что такое замыкание в Go? Как будет себя вести GC в таком случае?

Заголовок раздела «Что такое замыкание в Go? Как будет себя вести GC в таком случае?»

Коротко. Замыкание — это значение функции вместе с захваченным окружением: под капотом это указатель на структуру, где лежат указатель на код и захваченные переменные. Захваченные переменные, если замыкание переживает породившую функцию, уезжают в кучу и удерживаются GC ровно столько, сколько жив сам объект замыкания.

Глубже. Компилятор захватывает переменную по значению, если она только читается и не меняется после захвата, и по ссылке (через указатель на переменную, поднятую в кучу) — если она модифицируется внутри или снаружи замыкания либо от неё берётся адрес. Если само замыкание не убегает (например, сразу вызывается или передаётся в функцию с does not escape параметром), контекст может остаться на стеке и аллокации не будет.

Практическое следствие для GC: замыкание удерживает весь захваченный набор переменных целиком, а не только «полезную часть». Классическая утечка — горутина с замыканием, захватившим большой слайс ради одного поля: пока горутина жива, жив и слайс. Лечится тем, что нужное значение копируется в локальную переменную до создания замыкания. То же самое с долгоживущими колбэками, defer в длинном цикле и таймерами: time.AfterFunc(time.Hour, func(){ use(bigCache) }) продержит bigCache час. Отдельно — Go 1.22 изменил семантику переменной цикла: теперь for i := range n создаёт новую переменную на каждой итерации, поэтому замыкание в цикле захватывает своё значение, а не общее; раньше это была причина номер один багов с горутинами в цикле (и, кстати, одна общая переменная на все итерации означала одну аллокацию, а теперь — по одной на итерацию, если замыкание убегает).

  • «В Go GC без пауз» — паузы есть, две за цикл; правильная формулировка: паузы короткие и почти не зависят от размера кучи.
  • Путать alloc_space и inuse_space в pprof и на основании первого объявлять утечку.
  • Считать, что new/&T{} всегда даёт кучу, а var — стек. Решает escape analysis, а не синтаксис.
  • Говорить, что GC поколенческий и/или компактящий. В Go он ни то, ни другое.
  • «В Go утечек памяти не бывает, там же сборщик» — забывают про утечки горутин, вечные записи в картах и удержание backing array слайсом.
  • Считать make([]int, n) предвыделением и потом делать append — получится 2n элементов.
  • Думать, что delete из карты возвращает память: число бакетов не уменьшается, большую карту после чистки надо пересоздавать.
  • Считать растущий RSS доказательством утечки, не учитывая ленивый возврат страниц ОС и цель GOGC.
  • Не знать про GOMEMLIMIT и предлагать ballast как современное решение — с Go 1.19 это устаревший приём.
  • Утверждать, что GC чистит стек. Он его только сканирует как корни и может ужать.
  • Говорят, что new выделяет в куче, а var/литерал — на стеке. В Go область размещения выбирает escape-анализ, а не синтаксис.
  • Утверждают, что Go GC — поколенческий или уплотняющий. Он неперемещающий mark & sweep без поколений; отсюда, кстати, и возможность фрагментации.
  • Считают, что STW-пауза длится всё время маркировки. STW — только две короткие фазы (sweep termination и mark termination), сама маркировка идёт конкурентно.
  • Путают «GC собрал память» и «RSS уменьшился»: возврат страниц ОС делает фоновый scavenger, и он отложенный.
  • Думают, что GC освобождает стековые переменные. Он их только сканирует как корни; освобождение — это возврат из функции.
  • Считают delete из карты способом вернуть память: число бакетов не уменьшается, карту нужно пересоздавать.
  • Называют runtime.GC() и debug.FreeOSMemory() инструментом оптимизации. Обычно это способ сделать хуже; настраивать надо GOGC/GOMEMLIMIT, а лучше — сокращать аллокации.
  • Говорят, что стек горутины растёт «расширением сегмента». С Go 1.3 стеки непрерывные, рост — это выделение нового блока и копирование с правкой указателей.
  • Уверенно рассказывают про arena как про часть языка. Это GOEXPERIMENT, предложение на паузе.

Список исходных вопросов с привязкой к компаниям: ../questions/memory-gc.md