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