Всем привет, меня зовут Нина Пакшина, я работаю Golang разработчиком в Лента Онлайн.

В данной статье я расскажу о том, как управлять сборщиком мусора в Go, как оптимизировать потребление памяти приложением и защититься от ошибки out-of-memory.

Стек и куча в Go

Я не буду подробно рассказывать о том, как работает сборщик мусора, поскольку на эту тему уже существует много статей и есть подробная официальная документация (это и это). Но я хочу упомянуть базовые понятия, которые помогут разобраться в теме моей статьи.

Вероятно, вы уже знаете, что в Go данные могут быть сохранены в двух основных хранилищах памяти: стеке (stack) и куче (heap).

Все изображения сгенерированы с помощью Image Creator from Microsoft Bing
Все изображения сгенерированы с помощью Image Creator from Microsoft Bing

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

Стек управляется автоматически и работает по принципу LIFO (последний вошел - первый вышел). При вызове функции все данные, связанные с ней, помещаются в вершину стека, а при завершении функции эти данные удаляются из стека.

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

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

Такие данные сохраняются в куче.

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

Что идет в стек, а что в кучу?

Как я уже упоминала, в стек помещаются значения, у которых размер и время жизни могут быть предсказаны. Но на деле компилятор Go принимает во внимание множество нюансов при принятии решения о размещении данных в стеке или куче. Например, преаллоцированные срезы размером до 64 КБ будут храниться в стеке, а срезы размером больше 64 КБ - в куче. То же самое относится и к массивам: если массив превышает 10 МБ, то он будет сохранен в куче.

Вы можете использовать escape-анализ для определения, где будет храниться определенная переменная. Например, вы можете проанализировать ваше приложение, запустив его из командной строки с флагом -gcflags=-m:

 go build -gcflags=-m main.go
Hidden text

Например, если мы скомпилируем данное приложение main.go с флагом -m

package main

func main() {
  var arrayBefore10Mb [1310720]int
  arrayBefore10Mb[0] = 1

  var arrayAfter10Mb [1310721]int
  arrayAfter10Mb[0] = 1

  sliceBefore64 := make([]int, 8192)
  sliceOver64 := make([]int, 8193)
  sliceOver64[0] = sliceBefore64[0]
}

То результатом будет:

# command-line-arguments
./main.go:3:6: can inline main
./main.go:7:6: moved to heap: arrayAfter10Mb
./main.go:10:23: make([]int, 8192) does not escape
./main.go:11:21: make([]int, 8193) escapes to heap

Мы видим, что массив arrayAfter10Mb был перенесен в кучу, так как его размер превышает 10 МБ, в то время как arrayBefore10Mb остался в стеке (для int переменной 10 МБ это 10 * 1024 * 1024 / 8 = 1310720 элементов).

Также срез sliceBefore64 не был отправлен в кучу, поскольку его размер меньше 64 КБ, в то время как sliceOver64 был сохранен в куче (для int переменной 64 КБ это 64 * 1024 / 8 = 8192 элементов).

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

Таким образом, один из способов борьбы с кучей - избегать ее! Но что делать, если данные уже попали в кучу?

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

Инструмент, позволяющий переиспользовать память в куче и предотвращать ее полную блокировку, это сборщик мусора.

Немного о работе сборщика мусора

Сборщик мусора, он же GC (Garbage Collector) - это система, специально предназначенная для определения и освобождения динамически выделенной памяти. В Go используется алгоритм сборки мусора на основе трассировки и алгоритма пометок Mark and Sweep.

На этапе маркировки (mark) сборщик мусора помечает данные, которые активно используются приложением, в качестве живых (live heap). Затем на этапе очистки (sweep) GC проходит по всей памяти, которая не была помечена как живая, и переиспользует ее.

Работа сборщика мусора не является бесплатной, поскольку он потребляет два важных ресурса системы: процессорное время и физическую память.

Память в сборщике мусора содержит в себе живую память кучи (память, которая была помечена как живая в предыдущем цикле сборки мусора), новую память кучи (память кучи, которая еще не была проанализирована сборщиком мусора), а также память, используемую для хранения некоторых метаданных, которая обычно незначительна по сравнению с первыми двумя сущностями.

Потребление процессорного времени сборщиком мусора связано с его спецификой работы. Существуют реализации сборщика мусора, называемые "stop-the-world", которые полностью останавливают выполнение программы на время сборки мусора, что приводит к тому, что в какой-то момент все процессорное время расходуется не на полезную работу.

