Внутренности BEAM
Весь курс мы пользовались обещаниями BEAM: процессы дёшевы, один жадный процесс не заморозит остальные, сборка мусора не даёт глобальных пауз, падение изолировано. Пришло время открыть капот и посмотреть, за счёт чего эти обещания выполняются — и где они перестают выполняться.
Это знание нужно не из любопытства. Оно определяет три вещи: как правильно настроить ноду в контейнере, как читать метрики BEAM, когда что-то идёт не так, и почему «оптимизации», привычные в других рантаймах, здесь иногда вредят. Общая методология измерения и оптимизации — в треке Производительность; здесь — специфика самой виртуальной машины.
Планировщики: карта ноды
Одна нода BEAM — это один процесс ОС, внутри которого работает набор потоков со строго разными ролями.
run queue: max/high/normal/low"] S2["Scheduler 2
run queue"] S3["Scheduler N
run queue"] end subgraph DIRTY["Dirty-планировщики"] DC["Dirty CPU
долгие вычисления в NIF"] DIO["Dirty IO
блокирующие вызовы"] end AIO["Пул async-потоков
файловый ввод-вывод"] POLL["Poll-потоки
epoll/kqueue для сокетов"] ALLOC["Аллокаторы памяти
по потоку на планировщик"] end S1 <-->|миграция процессов
work stealing| S2 S2 <--> S3 S1 -.NIF помечен dirty.-> DC S1 -.file:read.-> AIO POLL -.готовность сокета.-> S1
Что здесь важно:
- Планировщиков по умолчанию столько, сколько логических ядер. Каждый держит свою очередь выполнения (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 владеет собственным куском памяти, устроенным так:
Ключевые свойства:
- Стек и куча растут навстречу друг другу в одном непрерывном блоке. Когда они встречаются — запускается сборка мусора для этого и только этого процесса.
- 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 превращается в системное мышление: Распределённые системы, Производительность и Паттерны отказоустойчивости. Но лучший следующий шаг прежний — реальный проект, проведённый через весь этот путь.