Learning by Doing: Event Loop in Rust

Before you read, concepts like: concurrency, traits, Arc/Mutex and Rust Channles (crossbeam lib) should be clear for you.

As a seasoned Java software developer, I’m always on the lookout for new challenges to further my understanding of different programming languages. Recently, after completing a project involving Event Loops with Vert.x, I found myself intrigued by the idea of exploring Rust more deeply. With its rising popularity in the tech community, Rust seemed like the perfect next step in my programming journey. And so, with the experience of working with Vert.x still fresh in my mind, I thought, “Why not continue this exploration with Rust?”

What is and why an event loop?

An event loop is a fundamental concept in programming that serves as a mechanism for managing asynchronous operations and ensuring system responsiveness. It functions by continuously checking for new events or messages within a program, processing them as needed, and then returning to check for more. This pattern is essential for handling tasks such as input/output operations, network communication, and user interactions without blocking the execution of other code.

The significance of an event loop lies in its ability to efficiently handle multiple tasks concurrently without relying on traditional synchronous methods that can lead to performance bottlenecks and unresponsive applications. By allowing the program to asynchronously process events as they occur, an event loop enables smoother and more efficient execution, making it a crucial component in modern software development, particularly in environments where responsiveness is a must.

Some use cases for event loops

  • Graphical User Interfaces (GUIs): Handling user interactions like mouse clicks and key presses.
  • Networking: Managing asynchronous I/O operations, such as incoming and outgoing network requests.
  • Game Development: Processing game states, rendering, and handling player inputs.
  • IoT Devices: Responding to sensor data and external commands asynchronously.

Implementing an event loop in Rust

Here is an example implementation of an event loop in Rust:

#![allow(dead_code)] use std::sync::{Arc, Mutex};
use std::{collections::HashMap, thread}; use crossbeam::channel::{Receiver, Sender};
use strum_macros::{Display, EnumString}; #[derive(Clone, Debug, PartialEq, Eq, Hash, Display, EnumString)]
pub enum EventName { Dummy, TestEvent, } pub type EventPayload = Vec<u8>; pub trait EventHandlerTrait: Send + Sync { fn handle_event(&self, event_name: EventName, payload: EventPayload);
} #[derive(Clone)]
pub struct EventHandler { pub event_name: EventName, pub handler: Arc<dyn EventHandlerTrait>,
} pub struct EventLoop { sender: Sender<(EventName, EventPayload)>, receiver: Receiver<(EventName, EventPayload)>, handlers: Arc<Mutex<HashMap<EventName, Vec<Arc<dyn EventHandlerTrait>>>>>,
} impl EventLoop { pub fn new() -> Self { let (sender, receiver) = crossbeam::channel::unbounded(); EventLoop { sender, receiver, handlers: Arc::new(Mutex::new(HashMap::new())), } } pub fn add_handler(&mut self, event_name: EventName, handler: Arc<dyn EventHandlerTrait>) { let mut handlers = self.handlers.lock().unwrap(); handlers .entry(event_name) .or_insert_with(Vec::new) .push(handler); } pub fn push_event(&mut self, event_name: EventName, event_data: EventPayload) { self.sender.send((event_name, event_data)).unwrap(); } pub fn run(&self) { let handlers = Arc::clone(&self.handlers); let receiver = self.receiver.clone(); thread::spawn(move || loop { if let Ok((event_name, event_data)) = receiver.recv() { let handlers = handlers.lock().unwrap(); if let Some(handlers_for_event) = handlers.get(&event_name) { for handler in handlers_for_event { handler.handle_event(event_name.clone(), event_data.clone()); } } } }); }
} 
Enter fullscreen mode
Exit fullscreen mode

Explanation

EventName Enum

#[derive(Clone, Debug, PartialEq, Eq, Hash, Display, EnumString)]
pub enum EventName { Dummy, TestEvent,
}
Enter fullscreen mode
Exit fullscreen mode

EventName is an enumeration representing the types of events that the event loop can handle. The Display and EnumString derives are used for easier handling and conversion of enum values.

Type Aliases

pub type EventPayload = Vec<u8>;
Enter fullscreen mode
Exit fullscreen mode

