Команда Go for Devs подготовила перевод статьи о том, как команда инженеров выявила регрессию использования памяти в Go 1.24. Оказалось, что всего одна оптимизация в аллокаторе памяти, случайно потерянная при рефакторинге, заставляла Go «съедать» сотни мегабайт RAM. Но сообщество Go-разработчиков быстро нашло и устранило проблему.


Когда в начале 2025 года вышел Go 1.24, мы с энтузиазмом начали раскатывать его на наши сервисы. Главная новинка — новая реализация map на основе Swiss Tables — обещала снизить нагрузку на CPU и сократить потребление памяти.

История началась во время внутреннего обновления. Вскоре после деплоя на один из сервисов обработки данных мы заметили неожиданное увеличение использования памяти:

Такая же картина — рост примерно на 20% — проявилась в нескольких окружениях, после чего мы приостановили деплой. Чтобы подтвердить догадку, мы сделали bisect на staging, и он прямо указал на обновление до Go 1.24 как на источник проблемы.

Но вот что оказалось действительно странным: рост потребления памяти не отражался ни в метриках рантайма Go, ни в live heap-профайлах. С точки зрения самого рантайма, сервис не использовал больше памяти. Это сразу привлекло наше внимание. Ведь Go 1.24 должен был уменьшить использование памяти благодаря Swiss Tables, а не увеличить его.

Исключаем крупные изменения в рантайме Go 1.24

Прежде чем углубляться в детали, нам нужно было исключить самых очевидных подозреваемых. В Go 1.24 появилось несколько крупных изменений, которые потенциально могли повлиять на использование памяти, и мы последовательно проверили каждое из них:

  • Swiss Tables. Эта функция должна была снизить потребление памяти, но нужно было убедиться, что именно она не вызывает проблему. Мы собрали тестовую сборку с отключёнными Swiss Tables, установив флаг GOEXPERIMENT=noswissmap. Однако это никак не улучшило ситуацию.

  • Spin bit mutex. В этой версии изменили внутреннюю реализацию мьютексов рантайма. Мы также проверили возврат к прежней реализации, собрав билд с флагом GOEXPERIMENT=nospinbitmutex. Но и в этом случае повышенное потребление памяти сохранилось.

Почему системные метрики расходятся с учётом памяти в Go

Отметив, что главные подозреваемые ни при чём, мы решили глубже копнуть в метрики рантайма Go, чтобы понять, что происходит «под капотом». Эти метрики дают ценные сведения о внутреннем управлении памятью: аллокациях на куче, циклах сборщика мусора (GC) и так далее. Начиная с Go 1.16, они доступны через пакет runtime/metrics.

Несмотря на очевидный рост общего потребления памяти, метрики рантайма Go почти не изменились после обновления до версии 1.24:

Это противоречие стало нашей первой серьёзной зацепкой. Если внутренняя статистика Go показывает стабильное потребление памяти, почему системные метрики говорят об обратном? Ведь именно на системные метрики — например, в Linux это делает OOM Killer — опираются механизмы контроля ресурсов вроде лимитов памяти в Kubernetes. Поэтому важно было разобраться, откуда этот разнобой.

Более глубокое изучение системных метрик показало значительный рост resident set size (RSS):

RSS — это показатель фактического использования оперативной памяти (RAM), в то время как метрики рантайма Go в основном отслеживают виртуальную память — адресное пространство, выделенное процессу, которое может быть больше фактической загрузки в RAM.

Наша теория: больше виртуальной памяти привязывается к физической RAM

Go 1.24 не запрашивал у системы дополнительную память, но что-то в новой версии приводило к тому, что ранее не закреплённая виртуальная память — выделенная, но ещё не использованная физически — начинала занимать место в реальной RAM. Это объясняет, почему внутренняя статистика Go оставалась стабильной, а системные метрики RSS показывали рост потребления памяти.

Возник вопрос: это касалось всех регионов памяти, выделяемых Go, или только отдельных?

