Я работаю над проектом, в котором сообщения из Kafka объединяются и записываются в Google Cloud Storage. Когда сравнил его с имеющимся решением в той же среде  —  тема с 50+ миллионами сообщений, он был не слишком хорош. Он завершается на час позже коннектора. Пытаясь выяснить, откуда такая задержка, я обнаружил проблемы сборщика мусора в приложении. Хотелось бы поделиться своим опытом и реализацией. В итоге отставание устранили и финишировали на 10 минут раньше.

Рисунок 1. До доработки — 1 час
Рисунок 2. После доработки +10 минут

В процессе важно писать тесты производительности и читать результаты pprof, ведь «преждевременная оптимизация  —  корень всех зол».

Немного памяти, выделенной в стеке и куче

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

Рисунок 3. Три горутины, у каждой из которых собственный стек и общая куча

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

В целом стоимость сборки мусора прямо пропорциональна объему памяти, выделяемой программой в куче.

Вот рекомендации по уменьшению кучи:

  • Применяйте sync.Pool, чтобы не инициализировать каждый раз объекты, а переиспользовать их. Будьте осторожны: некорректная реализация приносит больше вреда, чем пользы.
  • Предпочитайте strings.Builder, нежели концентрироваться на +.
  • Попробуйте распределять срезы и ассоциативные массивы заранее, если их размер известен.
  • Сократите использование указателей.
  • Старайтесь избегать больших локальных переменных в функции.
  • Следите за решениями компилятора посредством вывода go build -gcflag="-m" ./.... Обратите внимание на оптимизации встраивания  —  те, что с флагом -l.
  • Структурируйте метод выравнивания данных, используя средство контроля качества кода fieldalignment.

Вот дополнительные материалы:

  • О профилировщике Go.
  • Как узнать, выделена память для переменной в куче или стеке?
  • Как создать поток с ограничением ЦП ядер, а не узлов?

Изменение библиотеки json

  • Вместо encoding/json воспользовались библиотекой bytedance/sonic для сериализации и десериализации объектов размером ~1,50 Кб. Локальный нагрузочный тест на 100 тыс. сообщений Kafka, результаты pprof от 2,55 сек. до 0,68 сек.:
Рисунок 4. Sonic справляется очень хорошо

Сменой библиотеки json добиваемся роста скорости и производительности, но жертвуем стабильностью  —  такой вот компромисс. encoding/json оптимальнее других.

Настройка GOGC и GOMEMLIMIT

  • GOGC  —  это переменная окружения на Go, ею задается целевой темп роста кучи и таким образом контролируется агрессивность сборщика мусора. Например, если GOGC равно 100, то перед запуском сборки мусора куча увеличивается на 100 %. Меньшие значения чреваты учащением сборки, зато памяти расходуется меньше.
  • Переменной окружения GOMEMLIMIT, которая появилась в Go 1.19, задается мягкое ограничение на использование памяти для среды выполнения Go. Когда общий объём используемой программой памяти приближается к этому пределу, сборщик мусора более агрессивно старается остаться в рамках установленного бюджета. Так и управляется память в средах, где она ограничена.

Подробнее  —  здесь.

Мы задали GOGC=off и GOMEMLIMIT=90% лимита памяти cgroup, использовав библиотеку automemlimit. Параметром GOGC=off отключается регулярная сборка мусора, определяемая темпом роста кучи. Так что сборка мусора запускается, только когда расход памяти приближается к GOMEMLIMIT  —  мягкому ограничению для кучи.

Вот память и процессор нашего приложения:

resources:
limits:
cpu: '2'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi

Результаты

  • Частотность пауз, вызванных работой сборщика мусора, уменьшилась с 350 до 30 вызовов в минуту:
Рисунок 5. Частотность пауз, вызванных работой сборщика мусора, измеряется количеством вызовов в минуту во время «остановки мира» сборщиком мусора
Рисунок 6. Частотность пауз, вызванных работой сборщика мусора, измеряется количеством вызовов в минуту во время «остановки мира» сборщиком мусора
  • Продолжительность пауз, вызванных работой сборщика мусора, уменьшилась с 40 мс до 400 мкс на пике:
Рисунок 7. Продолжительность пауз, вызванных работой сборщика мусора, измеряется в миллисекундах на «остановку мира» сборщиком мусора
Рисунок 8. Продолжительность пауз, вызванных работой сборщика мусора, измеряется в миллисекундах на «остановку мира» сборщиком мусора
  • Продолжительность сборки мусора в минуту уменьшилась с 60 мкс до 2,5 мкс:
Рисунок 9. Продолжительность сборки мусора в минуту
Рисунок 10. Продолжительность сборки мусора в минуту
  • Загрузка процессора увеличилась с 3000 до 5000 %:
Рисунок 11. Загрузка и использование ЦП при различной пропускной способности сети
Рисунок 12. Загрузка и использование ЦП при различной пропускной способности сети
Рисунок 13. Загрузка и использование ЦП при различной пропускной способности сети
Рисунок 14. Загрузка и использование ЦП при различной пропускной способности сети
Рисунок 15. Загрузка и использование ЦП при различной пропускной способности сети
Рисунок 16. Загрузка и использование ЦП при различной пропускной способности сети
  • Общий объем используемой памяти увеличился, это допустили для давления на неагрессивный сборщик мусора:
Рисунок 17. Использование памяти
Рисунок 18. Использование памяти
Рисунок 19. Использование памяти
Рисунок 20. Использование памяти

Да, памяти выделили больше, зато сократили общее время сборки мусора и использования процессора.

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Abdulsamet İLERİ: Tuning Go Application, which has GC issues with a few steps

Предыдущая статья10 инструментов ИИ для SaaS-стартапов 2025
Следующая статьяРекомпозиция в Jetpack Compose и View-рендеринг на основе XML: в чем разница?