Expand description
Stakker is a lightweight low-level single-threaded actor runtime. It is designed to be layered on top of whatever event source or main loop the user prefers to use. Asynchronous calls are addressed to individual methods within an actor, rather like Pony behaviours. All calls and argument types are known and statically checked at compile-time giving the optimiser a lot of scope. Stakker also provides a timer queue for timeouts or delayed calls, a lazy queue to allow batching recent operations, and an idle queue for running a call when nothing else is outstanding.
By default Stakker uses unsafe code for better time and memory
efficiency. However if you prefer to avoid unsafe code, then
enable the no-unsafe feature which compiles the whole crate
with forbid(unsafe_code)
. Safe alternatives will be used, at
some cost in time and memory. There are other features that
provide finer-grained control (see below).
- Overview of types
- Efficiency
- Cargo features
- Testing
- Tutorial example
- Main loop examples
- Why the name Stakker?
See the Stakker Guide and Design Notes for additional documentation.
§Overview of types
Actor
and ActorOwn
are ref-counting references to an
actor. Create an actor with actor!
and call it with
call!
.
Fwd
and Ret
forward data to another destination
asynchronously, typically to a particular entry-point in a
particular actor. So Fwd
and Ret
instances take the role
of callback functions. The difference between them is that
Fwd
may be called multiple times, is ref-counted for cheap
cloning and is based on a Fn
with Copy
, whereas Ret
can be
used only once, is based on FnOnce
and is a “move” value. Also
the Ret
end-point is informed if the Ret
instance is
dropped without sending back a message, for example if a zombie
actor is called. See the fwd_*!
and
ret_*!
macros for creation of instances, and fwd!
and ret!
to make use of them.
Stakker
is the external interface to the runtime, i.e. how it
is managed from the event loop, or during startup.
Cx
is the context passed to all actor methods. It gives
access to methods related to the actor being called. It also
gives access to Core
.
Core
is the part of Stakker
which is accessible to actors
during actor calls via Cx
. Both Stakker
and Cx
references dereference to Core
and can be used wherever a
Core
ref is required.
Share
allows a mutable structure to be shared safely between
actors, a bit like IPC shared-memory but with guaranteed exclusive
access. This may be used for efficiency, like shared-memory
buffers are sometimes used between OS processes.
Deferrer
allows queuing things to run from Drop
handlers or
from other places in the main thread without access to Core
.
All actors have a built-in Deferrer
which can be used from
outside the actor.
For interfacing with other threads, PipedThread
wraps a thread
and handles all data transfer to/from it and all cleanup.
Channel
allows other threads to send messages to an actor.
Waker
is a primitive which allows channels and other data
transfer to the main thread to be coordinated. See here
for more details.
§Efficiency
A significant aim in the development of Stakker was to be lightweight and to minimize overheads in time and memory, and to scale well. Another significant aim was to be “as simple as possible but no simpler”, to try to find an optimal set of types and operations that provide the required functionality and ergonomics and that fit the Rust model, to make maximum use of the guarantees that Rust provides.
By default Stakker uses TCell
or
TLCell
for zero-cost protected access
to actor state, which also guarantees at compile-time that no
actor can directly access any other actor.
By default a cut-down ref-counting implementation is used instead
of Rc
, which saves around one usize
per Actor
or Fwd
instance.
With default features, only one thread is allowed to run a
Stakker
instance, which enables an optimisation which uses a
global variable for the Deferrer
defer queue (used for drop
handlers). However if more Stakker
instances need to be run,
then the multi-thread or multi-stakker features cause it
to use alternative implementations.
All deferred operations, including all async actor calls, are
handled as FnOnce
instances on a queue. The aim is to make this
cheap enough so that deferring something doesn’t have to be a big
decision. Thanks to Rust’s inlining, these are efficient – the
compiler might even choose to inline the internal code of the
actor call into the FnOnce
, as that is all known at
compile-time.
By default the FnOnce
queue is a flat heterogeneous queue,
storing the closures directly in a byte Vec
, which should give
best performance and cache locality at the cost of some unsafe
code. However a fully-safe boxed closure queue implementation is
also available.
Forwarding handlers (Fwd
) are boxed Fn
instances along with
a ref-count. Return handlers (Ret
) are boxed FnOnce
instances. Both typically queue a FnOnce
operation when
provided with arguments. These are also efficient due to
inlining. In this case two chunks of inlined code are generated
for each by the compiler: the first which accepts arguments and
pushes the second one onto the queue.
If no inter-thread operations are active, then Stakker will never do locking or any atomic operations, nor block for any reason. So the code can execute at full speed without triggering any CPU memory fences or whatever. Usually the only thing that blocks would be the external I/O poller whilst waiting for I/O or timer expiry. When other threads have been started and they defer wake-ups to the main thread, this is handled as an I/O event which causes the wake flags to be checked using atomic operations.
§Cargo features
Cargo features in Stakker do not change Stakker’s public API. The API stays the same, but the implementation behind the API changes.
Also, cargo features are additive. This means that if one crate using Stakker enables a feature, then it is enabled for all uses of Stakker in the build. So when features switch between alternative implementations, enabling a feature has to result in the more tolerant implementation, because all users of the crate have to be able to work with this configuration. This usually means that features switch from the most efficient and restrictive implementation, to a less efficient but more flexible one.
So using the default features is the best choice unless you have specific requirements. When a crate that uses Stakker doesn’t care about whether a feature is enabled or not, it should avoid setting it and leave it up to the application to choose.
Features enabled by default:
- inter-thread: Enables inter-thread operations such as
Waker
andPipedThread
.
Optional features:
-
no-unsafe-queue: Disable the fast FnOnce queue implementation, which uses unsafe code. Uses a boxed queue instead.
-
no-unsafe: Disable all unsafe code within this crate, at some cost in time and memory.
-
multi-thread: Specifies that more than one Stakker will run in the process, at most one Stakker per thread. This disables some optimisations that require process-wide access.
-
multi-stakker: Specifies that more than one Stakker may need to run in the same thread. This disables optimisations that require either process-wide or thread-local access.
-
inline-deferrer: Forces use of the inline
Deferrer
implementation instead of using the global or thread-local implementation. Possibly useful if thread-locals are very slow. -
logger: Enables Stakker’s core logging feature, which logs actor startup and termination, and which allows macros from the
stakker_log
crate to log with actor context information. SeeStakker::set_logger
.
These are the implementations that are switched, in order of preference, listing most-preferred first:
§Cell type
-
TCell
: Best performance, but only allows a single Stakker per process -
TLCell
: Best performance, but uses thread-locals at Stakker creation time and only allows a single Stakker per thread -
QCell
: Allows many Stakker instances per thread at some cost in time and memory
§Deferrer
-
Global deferrer: Uses a global variable to find the
Deferrer
-
Thread-local deferrer: Uses a thread-local to find the
Deferrer
, with safe and unsafe variants -
Inline deferrer: Keeps references to the
Deferrer
in all places where it is needed, with safe and unsafe variants. In particular this adds ausize
to all actors.
§Actor ref-counting
-
Packed: Uses a little unsafe code to save a
usize
per actor -
Standard: Uses
std::rc::Rc
§Call queues
-
Fast
FnOnce
queue: AppendsFnOnce
closures directly to a flat memory buffer. Gives best performance, but usesunsafe
code. -
Boxed queue: Stores closures indirectly by boxing them
§Testing
Stakker has unit and doc tests that give over 90% coverage
across all feature combinations. These tests also run cleanly
under valgrind and MIRI. In addition there are some fuzz tests
and stress tests under extra/
that further exercise particular
components to verify that they operate as expected.
§Tutorial example
// An actor is represented as a struct which holds the actor state
struct Light {
start: Instant,
on: bool,
}
impl Light {
// This is a "Prep" method which is used to create a Self value
// for the actor. `cx` is the actor context and gives access to
// Stakker `Core`. (`CX![]` expands to `&mut Cx<'_, Self>`.)
// A "Prep" method doesn't have to return a Self value right away.
// For example it might asynchronously attempt a connection to a
// remote server first before arranging a call to another "Prep"
// function which returns the Self value. Once a value is returned,
// the actor is "Ready" and any queued-up operations on the actor
// will be executed.
pub fn init(cx: CX![]) -> Option<Self> {
// Use cx.now() instead of Instant::now() to allow execution
// in virtual time if supported by the environment.
let start = cx.now();
Some(Self { start, on: false })
}
// Methods that may be called once the actor is "Ready" have a
// `&mut self` or `&self` first argument.
pub fn set(&mut self, cx: CX![], on: bool) {
self.on = on;
let time = cx.now() - self.start;
println!("{:04}.{:03} Light on: {}", time.as_secs(), time.subsec_millis(), on);
}
// A `Fwd` or `Ret` allows passing data to arbitrary destinations,
// like an async callback. Here we use it to return a value.
pub fn query(&self, cx: CX![], ret: Ret<bool>) {
ret!([ret], self.on);
}
}
// This is another actor that holds a reference to a Light actor.
struct Flasher {
light: Actor<Light>,
interval: Duration,
count: usize,
}
impl Flasher {
pub fn init(cx: CX![], light: Actor<Light>,
interval: Duration, count: usize) -> Option<Self> {
// Defer first switch to the queue
call!([cx], switch(true));
Some(Self { light, interval, count })
}
pub fn switch(&mut self, cx: CX![], on: bool) {
// Change the light state
call!([self.light], set(on));
self.count -= 1;
if self.count != 0 {
// Call switch again after a delay
after!(self.interval, [cx], switch(!on));
} else {
// Terminate the actor successfully, causing StopCause handler to run
cx.stop();
}
// Query the light state, receiving the response in the method
// `recv_state`, which has both fixed and forwarded arguments.
let ret = ret_some_to!([cx], recv_state(self.count) as (bool));
call!([self.light], query(ret));
}
fn recv_state(&self, _: CX![], count: usize, state: bool) {
println!(" (at count {} received: {})", count, state);
}
}
let mut stakker0 = Stakker::new(Instant::now());
let stakker = &mut stakker0;
// Create and initialise the Light and Flasher actors. The
// Flasher actor is given a reference to the Light. Use a
// StopCause handler to shutdown when the Flasher terminates.
let light = actor!(stakker, Light::init(), ret_nop!());
let _flasher = actor!(
stakker,
Flasher::init(light.clone(), Duration::from_secs(1), 6),
ret_shutdown!(stakker)
);
// Since we're not in virtual time, we use `Instant::now()` in
// this loop, which is then passed on to all the actors as
// `cx.now()`. (If you want to run time faster or slower you
// could use another source of time.) So all calls in a batch of
// processing get the same `cx.now()` value. Also note that
// `Instant::now()` uses a Mutex on some platforms so it saves
// cycles to call it less often.
stakker.run(Instant::now(), false);
while stakker.not_shutdown() {
// Wait for next timer to expire. Here there's no I/O polling
// required to wait for external events, so just `sleep`
let maxdur = stakker.next_wait_max(Instant::now(), Duration::from_secs(60), false);
std::thread::sleep(maxdur);
// Run queue and timers
stakker.run(Instant::now(), false);
}
§Main loop examples
Note that the 60s duration used below just means that the process will wake every 60s if nothing else is going on. You could make this a larger value.
§Virtual time main loop, no I/O, no idle queue handling
let mut now = Instant::now();
stakker.run(now, false);
while stakker.not_shutdown() {
now += stakker.next_wait_max(now, Duration::from_secs(60), false);
stakker.run(now, false);
}
§Real time main loop, no I/O, no idle queue handling
stakker.run(Instant::now(), false);
while stakker.not_shutdown() {
let maxdur = stakker.next_wait_max(Instant::now(), Duration::from_secs(60), false);
std::thread::sleep(maxdur);
stakker.run(Instant::now(), false);
}
§Real time I/O poller main loop, with idle queue handling
This example uses MioPoll
from the stakker_mio
crate.
let mut idle_pending = stakker.run(Instant::now(), false);
while stakker.not_shutdown() {
let maxdur = stakker.next_wait_max(Instant::now(), Duration::from_secs(60), idle_pending);
let activity = miopoll.poll(maxdur)?;
idle_pending = stakker.run(Instant::now(), !activity);
}
The way this works is that if there are idle queue items pending,
then next_wait_max
returns 0s, which means that the poll
call
only checks for new I/O events without blocking. If there are no
new events (activity
is false), then an item from the idle queue
is run.
§Why the name Stakker?
“Single-threaded actor runtime” → STACR → Stakker. The name is also a small tribute to the 1988 Humanoid track “Stakker Humanoid”, which borrows samples from the early video game Berzerk, and which rolls along quite economically as I hope the Stakker runtime also does.
Modules§
- aux
- Auxiliary types that are not interesting in themselves
- sync
- Cross-thread support
- task
- Support code for asynchronous tasks
Macros§
- CX
- Shorthand for context argument type
- actor
- Create a new actor and initialise it
- actor_
in_ slab - Create a new actor in an
ActorOwnSlab
- actor_
new - Create a new actor
- actor_
of_ trait - Create a new actor that implements a trait and initialise it
- after
- After a delay, perform an actor call or inline code
- at
- At the given
Instant
, perform an actor call or inline code - call
- Queue an actor call or inline code for execution soon
- fail
- Indicate failure of the actor
- fwd
- Forward data via a
Fwd
instance - fwd_do
- Create a
Fwd
instance which performs an arbitrary action - fwd_nop
- Create a
Fwd
instance which does nothing at all - fwd_
panic - Create a
Fwd
instance which panics when called - fwd_to
- Create a
Fwd
instance for actor calls - idle
- Perform an actor call or inline code when the thread becomes idle
- kill
- Kill an actor
- lazy
- Lazily perform an actor call or inline code
- query
- Synchronously query an actor for information
- ret
- Return data via a
Ret
instance - ret_do
- Create a
Ret
instance which performs an arbitrary action - ret_
fail - Create a
Ret
instance that terminates this actor with failure - ret_
failthru - Create a
Ret
instance that passes through actor failure - ret_nop
- Create a
Ret
instance which does nothing at all - ret_
panic - Create a
Ret
instance which panics when called - ret_
shutdown - Create a
Ret
instance which shuts down the event loop - ret_
some_ do - Create a
Ret
instance which performs an arbitrary action, ignoring drops - ret_
some_ to - Create a
Ret
instance for actor calls, ignoring drops - ret_to
- Create a
Ret
instance for actor calls - stop
- Indicate successful termination of the actor
- timer_
max - Create or update a “Max” timer
- timer_
min - Create or update a “Min” timer
Structs§
- Actor
- A ref-counting reference to an actor
- Actor
Own - An owning ref-counting reference to an actor
- Actor
OwnAnon - An owning ref-counting reference to an anonymous actor
- Actor
OwnSlab - A set of owning actor references
- Core
- Core operations available from both
Stakker
andCx
objects - Cx
- Context for an actor call
- Deferrer
- Defer queue which can be accessed without a
Core
ref - Fixed
Timer Key - Timer key for a fixed timer
- Fwd
- Forwarder for messages of type
M
- LogFilter
- Filter for logging levels
- LogLevel
Error - Invalid
LogLevel
passed toLogLevel::from_str
- LogRecord
- Log record that is passed to a logger
- MaxTimer
Key - Timer key for a Max timer
- MinTimer
Key - Timer key for a Min timer
- Ret
- Returner for messages of type
M
- Share
- Ref-counted shared mutable data
- Share
Weak - Weak reference to a
Share
- Stakker
- The external interface to the actor runtime
Enums§
Traits§
- LogVisitor
- Trait used when visiting all the log-record’s key-value pairs
Type Aliases§
- LogID
- Logging span identifier