More beginner friendly tutorial?
veniamin-ilmer opened this issue · 6 comments
I have never used an "Actor Model" before, and so I am new to this form of design...
I have been designing something in Rust, but I keep on hitting up against the Rust borrow checker.
Your crate looks promising, but confusing, so I hope maybe I can use what you have built.
I am sorry, I don't know a better way to reach you other than via this "Issues" section. I hope this is okay.
I have been building an emulator. The emulator consists of multiple emulated chips which need to communicate with each-other.
Ordinarily emulators' components are written in a tightly coupled form, where one chip would have a direct reference to another chip. However, I want to retain the ability for each chip to work independently of the others.
When it wants to interact with another chip, it should send a message to the motherboard. And the motherboard should route the message to the appropriate location.
The benefit of this design is the modularity / flexibility, being able to simulate a real computer. For example, the motherboard manufacturers might have chosen to leave some of the chip's pins disconnected. The chip is unaware of the lacking pin connections, and it will attempt to send out a message. The motherboard would take care of this lacking routing. The chip could continue to work as it was intended, regardless of how everything else connects to it. I would like to build this emulation.
At first, I built this emulator with all chips in separate threads using channel message passing.
This worked up to a point. The problem is that these emulated chips run at a fairly high speed with only 200 nanoseconds to spare.
After some testing, I realized that multithreading is not the solution. Simply sending a message and blocking on a reply took over 500 nanoseconds.
I realized I need to rewrite the emulator to run single threaded without channels. However, pretty quickly I ran into issues with the borrow checker. With all of the chips referencing the motherboard, and the motherboard referencing all of the chips back, I kept running into circular reference problems. No matter what I tried, the borrow checker kept being angry at me.
Finally, I found out about RefCell... which I want to avoid... It would be better if all references are checked at compile time.
That is how I discovered your stakker
and qcell
crates.
Considering that I am looking for all of these chips to communicate with each other through messages and be run at specific intervals, your stakker
crate seems like a good fit for my use-case. However, I am quite confused how to use it. It has multiple functions and macros which I am not used to.
Reading though your guide, I see that all messages are FnOnce
.. I am especially confused about this.
Currently I have a large set of Enums designating each message. Each chip has the ability to process the message by matching on the enum values and doing a certain behavior.
For example, the CPU chip can have an enum message Msg::GetMemoryByte(addr, &mut byte)
This message gets routed by the motherboard to the Memory chip. The memory chip reads the enum and sets the mutable byte.
When the memory chip is done, the CPU chip gets another enum message that the byte has been set.
Another example: the Timer chip has finished ticking and now wants to alert that time has elapsed. It sends out a message Msg::TimerElapsed{timer_id: 2}
. The motherboard sees this message. Ordinarily, it would route it to the CPU, however the manufacturers decided to only connect timer_id 1 to the CPU. timer_id 2 is left disconnected. So, the message will be dropped.
This is how the chips communicate, independent of each other.
Reading through your guide, I feel as if my enum solution is somewhat inefficient, and your FnOnce solution should be much better. However I continue to be quite confused, how to use it. Would you be able to tell me how my above examples could be reformatted to use FnOnce instead?
Thank you for your time.
After some reflection, I realized a way you could use FnOnce
as a message.
In the timer example, the timer chip could have an FnOnce which calls a motherboard's function TimerElapsed(timer_id)
. Doing it this indeed sounds more efficient because you can avoid matching on an enum message.
However I recognize a problem with this design: It requires the motherboard's functions to be defined consistently.
Just like I want the chips to be replaceable, I want the Motherboard to be replaceable too. Not all messages need to be read. Some motherboards will route the timer chip. Some won't. I can't think of a way to handle this situation with FnOnce. I might be forced to revert to using enum.
The approach used by Stakker is instead of having enums to express the messages, the messages are actually just methods on the actor structure. So instead of having Msg::GetMemoryByte, you'd have fn get_memory_byte(&mut self, cx: CX![], addr: usize, ret: Ret<u8>) {...}
. Then you could call it with call!([motherboard], get_memory_byte(0x100, ret_some_to!(...)));
. The ret_some_to!
macro (or other ret_*
macro) expresses what should happen when you get a response back (since it is asynchronous).
I think it is okay to hard-code the method names, since you are also hard-coding the enum names -- so it is about the same. If you need to switch out the Motherboard, you could do that with compile-time #[cfg(...)]
switches, e.g. based on features.
However if you really don't want everything fixed at compile-time (e.g. if you want to switch between different motherboards at runtime rather than compile-time), then you could do it all with Fwd
references. Alternatively you could use traits instead. There is a page on using traits or Fwd for this in the guide.
The business with FnOnce is that all messages are handled with FnOnce internally, but in user code, it looks like function calls on the destination object (although with slightly different syntax and wrapped in a macro like call!
or whatever). So the macro and the methods it calls take care of creating the FnOnce. However if you're using Fwd
, you have complete flexibility to point calls at anything you like. For example the motherboard could give some other chip a direct link to the timer chip, e.g. the Fwd
doesn't need to send the message via the motherboard at all. And alternatively when the timer chip is not implemented, that could be a fwd_nop!
.
Let me know if this makes sense. I'm happy to answer more questions.
Thank you for your response. After much thought, and testing with your examples, I am going ahead and rebuilding my emulator to use your framework.
Your functions/macros seem to magically make the Rust finally do what I have been trying to get it to do for the past week.
I will continue to treat the macros as magical and will try to expand and use them to the best of my ability. I will let you know if anything comes up.
Thank you.
Do you have any suggestions how to best declare actors which have a circular reference?
For example, the Motherboard saves a reference to all of the chips, but all of the chips also have a reference to the motherboard. If I declare all of the chips first, then I might have to have an Optional field in all of the chips for the motherboard originally set to None
. After motherboard gets declared, then all chips would need to be change their reference to Some(motherboard)
. Please let me know if there is a better way for me to handle cyclical references within the Actors.
This can be done using actor_new!
. It is designed exactly for this situation. So you create all your actors using actor_new!
, which gives you actor references, then you initialise all your actors with explicit calls to their init
methods, passing in cloned references to the other actors (or Fwd instances referring to the other actors, or whatever). See the docs for actor_new!
.
I'm just about to travel away for a couple of days. I'll be back online on Sunday if you have any more questions.
Thank you very much, and please don't feel obliged to answer my questions quickly!