Introduction to the Actor Model of Computation (by example)

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.

Installation

  • 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

Preparing the environment

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()
Small aside

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)

Fundamentals

An Actor in Python

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()


foo

(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.)

An Actor with state

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()


Start count: 1337
Message:     1
End count:   1338

Start count: 1338
Message:     2
End count:   1340

Start count: 1340
Message:     -10
End count:   1330

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).

Ping-pong

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()


[PING    ] send
[PING    ] finish
[    PONG] send
[    PONG] finish
[PING    ] send
[PING    ] finish
[    PONG] send
[    PONG] finish
[PING    ] send
[PING    ] finish
[    PONG] Done

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 PINGs before the corresponding series of PONGs.

Methods of self

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()


self:    <tartpy.runtime.Actor object at 0x110d1c438>
methods: ['become', 'create', 'send', 'throw']

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)

Changing Behavior (become)

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()


I'm a BLUE actor
I'm a GREEN actor
I'm a BLUE actor

Changing parametrized behaviors

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()


Current is 1
Current is 5
Poof
Fuse is blown
Fuse is blown

Creating actors

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()


Chain 5
Chain 4
Chain 3
Chain 2
Chain 1
go

Actor Idioms

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()


CUSTOMER: hi there

Request/Reply Idiom

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()


CUSTOMER: some data from the server

Forward Idiom

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()


CUSTOMER: hi
CUSTOMER: there

One-shot Idiom

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()


CUSTOMER: are we there yet?

Label and Tag Idioms

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()


CUSTOMER: {'message': 'label me', 'label': 'I was here!'}
CUSTOMER: {'message': 17, 'label': 'I was here!'}
CUSTOMER: {'message': ['some', 'object'], 'label': 'I was here!'}

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()


CUSTOMER: {'tag': <tartpy.runtime.Actor object at 0x1112c0518>, 'message': 'data'}

Interlude: Race Example

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


CUSTOMER: {'id': 2}

Try it again to see if a different service wins the race:


In [54]:
race << {'customer': log}
flush(2)


CUSTOMER: {'id': 7}

Revocable Actor

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()


CUSTOMER: hi
CUSTOMER: there

... 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()


CUSTOMER: hi

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.

Membranes

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]:
<tartpy.runtime.Actor at 0x104d2f940>

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]:
<tartpy.runtime.Actor at 0x1052399b0>

In [22]:
echo is proxy


Out[22]:
False

Now we check that it works. Send a message to proxy:


In [18]:
proxy << {'customer': log}
flush()


echo got: {'customer': <tartpy.runtime.Actor object at 0x10394c438>}
CUSTOMER: {'answer': {'customer': <tartpy.runtime.Actor object at 0x10394c438>}}

... 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]:
<tartpy.runtime.Actor at 0x1052398d0>