Чтобы разобраться, мы обратились к файловой системе Linux /proc — а именно к файлу /proc/[pid]/smaps, который даёт подробное отображение распределения памяти каждого процесса. В нём видно, сколько виртуальной памяти выделено и сколько физической реально используется по разным регионам адресного пространства. Это был нужный нам «микроскоп».

Изучая данные smaps для процесса на Go 1.24, мы нашли любопытное:

c000000000-c054800000 rw-p 00000000 00:00 0 ← Детали региона памяти
Size:            1384448 kB ← 1.28 GB виртуальной памяти выделено
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:             1360000 kB ← 1.26 GB физической памяти использовано (почти совпадает с виртуальной)

Так как этот регион располагается довольно близко к исполняемому файлу в адресном пространстве, имеет доступ на чтение/запись и объём примерно 1.2 GB, можно предположить, что это куча Go.

Для сравнения, в Go 1.23 данные smaps для кучи показывали, что RSS примерно на 300 МБ меньше, чем объём выделенной виртуальной памяти:

c000000000-c057400000 rw-p 00000000 00:00 0 ← Детали региона памяти
Size:            1429504 kB ← 1.33 GB виртуальной памяти выделено
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:             1117232 kB ← 1.04 GB физической памяти использовано (на ~300 МБ меньше)

После анализа всех остальных регионов стало ясно: рост RSS затронул только кучу Go.

Параллельно мы внимательно изучали changelog Go 1.24 в поисках любых намёков, которые могли бы объяснить наблюдаемое, например изменений в каналах или maps, помимо внедрения Swiss Tables.

Одно изменение особенно бросилось в глаза: серьёзный рефакторинг функции mallocgc в рантайме Go. Эта находка выглядела многообещающей, ведь именно изменения в механизме выделения памяти могли повлиять на то, как виртуальная память закрепляется за физической RAM.

Подведём итоги на этом этапе:

  • Go 1.24, по всей видимости, привязывает больше виртуальной памяти к физической RAM, чем Go 1.23, из-за чего увеличивается RSS, тогда как внутренняя статистика Go остаётся неизменной.

  • Единственный регион, на который это влияет, — куча Go.

  • У нас появилась догадка, что причина кроется в рефакторинге функции mallocgc в рантайме.

Определяем корневую причину вместе с сообществом Go

Вооружившись всей собранной информацией, мы открыли тред в Gophers Slack, рассчитывая получить отклик от сообщества Go и команды рантайма, чтобы быстро подтвердить наши выводы:

Профиль показывал, что большая часть памяти занята буферизованными каналами.

PJ Malloy (известный как thepudds), активный контрибьютор в сообществе Go, ранее разработал heapbench — инструмент для бенчмаркинга GC, который уже не раз помогал выявлять проблемы рантайма.

Чтобы точнее локализовать регрессию, thepudds спросил нас, какие именно данные находятся в куче нашего проблемного сервиса. Для этого мы обратились к live heap-профилям, которые позволяют получить «снимок» текущих данных в куче Go:

Как видно из профиля выше, основное потребление памяти приходилось на:

  • Буферизованные каналы (~50%)

  • Maps (map[string]someStruct) (~20%)

Опираясь на эти данные, thepudds использовал heapbench для исследования разных сценариев выделения памяти, анализируя такие факторы, как:

  • Разные структуры данных, начиная с буферизованных каналов и maps — именно они встречались в нашем сервисе обработки данных — а также слайсы и другие.

  • Малые аллокации (≤32 KiB) против крупных аллокаций (>32 KiB).

  • Аллокации, содержащие указатели, против аллокаций без указателей.

Крупные аллокации с указателями увеличивают RSS в Go 1.24

Результаты показали ярко выраженный паттерн: крупные аллокации (более 32 Kb), содержащие указатели, вызывали резкий рост RSS в Go 1.24 по сравнению с Go 1.23.

Например, большие буферы каналов со структурами, содержащими указатели, в Go 1.24 использовали почти вдвое больше RSS. Однако аналогичные по размеру аллокации простых целых чисел или небольшие аллокации не показали существенных различий между версиями.

