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