EventPayload is defined as a type alias for Vec<u8>, representing the data associated with an event.

EventHandlerTrait

pub trait EventHandlerTrait: Send + Sync { fn handle_event(&self, event_name: EventName, payload: EventPayload);
}
Enter fullscreen mode
Exit fullscreen mode

EventHandlerTrait is a trait that any event handler must implement. It requires a single method, handle_event, which takes an EventName and EventPayload. We will elaborate more on the Send & Sync traits later.

EventHandler Struct

#[derive(Clone)]
pub struct EventHandler { pub event_name: EventName, pub handler: Arc<dyn EventHandlerTrait>,
}
Enter fullscreen mode
Exit fullscreen mode

The EventHandler struct binds an event_name to a handler implementing the EventHandlerTrait, allowing the addition of multiple handlers for each event.

EventLoop Struct

pub struct EventLoop { sender: Sender<(EventName, EventPayload)>, receiver: Receiver<(EventName, EventPayload)>, handlers: Arc<Mutex<HashMap<EventName, Vec<Arc<dyn EventHandlerTrait>>>>>,
}
Enter fullscreen mode
Exit fullscreen mode

EventLoop holds the sender and receiver for event channels and a thread-safe collection of handlers.

About Send and Sync traits

Trait Send

Since the event loop runs in its own thread and potentially interacts with multiple handlers across different threads, handlers might need to be moved between threads. By requiring Send, you ensure that your handler implementations can be transferred across thread boundaries, making your event loop safe and robust in a multithreaded context.

Trait Sync

Handlers are stored in a shared Arc<Mutex<HashMap<EventName, Vec<Arc<dyn EventHandlerTrait>>>>>. The Arc (atomic reference count) allows multiple threads to share ownership of the handlers without needing to clone them. By requiring Sync, you ensure that multiple threads can hold references to the same handler safely. This means any read access to the handler’s state is thread-safe.

Let’s try it

Handler examples

pub struct TestEventHandler; impl EventHandlerTrait for TestEventHandler { fn handle_event(&self, event_name: EventName, payload: EventPayload) { let data = String::from_utf8(payload).unwrap(); let message = format!("{} => {}", event_name, data); info!("TestEvent: {}", message); }
}
Enter fullscreen mode
Exit fullscreen mode
pub struct DBTestEventHandler; impl EventHandlerTrait for DBTestEventHandler { fn handle_event(&self, event_name: EventName, payload: EventPayload) { let data = String::from_utf8(payload).unwrap(); let message = format!("{} => {}", event_name, data); // Persist data into db info!("Data saved on DB!"); }
}
Enter fullscreen mode
Exit fullscreen mode

Main function example

fn main() { env_logger::builder() .filter_level(log::LevelFilter::Info) .init(); info!("Starting event loop example"); info!("------------------------------"); info!("Events: {:?}", EventName::Dummy); info!("Events: {:?}", EventName::TestEvent); info!("------------------------------"); let mut event_loop = EventLoop::new(); event_loop.add_handler(EventName::TestEvent, Arc::new(TestEventHandler)); event_loop.add_handler(EventName::TestEvent, Arc::new(DBTestEventHandler)); // Start the event loop event_loop.run(); loop { info!("Give me some input, type 'exit' to quit"); let mut input = String::new(); io::stdin() .read_line(&mut input) .expect("Errore during input reading..."); let input = input.trim(); if input == "exit" { break; } let mut split = input.split_whitespace(); let name_data = ( split.next().unwrap_or_default().to_string(), split.next().unwrap_or_default().to_string(), ); let event_name = EventName::from_str(&name_data.0).unwrap_or_else(|_| EventName::Dummy); event_loop.push_event(event_name, name_data.1.as_bytes().to_vec()); }
} 
Enter fullscreen mode
Exit fullscreen mode

What’s next

I have several ideas running through my head, but the ones I would like to implement are:

  1. A shared, observable state between handlers. It would be very interesting to have handlers that are activated based on a certain state.

  2. Taking a cue from Vert.x, the possibility of having remote handlers. To begin with, one could use the remoc library, which allows me to have remote rust channels.

Stay tuned and.. “A Presto!”

Luis


Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

Discover more from Coursity

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top