В случае Go сборщик мусора не является полностью "stop-the-world" и выполняет большую часть своей работы, например, такую как разметка кучи (время выполнения которой пропорционально размеру кучи) параллельно с выполнением приложения.

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

Сборщик мусора
Сборщик мусора

Как управлять сборщиком мусора?

Существует параметр, который позволяет управлять сборщиком мусора в Go - это переменная окружения GOGC или ее функциональный аналог SetGCPercent из пакета runtime/debug.

Параметр GOGC определяет процент новой необработанной памяти кучи от живой памяти, при достижении которого будет запущена сборка мусора. Значение GOGC по умолчанию равно 100, что означает, что сборка мусора будет запущена, когда объем новой памяти достигнет 100% от объема живой памяти кучи.

Когда вызывается сборщик мусора (GC)
Когда вызывается сборщик мусора (GC)

Давайте рассмотрим пример программы и отследим изменение размера кучи с помощью инструмента go tool trace. Для запуска программы используем версию Go 1.20.1.

В данном примере, функция performMemoryIntensiveTask использует большое количество памяти размещаемой в куче. Данная функция запускает обработчик с размером очереди NumWorker и количество задач равное NumTasks.

package main

import (
  "fmt"
  "os"
  "runtime/debug"
  "runtime/trace"
  "sync"
  "time"
)

const (
  NumWorkers    = 4     // Количество воркеров.
  NumTasks      = 500   // Количество задач.
  MemoryIntense = 10000 // Размер память затратной задачи (число элементов).
)

func main() {
  // Запись в trace файл.
  f, _ := os.Create("trace.out")
  trace.Start(f)
  defer trace.Stop()

  // Установка целевого процента сборщика мусора. По умолчанию 100%.
  debug.SetGCPercent(100)

  // Очередь задач и очередь результата.
  taskQueue := make(chan int, NumTasks)
  resultQueue := make(chan int, NumTasks)

  // Запуск воркеров.
  var wg sync.WaitGroup
  wg.Add(NumWorkers)
  for i := 0; i < NumWorkers; i++ {
     go worker(taskQueue, resultQueue, &wg)
  }

  // Отправка задач в очередь.
  for i := 0; i < NumTasks; i++ {
     taskQueue <- i
  }
  close(taskQueue)

  // Получение результатов из очереди.
  go func() {
     wg.Wait()
     close(resultQueue)
  }()

  // Обработка результатов.
  for result := range resultQueue {
     fmt.Println("Результат:", result)
  }

  fmt.Println("Готово!")
}

// Функция воркера.
func worker(tasks <-chan int, results chan<- int, wg *sync.WaitGroup) {
  defer wg.Done()

  for task := range tasks {
     result := performMemoryIntensiveTask(task)
     results <- result
  }
}

// performMemoryIntensiveTask функция требующая много памяти.
func performMemoryIntensiveTask(task int) int {
  // Создание среза большого размера.
  data := make([]int, MemoryIntense)
  for i := 0; i < MemoryIntense; i++ {
     data[i] = i + task
  }

  // Имитация временной задержки
	time.Sleep(10 * time.Millisecond)

  // Вычисление результата.
  result := 0
  for _, value := range data {
     result += value
  }
  return result
}

Для трассировки работы программы результат записывается в файл trace.out:

  // Запись в trace файл.
  f, _ := os.Create("trace.out")
  trace.Start(f)
  defer trace.Stop()

Используя инструмент go tool trace, мы можем наблюдать за изменениями размера кучи и анализировать поведение сборщика мусора в вашей программе.

Hidden text

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

GOGC по умолчанию

Параметр GOGC можно установить с помощью функции debug.SetGCPercent(100) из пакета runtime/debug. По умолчанию GOGC равно 100 (процентам).

Давайте запустим выполнение нашей программы с помощью команды:

go run main.go

По завершению выполнения программы будет создан файл trace.out, который мы сможем проанализировать с помощью утилиты go tool. Для этого выполним команду:

go tool trace trace.out

Затем мы можем перейти в веб-версию трассировщика, открыв веб-браузер и перейдя по адресу http://127.0.0.1:54784/trace

Куча при GOGC = 100
Куча при GOGC = 100

Во вкладке STATS мы видим поле "Heap" (куча), которое отображает, как менялся размер кучи при исполнении приложения. Красная область на графике представляет занятую кучей память.

Во вкладке PROCS в поле "GC" (сборщик мусора) отображаются столбцы голубого цвета, которые показывают моменты запуска сборщика мусора.

