joerl — это библиотека модели акторов для Rust, вдохновленная Erlang и названная в честь Джо Армстронга, создателя Erlang. Если вам когда-либо приходилось строить конкурентные системы на Erlang/OTP и вы думали: «Эх, был бы здесь хоть намек на систему типов», — то вот она, ваша прелесть. Я начинал этот проект просто потренироваться в расте немного, но меня затянуло и я довел код более-менее до ума. Сам я на расте писать буду вряд ли, если кто-то ближе к телу захочет попробовать — буду признателен.

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

Еще одна нафиг библиотека?

  • Для эрлангистов: Та же терминология, те же концепции. Плавный переход, надеюсь, очень плавный; можно почти без кода запустить кластер. EPMD в комплекте, прозрачная адресация акторов, короче, distributed как мы привыкли;

  • Production-ready: Встроенная телеметрия, мониторинг здоровья системы, распределенный обмен сообщениями — все, что нужно для боевого применения;

  • Тщательно протестирована: Обширное property-based тестирование гарантирует корректность. Не верьте на слово — проверьте сами (но продакшена она пока не видала);

  • Производительность: Нормальная производительность, вроде. Раст же. Ну и бенчмарки в репе есть, разумеется.

Простой пример: Актор-счетчик

Начнем с простейшего примера — актора-счетчика. Ничего полезного не делает, но дает представление о внешнем виде кода:

use joerl::{Actor, ActorContext, ActorSystem, Message};
use async_trait::async_trait;

// Определяем актор
struct Counter {
    count: i32,
}

#[async_trait]
impl Actor for Counter {
    async fn started(&mut self, ctx: &mut ActorContext) {
        println!("Счетчик запущен с pid {}", ctx.pid());
    }

    async fn handle_message(&mut self, msg: Message, ctx: &mut ActorContext) {
        if let Some(cmd) = msg.downcast_ref::<&str>() {
            match *cmd {
                "increment" =>; {
                    self.count += 1;
                    println!("[{}] Счет: {}", ctx.pid(), self.count);
                },
                "get" => {
                    println!("[{}] Текущий счет: {}", ctx.pid(), self.count);
                },
                "stop" => {
                    ctx.stop(joerl::ExitReason::Normal);
                },
                _ => {}
            }
        }
    }

    async fn stopped(&mut self, reason: &joerl::ExitReason, ctx: &mut ActorContext) {
        println!("[{}] Счетчик остановлен: {}", ctx.pid(), reason);
    }
}

#[tokio::main]
async fn main() {
    let system = ActorSystem::new();
    let counter = system.spawn(Counter { count: 0 });
    
    counter.send(Box::new("increment")).await.unwrap();
    counter.send(Box::new("increment")).await.unwrap();
    counter.send(Box::new("get")).await.unwrap();
    counter.send(Box::new("stop")).await.unwrap();
    
    // Дадим сообщениям время обработаться
    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}

Сообщения принципиально сделаны type-erased — и позволяют отправлять что угодно; я не считаю типы — полезной херней.

Телеметрия и наблюдаемость

Одна из сильных сторон joerl — встроенный мониторинг для production. Не нужно гадать, что пошло не так в три часа ночи. Включаем фичу telemetry:

[dependencies]
joerl = { version = "0.5", features = ["telemetry"] }
metrics-exporter-prometheus = "0.15"

Добавляем телеметрию в приложение:

use joerl::telemetry;
use metrics_exporter_prometheus::PrometheusBuilder;

#[tokio::main]
async fn main() -> Result<(), Box> {
    // Запускаем Prometheus exporter
    PrometheusBuilder::new()
        .with_http_listener(([127, 0, 0, 1], 9090))
        .install()?;
    
    telemetry::init();
    
    let system = ActorSystem::new();
    // ... ваши акторы
    
    Ok(())
}

Теперь открываем http://localhost:9090/metrics и видим:

▸ Жизненный цикл акторов: создание, остановка, паники
Пропускная способность: сообщений отправлено/обработано в секунду
Глубина mailbox: индикаторы backpressure
Перезапуски супервизоров: статистика восстановления после сбоев
▸ Готовая интеграция с Grafana/Prometheus
▸ Поддержка OpenTelemetry для распределенного трейсинга

