Сборщик мусора Go останавливал программу на 40 мс из-за swap

Сборщик мусора Go останавливал программу на 40 мс из-за swap

Автор блога рассказывает, как чуть не включил swap (подкачку на диск) в продакшене, чтобы гасить скачки потребления памяти. Он поставил опыт: в одной cgroup работали два процесса. Первый, программа на Go, которая читает данные через io.ReadAll, разбирает их через proto.Unmarshal и строит граф структур (аллокатор Go помечает его как scan, то есть подлежащий сканированию). Второй, HTTP-сервер, который почти всё время молчит. Автор рассчитывал, что ядро вытесняет страницы в swap на уровне cgroup, а не отдельного процесса, поэтому страницы будут вытеснены у обоих процессов и «танец» подкачки туда-обратно между ядром и сборщиком мусора маловероятен. По его словам, он ошибался.

Главная находка: во время паузы stop-the-world (остановки всех горутин) сборщик мусора Go читает собственные служебные данные, которые лежат вне кучи в области, не освобождаемой рантаймом, и эти данные могут оказаться в swap. Тестовый прогон автор делал на сервере Hetzner с ядром 6.8 и включённым MGLRU, с моковым аллокатором; графики, скрипты bpf и python выложены в репозитории go-gc-swap-cost на GitHub. Медианная пауза составила около 51 мкс, а когда служебные данные лежали на NVMe-swap, худшая пауза достигла 40 мс.

Чтобы понять, куда уходят эти 40 мс, автор написал небольшой bpf-скрипт, считающий страничные прерывания (page faults) при остановленном мире. Худший случай: пауза 39902 мкс, 228 страничных прерываний за это время, 39013 мкс из них ушло на обработку прерываний. Адреса по выводу addr2line указывают на учёт внутри сборщика: runtime.finishsweep_m, runtime.nextMarkBitArenaEpoch, runtime.(*spanSet).reset. Сборщик Go останавливает мир в двух точках, при завершении sweep (sweep termination) и при завершении mark (mark termination); за 30 минут таких пауз было 312.

Механика, как её объясняет автор: рантайм выделяет эти страницы и не освобождает, а переиспользует, читая их в циклах сборки. Ядро вытесняет в swap наименее недавно использованные страницы, поэтому сборщик, остановив мир и обратившись к ним, получает серьёзное страничное прерывание (major page fault): ядру нужно прочитать записи таблицы страниц, вызвать do_swap_page, найти свободный кадр, записать его на счёт cgroup, считать страницы с диска и вернуть в память.

40 мс кажутся безобидными, но это пауза stop-the-world: в терминах Go остановлены все P, и если горутина ждала ввода-вывода, он может завершиться за время паузы, а обработать его будет некому. Автор отмечает, что 40 мс, в 800 раз больше медианной паузы, и в тесте такое случалось два-три раза за каждый скачок памяти.

Есть и вторая находка: сборка одного сообщения размером 511 КиБ, обычно занимающая 3, 5 мс, выросла до 105 мс на NVMe и до 903 мс на сетевом томе Hetzner. В пересчёте на сообщение это дороже, чем пауза из-за служебных данных, но цену платит только горутина, которая выделяет память, то есть эффект локален, а не глобален. Куда уходит это время, автор не выяснил, но счёл нужным упомянуть как ещё одну цену подкачки.

Итог автора: он согласен с Крисом Дауном в том, что swap, не зло, но с работой сборщика мусора подкачка уживается плохо, а в его продакшене сборка идёт часто. В обновлении от 14 сентября (год не указан) автор пишет, что на вопрос читателя измерил влияние сборщика мусора Green Tea из Go 1.26 на способ чтения служебных данных, эффект пренебрежимо мал.

Ключевые факты

  • Во время пауз stop-the-world сборщик мусора Go читает служебные данные вне кучи; если они вытеснены в swap, возникает серьёзное страничное прерывание.
  • В тестовом прогоне на Hetzner (ядро 6.8, MGLRU) медианная пауза была около 51 мкс, а худшая, 40 мс при служебных данных на NVMe-swap, это примерно в 800 раз больше медианы по словам автора.
  • В худшей паузе было 228 страничных прерываний, на них ушло 39013 мкс из 39902 мкс; всего за 30 минут было 312 пауз stop-the-world.
  • Сборка сообщения 511 КиБ, обычно занимающая 3, 5 мс, выросла до 105 мс на NVMe и до 903 мс на сетевом томе Hetzner; причину автор не подтвердил.
  • Сборщик Green Tea из Go 1.26, по измерению автора, на эту проблему влияет пренебрежимо мало.

Почему это важно

Swap часто предлагают как способ пережить скачки потребления памяти без аварийного завершения процесса. Эксперимент показывает конкретный механизм, при котором такой подход бьёт по программам на Go: пауза stop-the-world останавливает все горутины, и задержка в 40 мс при медиане около 51 мкс заметна для сервисов, которым важна предсказуемость ответа. Автор показывает не общие рассуждения, а измерения и места в коде рантайма, где возникают страничные прерывания.

Кому это важно

Разработчикам и инженерам эксплуатации, которые запускают сервисы на Go в контейнерах или cgroup и думают о включении swap для амортизации скачков памяти. Также тем, кто разбирается с редкими непонятными задержками в Go-программах на машинах с подкачкой.

Как это применить

Автор не предлагает способа обойти проблему со служебными данными в swap. Практическая польза текста в другом: он описывает методику измерения, bpf-скрипт, считающий страничные прерывания, пока мир остановлен, а скрипты, моковый аллокатор и графики выложены в репозитории go-gc-swap-cost на GitHub, так что опыт можно повторить на своей нагрузке до решения о включении swap.

Можно ли доверять

Это личный блог, автор в тексте не назван. Результаты получены в тестовом прогоне с моковым аллокатором на одном сервере Hetzner, а не при реальном инциденте в продакшене. Числа подкреплены выводом bpf-скрипта и стеком addr2line, код опубликован, что позволяет перепроверить. Автор прямо оговаривает, что причину замедления сборки сообщений не выяснил.

Риски и подводные камни

Результат получен на ядре 6.8 с MGLRU; для основного эксперимента версия Go не указана. Автор оценивает эффект Green Tea в Go 1.26 лишь словом «пренебрежимо» без цифр. Замедление сборки сообщения до 105 мс на NVMe и 903 мс на сетевом томе, отдельная цена swap, и её источник не подтверждён. Не сказано, отказался ли автор в итоге от swap в продакшене, поэтому выводить из текста запрет на swap нельзя: сам автор пишет, что swap, не зло.

«40 мс, это в 800 раз больше медианной паузы.»

— автор статьи