Axum – Ping Pong with Ordered Interaction Points
UPDATE: Removed code and explained that what I had was not intended behavior
After a slight diversion into F# mailbox processing, it’s time to come back to talk a little bit more about Axum. In our last Axum post, we discussed using dataflow networks to clean up our canonical Ping-Pong example. This time, we’ll try another approach using ordered interaction points.
A Little Housekeeping
Before we get started with our full demonstration, let’s take a brief look at some concepts that will come into play for this solution. In our previous example using data flow networks, we a shared-nothing approach between these two agents. This time, let’s introduce the concept of domains. A domain in Axum allows for a group of agents to safely share data while shielding this state from the outside world. Like normal classes, domains can have such things as fields and methods, but also, we can declare agents. We’ll discuss how this comes into play later on in the post.
domain A { private string fieldName; ... }
Another key concept to understand is reader and writer agents. Typically, when dealing with shared state of some sort, we have a notion of a reader-writer lock. This synchronization technique allows access to the shared state as one to many readers or a single writer to ensure consistency of that shared resource. Agents in Axum also follow this notion where a reader agent is only allowed to read shared state and a writer agent can both read and write to domain state.
domain A { private string fieldName; public reader agent A1 { ... } // Can read fieldName public writer agent A2 { ... } // Can read/write fieldName }
One more concept that needs to be covered before our solution is the idea of hosts. By using a host, we are able to associate an agent’s type with a particular instance in the domain. For example when we look at the following code, what we’re doing is when someone asks for our A1 domain with an address of myA1, we attach it to the current domain and then return the associated C channel endpoint.
Host<A1>("myA1");
We can connect to this host then through a mechanism such as the following:
var chan = new C("myA1");
Bringing this all together, let’s look at the Ping-Pong example through these eyes.
Getting to the Solution
In this solution, we’ll introduce the aspect of ordered interaction points. This is an interaction point that acts as both a source and a target and the keyword being ordered which means that the order of messages is preserved. To declare one, use the OrderedInteractionPoint<T> class such as the following:
var ip = new OrderedInteractionPoint<int>();
Now, let’s get to our solution. The main application is no different, but where we will find differences is the interaction between the Ping and Pong agents. As the code for that part hasn’t changed, let’s focus on the interaction between the ping and pong inside our domain. First, let’s define the domain and we’ll call it the PingPongDomain (imaginative, I know). In this domain, we need to set up the hosting for the Pong agent at the “Pong” address as well as the ordered interaction points for the ping and the pong.
public domain PingPongDomain { public PingPongDomain() { Host<Pong>("Pong"); } OrderedInteractionPoint<Signal> ping = new OrderedInteractionPoint<Signal>(); OrderedInteractionPoint<Signal> pong = new OrderedInteractionPoint<Signal>();
Once we have this defined for our domain, our Ping and Pong agents inside this domain will have access to these points. Since the Ping agent will ultimately both read and write messages, but using the immutable data type as the message type, we need not define it as a writer, but instead as a reader. Let’s define that below.
public reader agent Ping : channel PingPongStatus { public Ping() { PrimaryChannel::HowMany ==> Process; } private Signal Process(int iters) { // Create pong var chan = new PingPong("Pong"); // Send pings and receive pongs for (int i = 0; i < iters; i++) { ping <-- Signal.Value; receive(pong); Console.WriteLine("ping received pong"); } chan::Done <-- Signal.Value; return Signal.Value; } }
You’ll notice that our code doesn’t look too much different than our previous example, except for connecting to the PingPongChannel with the “Pong” address that we defined in our domain above. The other change is that we are sending a message on the ping ordered interaction point and receiving a message on the pong instance.
Moving along to our Pong agent, once again, the code does not deviate much. But, as we are reading and writing our domain state by sending messages that are immutable, we could declare ourselves as a reader agent. We will continuously receive messages either on the ping ordered interaction point or we will receive a message on the Done port on the primary channel. The code will look something like the following.
public reader agent Pong : channel PingPong { public Pong() { while (true) receive { from PrimaryChannel::Done: return; from ping: Console.WriteLine("pong received ping"); pong <-- Signal.Value; break; } } }
Now that we have a solution, but, unfortunately due to a bug, this doesn’t work because the compiler cannot distinguish whether the state is immutable or not. But, we can get it to run with an escape hatch.
Being Unsafe in a Safe Way
As I stated before, Axum does have a notion of an escape hatch when it comes to code that allows you to execute code that might modify shared state through the use of the unsafe keyword. Most times it’s better to be safe than sorry in this regard, but in this example, it fits quite well. You can think of this unsafe escape hatch much like the Haskell unsafePerformIO function. This function in Haskell is the back door into the IO monad which allows an IO computation to be performed at any time. And like our unsafe keyword, in order for this to be safe, we should be free of side effects and not dependent upon the environment.
With this in mind, let’s rewrite the example using the unsafe construct. First, let’s look at the Ping agent and how we might make that a little unsafe, but yet fully operational:
public agent Ping : channel PingPongStatus { public Ping() { PrimaryChannel::HowMany ==> Process; } private Signal Process(int iters) { // Create pong var chan = DefaultCommunicationProvider.Connect<PingPong>("Pong"); unsafe { // Send pings and receive pongs for (int i = 0; i < iters; i++) { ping <-- Signal.Value; receive(pong); Console.WriteLine("ping received pong"); } } chan::Done <-- Signal.Value; return Signal.Value; } }
By simply wrapping our for loop in the unsafe block, we are able to send messages to our ping ordered interaction point and receive from our pong ordered interaction point where we weren’t allowed to in the above example. The same applies for our Pong agent as well. We can wrap the while loop receiving messages in an unsafe block so that we can receive from the ping ordered interaction point and then send a signal to the pong ordered interaction point.
public agent Pong : channel PingPong { public Pong() { unsafe { while (true) receive { from PrimaryChannel::Done: return; from ping: Console.WriteLine("pong received ping"); pong <-- Signal.Value; break; } } } }
Running our sample, we get our expected results. You can find the complete source code to this example here.
Conclusion
Unlike Erlang and other shared-nothing messaging approaches, Axum allows for shared domain state through tightly controlled access. By doing so, we keep the safety aspect of messaging through such notions of a reader-writer lock. Of course, there is an escape hatch for those who truly understand their application and the ramifications of what they are doing. As you can see, Axum has a wide array of solving even the most simple and canonical examples of actor model concurrency. There is still more yet to cover including asynchronous behavior and an exploration of the Auction example that I gave in F#.
Download it today, use it in anger and give the team feedback as to help shape what actor model concurrency might look like on the .NET platform.