Implements a tunable configuration for a single or multi-threaded runtime. Co-authored-by: Zachery Aaron Shores-Chmielewski <zachanon@gmail.com> Reviewed-on: http://zachery.lol/code/code/zacheryasc/swactor/pulls/2
11 KiB
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.
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
}
}
}
Kimi Suggestions
IMPROVEMENTS FITTING DESIGN GOALS Here are improvements aligned with the stated goals: "maximum usability and speed while keeping line count low" and "no footguns." Priority 1: Critical Bug Fixes & MVP Completion
- Fix Runtime Constructor (~5 lines)
- Implement Runtime::new()
- Implement Builder::build()
- Fix examples to compile
- Handle Full Inboxes Gracefully (~15 lines)
- Return Result<(), Error> from send_to on full inbox
- Provide backpressure signal instead of silent drop
- Add try_send() vs send() distinction
- Implement Multithreaded Runtime (~30-40 lines)
- Complete threading infrastructure (already partially designed)
- Router runs in separate thread
- Actor processing pool with work-stealing (simple round-robin first) Priority 2: Usability Enhancements (Low Line Count)
- Ergonomic Macros (~20-30 lines procedural macro crate)
#[derive(Actor)]
struct MyActor { ... }
- Auto-impl ActorInterface for simple cases
- Reduces boilerplate significantly
- Request/Response Helper (~15 lines)
let resp: Response = rt.request(addr, msg).await?;
- Common pattern many users need
- Maintains simplicity
- Inbox Capacity Configuration (~5 lines)
- Per-actor capacity instead of global constant
- Allow spawn_with_capacity() Priority 3: Performance Optimizations
- Sharded Router (~30-40 lines)
- Multiple HashMaps based on address hash
- Reduces contention on messaging hot path
- Maintains O(1) lookups
- Actor Work Stealing (~40-50 lines)
- Multiple actor queues instead of single global queue
- Threads steal work when idle
- Improves cache locality
- Hybrid Channel (from DESIGN.md) (~25 lines)
- Implements the overflow mechanism described
- Ring buffer + Mutex for emergencies
- Prevent message loss under burst loads
- Actor State Colocation (~15 lines)
- Group related actors by affinity
- Optional "actor system" or "node" concept
- Better cache locality Priority 4: Observability (Minimal Overhead)
- Lightweight Metrics (~15-20 lines)
- Message counts per actor (atomic counters)
- Overflow/drop tracking
- Optional, compile-time feature flag
- Message Tracing (~10-15 lines opt-in)
- Optional trace ID in envelope
- Zero-cost when disabled (feature flag) Priority 5: Reliability Patterns
- Bounded Channels with Overflow (~20 lines)
- Implement HybridChannel from design doc
- Graceful degradation under load
- Watchdog Timer Pattern (~15 lines example)
- Show pattern: actor checking heartbeats
- Keep library simple, document patterns
SPECIFIC CODE IMPROVEMENTS Fix Silent Failures (Priority: CRITICAL) Current (src/runtime.rs:85-93): 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(|| ()) } Improved: pub fn try_send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { let envelope: Envelope = Box::new(msg); self.router_inbox .try_send(RouterMessage::SendToAddr { addr, msg: envelope }) .map_err(|| Error::from("Router inbox full")) } // Add send that blocks/resizes pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> { ... } Implement HybridChannel (Priority: HIGH) From design doc, add to ring_buffer.rs: pub struct HybridChannel { ring: ArrayQueue, overflow: Mutex<VecDeque>, overflow_count: AtomicUsize, } impl HybridChannel { fn push(&self, value: T) -> Result<(), T> { if self.ring.push(value).is_err() { self.overflow.lock().push_back(value); self.overflow_count.fetch_add(1, Relaxed); // Optionally resize ring if overflow_count > threshold } Ok(()) } } Fix Runtime Construction (Priority: CRITICAL) Current: Runtime::new() doesn't exist but examples use it. Add to runtime.rs: impl Runtime { pub fn new(capacity: usize, flavor: Option) -> Self { let router = Router::new(capacity); let router_inbox = router.new_sender();
Self {
flavor: flavor.unwrap_or_default(),
router,
router_inbox,
actor_queue: ArrayQueue::new(capacity),
thread_pool: Vec::new(),
}
}
} Add Sharded Router (Priority: MEDIUM) Current: Single HashMap for all addresses Improved: N HashMaps based on address modulo pub(crate) struct Router { shards: Vec<HashMap<ActorAddress, Box>>, shard_mask: usize, // shards.len() - 1 (power of 2) inbox: Receiver, } impl Router { fn get_shard(&self, addr: ActorAddress) -> &HashMap<...> { &self.shards[(addr as usize) & self.shard_mask] } }
RECOMMENDED ROADMAP Phase 1: Bug Fixes & MVP (1-2 days)
- Implement Runtime::new()
- Implement Builder::build()
- Fix compilation errors
- Add error handling for full inboxes
- Document API Phase 2: Single-Threaded Polish (1 week)
- Ergonomic macros
- Request/response helpers
- Inbox capacity configuration
- Example improvements
- Basic tests Phase 3: Multi-Threaded (2 weeks)
- Implement threaded runtime
- Worker thread pool
- Router in separate thread
- Work-stealing queues
- Performance benchmarks Phase 4: Production Hardening (2 weeks)
- Sharded router
- Hybrid channels
- Metrics (opt-in)
- Message tracing (opt-in)
- Stress testing Phase 5: Documentation & Patterns (1 week)
- Actor patterns guide
- Performance tuning guide
- WASM integration examples
- Best practices documentation
ALTERNATIVE ARCHITECTURES TO CONSIDER For Even Smaller Line Count If the goal is absolutely minimal code, consider:
- Single-threaded only: Remove multi-threading complexity entirely
- No router: Direct mpsc channels between actors (more Erlang-like)
- Simpler scheduling: Round-robin over actors array Tradeoff: Less flexible, but potentially <200 lines total. For Better Performance If performance outweighs minimalism:
- Lock-free HashMap: Use dashmap or equivalent for router
- SegQueue: Better for work-stealing than ArrayQueue
- Pre-allocated: Fixed-size actor pool with object pool pattern
- Lock-free message passing: Use crossbeam or tokio channels throughout Tradeoff: More dependencies, larger binary size. For Better Ergonomics If usability is primary goal:
- Async/Await native: Integrate with tokio or async-std
- Actor supervision: Basic supervisor trees (small implementation)
- Message DSL: Macro for pattern-matching message handlers Tradeoff: Increases complexity substantially, may conflict with "small" goal.
CONCLUSION swactor has a solid, minimalist architecture that delivers on its core promise: a small, WASM-compatible actor library. The design is clean, modular, and avoids unnecessary complexity. Current Grade: C+ (Incomplete MVP)
- Architecture: B+
- Ease of Use: D (examples don't compile, silent failures)
- Performance: B (good primitives but scalability concerns) Potential Grade with improvements: A-
- Fixing critical bugs would make it immediately usable
- Sharded router + work-stealing would address scalability
- Ergonomic macros would dramatically improve UX
- Hybrid channels would solve burst-load scenarios Recommendation: Focus on completing Phase 1 (bug fixes) and Phase 2 (usability). The architecture is sound—it's just incomplete. Avoid premature optimization; measure performance first, then implement sharding/work-stealing if benchmarks show contention.