Как только размер новой кучи достигает 100% от размера живой кучи, запускается сборка мусора. Например, если размер живой кучи составляет 10 Мб, то сборщик мусора запустится, когда размер новой кучи достигнет 10 Мб (а общая память в GC = 20 Мб).

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

Количество вызовов GC при GOGC=100
Количество вызовов GC при GOGC=100

В нашем случае сборщик мусора вызывался 16 раз с общим временем выполнения 14 мс.

Вызываем GC чаще

Если мы запустим код, предварительно установив debug.SetGCPercent(10) на 10%, то мы увидим, что частота вызова сборщика мусора увеличится: теперь сборщик мусора будет вызываться, когда размер текущей кучи составляет 10% от размера живой кучи.

Другими словами, если размер живой кучи составляет 10 Мб, то сборщик мусора будет запускаться, когда текущая куча достигнет размера 1 Мб (а общий размер = 11 Мб).

Куча при GOGC = 10
Куча при GOGC = 10

В данном случае сборщик мусора вызывался 38 раз, а общее время вызова сборщика мусора составило 28 мс.

Количество вызовов GC при GOGC=10
Количество вызовов GC при GOGC=10

Мы видим, что установка GOGC в значение меньше 100% может увеличить частоту сборки мусора, что может привести к увеличенному использованию процессорного времени и снижению производительности программы.

Вызываем GC реже

Если мы вызовем ту же программу, но с настройкой debug.SetGCPercent(1000) в 1000%, то получим следующий результат:

Мы видим, что текущая куча растет до тех пор, пока не достигнет размера, равного 1000% от размера живой кучи. Другими словами, если размер живой кучи составляет 10 Мб, то сборщик мусора будет запущен, когда текущий размер кучи достигнет 100 Мб (общий объем кучи = 110 Мб).

Количество вызовов GC при GOGC=1000
Количество вызовов GC при GOGC=1000

В текущем случае сборщик мусора был вызван 1 раз и выполнялся в течение 2 мс.

Отключаем GC

Мы можем также отключить сборщик мусора, установив GOGC=off или используя debug.SetGCPercent(-1).

Так ведет себя куча при отключенном сборщике мусора без использования GOMEMLIMIT:

Сборщик мусора не вызывается при GOGC=off
Сборщик мусора не вызывается при GOGC=off

Сколько памяти занимает куча?

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

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

Например, если при выполнении нескольких параллельных задач размер живой кучи может достигать 800 Мб, то сборщик мусора будет запущен только тогда, когда текущий размер кучи достигнет 1,6 Гб.

Размер кучи в реальной жизни может меняться скачками
Размер кучи в реальной жизни может меняться скачками

В современной разработке большую часть приложений мы запускаем в контейнерах, которые имеют ограничения по использованию памяти. Таким образом, если нашему контейнеру был установлен лимит памяти в 1 Гб, а общий размер кучи в какой-то момент увеличился до 1.6 Гб, то контейнер выйдет из строя из-за ошибки OOM (out-of-memory).

Давайте смоделируем эту ситуацию. Например запустим нашу программу в контейнере с ограничением по памяти 10 Мб (такое значение нереалистично, мы его используем исключительно для тестовых целей):

Описание Dockerfile для контейнера, исполняющего программу на Go:

FROM golang:latest as builder


WORKDIR /src
COPY . .


RUN go env -w GO111MODULE=on


RUN go mod vendor
RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -a -installsuffix cgo -o app ./cmd/


FROM golang:latest
WORKDIR /root/
COPY --from=builder /src/app .
EXPOSE 8080
CMD ["./app"]

Описание docker-compose:

version: '3'
services:
 my-app:
   build:
     context: .
     dockerfile: Dockerfile
   ports:
     - 8080:8080
   deploy:
     resources:
       limits:
         memory: 10M

Давайте воспользуемся предыдущим вариантом кода, в котором мы установили значение GOGC равным 1000%.

Запустим контейнер:

docker-compose build
docker-compose up 

Через пару секунд наш контейнер упадет с ошибкой, которая соответствует ошибки OOM (out-of-memory):

exited with code 137

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

Как избежать OOM?

Начиная с версии 1.19 в Golang вводится так называемое мягкое управление памятью с помощью переменной окружения GOMEMLIMIT или аналогичной функции из пакета runtime/debug SetMemoryLimit (здесь можно прочитать некоторые интересные детали проектирования данного механизма).

