Когда события управляют кодом: Event-driven на Rust
Представьте, что ваш код — это дирижёр оркестра. В классическом синхронном программировании дирижёр сам решает, когда какая нота должна звучать. В event-driven архитектуре дирижёр просто реагирует на то, что музыканты уже начали играть. Звучит парадоксально? Только на первый взгляд.
Event-driven (событийно-ориентированная) архитектура — это подход, при котором поток выполнения программы определяется событиями: клик мыши, получение HTTP-запроса, завершение ввода-вывода, таймер. Rust с его системой владения и нулевой стоимостью абстракций — идеальная почва для такой архитектуры. Особенно если вы пришли из Python, где event loop (например, в asyncio) работает, но часто упирается в GIL и накладные расходы на динамическую типизацию.
В этой статье мы не просто поговорим о теории. Мы напишем реальный код, разберём event-driven архитектуру на Rust с использованием популярных крейтов и покажем, как это может ускорить ваши приложения в разы.
Почему Rust, а не Python для событий?
Python — прекрасный язык для прототипирования. Но когда вам нужна обработка событий в реальном времени с тысячами одновременных подключений, Python начинает тормозить. Rust же даёт:
- Нулевую стоимость абстракций — async/await не добавляет оверхэда, если вы не используете его возможности
- Отсутствие сборщика мусора — нет внезапных пауз при обработке событий
- Систему типов, которая ловит ошибки на этапе компиляции
- Настоящий параллелизм — не кооперативная многозадачность, а реальные потоки без GIL
Если вы пишете event-driven приложения на Rust, вы получаете контроль над каждым байтом памяти и каждым тактом процессора. И это не маркетинг — это факт.
Основы: Event Loop и обработка событий
В основе любой событийно-ориентированной системы лежит event loop (цикл событий). Это бесконечный цикл, который:
- Ждёт появления нового события
- Извлекает его из очереди
- Передаёт обработчику
- Повторяет
В Rust стандартный event loop предоставляется крейтом tokio. Давайте напишем простейший пример:
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Создаём канал для передачи событий
let (tx, mut rx) = mpsc::channel::<String>(32);
// Продюсер: генерирует события
tokio::spawn(async move {
for i in 0..5 {
let event = format!("Событие №{}", i);
tx.send(event).await.unwrap();
sleep(Duration::from_millis(500)).await;
}
});
// Консьюмер: обрабатывает события
while let Some(event) = rx.recv().await {
println!("Обработано: {}", event);
// Здесь может быть ваша бизнес-логика
}
}
Этот код демонстрирует базовый паттерн event-driven programming в Rust: один компонент генерирует события, другой — их обрабатывает. Канал mpsc (multi-producer, single-consumer) обеспечивает безопасную передачу данных между задачами.
Продвинутый пример: система обработки заказов
Давайте усложним задачу. Представьте, что мы пишем бэкенд для интернет-магазина. Когда приходит новый заказ, нужно:
- Проверить наличие товара
- Зарезервировать его
- Отправить уведомление
- Обновить статистику
Классический подход — всё это делать синхронно. Event-driven подход — разбить на события:
use tokio::sync::broadcast;
use tokio::time::sleep;
use std::time::Duration;
#[derive(Debug, Clone)]
enum OrderEvent {
NewOrder { id: u32, items: Vec<String> },
PaymentReceived { order_id: u32 },
ShipmentStarted { order_id: u32 },
OrderCompleted { order_id: u32 },
}
#[tokio::main]
async fn main() {
let (tx, _) = broadcast::channel::<OrderEvent>(16);
// Подписчик 1: проверка наличия
let mut rx1 = tx.subscribe();
tokio::spawn(async move {
while let Ok(event) = rx1.recv().await {
match event {
OrderEvent::NewOrder { id, items } => {
println!("[Склад] Проверка заказа #{}: {:?}", id, items);
sleep(Duration::from_millis(100)).await;
println!("[Склад] Товары в наличии, резервируем");
}
_ => {}
}
}
});
// Подписчик 2: логирование и уведомления
let mut rx2 = tx.subscribe();
tokio::spawn(async move {
while let Ok(event) = rx2.recv().await {
println!("[Логгер] Событие: {:?}", event);
}
});
// Публикуем события
println!("[Магазин] Новый заказ!");
tx.send(OrderEvent::NewOrder {
id: 1,
items: vec!["Книга по Rust".to_string(), "Ноутбук".to_string()],
}).unwrap();
sleep(Duration::from_millis(200)).await;
tx.send(OrderEvent::PaymentReceived { order_id: 1 }).unwrap();
sleep(Duration::from_millis(200)).await;
tx.send(OrderEvent::ShipmentStarted { order_id: 1 }).unwrap();
sleep(Duration::from_millis(200)).await;
tx.send(OrderEvent::OrderCompleted { order_id: 1 }).unwrap();
sleep(Duration::from_secs(1)).await;
}
Обратите внимание: мы использовали broadcast, а не mpsc. Это позволяет нескольким подписчикам получать одни и те же события. Каждый обработчик работает независимо и параллельно.
Актёрская модель: когда событий много
Для сложных систем с сотнями типов событий удобно использовать actor model. В Rust для этого есть крейт actix (не путать с actix-web). Но можно построить простую актёрскую систему и на нативном tokio:
use tokio::sync::mpsc;
use std::collections::HashMap;
type ActorRef = mpsc::Sender<ActorMessage>;
#[derive(Debug)]
enum ActorMessage {
Greet { name: String },
Count,
Stop,
}
struct CounterActor {
count: u32,
children: HashMap<String, ActorRef>,
}
impl CounterActor {
fn new() -> (Self, mpsc::Receiver<ActorMessage>) {
let (tx, rx) = mpsc::channel(32);
let actor = CounterActor {
count: 0,
children: HashMap::new(),
};
(actor, rx)
}
async fn handle(&mut self, msg: ActorMessage) {
match msg {
ActorMessage::Greet { name } => {
self.count += 1;
println!("Привет, {}! Всего приветствий: {}", name, self.count);