Elixir Внутренности BEAM: планировщики, редукции, сборка мусора и настройка ноды
0%

Внутренности BEAM: планировщики, редукции, сборка мусора и настройка ноды

Внутренности BEAM

Весь курс мы пользовались обещаниями BEAM: процессы дёшевы, один жадный процесс не заморозит остальные, сборка мусора не даёт глобальных пауз, падение изолировано. Пришло время открыть капот и посмотреть, за счёт чего эти обещания выполняются — и где они перестают выполняться.

Это знание нужно не из любопытства. Оно определяет три вещи: как правильно настроить ноду в контейнере, как читать метрики BEAM, когда что-то идёт не так, и почему «оптимизации», привычные в других рантаймах, здесь иногда вредят. Общая методология измерения и оптимизации — в треке Производительность; здесь — специфика самой виртуальной машины.

Планировщики: карта ноды

Одна нода BEAM — это один процесс ОС, внутри которого работает набор потоков со строго разными ролями.

Что здесь важно:

  • Планировщиков по умолчанию столько, сколько логических ядер. Каждый держит свою очередь выполнения (run queue) и крутит цикл «взять процесс — дать поработать — вернуть в очередь».
  • Очередей на самом деле четыре — по приоритетам :max, :high, :normal, :low. Приоритеты в прикладном коде почти всегда ошибка: :high-процесс легко заморит остальные. Единственный оправданный случай — системный наблюдатель, который обязан отвечать даже под нагрузкой.
  • Балансировка идёт через миграцию процессов между планировщиками и work stealing: простаивающий планировщик забирает работу у загруженного.
  • Poll-потоки (+K) следят за готовностью сокетов через epoll/kqueue, поэтому сто тысяч соединений не означают сто тысяч потоков ОС. Про сам механизм — трек Операционные системы.

Редукции: как достигается справедливость

Вытеснение в BEAM не полагается на прерывания ОС. Вместо этого VM считает редукции — условные единицы работы. Примерно одна редукция на вызов функции; встроенные функции (BIF) списывают редукции пропорционально своей стоимости, например binary_to_term/1 — тем больше, чем крупнее данные.

Когда процесс израсходовал бюджет (около 4000 редукций), планировщик снимает его с ядра и берёт следующий из очереди. Отсюда прямые следствия:

  • Справедливость не зависит от поведения кода. Бесконечный цикл def loop, do: loop() съест своё ядро, но не заблокирует ни один другой процесс — в отличие от event loop в Node.js или корутин без точек передачи управления.
  • Задержка предсказуема. Худшее ожидание процесса ограничено числом процессов в очереди, умноженным на бюджет редукций. Это и есть «soft real-time», о котором говорят применительно к BEAM.
  • Единственный способ сломать эту гарантию — уйти в нативный код. NIF выполняется как единая непрерывная операция; планировщик не может его прервать. Отсюда правило «1 мс на NIF» из главы Erlang-интероп.
  • Редукции — это ещё и метрика. Process.info(pid, :reductions) показывает, сколько работы сделал процесс. Дельта редукций за интервал — лучший ответ на вопрос «кто сейчас жжёт CPU»: :recon.proc_count(:reductions, 10).

Сравните с вытесняющей многозадачностью ОС из трека Операционные системы: там квант времени отмеряет таймер и прерывание, здесь — счётчик внутри интерпретатора. BEAM платит небольшим оверхедом на подсчёт и получает переключение контекста в десятки раз дешевле, чем у потоков ОС.

Память процесса и сборка мусора

Каждый процесс BEAM владеет собственным куском памяти, устроенным так:

Раскладка памяти процесса BEAM

Ключевые свойства:

  • Стек и куча растут навстречу друг другу в одном непрерывном блоке. Когда они встречаются — запускается сборка мусора для этого и только этого процесса.
  • GC поколенческий и копирующий. Живые данные копируются из молодого поколения в старое; мусор не трогается вовсе, поэтому стоимость сборки пропорциональна объёму живых данных, а не объёму мусора. Процесс, который выделил гигабайты короткоживущих термов, собирается почти мгновенно.
  • Пауз «на весь мир» не бывает. Останавливается один процесс на время своей сборки. Пока он собирается, остальные работают. Именно это даёт стабильные хвостовые задержки под нагрузкой.
  • Смерть процесса — самый дешёвый способ освободить память. Куча отдаётся целиком, без обхода объектов. Отсюда идиома: короткоживущий процесс на задачу часто эффективнее долгоживущего с накоплением состояния.