Прозрачный distribution

joerl обеспечивает location-transparent messaging: тот же API для локальных и удаленных акторов. Никакой разницы, работаете вы на одной машине или в кластере из десятков нод. Эх, почти, как в эрланге!

Запускаем два узла:

# Терминал 1: Запускаем EPMD сервер
cargo run --example epmd_server

# Т��рминал 2: Запускаем узел A
cargo run --example distributed_cluster -- node_a 5001

# Терминал 3: Запускаем узел B
cargo run --example distributed_cluster -- node_b 5002

Узлы автоматически обнаруживают друг друга через EPMD (Erlang Port Mapper Daemon). Как в Erlang, только лучше — потому что на Rust (шутка, конечно, вообще не лучше).

Пример кода:

use joerl::ActorSystem;

#[tokio::main]
async fn main() {
    // Создаем распределенную систему
    let system = ActorSystem::new_distributed(
        "mynode@localhost",
        5000,
        "127.0.0.1:4369"  // адрес EPMD
    ).await.unwrap();
    
    // Создаем актор — работает точно так же, как локально
    let actor = system.spawn(MyActor::new());
    
    // Отправляем сообщение — работает для локальных И удаленных акторов
    actor.send(Box::new("hello")).await.unwrap();
    
    // Подключаемся к другому узлу
    system.connect_to_node("othernode@localhost").await.unwrap();
    
    // Получаем pid удаленного актора и прозрачно отправляем сообщения
    // ... тот же API, никаких изменений в коде!
}

▸ Единый API: spawn(), send(), link() работают идентично
Автоматическое обнаружение: EPMD обрабатывает регистрацию узлов
Двунаправленные связи: Полная семантика соединений в стиле Erlang
Сериализация: Trait-based сериализация сообщений с глобальным реестром

Ну и мониторинг всякий, линкинг и прочий дуинг работают тоже прозрачно.

Для разработчиков на Erlang/OTP

Если вы знаете Erlang, вы уже знаете joerl. Смотрите:

Erlang

joerl

Описание

spawn/1

system.spawn(actor)

Создать новый процесс

Pid ! Msg

actor.send(msg).await

Отправить сообщение

gen_server:call/2

server.call(request).await

Синхронный RPC

gen_server:cast/2

server.cast(msg).await

Асинхронное сообщение

link/1

system.link(pid1, pid2)

Двунаправленная связь

monitor/2

actor.monitor(from)

Однонаправленный монитор

process_flag(trap_exit, true)

ctx.trap_exit(true)

Обработка сбоев

{'EXIT', Pid, Reason}

Signal::Exit { from, reason }

Сигнал выхода

supervisor:start_link/2

spawn_supervisor(&amp;system, spec)

Дерево супервизоров

Пример: Конвертация эрланговского gen_server в joerl:

Erlang:

-module(counter).
-behaviour(gen_server).

init([]) -> {ok, 0}.

handle_call(get, _From, State) ->
    {reply, State, State};
handle_call({add, N}, _From, State) ->
    {reply, State + N, State + N}.

handle_cast(increment, State) ->
    {noreply, State + 1}.

joerl:

use joerl::gen_server::{GenServer, GenServerContext};

struct Counter;

#[async_trait]
impl GenServer for Counter {
    type State = i32;
    type Call = CounterCall;
    type Cast = CounterCast;
    type CallReply = i32;

    async fn init(&amp;mut self, _ctx: &mut GenServerContext<'_, Self>) -> Self::State {
        0
    }

    async fn handle_call(
        &mut self,
        call: Self::Call,
        state: &mut Self::State,
        _ctx: &mut GenServerContext<'_, Self>,
    ) -> Self::CallReply {
        match call {
            CounterCall::Get => *state,
            CounterCall::Add(n) => {
                *state += n;
                *state
            }
        }
    }

    async fn handle_cast(
        &mut self,
        cast: Self::Cast,
        state: &mut Self::State,
        _ctx: &mut GenServerContext<'_, Self>,
    ) {
        match cast {
            CounterCast::Increment =>; *state += 1,
        }
    }
}

