We cannot anymore click on the transfer call and have a look at the implementation. Instead, we need to go through the entire pattern match inside handle_message to see where the case for Transfer is handled. If there are several message variants, the actual code dealing with transfers is probably tucked away in an internal function somewhere else
I agree that there are more level of indirections when using an actor model. Below is an example in Erlang from https:// github.com/francescoc/scalabilitywitherlangotp/blob/master/ch3/frequency.erl where the loop method that processes messages calls other methods depending on the message:
loop(Frequencies) ->
receive
{request, Pid, allocate} ->
{NewFrequencies, Reply} = allocate(Frequencies, Pid),
reply(Pid, Reply),
loop(NewFrequencies);
{request, Pid , {deallocate, Freq}} ->
NewFrequencies = deallocate(Frequencies, Freq),
reply(Pid, ok),
loop(NewFrequencies);
{request, Pid, stop} ->
reply(Pid, ok)
end.
%% The Internal Helper Functions used to allocate and
%% deallocate frequencies.
allocate({[], Allocated}, _Pid) ->
{{[], Allocated}, {error, no_frequency}};
allocate({[Freq|Free], Allocated}, Pid) ->
{{Free, [{Freq, Pid}|Allocated]}, {ok, Freq}}.
deallocate({Free, Allocated}, Freq) ->
NewAllocated=lists:keydelete(Freq, 1, Allocated),
{[Freq|Free], NewAllocated}.
But I personally find it very readable. And when using behaviours such as gen_server, I still find the code readable (https:// github.com/francescoc/scalabilitywitherlangotp/blob/master/ch4/frequency.erl):
handle_call({allocate, Pid}, _From, Frequencies) ->
{NewFrequencies, Reply} = allocate(Frequencies, Pid),
{reply, Reply, NewFrequencies}.
handle_cast({deallocate, Freq}, Frequencies) ->
NewFrequencies = deallocate(Frequencies, Freq),
{noreply, NewFrequencies};
handle_cast(stop, LoopData) ->
{stop, normal, LoopData}.
allocate({[], Allocated}, _Pid) ->
{{[], Allocated}, {error, no_frequency}};
allocate({[Res|Resources], Allocated}, Pid) ->
{{Resources, [{Res, Pid}|Allocated]}, {ok, Res}}.
deallocate({Free, Allocated}, Res) ->
NewAllocated = lists:keydelete(Res, 1, Allocated),
{[Res|Free], NewAllocated}.
One of the benefits of this code structure is that tracking and logging the changes of the actor state can be done in a single place (the infinite loop that processes messages), and such a feature is even avalaible for some behaviours defined in the OTP library in Erlang/Elixir.
Note about directly calling send_message:
The Erlang and Elixir communities actually recommend to expose functions that wrap calls of functions such as send_message to abstract the communication protocol for readability and maintainability, see https:// hexdocs.pm/elixir/GenServer.html#module-client-server-apis for an example. Note that, if such an interface function (a)waits for the reply from the actor, the function becomes synchronous and can have a return type.
With actors, we have to read the code to understand what is the flow of data in our system. When reading the code is not enough we have to instrument the actors at runtime to get some insight about what's going on.
I think this depends on the implementation, and I am sure that there are implementations that would make it easier to understand the flow of data in the system. Actors are very similar to objects in OOP (Object Oriented Programming), and there should be a way to pass a callback with the message as it is done in the OOP version.
Disclaimer: I am not an actor expert and I don't know anything about actors in Rust; I just read a couple of books on Erlang such as the excellent book "Designing for Scalability with Erlang/OTP" written by Francesco Cesarini.
I personally think that actors are true microservices in the sense that:
Therefore, an actor system is not always the ideal design. For example, I think that Futures or async/await are much better for data processing pipelines that do not require state management. Or that designing an actor system for bank transfers is not trivial because of the nature of bank transactions that makes the system state hard to split.
Still, I find the actor system a very elegant solution in other cases. For example, in his book "Designing for Scalability with Erlang/OTP", Francesco Cesarini demonstrates how to implement a coffee machine in Erlang, and I find the resulting code, in particular the handling of invalid actions, impressive:
I think that actors are a good option if a large distributed system state can be split into smaller parts. For example, let's consider a system to book a ticket with a seat number at a concert. The system has to store for each seat if a seat is free, being reserved or reserved, and, if it is not free, the person who is reserving or has reserved the seat. We could implement this system as follows:
If the concert venue has lots of seats, and the number of person interested in getting a ticket is high, a single BookingManager actor cannot handle all the requests quickly. In this case, we need multiple BookingManager actors, and a way to share the state of the seats between them. One way to design the system consists in splitting the seats state across multiple SeatsAreaManager actors, each of them being responsible of the seats of a distinct area of the concert venue. The booking process would work as follows:
This change in the design enables to scale the number of BookingManager and the number of SeatsAreaManager (the maximum number of SeatsAreaManager is the number of seats in the concert venue).
If the number of SeatsAreaManager actors is high, BookingManager actors can become slow because they need to query all the SeatsAreaManager to know which seats are free. In this case, we could introduce additional levels of SeatsAreaManager actors who would not store state but contact SeatsAreaManager actors at the level below to gather free seats in their areas. Another option could consist in making BookingManager actors store a cache of the free seats that would be frequently refreshed, for example every 100ms.
I hope that this example makes sense and helps to show the power of actors to scale a system. I am not sure how easy it would be to develop such a system using Futures or async/await because I don't see at the moment how such constructs could help to handle this type of "distributed state".
Note on the "Let it crash" paradigm: The point of the "Let it crash" is that the actor that crashes cannot properly propagate the error to other actors it was in contact with. Instead, other actors should be notified of the crash and will take care of updating their state accordingly. For example, if the Client C crashes, this crash could notify the BookingManager actor B that Client C was in contact with. BookingManager B could then decide to cancel any reservation in progress done by Client C.
At the start, you point out a practical concern for actors:
At the end, you highlight an amazing power for streams:
Aren't these the same thing?
Also, I'm not sure what you mean by "actors have to know about each other" but Akka has adapted response interaction pattern (hashnode says I can't post links).
The actor model has undeniably been a game-changer in building scalable and resilient systems, with platforms like Erlang/OTP and Akka leading the way. Yet, as we delve deeper, it becomes evident that actor systems have their limitations. The intricacies of turning actors "inside-out" to harness their full potential reveal the evolving nature of this model and the need for continued exploration in the realm of system design and development. It's a fascinating journey, uncovering the nuances that drive innovation in the ever-evolving landscape of technology.
Praveen V
Blogger @learnfreeblog.com, Blogger @hashnode, Word press developer, Web Developer, Data Scientist
devtohash.hashnode.dev/frontend-development-pract…