Общая теория сборщиков — Сборка мусора; BEAM интересен тем, что решение «много маленьких куч вместо одной большой» превращает классический компромисс «пропускная способность против пауз» в почти отсутствующую проблему.

Что живёт вне кучи процесса

  • Refc-бинарники (больше 64 байт) — в общей области, со счётчиком ссылок. Их сборка происходит косвенно: когда процесс, державший ссылку, делает GC. Долгоживущий процесс, который редко собирается, может удерживать гигабайты бинарников, сам занимая килобайты. Это и есть «binary leak».
  • ETS-таблицы — своя область памяти, GC туда не заглядывает. Данные копируются при вставке и при чтении.
  • :persistent_term — хранилище для данных, которые читаются постоянно и меняются почти никогда (конфигурация, скомпилированные правила). Чтение бесплатно и без копирования, но любая запись вызывает глобальный проход по всем процессам. Классическая ошибка — писать туда в горячем пути.
  • Атомы — глобальная таблица, которая никогда не очищается и ограничена по размеру (+t, по умолчанию около миллиона). Отсюда запрет на String.to_atom/1 для внешних данных.

Ручки настройки GC

# Процессу, который создаёт много мусора, полезна большая начальная куча:
# меньше сборок на старте, но больше базовая память
GenServer.start_link(__MODULE__, arg, spawn_opt: [min_heap_size: 100_000])

# Процессу, который спит часами, полезен hibernate:
# полная сборка + сжатие кучи до минимума
def handle_info(:idle, state), do: {:noreply, state, :hibernate}

# Точечная помощь при известной проблеме с бинарниками
:erlang.garbage_collect(pid)

fullsweep_after (по умолчанию 65535) задаёт, через сколько сборок делать полную вместо поколенческой. Для процессов, держащих refc-бинарники, его иногда снижают — это стандартное лечение утечки бинарников без правки логики.

Жизненный цикл процесса глазами планировщика

Эта диаграмма объясняет главный диагностический признак Elixir-систем: если процесс подолгу в Waiting, а его message_queue_len растёт — он не успевает разгребать почту, и он ваше узкое место. Если процесс постоянно Running с растущими редукциями — он жжёт CPU.

Аллокаторы и фрагментация

BEAM не зовёт malloc на каждый терм. Между ним и ОС стоит система аллокаторов, по набору на каждый планировщик, что убирает борьбу за блокировки:

Аллокатор Что держит
eheap_alloc кучи процессов
binary_alloc refc-бинарники
ets_alloc таблицы ETS
driver_alloc буферы драйверов и портов
temp_alloc временные буферы внутри операций

Память берётся у ОС большими блоками — карриерами. Мультиблочные карриеры (mbcs) нарезаются под много объектов; объект больше порога получает собственный синглблочный карриер (sbcs).

Отсюда специфическая проблема: фрагментация. Карриер нельзя вернуть ОС, пока в нём живёт хоть один объект. Всплеск нагрузки создал миллион бинарников, они распределились по тысяче карриеров, нагрузка спала — и в каждом карриере остался один живой объект, удерживающий весь блок. :erlang.memory() покажет умеренные числа, а RSS процесса в top останется огромным. Диагностика:

# Соотношение «полезные данные / занятые карриеры» по аллокаторам
:recon_alloc.average_block_sizes(:current)
:recon_alloc.fragmentation(:current)
:recon_alloc.cache_hit_rates()

Лечение — смена стратегии распределения для проблемного аллокатора, чаще всего aobf (address order best fit) вместо стратегии по умолчанию:

+MBas aobf +MBacul 0

Это уже тонкая настройка: сначала убедитесь, что проблема именно во фрагментации, а не в честной утечке.

Настройка ноды: vm.args

Релиз, собранный через mix release, читает флаги VM из rel/vm.args.eex. Минимальный осмысленный набор для продакшена:

## Имя ноды и cookie — для кластера и для bin/my_app remote
-name <%= @release.name %>@${NODE_IP}
-setcookie ${RELEASE_COOKIE}

## Планировщики: столько, сколько ядер РЕАЛЬНО доступно (см. про контейнеры ниже)
+S ${SCHEDULERS}:${SCHEDULERS}

## Busy wait: не жечь CPU в ожидании работы
+sbwt none
+sbwtdcpu none
+sbwtdio none

## Kernel poll — epoll вместо select
+K true

## Лимиты
+P 2000000        # максимум процессов
+Q 262144         # максимум портов
+t 5000000        # размер таблицы атомов

## Не выводить прогресс-репорты SASL в stdout
-kernel logger_level info

Разберём два флага, которые чаще всего решают реальные проблемы.

+sbwt и «нода ест 100% CPU на холостом ходу»

По умолчанию планировщик, оставшийся без работы, некоторое время активно ждёт (busy wait), прежде чем уснуть. На выделенном железе это правильно: просыпание потока стоит дороже, чем несколько микросекунд ожидания, и задержки получаются ниже.

В контейнере это катастрофа. Busy wait выглядит для cgroups как настоящая работа: вы упираетесь в CPU quota, вас троттлят, метрика container_cpu_usage показывает 100% на простаивающем сервисе, автоскейлер сходит с ума. Лечение — +sbwt none (и парные флаги для dirty-планировщиков). Платите десятками микросекунд задержки, получаете вменяемый учёт CPU.

+S и «BEAM видит все ядра хоста»

BEAM определяет число ядер через системный вызов и не знает про cgroup-квоту. На 64-ядерном узле k8s под с лимитом cpu: 2 поднимет 64 планировщика. Результат: 64 потока дерутся за два ядра, растут переключения контекста, задержки скачут, а сама нода тратит заметную часть квоты на собственную балансировку.

Всегда задавайте +S явно, исходя из лимита пода:

# в rel/env.sh.eex
export SCHEDULERS=${CPU_LIMIT:-2}

Это, пожалуй, самая частая ошибка эксплуатации Elixir в Kubernetes — и самая дешёвая в исправлении.

Измерение: что смотреть и чем

# Загрузка планировщиков — честная, в отличие от CPU% из top
:scheduler.utilization(1)
# [{:normal, 1, 0.42, '42.0%'}, {:normal, 2, 0.39, '39.0%'}, {:total, 0.40, '40.0%'}, ...]

# Длины очередей выполнения: суммарно и по планировщикам
:erlang.statistics(:total_run_queue_lengths)
:erlang.statistics(:run_queue_lengths)

# Память по категориям
:erlang.memory()
# [total: _, processes: _, system: _, atom: _, binary: _, code: _, ets: _]

# Микросостояния: куда на самом деле уходит время планировщиков
:msacc.start(5000)
:msacc.print()
# emulator / gc / port / check_io / sleep / aux — сразу видно, если время уходит в GC

Ориентиры для алертов на ноде BEAM:

Сигнал Что означает Порог для внимания
total_run_queue_lengths процессы ждут ядро стабильно больше числа планировщиков
scheduler.utilization реальная загрузка больше 0.8 длительно
message_queue_len у ключевых процессов GenServer не успевает растёт монотонно
доля gc в msacc давление на память больше 10–15%
binary в :erlang.memory() утечка бинарников растёт при стабильной нагрузке
process_count утечка процессов приближается к +P

Главное отличие от других рантаймов: top показывает загрузку потоков ОС, а не полезную работу BEAM. При включённом busy wait он врёт всегда. Верить нужно :scheduler.utilization/1.

Трассировка в проде: осторожно

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

# ОПАСНО: :dbg без ограничений может завалить ноду потоком трассировочных сообщений
# БЕЗОПАСНО: recon_trace с жёстким лимитом

# Не более 10 срабатываний, максимум 100 в секунду
:recon_trace.calls({MyApp.Billing, :charge, :_}, 10)

# С аргументами и возвращаемым значением, с ограничением скорости
:recon_trace.calls({MyApp.Billing, :charge, fn _ -> :return_trace end}, {100, 1000})

:recon_trace.clear()

