feat: rewrite

Did not like the requirement of a tokio runtime and async/await. Rewrote
to use lock free queues (`crossbeam_queue::ArrayQueue`) as the basic
primitive to enable a runtime consisting of many psuedo-processes that
can pass messages to each other.
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-01-23 11:56:32 +07:00
parent b8b5b839df
commit 4edca69d7c
6 changed files with 446 additions and 213 deletions

109
Cargo.lock generated
View file

@ -3,102 +3,53 @@
version = 4 version = 4
[[package]] [[package]]
name = "bytes" name = "cfg-if"
version = "1.11.0" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "futures-core" name = "crossbeam-queue"
version = "0.3.31" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [ dependencies = [
"unicode-ident", "crossbeam-utils",
] ]
[[package]] [[package]]
name = "quote" name = "crossbeam-utils"
version = "1.0.42" version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"proc-macro2", "cfg-if",
"libc",
"wasi",
] ]
[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]] [[package]]
name = "swactor" name = "swactor"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"tokio", "crossbeam-queue",
"tokio-util", "getrandom",
] ]
[[package]] [[package]]
name = "syn" name = "wasi"
version = "2.0.111" version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"

View file

@ -6,6 +6,10 @@ edition = "2024"
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[features]
default = ["getrandom"]
getrandom = ["dep:getrandom"]
[dependencies] [dependencies]
tokio = { version = "1.48.0", features = ["rt", "macros"] } getrandom = { version = "0.2", optional = true }
tokio-util = "0.7.17" crossbeam-queue = "0.3.12"

77
DESIGN.md Normal file
View file

@ -0,0 +1,77 @@
# Design goals
Get as much usability and speed as possible while keeping line count low. Aim for no footguns, ability to plug in
logic easily, and run near anywhere. We may make this a `![no_std]` library, but the MVP will use the
memory allocator and threading provided by the rust standard library.
We are not building a new erlang/BEAM. Minimal feature set means spawning actor processes, not having supervisiors, lots of process
monitoring tools, prempting, etc.
## Actor model
An actor has:
- An inbox:
this is a mpsc channel that the runtime/router dumps messages into and the actor consumes when the runtime loads it
Implemented as a barebones atomic ring buffer. The router is responsible for inserting messages.
- an outbox channel connection:
this is a mpmc channel that is implemented by the runtime and router. Actors on this specific channel put responses and outgoing messages into this channel, to be routed to the given address.
- a growable and mutable state:
An actor owns some, from the runtime perspective, type erased bytes. The actor when processing messages can access its own state, but no other task can. This includes viewing.
- a set of functions for processing messages:
When the runtime loads the actor, it locks the inbox and attempts to process the messages therein.
## Runtime
In order for an actor to consume and send messages, it is processed by a runtime. The runtime, in order to negotiate messages between
actors, possesses a router.
A runtime has:
- An actor processing thread(s):
the processor will mark an actor as busy, load its state and inbox, and begin consuming messages from the inbox. The number of messages consumed is determined by the runtime. A good start is a backpressure strategy: after loading, process messages until mailbox is empty or size drops below a threshold (e.g., "drain to 50%").
- A message router:
the router is responsible for ensuring messages posted by actors get delivered to the appropriate inbox.
- An atomic ring buffer containing thread-safe references to actors that are not currently loaded. Actors are popped off the buffer, messages are
processed, and the reference is returned to the buffer/queue before the next actor is loaded.
## Router
The router is the engine for message delivery. It posesses:
- An actor address book:
The address book maps actor ids to `Sender` references that can be used to deliver messages to the actor inbox.
- Its own inbox:
The router possesses its own mpsc queue where references to messages are stored. The router will process this queue by dereferencing and writing directly into the recipient's inbox buffer.
### Misc
A means of providing an emergency overflow without adding much more code complexity. The mutex means
this will not be `no_std` however.
```rust
struct HybridChannel<T> {
// Start with lock-free ring buffer
ring: AtomicRingBuffer<T>,
// When full, spill into a Mutex<VecDeque<T>>
overflow: parking_lot::Mutex<VecDeque<T>>,
// Track overflow frequency to resize ring proactively
overflow_count: AtomicUsize,
}
impl<T> HybridChannel<T> {
fn push(&self, value: T) {
if self.ring.push(value).is_err() {
self.overflow.lock().push_back(value);
self.overflow_count.fetch_add(1, Relaxed);
// Optionally: if overflow_count > threshold, grow ring
}
}
}
```

View file

