Устранение утечки больших двоичных файлов

У меня есть приложение elixir/OTP, которое выходит из строя из-за проблемы с памятью. Функция, вызывающая сбой, вызывается каждые 6 часов в специальном процессе. Для запуска требуется несколько минут (~ 30) и выглядит следующим образом:

def entry_point do
  get_jobs_to_scrape()
  |> Task.async_stream(&scrape/1)
  |> Stream.map(&persist/1)
  |> Stream.run()
end

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

Использование памяти наблюдателя показывает постоянный рост памяти больших двоичных файлов

Обратите внимание, что когда я вручную запускаю сбор мусора в процессе, который выполняет эту функцию, потребление памяти значительно падает, поэтому это определенно не проблема с несколькими разными процессами, неспособными к GC, но только с тем, что не GC должным образом. Кроме того, важно сказать, что каждые несколько минут процесс справляется с GC, но иногда этого недостаточно. Производственный сервер имеет только 1 ГБ оперативной памяти, и он сработает до того, как GC запустится.

Пытаясь решить проблему, я столкнулся с Erlang in Anger (см. страницы 66-67). Одно из предложений - положить все большие манипуляции с бинарниками в одноразовых процессах. Возвращаемое значение функции scrape - это карта, содержащая большие двоичные файлы. Поэтому они распределяются между Task.async_stream "рабочими" и процессом, выполняющим функцию. Поэтому теоретически я мог бы поставить persist вместе с scrape внутри Task.async_stream. Я предпочитаю не делать этого и поддерживать вызовы persist в процессе.

Другое предложение - периодически называть :erlang.garbage_collect. Похоже, он решает проблему, но чувствует себя слишком хаки. Автор этого также не рекомендует. Здесь мое текущее решение:

def entry_point do
  my_pid = self()
  Task.async(fn -> periodically_gc(my_pid) end)
  # The rest of the function as before...
end

defp periodically_gc(pid) do
  Process.sleep(30_000)
  if Process.alive?(pid) do
    :erlang.garbage_collect(pid)
    periodically_gc(pid)
  end
end

И приведенная загрузка памяти:

использование памяти наблюдателя после GC hack

Я не совсем понимаю, как другие предложения в книге соответствуют этой проблеме.

Что бы вы порекомендовали в этом случае? Храните хакерское решение или есть лучшие варианты.

Ответ 1

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

Я предлагаю вам попробовать настроить флаг fullsweep_after. Его можно установить глобально через :erlang.system_flag(:fullsweep_after, value) или для вашего конкретного процесса, используя :erlang.spawn_opt/4.

Из документов:

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

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

Несколько случаев, когда может быть полезно изменить fullsweep_after:

  • Если бинарные файлы, которые больше не используются, должны быть выброшены как можно скорее. (Установите номер в ноль.)
  • Процесс, который в основном имеет недолговечные данные, заполняется редко или никогда, т.е. старая куча содержит в основном мусор. Чтобы обеспечить полную паузу, установите Number на подходящее значение, например 10 или 20.
  • Во встроенных системах с ограниченным объемом оперативной памяти и без виртуальной памяти вы можете сохранить память, установив Number в ноль. (Значение можно задать глобально, см. Erlang: system_flag/2.)

Значение по умолчанию - 65535 (если вы уже не изменили его с помощью переменной окружения ERL_FULLSWEEP_AFTER), поэтому любое меньшее значение сделает сборку мусора более агрессивной.

Это хорошее чтение по теме: https://www.erlang-solutions.com/blog/erlang-19-0-garbage-collector.html