Crate interthread
source ·Expand description
§Intro
This document covers the usage of the crate’s macros, it does not delve into the detailed logic of the generated code.
For a comprehensive understanding of the underlying
concepts and implementation details of the Actor Model,
it’s recommended to read the article Actors with Tokio
by Alice Ryhl ( also known as Darksonn ) also a great
talk by the same author on the same subject if a more
interactive explanation is prefered
Actors with Tokio – a lesson in ownership - Alice Ryhl
(video).
This article not only inspired the development of the
interthread
crate but serves as foundation
for the Actor Model implementation logic in it.
§What is an Actor ?
Despite being a fundamental concept in concurrent programming, defining exactly what an actor is can be ambiguous.
-
Carl Hewitt, often regarded as the father of the Actor Model, The Actor Model (video).
-
Wikipidia Actor Model
a quote from Actors with Tokio:
“The basic idea behind an actor is to spawn a self-contained task that performs some job independently of other parts of the program. Typically these actors communicate with the rest of the program through the use of message passing channels. Since each actor runs independently, programs designed using them are naturally parallel.”
- Alice Ryhl
§What is the problem ?
To achieve parallel execution of individual objects within the same program, it is challenging due to the need for various types that are capable of working across threads. The main difficulty lies in the fact that as you introduce thread-related types, you can quickly lose sight of the main program idea as the focus shifts to managing thread-related concerns.
It involves using constructs like threads, locks, channels, and other synchronization primitives. These additional types and mechanisms introduce complexity and can obscure the core logic of the program.
Moreover, existing libraries like actix
, axiom
,
designed to simplify working within the Actor Model,
often employ specific concepts, vocabulary, traits and types that may
be unfamiliar to users who are less experienced with
asynchronous programming and futures.
§Solution
The actor
macro - when applied to the
implementation block of a given “MyActor” object,
generates additional Struct types
that enable communication between threads.
A notable outcome of applying this macro is the
creation of the MyActorLive
struct (“ActorName” + “Live”),
which acts as an interface/handle to the MyActor
object.
MyActorLive
retains the exact same public method signatures
as MyActor
, allowing users to interact with the actor as if
they were directly working with the original object.
§Examples
Filename: Cargo.toml
[dependencies]
interthread = "2.0.2"
oneshot = "0.1.6"
Filename: main.rs
pub struct MyActor {
value: i8,
}
#[interthread::actor]
impl MyActor {
pub fn new( v: i8 ) -> Self {
Self { value: v }
}
pub fn increment(&mut self) {
self.value += 1;
}
pub fn add_number(&mut self, num: i8) -> i8 {
self.value += num;
self.value
}
pub fn get_value(&self) -> i8 {
self.value
}
}
fn main() {
let actor = MyActorLive::new(5);
let mut actor_a = actor.clone();
let mut actor_b = actor.clone();
let handle_a = std::thread::spawn( move || {
actor_a.increment();
});
let handle_b = std::thread::spawn( move || {
actor_b.add_number(5);
});
let _ = handle_a.join();
let _ = handle_b.join();
assert_eq!(actor.get_value(), 11)
}
An essential point to highlight is that when invoking
MyActorLive::new
, not only does it return an instance
of MyActorLive
, but it also spawns a new thread that
contains an instance of MyActor
in it.
This introduces parallelism to the program.
The code generated by the actor
takes
care of the underlying message routing and synchronization,
allowing developers to rapidly prototype their application’s
core functionality. This fast sketching capability is
particularly useful when exploring different design options,
experimenting with concurrency models, or implementing
proof-of-concept systems. Not to mention, the cases where
the importance of the program lies in the result of its work
rather than its execution.
§SDPL Framework
The code generated by the actor
macro
can be divided into four more or less important but distinct
parts: script
,direct
,
play
, live
.
This categorization provides an intuitive and memorable way to understand the different aspects of the generated code.
Expanding the above example, uncomment the example
placed above the main
function, go to examples/inter/main.rs
in your
root directory and find MyActor
along with additional SDPL parts :
§script
Think of script as a message type definition.
The declaration of an ActorName + Script
enum, which is
serving as a collection of variants that represent
different messages that may be sent across threads through a
channel.
Each variant corresponds to a struct with fields that capture the input and/or output parameters of the respective public methods of the Actor.
pub enum MyActorScript {
Increment {},
AddNumber { num: i8, inter_send: oneshot::Sender<i8> },
GetValue { inter_send: oneshot::Sender<i8> },
}
Note: Method
new
not included as a variant in thescript
.
§direct
The implementation block of script
struct, specifically
the direct
method which allows
for direct invocation of the Actor’s methods by mapping
the enum variants to the corresponding function calls.
impl MyActorScript {
pub fn direct(self, actor: &mut MyActor) {
match self {
MyActorScript::Increment {} => {
actor.increment();
}
MyActorScript::AddNumber { num, inter_send } => {
inter_send
.send(actor.add_number(num))
.unwrap_or_else(|_error| {
core::panic!(
"'MyActorScript::AddNumber.direct'. Sending on a closed channel."
)
});
}
MyActorScript::GetValue { inter_send } => {
inter_send
.send(actor.get_value())
.unwrap_or_else(|_error| {
core::panic!(
"'MyActorScript::GetValue.direct'. Sending on a closed channel."
)
});
}
}
}
}
§play
The implementation block of script
struct, specifically
the play
associated (static) method responsible for
continuously receiving script
variants from
a dedicated channel and direct
ing them.
Also this function serves as the home for the Actor itself.
impl MyActorScript {
pub fn play(receiver: std::sync::mpsc::Receiver<MyActorScript>,
mut actor: MyActor) {
while let std::result::Result::Ok(msg) = receiver.recv() {
msg.direct(&mut actor);
}
eprintln!("MyActor the end ...");
}
}
When using the edit
argument in the actor
macro, such as
#[interthread::actor(edit(script(imp(play))))]
it allows for manual implementation of the play
part, which
gives the flexibility to customize and modify
the behavior of the play
to suit any requared logic.
In addition the Debug trait is implemented for the script
struct.
impl std::fmt::Debug for MyActorScript {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MyActorScript::Increment { .. } => write!(f, "MyActorScript::Increment"),
MyActorScript::AddNumber { .. } => write!(f, "MyActorScript::AddNumber"),
MyActorScript::GetValue { .. } => write!(f, "MyActorScript::GetValue"),
}
}
}
§live
A struct ActorName + Live
, which serves as an interface/handler
replicating the public method signatures of the original Actor.
Invoking a method on a live instance, it’s triggering the eventual invocation of the corresponding method within the Actor.
The special method of live
method new
- declares a new channel
- initiates an instace of the Actor
- spawns the
play
component in a separate thread - returns an instance of
Self
#[derive(Clone)]
pub struct MyActorLive {
sender: std::sync::mpsc::Sender<MyActorScript>,
}
impl MyActorLive {
pub fn new(v: i8) -> Self {
let actor = MyActor::new(v);
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || { MyActorScript::play(receiver, actor) });
Self { sender }
}
pub fn increment(&mut self) {
let msg = MyActorScript::Increment {};
let _ = self
.sender
.send(msg)
.expect("'MyActorLive::method.send'. Channel is closed!");
}
pub fn add_number(&mut self, num: i8) -> i8 {
let (inter_send, inter_recv) = oneshot::channel();
let msg = MyActorScript::AddNumber {
num,
inter_send,
};
let _ = self
.sender
.send(msg)
.expect("'MyActorLive::method.send'. Channel is closed!");
inter_recv
.recv()
.unwrap_or_else(|_error| {
core::panic!("'MyActor::add_number' from inter_recv. Channel is closed!")
})
}
pub fn get_value(&self) -> i8 {
let (inter_send, inter_recv) = oneshot::channel();
let msg = MyActorScript::GetValue {
inter_send,
};
let _ = self
.sender
.send(msg)
.expect("'MyActorLive::method.send'. Channel is closed!");
inter_recv
.recv()
.unwrap_or_else(|_error| {
core::panic!("'MyActor::get_value' from inter_recv. Channel is closed!")
})
}
}
The methods of live
type have same method signature
as Actor’s own methods
- declare a
oneshot
channel - declare a
msg
specificscript
variant - send the
msg
vialive
’s channel - receive and return the output if any
§Panics
The model will panic if an attempt is made to send or
receive on the channel after it has been dropped.
Generally, such issues are unlikely to occur, but
if the interact
option is used, it introduces a
potential scenario for encountering this situation.
§Macro Implicit Dependencies
The actor
macro generates code
that utilizes channels for communication. However,
the macro itself does not provide any channel implementations.
Therefore, depending on the libraries used in your project,
you may need to import additional crates.
§Crate Compatibility
lib | oneshot | async_channel |
---|---|---|
std | ✓ | - |
smol | ✓ | ✓ |
tokio | - | - |
async-std | ✓ | - |
Note: The table shows the compatibility of the macro with different libraries, indicating whether the dependencies are needed (✔) or not. The macros will provide helpful messages indicating the necessary crate imports based on your project’s dependencies.
Attribute Macros§
- Evolves a regular object into an actor
- Code transparency and exploration
- A set of actors sharing a single thread