Introduction
Stewart is a lightweight actor system, with a modular ecosystem built around it. It is built primarily for performance-sensitive and real-time applications like games, but can be used anywhere.
At the core of stewart is the stewart
crate, which implements a lightweight single-threaded
actor runtime.
All features are built on top of this core abstraction, including threading and distribution.
Stewart is meant to get out of your way as soon as possible, providing you just what you need to develop reliable and high-performance asynchronous systems.
🚧 Stewart is very early in development. A lot of the features you may expect from a full-featured actor framework are not yet implemented.
Who is this book for?
This book is a quick guide on stewart and its use of actors, to learn how and why to use it. Additionally, this book gives advice on how to use stewart effectively in the real world.
The chapters are organized in depth-first intended reading order, when read as a complete guide.
Actors Theory
Functions are great! When you need to "abstract" something you want your code to do, you simply wrap it in a name with some parameters. This makes it a lot easier to reason around what you're making, and what your code is doing.
Sometimes, however, you need to create a task that needs to 'wait' on something. Maybe you have to wait for a network adapter to receive a packet, or wait for another task to be done performing some intensive calculations.
While it's perfectly possible to use OS threads for this, and 'block' the current one until you get what you need, this is very resource-intensive. Additionally, OS threads usually don't expect to be rapidly switching between a lot of different threads in more complex programs.
async-await
One approach to solving this instead is using the "async-await" pattern. With this pattern you can still write your functions as normal, but additionally you can 'await' on something before continuing.
How this is implemented depends on the language. Rust for example, will 'generate' a state machine for your function. This is nice, as it can track a lot of guarantees about memory and ownership semantics.
There are a few downsides to this approach. A lot of complexity happens behind the scenes, making it a lot harder to reason about your code. As well, while this pattern is well suited to a linear set of steps, it doesn't model ongoing processes as well.
The Actor Pattern
Enter, the "Actor Pattern". Conceptually, actors are cooperative multitasking tasks, that communicate through messages. This is, of course, highly simplifying and generalizing the concept. The computer science of actors is however outside the scope of this guide.
In contrast to async-await
, actors hide very little in how they work.
An actor maintains its internal state manually.
Its handler function returns when it is done processing, yielding back to the runtime.
Stewart
The core stewart library is a small single-threaded actor system. A few small essential utilities are also included, like messaging.
Your First Actor
You can create a new stewart actor behavior by implementing the Actor
trait.
struct MyActor {
message: String,
}
impl Actor for MyActor {
fn process(&mut self, _world: &mut World, _meta: &mut Metadata) -> Result<(), Error> {
println!("Woken up!");
println!("Message: {}", self.message);
Ok(())
}
}
Actors in stewart are "suspended" using "cooperative multitasking".
When your actor is woken up, its process
callback is called.
Your actor processing doesn't necessarily mean there are messages available to be processed. The only guarantee is that after a "signal" is sent to your actor, your actor will be processed when the world gets to it. If the entire system doesn't get dropped before that, of course.
Adding an actor to a world
Creating an instance of your actor works the same as any other Rust type. A stewart world is simply a collection of actor instances.
To keep your concrete actor type private, you can create a function to start a new instance of your actor.
fn start_my_actor(world: &mut World) -> Result<Signal, Error> {
// Create and insert the actor
let actor = MyActor {};
let id = world.insert("my-actor", actor)?;
// Return the signal to wake it up
let signal = world.signal(id);
Ok(signal)
}
This function returns a Signal
to wake up the actor to keep things simple, but typically you would
return for example a Sender
instead.
The message
module that implements Sender
internally uses Signal
.
You can send a Signal
from anywhere on the same thread, it does not have to happen during actor
processing.
Creating and processing a world
To put it all together, you need to have a world to add your actor to.
After signalling your actor, it will then be processed when calling process
on the world.
fn main() -> Result<(), Error> {
// Create a world with an actor
let mut world = World::default();
let signal = start_my_actor(&mut world)?;
// Wake up the actor
signal.send();
world.process()?;
Ok(())
}
This is the most basic, and perfectly functional, way to create an actor runtime.
If all your actors block internally and never have to wait on external data this works perfectly
fine as process
will keep running until no actors are waiting for processing.
If you do however have things your world may need to wait on while not processing an actor, you
can implement this here.
For example, stewart-mio
implements a world processing loop based on a mio event loop.