Стек и куча в Go
Написал бесконечную рекурсию и запустил. Программа не зависла и всю память не съела: рантайм напечатал goroutine stack exceeds 1000000000-byte limit и упал с fatal error: stack overflow. Спрашивают, как растёт стек горутины, чем он дешевле кучи и зачем нужен sync.Pool.
Стержень: стек горутины растёт копированием и упирается в гигабайт, куча стоит работы сборщику - отсюда предвыделение и пулы.
// Формулировки: «что при нехватке стека?», «зачем sync.Pool?», «бывают ли утечки при живом сборщике?»
Растущий стек
Стартует стек горутины крошечным: в исходниках рантайма stackMin = 2048, две тысячи сорок восемь байт. Кончилось место - рантайм берёт вдвое больший кусок, копирует туда кадры и правит указатели внутри них. В stack.go это строка newsize := oldsize * 2.
Удвоения идут до потолка maxstacksize, в proc.go он равен 1000000000 байт на 64-битной системе и 250000000 на 32-битной. В моём падении рантайм напечатал границы стека: 0xc020080000 и 0xc040080000, разница 0x20000000 - это 512 МиБ. Следующее удвоение дало бы 1073741824 байта, что больше миллиарда, потолок и сработал.
// Два следствия. Первое: переполнение стека это fatal error, а не паника, поэтому recover не поймает и отложенные функции не отработают. У меня defer с печатью глубины так и не выполнился. Второе: с версии 1.19 стартовый размер рантайм подстраивает под наблюдаемое среднее, так что 2 КБ это нижняя граница, а не константа навсегда.
- рост стека
- новый кусок вдвое больше плюс копирование кадров
- fatal error
- падение без паники: recover и defer не работают
Почему предвыделение это не вкусовщина
Замерил append: собирал слайс из 1000 элементов, начиная с nil, и печатал вместимость на каждом перевыделении. Вышло так: 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 848, 1280. Двенадцать перевыделений, и всего скопировано 1871 элемент, чтобы уложить 1000.
То есть почти двойная работа на ровном месте. make([]int, 0, 1000) убирает её целиком: одно выделение, ноль копирований. Заодно из цифр видно, что удвоение не вечное - после 256 элементов новая вместимость считается как старая плюс четверть от суммы старой и 768, а потом округляется вверх до размерного класса аллокатора. Отсюда 848 вместо ожидаемой тысячи: 512 + (512 + 768) / 4 = 832, и округление до класса дало 848.
// Побочный эффект, который ловят руками: при перевыделении массив переезжает на новое место. Указатель на элемент, взятый до append, продолжает смотреть в старый массив, и запись по нему пропадает бесследно.
- предвыделение
- make([]T, 0, n) - одно выделение вместо череды
- размерный класс
- аллокатор округляет запрос до ближайшего размера
sync.Pool: положил, собрал мусор, не нашёл
Опыт в три строки. Кладу объект в sync.Pool и тут же беру - вернулся тот же самый, сравнение указателей дало true. Кладу обратно, зову runtime.GC() - это принудительный запуск сборщика мусора, garbage collector, - и беру снова: вернулся новый, сравнение дало false.
Из этого следует всё правило пользования. sync.Pool - склад временных объектов, обычно буферов: вместо аллокации на каждый запрос берёшь готовый и возвращаешь после. Сборщику меньше работы. Но склад чистят при каждой сборке, поэтому держать там что-то ценное нельзя: это кэш без гарантий, а не хранилище.
// Вторая обязательная привычка: взял объект - приведи его в чистое состояние, для буфера это buf = buf[:0]. Иначе одному пользователю приедет хвост данных другого, и разговор пойдёт уже не про производительность.
- sync.Pool
- склад временных объектов, чистится при сборке
- очистка при взятии
- buf = buf[:0], иначе утекут чужие данные
Утечки при живом сборщике
Замер, который лечит веру в магию. Выделил 100 МиБ, сделал small := big[:10], обнулил big, позвал сборку. HeapAlloc после этого - ровно 100,0 МиБ. Повторил, скопировав нужные десять байт в отдельный слайс: 0,0 МиБ.
Срез это указатель в массив, длина и вместимость. Пока жив срез, жив весь массив, в который он смотрит, даже если тебе оттуда нужны десять байт из ста мегабайт. Сборщик тут ни при чём: объект достижим, значит живой.
// Тот же класс ошибок: глобальная мапа, которая только растёт, кэш без вытеснения, горутина, заблокированная навсегда вместе со всем, что она держала. Сборщик убирает недостижимое, а не ненужное - разницу между этими словами и проверяет вопрос.
- логическая утечка
- объект достижим, хотя давно не нужен
- удержание массива срезом
- срез на 10 байт держит 100 МиБ живыми
Как отвечать: «Что происходит при нехватке стека горутины и зачем sync.Pool?»
Стек горутины стартует с двух килобайт и растёт динамически: когда места не хватает, рантайм выделяет вдвое больший кусок, копирует туда кадры и правит указатели внутри них. Растёт он до потолка в миллиард байт на 64-битной системе - я это проверял бесконечной рекурсией, рантайм печатает goroutine stack exceeds 1000000000-byte limit и падает. Причём падает именно с fatal error, а не паникой: recover не поможет, отложенные функции не отработают. Стек вообще дешевле кучи: выделение там сдвиг указателя, освобождение бесплатное, сборщику делать нечего. sync.Pool решает соседнюю задачу - переиспользование временных объектов, обычно буферов: вместо аллокации на каждый запрос берём готовый из пула и возвращаем обратно, и сборщик получает меньше работы. Две оговорки обязательны. Первая: пул чистится при каждой сборке мусора, я это проверял - до runtime.GC возвращается тот же объект, после уже новый, поэтому ничего ценного там хранить нельзя. Вторая: взятый объект надо привести в чистое состояние, иначе в ответ приедет хвост данных предыдущего запроса. И беру я пул только после профиля, когда аллокации действительно в горячем пути.
Сильный ответ: механизм роста стека назван точно, вместе с потолком и с тем, что переполнение непоправимо. Разница цены стека и кучи объяснена через механику, а по sync.Pool даны обе критичные оговорки и условие применения - после профиля, а не по привычке.
На чём валят
- −Думают, что стек горутины фиксирован, как у потока операционной системы.
- −Надеются поймать переполнение стека через recover. Это fatal error, defer тоже не отработает.
- −Кладут в sync.Pool то, что нельзя потерять при сборке мусора.
- −Берут объект из пула, не очистив, и утаскивают в ответ чужие данные.
- −Держат срезом на десять байт стомегабайтный массив вместо копирования нужного куска.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.
- Передача большой структуры в функцию по значению — что происходит?A)Передаётся ссылка, копирования не происходит — Go оптимизирует это самB)Структура автоматически уходит в кучу под управление GCC)Копируется целиком; для больших берут указатель, чтоб не копироватьD)Компилятор запретит: большие структуры передают только указателем
показать ответ и разбор
+C)Копируется целиком; для больших берут указатель, чтоб не копировать// разбор: Go всё передаёт по значению: структура-аргумент копируется целиком (все поля). Для мелких это дёшево и даёт приятную семантику значения; для крупных — накладно, поэтому передают указатель *T (копируется только адрес). Сама копия не означает кучу: аргумент-значение обычно живёт на стеке вызываемого, если не «убегает».
- В языке с GC утечки памяти невозможны. Верно ли это для Go?A)Верно: сборщик мусора рано или поздно освобождает всё, и утечкам в Go взяться неоткудаB)Верно, при условии что не используется пакет unsafeC)Неверно: живые, но ненужные ссылки (мапа, глобаль, горутина) держат памятьD)Неверно, но только при прямых вызовах malloc через cgo
показать ответ и разбор
+C)Неверно: живые, но ненужные ссылки (мапа, глобаль, горутина) держат память// разбор: GC освобождает только НЕДОСТИЖИМОЕ. Если на ненужный объект остаётся достижимая ссылка — растущая мапа-кэш без вытеснения, слайс, который только append'ят, глобаль, заблокированная горутина с захваченными переменными, — память живёт и течёт, хоть формально «жива». Это логическая утечка; GC от неё не спасает. Лечится удалением ссылок (delete из мапы, обрезка слайса, завершение горутин).
- Что происходит, когда стеку горутины перестаёт хватать места?A)Горутина паникует с сообщением о переполнении стекаB)Рантайм связывает новый сегмент со старым, оставляя стек разрывнымC)Оставшиеся кадры размещаются в куче, а стек остаётся прежнего размераD)Рантайм выделяет больший стек и копирует туда содержимое старого
показать ответ и разбор
+D)Рантайм выделяет больший стек и копирует туда содержимое старого// разбор: Стек стартует с пары килобайт и растёт копированием: рантайм выделяет вдвое больший кусок, переносит кадры и правит указатели внутри. Поэтому глубокая рекурсия в Go не падает на ровном месте, а упирается в лимит около гигабайта. Разрывные стеки из сегментов пробовали раньше и отказались — на границе сегмента в горячем цикле возникал провал производительности.
- Какую задачу решает sync.Pool?A)Ограничивает число одновременно работающих горутинB)Хранит соединения с базой, переиспользуя их между запросамиC)Переиспользует временные объекты, снижая нагрузку на сборщик мусораD)Кэширует результаты вычислений между вызовами функции
показать ответ и разбор
+C)Переиспользует временные объекты, снижая нагрузку на сборщик мусора// разбор: Классический случай — буферы: вместо выделения нового на каждый запрос его берут из пула и возвращают обратно. Сборщику меньше работы, аллокаций меньше. Важное свойство: пул очищается при сборке мусора, поэтому хранить в нём что-то ценное нельзя, и объект после взятия обязательно приводят в чистое состояние — иначе в него утекут данные прошлого запроса.
- Зачем задавать вместимость при создании слайса — make([]T, 0, n)?A)Чтобы слайс не рос сверх заданного предела вместимостиB)Чтобы избежать перевыделений и копирований при последовательном appendC)Чтобы элементы разместились на стеке горутины, а не в кучеD)Чтобы сборщик мусора обошёл этот участок памяти стороной
показать ответ и разбор
+B)Чтобы избежать перевыделений и копирований при последовательном append// разбор: Без запаса append растит массив, периодически выделяя новый и копируя в него всё содержимое: набор из тысячи элементов проходит через десяток перевыделений. Известно заранее сколько — одно выделение и ноль копирований. На горячих путях это заметная разница, которую видно в бенчмарке с b.ReportAllocs.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.