@ -1,46 +1,58 @@
use swactor::Actor; use swactor::{ActorAddress, ActorInterface, Message, Runtime, RuntimeFlavor};
use tokio::sync::oneshot;
pub enum GreeterMessage { #[derive(Debug, Default)]
Name(String), struct Greeter {
pub num_greeted: usize,
} }
pub enum GreeterResponse { #[derive(Debug, Default, Clone)]
Hello(String), struct GreetMessage {
/// who do we greet?
who: String,
/// who do we send out greeting back to?
return_addr: ActorAddress,
} }
impl Message for GreetMessage {}
pub struct Greeter; impl ActorInterface for Greeter {
type Incoming = GreetMessage;
type Response = GreetResponse;
impl Actor for Greeter { fn handle(&mut self, ctx: &Runtime, msg: GreetMessage) {
type Message = GreeterMessage; let res = GreetResponse(format!("Hello, {}!", msg.who));
type Response = GreeterResponse; self.num_greeted += 1;
if let Err(_) = ctx.send_to(msg.return_addr, res) {
fn handle_message(&self, msg: Self::Message, tx: oneshot::Sender<Self::Response>) { // no error handling
let rep = match msg { self.num_greeted -= 1;
GreeterMessage::Name(name) => GreeterResponse::Hello(format!("Hello, {name}!")),
};
if let Err(_) = tx.send(rep) {
// Greeter is not responsible for a dropped Receiver
} }
} }
} }
#[derive(Debug, Default, Clone)]
struct GreetResponse(String);
impl Message for GreetResponse {}
fn main() { fn main() {
let rt = tokio::runtime::Builder::new_current_thread() let mut rt = Runtime::new(100, Some(RuntimeFlavor::SingleThreaded));
.build() let addr = rt
.expect("failed to build runtime"); .spawn(Greeter::default())
let greeter = Greeter.spawn(&rt); .expect("failed to spawn greeter");
let inbox = rt.new_inbox::<GreetResponse>();
let response = rt rt.send_to(
.block_on(async move { addr,
greeter GreetMessage {
.send(GreeterMessage::Name("world".to_string())) who: "world".into(),
.await return_addr: *inbox.addr(),
}) },
.expect("failed to get respose"); )
.unwrap();
match response { for _ in 0..3 {
GreeterResponse::Hello(hello) => println!("{hello}"), rt.tick();
} }
let resp = inbox.try_recv().expect("greeter should have said hello");
println!("{}", resp.0);
} }

View file

