Erlang-интероп и бинарные данные
Elixir не работает «поверх» Erlang — он компилируется в тот же байт-код BEAM. Модуль Elixir и модуль Erlang неразличимы для виртуальной машины, вызов между ними стоит ровно столько же, сколько вызов внутри языка, и никакого моста, маршалинга или FFI между ними нет.
Это открывает две большие возможности, о которых обычно узнают слишком поздно: сорокалетняя библиотека OTP доступна вам напрямую, и BEAM умеет разбирать двоичные протоколы так, как не умеет почти никто. А ещё — рано или поздно приходится звать наружу: запускать внешнюю программу или подключать нативный код. Разберём всё это, включая цену каждого варианта.
Вызов Erlang: правила перевода
Модуль Erlang — это просто атом. :lists, :crypto, :ets, :queue — обычные значения Elixir.
:crypto.hash(:sha256, "данные") |> Base.encode16(case: :lower)
:erlang.system_info(:logical_processors)
:rand.uniform(100)
:zlib.gzip("много текста")
:timer.tc(fn -> heavy() end) # {микросекунды, результат}
:queue.new() |> :queue.in(1) |> :queue.out()
Когда это оправдано: :crypto, :ssl, :gen_tcp/:gen_udp, :inet, :zlib, :erlang, :persistent_term, :atomics, :counters — вещи, для которых в Elixir просто нет обёрток и не нужно. Не надо писать свой велосипед поверх — зовите напрямую.
Что нужно помнить при переходе через границу:
| Elixir | Erlang | Ловушка |
|---|---|---|
"строка" (binary) |
<<"строка">> |
Erlang-функции часто возвращают charlist ~c"abc" |
~c"abc" (charlist) |
"abc" |
IO.inspect покажет список чисел, если есть непечатаемые байты |
nil |
undefined |
Erlang-API отдаёт :undefined, а не nil |
%Struct{} |
record (кортеж) | нужен Record.defrecord/2 |
first_arg — данные |
часто данные последним | пайплайны с Erlang-функциями работают не всегда |
| индексация с 0 | с 1 | :lists.nth(1, list) — это Enum.at(list, 0) |
Записи Erlang
Многие OTP-библиотеки (:ssl, :xmerl, :public_key) возвращают records — обычные кортежи с именем в первом элементе. Работать с ними «по индексу» невозможно поддерживать, поэтому есть Record:
defmodule Cert do
require Record
# Тянем определение record прямо из .hrl-файла OTP
Record.defrecord(:certificate, :Certificate,
Record.extract(:Certificate, from_lib: "public_key/include/public_key.hrl"))
def serial(cert), do: certificate(cert, :serialNumber)
end
Record.extract/2 читает заголовочный файл на этапе компиляции — снова тот приём из главы Метапрограммирование: работа делается при сборке, в рантайме остаётся обращение к элементу кортежа за O(1).
Erlang-зависимости в mix.exs
Пакеты, собранные rebar3, подключаются как обычные:
defp deps do
[
{:telemetry, "~> 1.2"}, # написан на Erlang, ставится как есть
{:recon, "~> 2.5"},
{:jose, "~> 1.11", manager: :rebar3} # менеджер указывают явно, если нужно
]
end
Битовый синтаксис: убийственная фича BEAM
Здесь Elixir делает то, ради чего в других языках пишут ручные парсеры со сдвигами и масками. Двоичные данные сопоставляются с образцом декларативно, на уровне битов.
<<значение::размер-единица-тип-порядок, ...>>
Спецификаторы:
- тип:
integer(по умолчанию),float,binary,bitstring,bits,bytes,utf8,utf16,utf32 - знак:
signed/unsigned(по умолчанию) - порядок байт:
big(по умолчанию) /little/native - размер и единица:
size(N)-unit(M)— итоговая ширина поля равнаN × Mбит; дляintegerединица по умолчанию 1 бит, дляbinary— 8
<<1, 2, 3>> # три байта
<<255::16>> # <<0, 255>> — 16 бит big-endian
<<255::16-little>> # <<255, 0>>
<<1::1, 0::1, 1::1>> # битстрока из трёх бит (не кратна байту!)
<<"привет"::binary>> # UTF-8 байты
Разбор реального заголовка
Возьмём заголовок IPv4 — типичная задача, где битовый синтаксис показывает себя во всей красе.
defmodule IPv4 do
import Bitwise, only: [&&&: 2]
@doc "Разбирает заголовок IPv4 и возвращает {:ok, map, payload} | {:error, reason}"
def parse(
<<4::4, ihl::4, dscp::6, ecn::2, total_length::16,
id::16, flags::3, frag_offset::13,
ttl::8, protocol::8, checksum::16,
src::binary-size(4), dst::binary-size(4),
rest::binary>>
)
when ihl >= 5 do
# опции занимают (ihl - 5) 32-битных слов
options_bytes = (ihl - 5) * 4
<<_options::binary-size(options_bytes), payload::binary>> = rest
{:ok,
%{
version: 4,
header_length: ihl * 4,
dscp: dscp,
ecn: ecn,
total_length: total_length,
id: id,
dont_fragment: (flags &&& 0b010) != 0,
more_fragments: (flags &&& 0b001) != 0,
fragment_offset: frag_offset * 8,
ttl: ttl,
protocol: protocol_name(protocol),
checksum: checksum,
src: ip_to_tuple(src),
dst: ip_to_tuple(dst)
}, payload}
end
def parse(<<version::4, _::bitstring>>), do: {:error, {:unsupported_version, version}}
def parse(_), do: {:error, :truncated}
defp protocol_name(1), do: :icmp
defp protocol_name(6), do: :tcp
defp protocol_name(17), do: :udp
defp protocol_name(n), do: {:unknown, n}
defp ip_to_tuple(<<a, b, c, d>>), do: {a, b, c, d}
end
Сравните с эквивалентом на C или Python: сдвиги, маски, struct.unpack, ручной подсчёт смещений. Здесь структура пакета записана в сигнатуре функции — она одновременно документация, парсер и валидатор. Ошибка в длине — не выход за границы буфера, а честный MatchError, который ловится следующей клаузой. Про сами протоколы — трек Сети.
Тот же приём для потокового разбора с накоплением остатка:
# Классический паттерн «длина-значение» для TCP-потока
def handle_data(<<len::32, payload::binary-size(len), rest::binary>>, acc) do
handle_data(rest, [decode(payload) | acc]) # полное сообщение — разбираем
end
def handle_data(incomplete, acc) do
{Enum.reverse(acc), incomplete} # хвост оставляем до следующего пакета
end
Сборка бинарных данных выглядит зеркально:
def encode(%{type: type, body: body}) do
<<byte_size(body) + 1::32, type::8, body::binary>>
end
Как бинарники живут в памяти
Знание внутреннего устройства бинарников определяет, будет ли ваш сервис течь по памяти.
- Heap binary (до 64 байт) — лежит в куче процесса, копируется при отправке сообщений, умирает вместе с процессом.
- Refc binary (больше 64 байт) — лежит в общей области VM, а в куче процесса только маленькая ссылка со счётчиком. При отправке сообщения копируется ссылка, а не данные — вот почему передавать мегабайтный бинарник между процессами дёшево.
- Sub-binary — «окно» в другой бинарник.
binary_part/3и битовый матчинг создают именно их: без копирования, за O(1).
Отсюда классическая утечка: вы взяли двухсимвольный кусок из мегабайтного лога и положили в состояние GenServer. Кусок — sub-binary, он держит счётчик ссылок на весь мегабайт, и тот не освободится никогда.
# Утечка: маленький кусок удерживает весь исходный бинарник
key = binary_part(huge_log_line, 0, 8)
:ets.insert(:cache, {key, value})
# Лечение: явная копия обрывает связь с оригиналом
key = :binary.copy(binary_part(huge_log_line, 0, 8))
Диагностика — :recon.bin_leak(10) из главы Деплой и наблюдаемость: она покажет процессы, освобождающие больше всего памяти после принудительной сборки мусора.
iodata: не склеивайте строки
"a" <> "b" <> "c" создаёт новый бинарник на каждой конкатенации — O(n²) на длинном тексте. BEAM предлагает iodata: вложенный список бинарников и байтов, который системные вызовы умеют писать как есть, без склейки.
# Плохо: N промежуточных бинарников
Enum.reduce(rows, "", fn row, acc -> acc <> render(row) end)
# Хорошо: строим дерево, склеиваем один раз (или вообще не склеиваем)
iodata = Enum.map(rows, &render/1)
IO.iodata_length(iodata)
File.write!("out.csv", iodata) # writev пишет дерево напрямую
IO.iodata_to_binary(iodata) # если бинарник всё-таки нужен
Именно поэтому HEEx-шаблоны и JSON-энкодеры возвращают iodata, а не строку: конкатенация откладывается до самой границы с сокетом, а часто не происходит вовсе.
Выход наружу: три двери
Рано или поздно нужно позвать код, которого на BEAM нет: библиотеку компьютерного зрения, кодек, утилиту командной строки. Дверей три, и они принципиально различаются ценой риска.
решение на BEAM?"} Q1 -->|да| PURE["Чистый Elixir/Erlang
Риск: нет"] Q1 -->|нет| Q2{"Это разовый вызов
утилиты?"} Q2 -->|да| CMD["System.cmd/3
Риск: нет изоляции таймаута"] Q2 -->|нет| Q3{"Долгий обмен
с процессом?"} Q3 -->|да| PORT["Port
Отдельный процесс ОС.
Падение НЕ роняет ноду"] Q3 -->|нет| Q4{"Вызов короче 1 мс
и предсказуем?"} Q4 -->|да| NIF["NIF (Rustler)
Максимум скорости,
падение роняет ВСЮ ноду"] Q4 -->|нет| DIRTY["Dirty NIF или yielding NIF
иначе сломаете планировщик"]
Порты: безопасный вариант по умолчанию
Порт — это отдельный процесс операционной системы, с которым BEAM общается через stdin/stdout. Он не может уронить ноду, не может испортить её память и убивается по таймауту.
defmodule ImageResizer do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def resize(data), do: GenServer.call(__MODULE__, {:resize, data}, 30_000)
@impl true
def init(_opts) do
port =
Port.open({:spawn_executable, System.find_executable("my_resizer")}, [
:binary,
:exit_status,
{:packet, 4}, # фреймы с 4-байтовым префиксом длины — без ручного парсинга
{:args, ["--stdin"]}
])
{:ok, %{port: port, waiting: nil}}
end
@impl true
def handle_call({:resize, data}, from, state) do
Port.command(state.port, data)
{:noreply, %{state | waiting: from}}
end
@impl true
def handle_info({port, {:data, result}}, %{port: port, waiting: from} = state) do
GenServer.reply(from, {:ok, result})
{:noreply, %{state | waiting: nil}}
end
# Внешняя программа умерла — падаем сами, супервизор поднимет новую
@impl true
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
{:stop, {:external_exited, status}, state}
end
end
планировщик не блокируется OS->>P: stdout + префикс длины P->>G: сообщение data результат G-->>C: reply ok результат Note over OS,P: если процесс ОС умрёт,
придёт exit_status — падаем,
супервизор поднимет заново
Ключевые детали:
{:packet, 4}избавляет от ручной сборки фреймов: BEAM сам добавляет и снимает префикс длины. Это самая частая ошибка новичков — считать, что данные из порта приходят целыми сообщениями. Без:packetони приходят кусками произвольного размера.:exit_statusпревращает смерть внешней программы в обычное сообщение — и «let it crash» продолжает работать через границу ОС.- Порт-сирота. Если BEAM умрёт жёстко, дочерний процесс может остаться жить. Для внешних программ, за которыми надо следить всерьёз, есть MuonTrap с cgroups-контролем.
- Для разовых запусков достаточно
System.cmd("git", ["status"], stderr_to_stdout: true)— но помните, что он блокирует вызывающий процесс и не имеет встроенного таймаута.
Про механику процессов ОС и стандартные потоки — трек Системное программирование.
NIF: скорость ценой изоляции
NIF (Native Implemented Function) — нативная функция, вызываемая прямо в потоке планировщика BEAM, без переключения контекста. Быстро настолько, насколько вообще возможно. И опасно ровно настолько же:
- Segfault в NIF роняет всю ноду. Не процесс, не супервизор — весь узел вместе с тысячами соединений.
- Долгий NIF ломает soft-realtime. Планировщик BEAM вытесняет процессы по редукциям (подробнее — в следующей главе), но нативный код вытеснить нельзя. Официальное правило: NIF должен укладываться в 1 мс. Иначе — растущие задержки у всех процессов на этом планировщике.
- Для долгих операций есть dirty schedulers (отдельный пул потоков для CPU- и IO-нагрузки) или разбиение работы с
enif_schedule_nif.
Rustler: NIF, который не роняет ноду
Rustler снимает главный риск: Rust не даёт сегфолтнуться, а паника внутри NIF перехватывается и превращается в обычное исключение Elixir вместо падения VM.
// native/myapp_nif/src/lib.rs
#[rustler::nif]
fn levenshtein(a: &str, b: &str) -> usize {
// ... быстрая реализация
}
// Долгая работа — на dirty-планировщик, чтобы не блокировать обычный
#[rustler::nif(schedule = "DirtyCpu")]
fn compress(data: rustler::Binary) -> Vec<u8> {
zstd::encode_all(data.as_slice(), 3).unwrap()
}
rustler::init!("Elixir.MyApp.Native", [levenshtein, compress]);
defmodule MyApp.Native do
use Rustler, otp_app: :my_app, crate: "myapp_nif"
# Эти тела никогда не выполняются — их заменяет нативная реализация при загрузке
def levenshtein(_a, _b), do: :erlang.nif_error(:nif_not_loaded)
def compress(_data), do: :erlang.nif_error(:nif_not_loaded)
end
Где Rustler реально окупается: сжатие, криптография, парсинг больших объёмов, обработка изображений, вычисления над числовыми массивами — то есть ровно те «слабые ниши» Elixir, о которых говорилось в обзоре курса. Про сам Rust и его FFI — трек Rust.
Промежуточный вариант — NIF на отдельном потоке: нативный код запускает свой поток и присылает результат обычным сообщением через enif_send. Так работает, например, часть драйверов БД. Сложнее, но не блокирует планировщик вовсе.
Сравнение
| Критерий | Чистый BEAM | Port | NIF / Rustler |
|---|---|---|---|
| Падение внешнего кода | — | процесс ОС, нода жива | вся нода (Rust снижает риск) |
| Стоимость вызова | наносекунды | ~микросекунды + сериализация | наносекунды |
| Долгие операции | безопасно | безопасно | только dirty/yielding |
| Сложность сборки | нет | нужна внешняя программа | тулчейн в CI и Docker |
| Когда выбирать | почти всегда | внешние утилиты, ffmpeg, python-скрипты | горячая точка после профилирования |
Порядок действий: сначала измерьте (см. трек Производительность), потом попробуйте порт, и только для доказанной горячей точки — Rustler.
Распределённость поверх интеропа
Раз Elixir и Erlang — один рантайм, нода на Elixir и нода на Erlang соединяются в один кластер без адаптеров: Node.connect/1, общий cookie, обычный send. Это реальный сценарий миграции: старую Erlang-систему не переписывают целиком, а обвешивают новыми сервисами на Elixir, которые говорят с ней напрямую.
Для языков вне BEAM есть C-ноды и erl_interface — программа на C притворяется нодой Erlang. Инструмент нишевый; в 2020-х чаще берут gRPC или очередь.
Типичные ошибки
- Ожидать целые сообщения из порта без
{:packet, N}. Данные приходят кусками; фрейминг — ваша забота. - Держать sub-binary от большого бинарника — утечка памяти без единого «утекающего» процесса.
- Конкатенация строк в цикле вместо iodata — квадратичная сложность и мусор в куче.
- NIF дольше миллисекунды на обычном планировщике — плавающие задержки по всей ноде, которые невозможно объяснить по метрикам приложения.
String.to_atom/1на данных из внешнего протокола — атомы не собираются GC; это DoS (об этом было в главе Идиоматика).- Путать charlist и binary при переходе в Erlang —
~c"ok"и"ok"это разные вещи, и функция молча вернёт не то. System.cmd/3без таймаута в горячем пути — зависшая утилита держит процесс BEAM бесконечно.
Мини-итог
Битовый синтаксис — причина, по которой на BEAM пишут протоколы, шлюзы и телеком: структура пакета живёт в сигнатуре функции, а не в арифметике смещений. Erlang доступен напрямую и бесплатно — не пишите обёртку там, где можно вызвать :crypto. А выход за пределы VM подчиняется простому правилу: порт, пока не доказано, что нужен NIF, потому что цена ошибки в NIF — не упавший процесс, а упавшая нода.
Источники
- Elixir — Binaries, strings and charlists, Bitstring
- Erlang — Ports and Port Drivers, NIFs
- Rustler и Rustler Precompiled — как не собирать Rust у пользователей.
- Книга «Erlang in Anger» (Fred Hébert) — глава про утечки бинарников обязательна к прочтению.
Что дальше
Внутренности BEAM — планировщики, редукции, сборка мусора и настройка ноды под реальную нагрузку.