Elixir Erlang-интероп и бинарные данные: битовый синтаксис, iodata, порты, NIF и Rustler
0%

Erlang-интероп и бинарные данные: битовый синтаксис, iodata, порты, NIF и Rustler

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 — типичная задача, где битовый синтаксис показывает себя во всей красе.

Разбор заголовка IPv4 битовым синтаксисом Elixir

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 общается через 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

Ключевые детали:

  • {: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 — не упавший процесс, а упавшая нода.

Источники

Что дальше

Внутренности BEAM — планировщики, редукции, сборка мусора и настройка ноды под реальную нагрузку.

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

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

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

Доска запросов
Дальше