@ -1,118 +1,251 @@
mod ring_buffer;
use std::collections::HashMap;
use crossbeam_queue::ArrayQueue;
use ring_buffer::{Receiver, Sender};
pub mod error; pub mod error;
use error::Error;
/// Public export as the oneshot channel is in the `Actor` trait signature #[cfg(feature = "getrandom")]
pub use tokio::sync::oneshot; pub fn get_random(buf: &mut [u8]) {
getrandom::getrandom(buf).unwrap()
use tokio::{sync::mpsc, task::JoinHandle};
use tokio_util::sync::CancellationToken;
use crate::error::{Result, convert_err};
const DEFAULT_CHANNEL_SIZE: usize = 100;
type ActorRequest<A> = (
<A as Actor>::Message,
oneshot::Sender<<A as Actor>::Response>,
);
/// Wrapper defining the transmission end of a Request/Response channel with an `Actor`
pub struct ActorRequestSender<A: Actor>(mpsc::Sender<ActorRequest<A>>);
impl<A: Actor> ActorRequestSender<A> {
pub async fn send(&self, request: A::Message) -> Result<A::Response> {
let (tx, rx) = oneshot::channel::<A::Response>();
self.0.send((request, tx)).await.map_err(convert_err)?;
rx.await.map_err(convert_err)
}
} }
impl<A: Actor> From<mpsc::Sender<ActorRequest<A>>> for ActorRequestSender<A> { /// The strategy for message processing is such:
fn from(value: mpsc::Sender<ActorRequest<A>>) -> Self { /// if total_messages < WATERLEVEL:
Self(value) /// process all
} /// else
/// process total_messages // 2
const WATERLEVEL: usize = 10;
const DEFAULT_INBOX_CAPACITY: usize = 100;
pub trait Message: 'static + Sized + Clone + Send {}
pub type Envelope = Box<dyn std::any::Any + Send>;
pub trait ActorInterface: 'static + Send {
type Incoming: Message;
type Response: Message;
fn handle(&mut self, ctx: &Runtime, msg: Self::Incoming);
} }
impl<A: Actor> Clone for ActorRequestSender<A> { pub type ActorAddress = u64;
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
/// Combined 'JoinHandle' to await the actor process and 'Sender' for communication pub struct Actor<A>
pub struct Handle<A>
where where
A: Actor, A: ActorInterface,
{ {
cancel_token: CancellationToken, _addr: ActorAddress,
tx: ActorRequestSender<A>, inbox: Receiver<A::Incoming>,
/// the task drops when the `JoinHandle` does, so be careful with the `Handle` inner: A,
_handle: JoinHandle<Result<()>>,
// to prevent accidental swaps, strongly type the handle
_type: std::marker::PhantomData<A>,
} }
impl<A: Actor> Handle<A> { /// Trait for type-erased actors
/// Send a message to the spawned `Actor` task and get a response corresponding to the `Actor::Response` type trait AnyActor: Send {
pub async fn send(&self, msg: A::Message) -> Result<A::Response> { fn tick(&mut self, ctx: &Runtime);
self.tx.send(msg).await
}
/// Get a cloned sender for messaging the `Actor` this handle is for
pub fn get_connection(&self) -> ActorRequestSender<A> {
self.tx.clone()
}
} }
impl<A: Actor> Drop for Handle<A> { impl<A> AnyActor for Actor<A>
fn drop(&mut self) { where
self.cancel_token.cancel(); A: ActorInterface,
} {
} fn tick(&mut self, ctx: &Runtime) {
let total_messages = self.inbox.len();
/// Primary trait defining an `Actor` capable of receiving, processing, and transmitting messages let messages_to_process = if total_messages < WATERLEVEL {
pub trait Actor: Send + Sized + 'static { total_messages
/// The type for messages received by this `Actor` } else {
type Message: Send; total_messages >> 1
/// The type for responses given by this actor when called from `Handle::send(..)`
type Response: Send;
/// Inner method that defines actor behavior
fn handle_message(&self, msg: Self::Message, tx: oneshot::Sender<Self::Response>);
/// Spawns the `Actor` utilizing the given runtime context
/// Only `tokio` runtime is accepted for now
fn spawn(self, ctx: &tokio::runtime::Runtime) -> Handle<Self> {
let cancel_token = CancellationToken::new();
let cancel = cancel_token.clone();
let (tx, mut rx) =
mpsc::channel::<(Self::Message, oneshot::Sender<Self::Response>)>(DEFAULT_CHANNEL_SIZE);
let handle = ctx.spawn(async move {
let mut res = Ok(());
loop {
tokio::select! {
_ = cancel.cancelled() => {
break;
},
msg = rx.recv() => {
match msg {
Some(m) => { self.handle_message(m.0, m.1); },
None => {res = Err(format!("Sender handle was dropped without calling cancel!").into()); break; },
}
}
}; };
for _ in 0..messages_to_process {
match self.inbox.try_recv() {
Some(msg) => self.inner.handle(ctx, msg),
None => unreachable!(
"We checked number of unprocessed messages in the queue ahead of processing"
),
}
}
}
}
pub struct Inbox<M: Message> {
addr: ActorAddress,
inner: Receiver<M>,
}
impl<M: Message> Inbox<M> {
pub fn addr(&self) -> &ActorAddress {
&self.addr
}
pub fn try_recv(&self) -> Option<M> {
self.inner.try_recv()
}
}
#[derive(Debug, Default)]
pub enum RuntimeFlavor {
#[default]
SingleThreaded,
Multithreaded(usize),
}
pub struct Runtime {
flavor: RuntimeFlavor,
router: Router,
router_inbox: Sender<RouterMessage>,
actor_queue: ArrayQueue<Box<dyn AnyActor>>,
}
impl Runtime {
pub fn new(capacity: usize, flavor: Option<RuntimeFlavor>) -> Self {
let router = Router::new(DEFAULT_INBOX_CAPACITY);
let router_inbox = router.new_sender();
Self {
flavor: flavor.unwrap_or_default(),
router,
router_inbox,
actor_queue: ArrayQueue::new(capacity),
}
}
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
let addr = {
let mut bytes = u64::to_le_bytes(0);
get_random(&mut bytes);
u64::from_le_bytes(bytes)
};
let inbox = Receiver::<A::Incoming>::new(DEFAULT_INBOX_CAPACITY);
let sender = inbox.new_sender();
// Register the sender with the router
let _ = self
.router_inbox
.try_send(RouterMessage::AddAddr(addr, Box::new(sender)));
self.actor_queue
.push(Box::new(Actor {
_addr: addr,
inbox,
inner: actor,
}))
.map_err(|_| Error::from("Runtime error: Failed to spawn actor."))?;
Ok(addr)
}
pub fn send_to<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), ()> {
let envelope: Envelope = Box::new(msg);
self.router_inbox
.try_send(RouterMessage::SendToAddr {
addr,
msg: envelope,
})
.map_err(|_| ())
}
pub fn tick(&mut self) {
// Pop actor, tick it, push it back
if let Some(mut actor) = self.actor_queue.pop() {
actor.tick(self);
let _ = self.actor_queue.push(actor);
}
match self.flavor {
RuntimeFlavor::Multithreaded(_) => (), // router has its own thread
RuntimeFlavor::SingleThreaded => self.router.tick(),
}
}
pub fn new_inbox<M: Message>(&self) -> Inbox<M> {
let addr = {
let mut bytes = u64::to_le_bytes(0);
get_random(&mut bytes);
u64::from_le_bytes(bytes)
};
let receiver = Receiver::<M>::new(DEFAULT_INBOX_CAPACITY);
let sender = receiver.new_sender();
// Register the sender with the router
let _ = self
.router_inbox
.try_send(RouterMessage::AddAddr(addr, Box::new(sender)));
Inbox {
addr,
inner: receiver,
}
}
}
pub trait SenderT: Send {
fn try_send(&self, envelope: Envelope);
}
impl<M: Message> SenderT for Sender<M> {
fn try_send(&self, envelope: Envelope) {
if let Ok(msg) = envelope.downcast::<M>() {
let _ = Sender::try_send(self, *msg);
}
}
}
/// Internal messages for the Router's own inbox
pub enum RouterMessage {
/// register addrs <addr> with sender <sender>
AddAddr(ActorAddress, Box<dyn SenderT>),
/// remove an actor from the address book
RemoveAddr(ActorAddress),
/// send <msg> to <addr>
SendToAddr { addr: ActorAddress, msg: Envelope },
}
struct Router {
directory: HashMap<ActorAddress, Box<dyn SenderT>>,
inbox: Receiver<RouterMessage>,
}
impl Router {
pub fn new(cap: usize) -> Self {
Self {
directory: HashMap::new(),
inbox: Receiver::new(cap),
}
}
pub fn tick(&mut self) {
let total_messages = self.inbox.len();
let messages_to_process = if total_messages < WATERLEVEL {
total_messages
} else {
total_messages >> 1
};
for _ in 0..messages_to_process {
match self.inbox.try_recv() {
Some(msg) => self.handle(msg),
None => unreachable!("We ran checks on total messages before processing."),
}
}
}
pub fn new_sender(&self) -> Sender<RouterMessage> {
self.inbox.new_sender()
}
fn handle(&mut self, msg: RouterMessage) {
match msg {
RouterMessage::AddAddr(addr, sender) => {
self.directory.insert(addr, sender);
}
RouterMessage::RemoveAddr(addr) => {
self.directory.remove(&addr);
}
RouterMessage::SendToAddr { addr, msg } => {
if let Some(sender) = self.directory.get(&addr) {
sender.try_send(msg);
}
} }
res
});
Handle {
cancel_token,
_handle: handle,
tx: tx.into(),
_type: std::marker::PhantomData::<Self>,
} }
} }
} }

