Сборщик мусора в Go. Часть 3: Управление скоростью GC
- вторник, 14 октября 2025 г. в 00:00:12
Команда Go for Devs подготовила перевод статьи о том, как в Go устроено управление скоростью работы сборщика мусора. TL;DR: даже при тысячах горутин GC подстраивается под нагрузку, выбирая между меньшим числом долгих пауз и большим числом коротких. Итог — разработчику почти не нужно вручную «крутить» настройки, рантайм сам находит оптимальный ритм.
Это третья статья из серии из трёх статей, цель которой — помочь вам понять механику и семантику работы сборщика мусора в Go. В этой статье речь пойдёт про то, как GC управляет скоростью своей работы.
Содержание серии:
Сборщик мусора в Go устроен так, чтобы не только безопасно управлять памятью, но и «сам себя регулировать», находя баланс между низкой задержкой и высокой пропускной способностью. В этой статье мы разберём, как GC подстраивает темп своей работы под нагрузку — на примере последовательных и конкурентных программ — и почему уменьшение числа выделений памяти на единицу работы остаётся самым надёжным способом снизить нагрузку на него. Впервые статья была опубликована в 2019 году, но её основные идеи актуальны до сих пор: они помогают Go-разработчикам лучше понимать адаптивное поведение рантайма и доверять тому, что GC найдёт правильный ритм без сложной ручной настройки.
Во второй части я показал, как ведёт себя сборщик мусора и как с помощью инструментов наблюдать задержки, которые он накладывает на приложение. Мы запускали реальное веб-приложение, генерировали GC-трейсы и профили, а затем разбирали, как читать эти данные, чтобы находить пути к оптимизации.
Главный вывод той части совпал с выводом первой: если уменьшить нагрузку на кучу, то уменьшатся задержки, а производительность приложения вырастет. Лучший способ «быть в ладах» со сборщиком — снижать количество или объём выделений памяти на единицу работы. В этой статье я покажу, как алгоритм регулирования способен со временем находить оптимальный темп для конкретной нагрузки.
Мы будем использовать код отсюда:
https://github.com/ardanlabs/gotraining/tree/master/topics/go/profiling/trace
Эта программа считает частоту встречаемости заданной темы в коллекции RSS-документов. В ней есть несколько реализаций алгоритма поиска, чтобы проверить разные варианты конкурентности. Мы сосредоточимся на версиях freq
, freqConcurrent
и freqNumCPU
.
Код я запускал на Macbook Pro с процессором Intel i9 (12 аппаратных потоков) и Go 1.12.7. На других архитектурах, ОС и версиях Go результаты будут отличаться, но суть останется прежней.
Начнём с версии freq
. Это последовательный вариант без конкурентности. Он даст базовый уровень для сравнения с конкурентными реализациями.
func freq(topic string, docs []string) int {
var found int
for _, doc := range docs {
file := fmt.Sprintf("%s.xml", doc[:8])
f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
return 0
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
return 0
}
var d document
if err := xml.Unmarshal(data, &d); err != nil {
log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
return 0
}
for _, item := range d.Channel.Items {
if strings.Contains(item.Title, topic) {
found++
continue
}
if strings.Contains(item.Description, topic) {
found++
}
}
}
return found
}
В листинге 1 показана функция freq
. Последовательная версия проходит по списку файлов и для каждого выполняет четыре действия: открыть, прочитать, декодировать и поискать. Всё это происходит последовательно, файл за файлом.
Когда я запускаю этот вариант freq
на своей машине, получаю такой результат:
$ time ./trace
2019/07/02 13:40:49 Searching 4000 files, found president 28000 times.
./trace 2.54s user 0.12s system 105% cpu 2.512 total
Как видно, программа обрабатывает 4000 файлов примерно за 2.5 секунды. Было бы полезно узнать, какой процент времени уходит на работу сборщика мусора. Сделать это можно с помощью трассировки программы. Так как она запускается и завершается, можно использовать пакет trace
для генерации трейс-файла.
import "runtime/trace"
func main() {
trace.Start(os.Stdout)
defer trace.Stop()
Листинг 3 показывает минимальный код для генерации трассы. Подключаем пакет trace
из стандартной библиотеки и вызываем trace.Start
и trace.Stop
. Вывод направляем в os.Stdout
— так проще.
Теперь можно пересобрать и снова запустить программу, не забыв перенаправить вывод в файл:
$ go build
$ time ./trace > t.out
Searching 4000 files, found president 28000 times.
./trace > t.out 2.67s user 0.13s system 106% cpu 2.626 total
К времени работы добавилось чуть больше 100 мс — ожидаемо, ведь трассировка фиксирует каждый вызов функции с точностью до микросекунд. Главное, что у нас теперь есть файл t.out
с данными.
Чтобы их посмотреть, нужно прогнать файл через инструмент трассировки.
$ go tool trace t.out
Эта команда откроет браузер Chrome с таким экраном:
Инструмент трассировки использует встроенные возможности браузера Chrome и работает только в нём.
На рисунке 1 показано 9 ссылок, которые отображаются при запуске инструмента трассировки. На данном этапе важна первая ссылка — View trace
. После её выбора вы увидите примерно следующее.
На рисунке 2 показано полное окно трассы после запуска программы на моей машине. В этом посте мы сосредоточимся на частях, связанных со сборщиком мусора. Это вторая секция с меткой Heap
и четвёртая секция с меткой GC
.
На рисунке 3 показан более детальный вид первых 200 миллисекунд трассы. Обратите внимание на Heap
(зелёная и оранжевая области) и GC
(синие линии внизу).
Секция Heap показывает две вещи:
оранжевая область — это текущее используемое пространство кучи в любой момент времени;
зелёная область — это граница используемого пространства, при достижении которой запускается следующая сборка мусора.
Именно поэтому каждый раз, когда оранжевая область достигает верхней границы зелёной, запускается сборщик мусора. Синие линии обозначают факт запуска GC
.
В этой версии программы объём занятой памяти в куче остаётся около 4 МБ на протяжении всего выполнения. Чтобы получить статистику по всем отдельным запускам GC
, используйте инструмент выделения и обведите рамкой все синие линии.
На рисунке 4 показано, как с помощью стрелочного инструмента синяя рамка обведена вокруг всех синих линий. Нужно выделить каждую линию. Число внутри рамки показывает, сколько времени занимают выбранные элементы графика. В данном случае выделено около 316 миллисекунд (ms, μs, ns). Когда все синие линии выделены, отображается следующая статистика.
На рисунке 5 видно, что все синие линии графика расположены между отметками 15.911 миллисекунд и 2.596 секунд. За это время произошло 232 запуска GC, суммарно занявших 64.524 миллисекунды. Среднее время одного запуска составило 287.121 микросекунды.
Учитывая, что программа работала 2.626 секунд, это значит, что работа сборщика заняла всего ~2% от общего времени выполнения. По сути, его вклад в стоимость работы программы оказался незначительным.
Имея такой базовый уровень, можно попробовать конкурентный алгоритм для выполнения той же задачи — с надеждой ускорить программу.
func freqConcurrent(topic string, docs []string) int {
var found int32
g := len(docs)
var wg sync.WaitGroup
wg.Add(g)
for _, doc := range docs {
go func(doc string) {
var lFound int32
defer func() {
atomic.AddInt32(&found, lFound)
wg.Done()
}()
file := fmt.Sprintf("%s.xml", doc[:8])
f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
return
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
return
}
var d document
if err := xml.Unmarshal(data, &d); err != nil {
log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
return
}
for _, item := range d.Channel.Items {
if strings.Contains(item.Title, topic) {
lFound++
continue
}
if strings.Contains(item.Description, topic) {
lFound++
}
}
}(doc)
}
wg.Wait()
return int(found)
}
Листинг 6 показывает одну из возможных конкурентных версий функции freq
. Основной приём здесь — паттерн fan out. Для каждого файла из коллекции docs
создаётся отдельная горутина, которая обрабатывает этот файл. Если файлов 4000, то будет создано 4000 горутин.
Преимущество такого алгоритма в том, что это самый простой способ использовать конкурентность: каждая горутина обрабатывает ровно один файл. Координация выполнения решается с помощью WaitGroup
, а счётчик синхронизируется атомарной инструкцией.
Недостаток в том, что алгоритм плохо масштабируется по числу файлов и ядер. Все горутины запускаются практически сразу в начале программы, что приводит к быстрому росту потребления памяти. Кроме того, возникают проблемы с когерентностью кэша при обновлении переменной found
(строка 12): каждое ядро вынуждено делить одну и ту же кэш-линию для этой переменной. С ростом числа файлов или ядер ситуация только ухудшается.
Теперь, имея этот код, можно пересобрать и снова запустить программу.
$ go build
$ time ./trace > t.out
Searching 4000 files, found president 28000 times.
./trace > t.out 6.49s user 2.46s system 941% cpu 0.951 total
Как видно из вывода в листинге 7, программа теперь обрабатывает те же 4000 файлов за 951 миллисекунду. Это примерно 64% улучшения производительности. Давайте посмотрим на трассу.
На рисунке 6 видно, что эта версия программы использует значительно больше вычислительных ресурсов процессора. В начале графика плотность очень высокая: это связано с тем, что при создании всех горутин они начинают работать и одновременно пытаться выделять память в куче. Как только первые 4 МБ памяти распределены (а это происходит очень быстро), запускается GC. Во время этого GC каждая горутина получает время выполнения, и большинство из них попадают в состояние ожидания при запросах к куче. По крайней мере 9 горутин продолжают выполняться и увеличивают кучу примерно до 26 МБ к моменту окончания сборки.
На рисунке 7 видно, что огромное количество горутин находится в состояниях Runnable и Running на протяжении почти всей первой сборки мусора, и этот процесс очень быстро повторяется снова. Обратите внимание, что профиль кучи выглядит неровным, а сборки происходят без регулярного ритма, как это было ранее. Если присмотреться, можно заметить, что вторая сборка начинается почти сразу после первой.
Если выделить все сборки на этом графике, получится следующее.
На рисунке 8 показано, что все синие линии на графике расположены между отметками 4.828 миллисекунды и 906.939 миллисекунды. За это время произошло 23 сборки мусора, суммарно занявшие 284.447 миллисекунды, при среднем времени одной сборки 12.367 миллисекунды.
Учитывая, что программа работала 951 миллисекунду, это значит, что GC занял около 34% от всего времени выполнения.
Это заметная разница и по производительности, и по времени работы GC по сравнению с последовательной версией. Однако запуск большего числа горутин параллельно позволил завершить работу примерно на 64% быстрее. Цена за это — значительно большее потребление ресурсов. К сожалению, на пике одновременно использовалось около 200 МБ памяти в куче.
Имея теперь конкурентный базовый уровень, следующий алгоритм пытается использовать ресурсы более эффективно.
func freqNumCPU(topic string, docs []string) int {
var found int32
g := runtime.NumCPU()
var wg sync.WaitGroup
wg.Add(g)
ch := make(chan string, g)
for i := 0; i < g; i++ {
go func() {
var lFound int32
defer func() {
atomic.AddInt32(&found, lFound)
wg.Done()
}()
for doc := range ch {
file := fmt.Sprintf("%s.xml", doc[:8])
f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
return
}
data, err := ioutil.ReadAll(f)
if err != nil {
f.Close()
log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
return
}
f.Close()
var d document
if err := xml.Unmarshal(data, &d); err != nil {
log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
return
}
for _, item := range d.Channel.Items {
if strings.Contains(item.Title, topic) {
lFound++
continue
}
if strings.Contains(item.Description, topic) {
lFound++
}
}
}
}()
}
for _, doc := range docs {
ch <- doc
}
close(ch)
wg.Wait()
return int(found)
}
Листинг 8 показывает версию программы freqNumCPU
. Основной приём здесь — паттерн пула. Создаётся пул горутин в количестве, равном числу логических процессоров, и они обрабатывают все файлы. Если доступно 12 логических процессоров, то создаются 12 горутин.
Преимущество этого алгоритма в том, что он поддерживает потребление ресурсов стабильным от начала и до конца работы. Поскольку используется фиксированное число горутин, требуется только память, необходимая этим 12 горутинам. Это также устраняет проблему когерентности кэша: атомарная операция на строке 14 выполняется лишь ограниченное число раз.
Недостаток — больше сложность. Добавляется использование канала для передачи задач в пул горутин. При использовании пула всегда возникает вопрос: какое количество горутин будет «правильным». Обычно я начинаю с 1 горутины на логический процессор. Затем, с помощью нагрузочного тестирования или анализа метрик в продакшене, можно вычислить финальное оптимальное значение.
Теперь, имея этот код, можно пересобрать и снова запустить программу.
$ go build
$ time ./trace > t.out
Searching 4000 files, found president 28000 times.
./trace > t.out 6.22s user 0.64s system 909% cpu 0.754 total
Как видно из вывода в листинге 9, программа теперь обрабатывает те же 4000 файлов за 754 миллисекунды. Это примерно на 200 миллисекунд быстрее, что является значительным улучшением для такой небольшой нагрузки. Давайте посмотрим на трассу.
На рисунке 9 видно, что и эта версия программы полностью загружает процессор. Если присмотреться, снова появляется регулярный ритм выполнения — очень похожий на последовательную версию.
На рисунке 10 показан более детальный вид основных метрик за первые 20 миллисекунд выполнения программы. Сборки действительно длиннее, чем в последовательной версии, но одновременно работают 12 горутин. При этом объём занятой памяти в куче остаётся примерно 4 МБ на протяжении всей работы программы — снова, как в последовательной версии.
Если выделить все сборки на этом графике, то получится следующее.
На рисунке 11 показано, что все синие линии на графике находятся между отметками 3.055 миллисекунды и 719.928 миллисекунды. За это время произошло 467 сборок мусора, суммарно занявших 177.709 миллисекунды, при среднем времени одной сборки 380.535 микросекунды.
Учитывая, что программа работала 754 миллисекунды, это значит, что работа GC составила примерно 25% от общего времени выполнения. Это на 9% лучше, чем у предыдущей конкурентной версии.
Эта версия конкурентного алгоритма, похоже, будет лучше масштабироваться с ростом числа файлов и ядер. На мой взгляд, цена в виде усложнения кода того стоит. Канал можно заменить на разбиение списка файлов на блоки задач для каждой горутины. Это сделает код ещё сложнее, но может уменьшить задержки, связанные с каналом. При большем числе файлов и ядер это может оказаться заметным, но сложность придётся оценивать отдельно. Это то, что можно попробовать самостоятельно.
Что мне особенно нравится в сравнении трёх версий алгоритма — это то, как GC адаптировался в каждой ситуации.
Общее количество памяти, необходимое для обработки файлов, не меняется ни в одной из версий. Меняется лишь способ распределения:
Когда используется одна горутина, базовые 4 МБ кучи полностью покрывают все потребности.
Когда программа сразу выкидывает всю работу в рантайм, GC выбирает стратегию роста кучи, уменьшая число сборок, но делая каждую из них более долгой.
Когда программа контролирует количество одновременно обрабатываемых файлов, GC снова придерживается маленькой кучи, увеличивая число сборок, но сокращая их продолжительность.
Каждый раз GC выбирает тот подход, который позволяет программе работать с минимальными издержками.
Алгоритм | Время программы | Время GC | % GC | Кол-во GC | Среднее GC | Макс. куча |
---|---|---|---|---|---|---|
freq | 2626 ms | 64.5 ms | ~2% | 232 | 278 μs | 4 MB |
concurrent | 951 ms | 284.4 ms | ~34% | 23 | 12.3 ms | 200 MB |
numCPU | 754 ms | 177.7 ms | ~25% | 467 | 380.5 μs | 4 MB |
Версия freqNumCPU
имеет и другие плюсы — например, лучше справляется с когерентностью кэша. Однако общее время работы GC у двух конкурентных версий довольно близко: ~284.4 мс против ~177.7 мс. В некоторые дни на моей машине результаты были ещё ближе. При экспериментах с Go 1.13.beta1 я видел, как обе версии показывали одинаковое время работы, что может намекать на будущие улучшения: GC сможет лучше предсказывать, как себя вести.
Всё это вселяет уверенность в том, что можно «накидывать» рантайму огромное количество работы — например, веб-сервис с 50 тысячами горутин (по сути, fan out, как в первой конкурентной версии). GC проанализирует нагрузку и найдёт оптимальный ритм, чтобы минимизировать своё влияние. Для меня лично — это уже достаточная причина, чтобы не задумываться об этом вручную.
Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!