Код почти не изменился: акторы, супервизоры, связи, мониторы, появилась type safety и производительность Rust (не уверен, что это плюс, впрочем).

Property-Based тестирование: доказательство корректности

joerl использует обширное property-based тестирование с QuickCheck для проверки корректности. Вместо написания отдельных тестовых случаев определяются свойства, которые должны выполняться для всех входных данных, затем генерируются сотни случайных тестов.

Пример property-теста:

use quickcheck_macros::quickcheck;

/// Свойство: Сериализация Pid должна быть без потерь
#[quickcheck]
fn prop_pid_serialization_roundtrip(pid: Pid) -> bool {
    let serialized = serde_json::to_string(&pid).unwrap();
    let deserialized: Pid = serde_json::from_str(&serialized).unwrap();
    pid == deserialized
}

QuickCheck генерирует случайные значения Pid и проверяет, что свойство выполняется для всех них.

Запуск property-тестов:

# Запустить все property-тесты
cargo test --tests proptest

# Запустить 1000 случайных случаев на свойство
QUICKCHECK_TESTS=1000 cargo test --test proptest_pid

# Запустить конкретный тест
cargo test --test proptest_serialization prop_message_roundtrip

Property-based тестирование — в 2025 году для меня — не прихоть, а доказательство того, что код работает не только на примерах из README, но и в реальной жизни со всеми ее сюрпризами.

Примеры валяются в директории examples, сравнение с actix — тоже есть.

В общем, оно как-то работает, как-то проверено и как-то задокументировано. Что еще нужно?