Правила безопасной трассировки в проде: всегда указывайте лимит срабатываний, никогда не трассируйте «горячие» функции без частотного ограничения, не трассируйте :_ по всем модулям, и снимайте трассировку явно.

Чтение crash dump

Если нода умерла, она (если успела) записала erl_crash.dump. Первая строка — причина:

  • eheap_alloc: Cannot allocate N bytes — кончилась память. Смотрите раздел с процессами, отсортированный по размеру кучи: почти всегда виноват один процесс с гигантским состоянием или раздутым mailbox.
  • Maximum number of processes reached — утечка процессов: где-то spawn без завершения или Task без ожидания.
  • Maximum number of ports reached — незакрытые сокеты или порты.
  • Kernel pid terminated (application_controller) — упало критичное приложение, дерево супервизии эскалировало отказ до самого верха.

Читать дамп удобно через :crashdump_viewer.start(). Практический совет: настройте ERL_CRASH_DUMP на примонтированный том, иначе в контейнере дамп умрёт вместе с подом.

Чек-лист здоровой ноды

  • +S выставлен по CPU-лимиту контейнера, а не по ядрам хоста.
  • +sbwt none (и dirty-варианты) при работе под cgroups-квотой.
  • +K true, +P и +Q заданы с запасом относительно ожидаемой нагрузки.
  • В метриках есть run_queue, scheduler.utilization, memory по категориям, process_count.
  • Алерт на рост message_queue_len у ключевых GenServer.
  • ERL_CRASH_DUMP пишется наружу контейнера.
  • recon есть в зависимостях прода, и вы умеете им пользоваться до инцидента.
  • Есть доступ к bin/my_app remote с аудитом — это ваш последний рубеж диагностики.

Типичные ошибки

  • Считать, что BEAM сам всё настроит в контейнере. Он не видит cgroup-лимиты; +S и +sbwt задаёте вы.
  • Верить top. При busy wait он показывает 100% на простое; смотрите :scheduler.utilization/1.
  • Гигантское состояние в одном GenServer. Дорогая сборка, дорогое копирование при отправке, единственная точка сериализации. Дробите или уносите в ETS.
  • Приоритеты процессов «для важного». :high почти всегда делает хуже: важный процесс начинает морить остальных, и система деградирует неочевидным образом.
  • Запись в :persistent_term в горячем пути — глобальный проход по всем процессам на каждую запись.
  • Трассировка без ограничений в проде — самый быстрый способ добить и без того больную ноду.
  • Реакция на фрагментацию как на утечку (и наоборот) — сначала :recon_alloc.fragmentation/1, потом выводы.

Мини-итог

Обещания BEAM держатся на четырёх инженерных решениях: редукции вместо доверия коду дают справедливое вытеснение; куча на процесс превращает сборку мусора из глобальной паузы в локальную мелочь; планировщик на ядро с миграцией масштабирует это по железу; аллокаторы по потокам убирают борьбу за блокировки. Каждое из решений имеет обратную сторону — нативный код ломает вытеснение, разделяемые бинарники ломают локальность GC, карриеры фрагментируются, а незнание про cgroups ломает всё сразу в контейнере.

Разработчик, который это понимает, чинит продакшн-инциденты на BEAM за минуты вместо дней. Это и есть та глубина, ради которой стоило пройти весь курс.

Источники

  • Erlang — Efficiency Guide и Scheduling
  • BEAM Book (Erik Stenman) — самое подробное открытое описание виртуальной машины.
  • Книга «Erlang in Anger» (Fred Hébert) — диагностика, recon, чтение дампов; бесплатна онлайн.
  • recon и :recon_alloc — рабочий инструментарий эксплуатации.
  • Доклад Saša Jurić «The Soul of Erlang and Elixir» — интуиция про изоляцию и отзывчивость.

Что дальше

Курс пройден целиком: от = как оператора сопоставления до планировщиков BEAM. Освежить карту можно в обзоре курса, а дальше стоит идти вширь — туда, где знание BEAM превращается в системное мышление: Распределённые системы, Производительность и Паттерны отказоустойчивости. Но лучший следующий шаг прежний — реальный проект, проведённый через весь этот путь.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов