Платформа BEAM: виртуальная машина Erlang

Двигатель, который превращает идеи Erlang в работающую систему.

BEAM (Bogdan/Björn's Erlang Abstract Machine) — виртуальная машина, исполняющая байт-код Erlang и управляющая лёгкими процессами, планированием и памятью.

Язык Erlang — это лишь синтаксис. Настоящая магия происходит в среде выполнения BEAM. Понимание BEAM объясняет, почему Erlang ведёт себя не так, как привычные платформы вроде JVM или интерпретатора Python. Можно знать весь синтаксис наизусть и всё равно не понимать, почему Erlang масштабируется так, как масштабируется, — ответ всегда лежит в устройстве этой виртуальной машины.

Название BEAM расшифровывается как Bogdan/Björn's Erlang Abstract Machine, по именам инженеров, стоявших за её разработкой. Это не первая и не единственная виртуальная машина Erlang в истории, но именно она победила и стала стандартом. Современная BEAM — результат десятилетий шлифовки в боевых условиях телеком-систем, и в ней закодирован огромный практический опыт о том, как заставить тысячи параллельных задач уживаться на одном железе. Любопытно, что та же машина теперь несёт на себе и язык Elixir, и ряд других языков — то есть BEAM переросла Erlang и стала самостоятельной платформой.

Лёгкие процессы вместо потоков ОС

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

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

% Запуск миллиона процессов — обычное дело в Erlang
spawn_many(0) -> ok;
spawn_many(N) ->
    spawn(fun() -> receive stop -> ok end end),
    spawn_many(N - 1).
% spawn_many(1000000) создаст миллион процессов

Планировщики и вытесняющая многозадачность

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

Редукция — единица работы в BEAM. Отработав отведённое число редукций, процесс уступает место другому.

Это принципиальное отличие от так называемой кооперативной многозадачности, которая встречается, например, в некоторых средах с async/await. Там процесс уступает управление только в специально расставленных точках, и если он по ошибке «зациклится» без таких точек, он заблокирует всё вокруг — знакомая боль event loop. BEAM же применяет вытесняющую (preemptive) многозадачность: планировщик отбирает управление принудительно, не спрашивая разрешения. Благодаря этому система остаётся отзывчивой даже под нагрузкой, и время отклика на новые события (latency) предсказуемо мало. Для систем мягкого реального времени — а телеком именно таков — это критически важное свойство: лучше выполнить всё чуть медленнее, но равномерно, чем дать одной задаче на секунду «подвесить» остальные.

Сборка мусора на каждый процесс

У каждого процесса своя личная куча памяти и свой сборщик мусора. Когда BEAM очищает память одного процесса, остальные продолжают работать — нет «глобальной паузы», которая останавливает всё приложение. А когда процесс завершается, вся его память освобождается мгновенно и целиком. Это ещё одна причина, почему «let it crash» дёшев: смерть процесса не оставляет за собой мусора.

Стоит оценить, насколько это удачное решение. В платформах с одной общей кучей сборка мусора рано или поздно вынуждена остановить весь мир (stop-the-world), чтобы навести порядок, и в этот момент приложение замирает — пользователи видят «фриз». Инженеры годами борются с такими паузами, придумывая всё более хитрые сборщики. В BEAM проблема решена самой архитектурой: кучи маленькие и приватные, поэтому сборка одного процесса занимает доли миллисекунды и затрагивает лишь его одного. Чем больше у вас мелких процессов, тем меньше каждая отдельная куча и тем незаметнее работа сборщика. Изоляция памяти, придуманная ради безопасности, бесплатно дала ещё и предсказуемую производительность.

Как работает под капотом

  ОС-нить 1            ОС-нить 2
  [Планировщик A]      [Планировщик B]
   |  |  |              |  |  |
   p1 p2 p3 ...         p4 p5 p6 ...
   (своя куча у каждого процесса)

Планировщики берут процессы из общих очередей и при необходимости перераспределяют нагрузку между ядрами (это называют work stealing — «кража работы»). Если один планировщик простаивает, а у соседнего скопилась очередь, простаивающий «крадёт» у него часть процессов и берёт на себя. Так нагрузка сама растекается по ядрам без участия программиста. Ввод-вывод (сеть, файлы) обрабатывается отдельными нитями, чтобы медленная операция не блокировала планировщик. В результате BEAM прекрасно держит десятки тысяч одновременных сетевых соединений — именно то, что нужно мессенджерам и серверам.

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

Чем BEAM отличается от JVM

СвойствоBEAMJVM (типично)
Единица конкурентностилёгкий процесспоток ОС
Память между задачамиизолированаобщая (shared)
Сборка мусорана процесс, без общей паузычасто общая для кучи
Связьсообщенияобщая память + блокировки

Частые ошибки

  • Путать процесс BEAM с потоком ОС. Это разные сущности; масштаб различается на порядки.
  • Запускать тяжёлые вычисления в одном процессе и ждать, что BEAM «распараллелит» сам. Параллелизм нужно выражать процессами явно.
  • Игнорировать, что NIF-функции (нативный код) могут заблокировать планировщик. Долгий нативный код ломает честное вытеснение.

Итоги

  • BEAM исполняет байт-код Erlang и управляет лёгкими процессами.
  • Планировщики честно вытесняют процессы по редукциям — нет «зависаний».
  • У каждого процесса своя куча и свой сборщик мусора — нет глобальных пауз.
  • Изоляция и сообщения вместо общей памяти — фундамент устойчивости Erlang.
Проверьте себя
1. Чем процесс BEAM отличается от потока операционной системы?
AНичем, это синонимы
BПроцесс BEAM лёгкий, управляется виртуальной машиной и не требует ресурсов ОС-потока
CПроцесс BEAM всегда медленнее
DПроцесс BEAM хранится в базе данных
2. Что обеспечивает «редукция» в планировщике BEAM?
AУменьшение размера кода
BЧестное вытеснение: процесс уступает место, отработав отведённую долю работы
CСжатие памяти
DУдаление неиспользуемых модулей
3. Почему сборка мусора в BEAM не вызывает глобальной паузы?
AМусор не собирается вообще
BУ каждого процесса своя куча и свой сборщик
CПамять общая для всех
DGC отключён