56
src/ring_buffer.rs Normal file
View file

@ -0,0 +1,56 @@
pub use crossbeam_queue::ArrayQueue;
use std::sync::Arc;
/// The receiving end of a `crossbeam_queue::ArrayQueue`, a lock-free mpsc queue.
/// The queue is constructed by the `Receiver::new()` method.
/// Responsible for creating the `Sender` ends of itself.
///
/// Notably: The `Receiver` provides no guarentees that a sending end of the channel exists.
pub(crate) struct Receiver<T> {
queue: Arc<ArrayQueue<T>>,
}
impl<T> Receiver<T> {
/// Constructs a new `ArrayQueue` with given capacity.
///
/// # Panics
/// Will panic if capacity is passed as 0
pub fn new(capacity: usize) -> Self {
Self {
queue: Arc::new(ArrayQueue::new(capacity)),
}
}
/// Returns the number of elements in the inner queue
pub fn len(&self) -> usize {
self.queue.len()
}
/// Attempt to retrieve a value from the queue. Returns `None` if empty
pub fn try_recv(&self) -> Option<T> {
self.queue.pop()
}
/// Construct a new `Sender` assosciated with this queue.
pub fn new_sender(&self) -> Sender<T> {
Sender {
queue: self.queue.clone(),
}
}
}
/// The sending end of a `crossbeam_queue::ArrayQueue`, a lock free mpsc queue.
/// The queue is initialized via calling the corresponding `Receiver::<T>::new()` method,
/// and the sending end of the queue is constructed via calling `receiver.new_sender()`.
///
/// Notably: The `Sender` provides no guarentees that a receiving end of the channel exists.
pub(crate) struct Sender<T> {
queue: Arc<ArrayQueue<T>>,
}
impl<T> Sender<T> {
/// Attempt to push a value to the queue. Returns Err(value) if the queue is full.
pub fn try_send(&self, value: T) -> Result<(), T> {
self.queue.push(value)
}
}