Чтобы продолжить поиск корневой причины, thepudds с помощью воспроизводящего примера запустил git bisect, который указал именно на тот рефакторинг mallocgc, на который мы и подозревали. Измерения RSS чётко фиксировали резкий рост после этого коммита. С этими убедительными данными было заведено issue в репозитории Go.

Майкл Кнышек из команды Go быстро нашёл конкретную проблему: при рефакторинге аллокатора памяти (mallocgc) случайно убрали важную оптимизацию.

  • Раньше, при выделении больших объектов (>32 Kb), содержащих указатели, Go не выполнял повторную инициализацию нулями памяти, только что полученной от операционной системы (так как ОС уже возвращает обнулённые страницы).

  • В Go 1.24 эта оптимизация пропала, и все крупные объекты с указателями начали обнуляться безусловно, даже когда в этом не было необходимости.

Это идеально совпадало с нашими наблюдениями: избыточное обнуление заставляло Go закреплять больше виртуальных страниц за физической RAM, увеличивая RSS, но при этом не затрагивая внутренние метрики памяти Go. Наш сервис обработки данных как раз активно использует большие буферы каналов со структурами с указателями — что полностью объясняло ситуацию.

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

Чтобы подтвердить решение, мы собрали кастомную версию Go с этим фикс-коммитом и проверили результат:

RSS вернулся на прежний уровень, как до Go 1.24.

Мы сообщили об этом и фикс будет включён в Go 1.25. Следить за новостями о бэкпорте в Go 1.24 можно в issue #73800.

Деплоим сервис обработки данных с учётом влияния на RSS

Возвращаясь к нашему сервису обработки данных: теперь мы хорошо понимали природу проблемы и могли предсказывать влияние на RSS при деплое в новые продакшн-окружения. В худшем случае использование RSS должно было примерно совпадать с объёмом виртуальной памяти, который показывает рантайм Go.

Убедившись, что в рамках текущих лимитов памяти у сервисов есть достаточно запаса, мы продолжили деплой во все продакшн-окружения. Как и ожидалось, в средах с низкой нагрузкой значения RSS и управляемой Go памяти сошлись. Но в окружении с наибольшим трафиком мы увидели неожиданное улучшение:

Виртуальная память уменьшилась
Виртуальная память уменьшилась
RSS тоже снизился
RSS тоже снизился

В наиболее нагруженной среде мы зафиксировали фактическое падение потребления памяти: виртуальная память сократилась примерно на 1 GB на под (~20%), а RSS — на 600 Mb на под (~12%). Эти улучшения резко контрастировали с регрессией, которую мы видели в менее нагруженных окружениях. Что стояло за этим улучшением? И почему оно не проявилось там, где трафик ниже?

Русскоязычное Go сообщество

Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Заключение

Во второй части этой статьи мы подробно разберём новую реализацию map на основе Swiss Tables, покажем, как она снизила потребление памяти в одном из крупнейших in-memory map, и поделимся оптимизациями на уровне структур, которые дали ещё больший эффект всем сервисам.

Наше расследование помогло выявить и подтвердить регрессию в выделении памяти, вызванную потерей оптимизации. С помощью сообщества Go мы отследили проблему до конкретного рефакторинга рантайма, подтвердили корневую причину и протестировали фикс, который войдёт в Go 1.25.

Комментарии (1)


  1. dersoverflow
    08.09.2025 12:20

    избыточное обнуление заставляло Go закреплять больше виртуальных страниц за физической RAM

    смиялсо.
    мой OffPool использует MemAlloc:

    func MemAlloc(n uintptr) unsafe.Pointer {
    	return rt_mallocgc(n, nil, false)
    }
    
    //go:linkname rt_mallocgc runtime.mallocgc
    func rt_mallocgc(size uintptr, typ unsafe.Pointer, needzero bool) unsafe.Pointer

    поднимите руки те, кто понял почему там FALSE needzero ;)
    https://ders.by/go/blobmap/blobmap.html