We will use a minimal actor language to illustrate the basic primitives of the actor model of computation and some patterns.
See the introduction slides for an overview of the actor model.
Install Python 3.3+
Install the tartpy
module with:
pip install git+https://github.com/waltermoreira/tartpy
Clone this project:
git clone https://github.com/waltermoreira/actor_model.git
Start an ipython
server:
cd actor_model
ipython notebook
Load the notebook Examples.ipynb
Import the Runtime
class and the behavior decorator:
In [5]:
from tartpy.runtime import Runtime, behavior
The Runtime
class creates a singleton, which is responsible for initially creating the actors.
In [6]:
runtime = Runtime()
The output from the actors is asynchronous. The following is a silly trick to make sure the output appears right after the cell we are evaluating. This is just an artifact of the IPython notebook.
In [7]:
import time
def flush(wait=0.1):
time.sleep(wait)
An actor can be seen as a bundles of behaviors. The behavior is responsible for handling one message, and it designates the behavior for the next message.
In tartpy
, a behavior gets two arguments:
self
: a reference to the actor that is executing this behavior,
message
: the message being passed.
The first example just prints any message it receives:
In [4]:
@behavior
def demo_beh(self, message):
print(message)
A behavior is used to create an actor, by asking the runtime to do it. In theory, only actors can create other actors, but we need to bootstrap our system.
In [5]:
demo_actor = runtime.create(demo_beh)
Now the actor is "running", which means it is ready to receive a message which will activate its behavior:
In [6]:
demo_actor << "foo"
flush()
(remember the flush
function is an artifact just to force the display of any output, it does not correspond to any concept in the actor model.)
In addition to the last two arguments of a behavior (self
and message
), any other arguments parametrizes the behavior.
Mutating these arguments produces state in the behavior (but it will never be shared state, due to the semantics of actors).
In [7]:
@behavior
def counter_beh(state, self, message):
print('Start count:', state['count'])
print('Message: ', message)
state['count'] += message
print('End count: ', state['count'])
print()
Create the actor with an inital state, which is a dictionary (mutable in Python):
In [8]:
counter = runtime.create(counter_beh, {'count': 1337})
And test it with some messages:
In [9]:
counter << 1
counter << 2
counter << -10
flush()
It's important to note that this way to keep state in the actor goes outside of the pure model. The proper way is to use become
(see Changing parametrized behaviors).
A classical example in concurrency: create two entities that send back and forth a message between themselves.
The first actor, ping
, will assume that the message is the address of its companion, pong
, and it will send itself as a message to that address. pong
will do the same, but it will also keep a state to limit the number of iterations.
We add debugging statements to show the asynchrony of message sending.
In [10]:
@behavior
def ping_beh(self, message):
print('[PING ] send')
message << self
print('[PING ] finish')
@behavior
def pong_beh(state, self, message):
if state['count'] == 0: # only play `count` times
print('[ PONG] Done')
return
print('[ PONG] send')
message << self
print('[ PONG] finish')
state['count'] -= 1
ping = runtime.create(ping_beh)
pong = runtime.create(pong_beh, {'count': 2}) # let's play just twice
We start playing by sending the message pong
to ping
:
In [11]:
ping << pong
flush()
Notice that sending a message does not block the execution of the actor. If the execution were synchronous, we would have seen a nested series of PING
s before the corresponding series of PONG
s.
The following actor shows the methods available to itself via the reference self
. We ignore methods starting with _
(underscore) since they are just an implementation detail.
In [12]:
@behavior
def show_self_beh(self, message):
print('self: ', self)
print('methods:', [method for method in dir(self) if not method.startswith('_')])
show_self = runtime.create(show_self_beh)
show_self << None # None is a null message
flush()
In addition to the three primitives of the actor model: send
, create
, and become
, it also includes the method throw
that raises an error to the runtime.
Note that the syntax <<
is just another way of writing send
:
a << m == a.send(m)
The following is an example of an actor that changes behavior.
We first define two different behaviors:
In [13]:
@behavior
def blue_beh(self, message):
print("I'm a BLUE actor")
self.become(green_beh)
@behavior
def green_beh(self, message):
print("I'm a GREEN actor")
self.become(blue_beh)
We create an actor whose initial behavior is blue_beh
. The actor can be considered as the bundle of these two behaviors.
In [14]:
color = runtime.create(blue_beh)
color << None
color << None
color << None
flush()
Parametrized behaviors are the mechanism to keep state in the pure actor model (see also An Actor with state).
In [15]:
@behavior
def fuse_beh(state, self, message):
if state == 'off':
print('Fuse is blown')
return
if message > 10: # if more than 10 amps, fuse blows
print('Poof')
self.become(fuse_beh, 'off')
else:
print('Current is', message)
fuse = runtime.create(fuse_beh, 'on')
fuse << 1
fuse << 5
fuse << 11
fuse << 12
fuse << 3
flush()
The next example is just to illustrate the remaining primitive: create
. It creates a chain of actors who propagate the initial message.
In [17]:
@behavior
def chain_beh(count, self, message):
if count > 0:
print('Chain', count)
next = self.create(chain_beh, count-1)
next << message
else:
print(message)
chain = runtime.create(chain_beh, 5)
chain << 'go'
flush()
This section shows a set of common patterns in distributed systems expressed with actors.
Throughout the examples we are going to use an actor that just displays any message it receives:
In [14]:
@behavior
def log_beh(self, message):
print('CUSTOMER:', message)
log = runtime.create(log_beh)
Let's test it:
In [15]:
log << 'hi there'
flush()
This is one of the most common patterns: the client/server architecture. It is so ingrained in our thinking that it's usually considered a primitive.
Notice that with actors, the client that receives a reply from a server (usually called customer) does not need to be the same as the one that sends the message. The server cannot tell the identity of the sender from the customer.
In [25]:
@behavior
def service_beh(self, message):
customer = message['customer']
customer << 'some data from the server'
service = runtime.create(service_beh)
Send a message to the service and ask it to reply to log
as customer:
In [26]:
service << {'customer': log}
flush()
This is an actor that forwards any message it receives to a given subject:
In [27]:
@behavior
def forward_beh(subject, self, message):
subject << message
# let's create a forwarder for `log`
forward = runtime.create(forward_beh, log)
forward << 'hi'
forward << 'there'
flush()
This actor forwards only one message, and then ignores any subsequent one:
In [28]:
@behavior
def one_shot_beh(subject, self, message):
subject << message
self.become(ignore_beh)
@behavior
def ignore_beh(self, message):
pass
one_shot = runtime.create(one_shot_beh, log)
one_shot << 'are we there yet?'
one_shot << 'are we there yet?'
one_shot << 'are we there yet?'
flush()
These actors are just forwarders that add extra information to the messages.
The label actor adds an arbitrary label to the message:
In [34]:
@behavior
def label_beh(subject, label, self, message):
subject << {'label': label,
'message': message}
label = runtime.create(label_beh, log, 'I was here!')
label << 'label me'
label << 17
label << ['some', 'object']
flush()
A important particular case of the label idiom is to add a secure token to a message. Using the fact that actor addresses are unique and unforgeable, we could do:
In [39]:
@behavior
def tag_beh(subject, self, message):
subject << {'tag': self,
'message': message}
tag = runtime.create(tag_beh, log)
tag << 'data'
flush()
This example creates several servers, responding at random times to a customer, and we want to process only the fastest response.
The race
actor sends a message to all the servers, and makes sure to deliver only the first response to its customer:
In [40]:
@behavior
def race_beh(services, self, message):
customer = message['customer']
one_shot = self.create(one_shot_beh, customer)
message['customer'] = one_shot
for service in services:
service << message
The random_service
actor waits a random time between 0 and 10 seconds, before sending a reply to the customer:
In [41]:
from random import random
from tartpy.eventloop import EventLoop
@behavior
def random_service_beh(uid, self, message):
customer = message['customer']
EventLoop().later(random()*10,
lambda: customer << {'id': uid})
Finally, we create 10 random services and we let race
send its messages:
In [52]:
services = [runtime.create(random_service_beh, i) for i in range(10)]
race = runtime.create(race_beh, services)
race << {'customer': log}
flush(2) # wait sometime to give time to deliver all messages
Try it again to see if a different service wins the race:
In [54]:
race << {'customer': log}
flush(2)
We use the unforgeability of the actor addresses to create a proxy to an actor, that can be securely revoked only by an authorized party. The address of the proxy can then be distributed publicly. This is an example of a capability.
In [55]:
@behavior
def revocable_beh(actor, self, message):
if message is actor:
self.become(ignore_beh)
else:
actor << message
log_proxy = runtime.create(revocable_beh, log)
log_proxy
behaves exactly as log
:
In [56]:
log_proxy << 'hi'
log_proxy << 'there'
flush()
... but the creater, who is the only one in possesion of the reference to log
, can revoke log_proxy
:
In [57]:
log_proxy << log
Now log_proxy
is revoked and it will ignore all messages:
In [58]:
log_proxy << 'hi'
flush()
Note that log
itself is still alive and well:
In [59]:
log << 'hi'
flush()
Warning: the security of this scheme is dependent on the implementation. For example, in Python, the reference to log
can be recovered from log_proxy
if we are in the local domain.
A membrane is an actor that can transparently create proxies, in order control the access to a given set of actors. The main feature is that it can revoke the proxies at any time, effectively and securely severing the relation between the original actors.
We create a membrane
actor by instantiating a factory, which is just an easy way to keep the state of the actor:
In [2]:
from tartpy.membrane import MembraneFactory
membrane_instance = MembraneFactory()
membrane = runtime.create(membrane_instance.membrane_beh)
The echo
actor shows the message it receives and replies to the customer with the same message:
In [9]:
@behavior
def echo_beh(self, message):
print('echo got:', message)
message['customer'] << {'answer': message}
echo = runtime.create(echo_beh)
Notice the identity of the echo
actor, that we will compare later to the proxy:
In [10]:
echo
Out[10]:
Here we use a magic actor wait
that allow us to receive a message outside of the actor world. It is done so we can inspect interactively the result. Let's call proxy
the created proxy for echo
.
In [11]:
from tartpy.tools import Wait
w = Wait()
wait = runtime.create(w.wait_beh)
membrane << {'tag': 'create_proxy',
'actor': echo,
'customer': wait}
proxy = w.join()
If we inspect the identity of proxy
, and we check that it is in fact different from echo
:
In [21]:
proxy
Out[21]:
In [22]:
echo is proxy
Out[22]:
Now we check that it works. Send a message to proxy
:
In [18]:
proxy << {'customer': log}
flush()
... and notice that it goes to the echo
actor. Notice that what echo
gets as a customer is a proxy for log
. This can be checked by looking at the identity of log
:
In [19]:
log
Out[19]: