µFork Tutorial

A pure actor-based concurrent machine architecture

with memory-safety and object-capability security

Off To The Races

Actors are ideal for coordinating asynchronous activity. A good example of that is arranging a "race" among multiple services. The idea is you make a request to multiple services and the first one to reply wins the race. All other replies are ignored.

Each request to a service actor includes a "reply-to" address called the customer actor. In a race, we provide a "once" customer that forwards one message (to the real customer) and ignores any subsequent messages.

once_beh:                   ; rcvr <- msg
    push #?                 ; data=#?
    push sink_beh           ; data code=sink_beh
    actor become            ; --
fwd_beh:                    ; rcvr <- msg
    msg 0                   ; msg
    state 0                 ; msg rcvr
    actor send              ; --
sink_beh:                   ; _ <- _
    end commit

By labeling multiple entry-points and sharing common tail-sequences of instructions, we define three useful actor behaviors. The sink_beh ignores/discards all messages. The fwd_beh forwards messages to another actor (not used in this example). And the once_beh forwards exactly one message.

The receiver of the forwarded message is provided as the private data/state of the actor at creation. The actor primitive become is similar to create but provides new code and data for the current actor rather than creating a new actor. This is important because become is the only mutation mechanism available in uFork. Note that actor primitives like become and send do not take effect until and unless the actor executes end commit.

Iteration

In Imperative Programming iteration over a collection is usually expressed as some kind of loop. In Functional Programming iteration can be expressed by recursion. In Actor-Based Programming iteration can be expressed through asynchonous messaging.

We will need a broadcast actor to send a request to each participant in the race. The broadcast actor sends the same message to each actor in a nil-terminated pair-list. We use nil-termination because we don't know how many actors there will be.

broadcast_beh:              ; value <- actors
    msg 0                   ; actors
    typeq #pair_t           ; is_pair(actors)
    if_not broadcast_done   ; --
    msg 0                   ; actors
    part 1                  ; rest first
    state 0                 ; rest first value
    roll 2                  ; rest value first
    actor send              ; rest
    actor self              ; rest SELF
    actor send              ; --
broadcast_done:
    end commit

If the message is not a pair, the broadcast is done. Otherwise we split the pair and send the value to the first actor. We send the rest of the list to ourself, thus iterating over the list.

Delta Service

A service is an actor that responds to each cust,args message with exactly one reply to the cust actor. For the puposes of our race, we will create several instances of a "delta" service.

delta_beh:                  ; delta <- cust,num
    msg -1                  ; num
    state 0                 ; num delta
    alu add                 ; reply=num+delta
    msg 1                   ; reply cust
    actor send              ; --
    end commit

When a cust,num message arrives, the delta service sends num+delta to cust.

Delay Proxy

There is no point in running a race if the winner is decided deterministically. Instead, we will introduce random delays to simulate variable network/processing time. The first step is to request a random number between min and min+range-1.

delay_beh:                  ; rcvr,min,range,random,timer <- msg
    state 3                 ; range
    state 0                 ; range cfg
    msg 0                   ; range cfg msg
    pair 1                  ; range data=msg,cfg
    push k_delay_beh        ; range data code=k_delay_beh
    actor create            ; range k_delay=k_delay_beh.data
    pair 1                  ; k_delay,range
    state 4                 ; k_delay,range random
    actor send              ; --
    end commit

A delay actor serves as a proxy with the same API as the service. It has more private state than our previous examples. It needs capabilities for both timer and random devices. Configuration parameters min and range specify the desired delay in milliseconds. And finally, rcvr is the service to eventually invoke.

The delay actor introduces a new asynchronous coordination challenge. We need a number from the random device before we can do anything else, but the device is accessed via actor messages. Our solution is to create a continuation customer k_delay with all the information needed to perform the rest of the computation. Since all data-structures are immutable, we can safely share the configuration data while adding the msg to the front.

k_delay_beh:                ; msg,rcvr,min,range,random,timer <- num
    state 1                 ; msg
    state 2                 ; msg rcvr
    state 3                 ; msg rcvr min
    msg 0                   ; msg rcvr min num
    alu add                 ; msg rcvr delay=min+num
    pair 2                  ; delay,rcvr,msg
    state -5                ; delay,rcvr,msg timer
    actor send              ; --
    end commit

When the random device provides num, we compose the request for the timer device, including delay=min+num. Note that the timer is the 5th tail of the state pair-list, not the 6th element. After the requested delay, the timer device will send the original msg to the rcvr actor.

Boot Behavior

With these definitions in place, we can now put all the pieces together and demonstrate a race among services.

boot:                       ; _ <- {caps}
    msg 0                   ; {caps}
    push dev.timer_key      ; {caps} timer_key
    dict get                ; timer_dev
    msg 0                   ; timer_dev {caps}
    push dev.random_key     ; timer_dev {caps} random_key
    dict get                ; timer_dev random_dev
    push 100                ; timer_dev random_dev range=100ms
    push 20                 ; timer_dev random_dev range min=20ms
    pair 3                  ; cfg=min,range,random,timer

We start by obtaining the device capabilities the services will need, and define the configuration parameters they will share.

    dup 1                   ; cfg cfg
    push 1                  ; cfg cfg delta=1
    push delta_beh          ; cfg cfg delta code=delta_beh
    actor create            ; cfg cfg rcvr=delta_beh.1
    pair 1                  ; cfg data=rcvr,cfg
    push delay_beh          ; cfg data code=delay_beh
    actor create            ; cfg delay_inc

The first service uses a delta value of +1. We wrap the service with a delay-proxy and call it delay_inc.

    pick 2                  ; cfg delay_inc cfg
    push 0                  ; ... cfg delta=0
    push delta_beh          ; ... cfg delta code=delta_beh
    actor create            ; ... cfg rcvr=delta_beh.0
    pair 1                  ; ... data=rcvr,cfg
    push delay_beh          ; ... data code=delay_beh
    actor create            ; ... delay_zero

The second service uses a delta value of 0. We wrap the service with a delay-proxy and call it delay_zero.

    roll 3                  ; delay_inc delay_zero cfg
    push -1                 ; ... cfg delta=-1
    push delta_beh          ; ... cfg delta code=delta_beh
    actor create            ; ... cfg rcvr=delta_beh.-1
    pair 1                  ; ... data=rcvr,cfg
    push delay_beh          ; ... data code=delay_beh
    actor create            ; ... delay_dec

The third service uses a delta value of -1. We wrap the service with a delay-proxy and call it delay_dec.

    push #nil               ; delay_inc delay_zero delay_dec #nil
    roll -4                 ; #nil delay_inc delay_zero delay_dec
    pair 3                  ; list=delay_dec,delay_zero,delay_inc,#nil

Our broadcast actor wants a nil-terminated list of actors, so we roll a #nil into place and build the list.

    push 5                  ; list num=5
    msg 0                   ; list num {caps}
    push dev.debug_key      ; list num {caps} debug_key
    dict get                ; list num debug_dev
    push once_beh           ; list num debug_dev once_beh
    actor create            ; list num cust=once_beh.debug_dev
    pair 1                  ; list msg=cust,num
    push broadcast_beh      ; list msg broadcast_beh
    actor create            ; list broadcast_beh.msg
    actor send              ; ---
    end commit

The service request we broadcast has a num value of 5 and a cust that is a forward-once to the debug device. Each time we run this example one value will appear on the console, either 4, 5, or 6 depending on which delta-service replies first.

Open in Playground

.import
    dev: "https://ufork.org/lib/dev.asm"

once_beh:                   ; rcvr <- msg
    ...

broadcast_beh:              ; value <- actors
    ...

delta_beh:                  ; delta <- cust,num
    ...


delay_beh:                  ; rcvr,min,range,random,timer <- msg
    ...

boot:                       ; _ <- {caps}
    ...

.export
    boot

Prev: Introduction