Комментарии (19)


  1. Dhwtj
    08.12.2025 20:49

    карму мне скоро выкрутят в минус

    Удивительно, правда?

    Вы вещаете из глубокого технологического колодца и ваше бубнение непонятно здешней публике. А адаптироваться вы не хотите.

    Что еще нужно?

    Демонстрации по бизнес ориентированным задачам. Тут только техно демки, proof of concept для тех кто и без того хорошо разбирается, то есть для ерланг программистов. А таковых тут нет


    1. vkni
      08.12.2025 20:49

      А адаптироваться вы не хотите.

      Да пошла эта публика, не понимающая технологий, на три весёлых буквы.

      Удивительно, правда?

      Нет, не удивительно. Но есть вещи, к которым не надо адаптироваться.

      А вообще, путь адаптаций, он может очень далеко завести...


      1. cupraer Автор
        08.12.2025 20:49

        Именно. Я даже думал взять ник «Randle McMurphy» но это было бы слишком претенциозно.

        У хабра в последние 15 лет есть очень опасная тенденция: здесь слишком поощряется серость. Чем ты усредненнее, тем лучше. Особенно, если ты не высовываешься, делаешь вид, что вежлив и даже чуточку интеллигентен, и если ты умеешь восемь раз в одном комментарии обосновать, что белое белее черного, а черное — чернее белого.

        Я ненавижу за такой позорный тип поведения советскую интеллигенцию и борюсь с ним по мере сил в свободное от написания текстов время. За любой голос против — тебя сожрёт не ужасное КГБ, а твои же соседи, под самыми благовидными предлогами.


    1. cupraer Автор
      08.12.2025 20:49

      Назовите хоть одну причину, по которой мне имело бы смысл адаптироваться.

      А таковых тут нет.

      Как однажды сказал Наум Коржавин на форуме славистов: «Я пишу не для славистов, я пишу для нормальных людей». Я публикую тексты здесь с единственной целью: сделать их подоступнее в поиске. Продавать правильную архитектуру людям, которые лучше горутин в своей жизни ничего не видели — мне неинтересно. Я не миссионер, не евангелист, не популяризатор и не коммивояжер.


  1. vkni
    08.12.2025 20:49

    Сообщения принципиально сделаны type-erased — и позволяют отправлять что угодно; я не считаю типы — полезной херней.

    Если я правильно помню, там проблема в том, что сообщения могут быть пересланы дальше, так? Соответственно, типизация должна быть как-то ослаблена. А вот «классы типов» там не подойдут? То есть, актор А принимает сообщения, реализующие trait Atrait, а актор Бэ — Бtrait. Хотя всё равно, хочется иметь переконфигурируемую сеть, но, кажется, всё не так уж и плохо получается?

    А есть ещё gradual typing.


    1. Dhwtj
      08.12.2025 20:49

      Микросервисы пересылают самодокументированный JSON, так и тут должно быть


      1. vkni
        08.12.2025 20:49

        Слова "самодокументированный JSON" - это совершенно другой уровень обобщения, нежели типы/классы типов/системы типов.


      1. cupraer Автор
        08.12.2025 20:49

        Кому должно? Как я сериализую в джейсон — пид, например? Ну, помимо того, что далеко не все микросервисы оперируют джейсоном, который удобен только для очень тривиальных доменов.


    1. cpud47
      08.12.2025 20:49

      Там проблема хуже. Набор сообщений, который автор готов обрабатывать динамически меняется со временем. При том, это буквально базовый функционал. Допустим актор A послал сообещние-запрос в автор B. Тогда после этого актор A умеет обрабатывать одно сообщение BReply — как только он его получит, он потеряет возможность обрабатывать сообщения BReply.

      Разумеется, правда, все эти проблемы проявляются только в авторах с нетривиальным поток управления. Когда авторы задаются через трейты — проблему обходят просто заставляя пользователя ручками делать конечный автомат и перечислять все функции, которые автор может вызвать...


      1. cupraer Автор
        08.12.2025 20:49

        Где «там»?

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


        1. cpud47
          08.12.2025 20:49

          Там — в типизации акторных систем.

          Я понял, что акторы у Вас задаются трейтами. Но как уже сказал, это весьма неприятный способ задания акторов. Сравните с тем же send/receive в эрланге — там акторы это код.


          1. cupraer Автор
            08.12.2025 20:49

            А, ну да. Но, насколько могу судить, я прошел по тонкой косе между двух волн: в раст тащить прям динамический подход эрланга — это перебор, но и приколачивать гвоздями типы сообщений, как это делает actix — не вариант.

            Я попытался оставаться пока возможно в парадигме раста, но для сообщений — это суицид. Поэтому вот так.


          1. cupraer Автор
            08.12.2025 20:49

            receive из эрланга более-менее адекватно в раст перенести не получится, но ведь и в самом эрланге сегодня им пользуется только сам Вирдинг :)

            А gen_server и gen_statem я затащил.


    1. cupraer Автор
      08.12.2025 20:49

      В принципе, в вакууме никакой проблемы нет. actix, который, насколько я понял, является «стандартной» реализацией акторной модели в расте, естественно, строго типизирует все сообщения.

      Но я приложил некоторое количество усилий к тому, чтобы кластер мог быть гетерогенным. На данный момент, мой epmd — обслуживает, конечно, только мои же ноды, но даже из названия понятно, что вместо этой заглушки я хочу в результате использовать родной эрланговский. У меня банально не хватило сил и времени по уму разрулить анбоксинг, но код полностью к этому готов.

      Если бы я завязался на хоть какую-то типизацию — прозрачно воткнуть три ноды на расте в отипишный кластер не удалось бы никогда.


  1. davaeron
    08.12.2025 20:49

    Что еще нужно?

    Registry из Elixir. Хотя, его можно и свой написать.

    @cupraer библиотека выглядит здóрово.


    1. cupraer Автор
      08.12.2025 20:49

      Спасибо.


    1. cupraer Автор
      08.12.2025 20:49

      А расскажите, что именно вас заставило упомянуть (пусть даже потом вычеркнув) Registry? В каком-то смысле у меня уже есть некое подобие, можно и написать свой Registry и использовать его вместо костылей, которые там сейчас.


  1. AnthonyMikh
    08.12.2025 20:49

    Зачем нужно было публиковать статью про свою библиотеку во второй раз, не добавив при этом ничего полезного?


  1. gBear
    08.12.2025 20:49

    Отлично получилось, в целом. Осталось реализовать ETF (ну и ещё "койчего") и можно цепляться как external node к.