Переменная окружения GOMEMLIMIT устанавливает общий объем памяти, которым может пользоваться среда выполнения Go (Go runtime), например:

GOMEMLIMIT = 8MiB

Для установки значения памяти используется суффикс размерности, например MiB - это Мб.

Запустим контейнер с установленной переменной окружения GOMEMLIMIT = 8MiB. Для этого пропишем в docker-compose переменную окружения enviroment:

version: '3'
services:
 my-app:
    environment:
      GOMEMLIMIT: "8MiB"
   build:
     context: .
     dockerfile: Dockerfile
   ports:
     - 8080:8080
   deploy:
     resources:
       limits:
         memory: 10M

Теперь, при запуске контейнера, программа выполняется полностью без ошибки OOM.

Это происходит потому, что после включения GOMEMLIMIT = 8MiB сборщик мусора вызывается всякий раз, когда общая память приближается к лимиту, и поддерживает размер кучи в заданных GOMEMLIMIT пределах. Это приводит к более частым вызовам сборщика мусора.

Именно для решения этой проблемы был придуман механизм GOMEMLIMIT.

Запуск программы при ограничении GOMEMLIMIT = 8MiB
Запуск программы при ограничении GOMEMLIMIT = 8MiB

Спираль смерти

GOMEMLIMIT является мощным и полезным инструментом, который также может выстрелить в ногу. Пример опасного поведения виден на предыдущем графике.

Когда размер общей памяти, вызванный ростом живой кучи или постоянными утечками горутин, приближается к GOMEMLIMIT, сборщик мусора начинает вызываться часто, чтобы уменьшить потребляемую память.

Из-за повторных вызовов сборщика мусора время работы приложения теоретически может неограниченно возрастать, забирая процессорное время у самого приложения. Такое поведение называется спиралью смерти. Это может привести к полной деградации работы приложения, и такое поведение, в отличие от ошибки OOM, очень сложно отследить.

Именно поэтому механизм GOMEMLIMIT работает как мягкое ограничение.

Go не предоставляет 100% гарантий соблюдения ограничения памяти GOMEMLIMIT. Это позволяет избежать ситуации частого вызова сборщика мусора, так как позволяет использовать память сверх лимита.

Для этого установлен предел использования процессорного времени. В настоящее время этот предел установлен на 50% с окном CPU в 2 * GOMAXPROCS секунды.

Также это значит в случае утечек памяти, что мы полностью не сможем избежать ошибки OOM, она просто произойдет значительно позже.

Как применять GOGC и GOMEMLIMIT

Механизм мягкого управления памятью с помощью GOMEMLIMIT и изменение настроек сборщика мусора GOGC может защитить нас от неприятных ситуаций и улучшить эффективность работы приложения.

Приведем примеры случаев, когда использованиеGOMEMLIMIT и GOGC может быть полезным:

  1. Приложение, запущенное в контейнере с ограниченным объемом памяти. Хорошей практикой будет настроить GOMEMLIMIT так, чтобы оставалось 5-10% от доступной в контейнере памяти.

  2. При запуске библиотеки или кода, требующего значительных ресурсов. Здесь можно динамически управлять GOMEMLIMIT для оптимальной работы.

  3. При запуске приложения в контейнере в качестве скрипта, где приложение выполняет определенную задачу в течение некоторого времени и затем завершается. Для повышения производительности можно отключить сборщик мусора GOGC=off, но установить GOMEMLIMIT, чтобы не превысить доступные ресурсы контейнера по памяти.

Существуют и случаи, когда лучше избегать использования GOMEMLIMIT:

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

  2. Не используйте ограничение памяти при развертывании в исполнительной среде, которой вы не управляете, особенно если использование памяти вашей программой пропорционально входным данным. Например, это может быть CLI-инструмент или настольное приложение.

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


  1. QValder
    03.07.2023 11:12
    +1

    Спасибо, познавательно. Хотелось бы еще узнать, какое место автор отводит функциональности arena из go 1.20, казалось бы идеальный вариант для приложений со сложным и непредсказуемым использованием памяти. Или это все еще считается go разработчиками экспериментальным и неблагонадёжным функционалом?


    1. Ninako Автор
      03.07.2023 11:12
      +1

      Arena интересная фишка. Мы в своих сервисах уже перешли на 1.20, но пока Arena не трогали. У нас есть договоренность не использовать фичу ради фичи. Но если будет такая задача, где без Arena не обойтись (ну или докажем, что это целесообразно), то возьмем попробовать :) Тем более очень интересно поработать в реальных условиях.