Let's Get Cooking!
In this example, we are going to look at a program which simulates cooking an omelette:
# first declare our resources
onion = Ingredient("onion")
pepper = Ingredient("pepper")
eggs = Ingredient("egg", 3)
cheese = Ingredient("cheese")
knife = Utensil("knife")
whisk = Utensil("whisk")
grater = Utensil("grater")
pan = Cookware("pan")
# ...our recipe
omelette = Recipe("omelette", {
"onion": "diced",
"pepper": "chopped",
"egg": "beaten",
"cheese": "grated"
})
# ...then our steps
knife.dice(onion)
knife.chop(pepper)
whisk.beat(eggs)
grater.grate(cheese)
pan.cook(omelette, (onion, pepper,
eggs, cheese))
With only one cook, there is no problem. However, if we introduce a second cook into the
kitchen,
then we start having potential problems. In programming terms, if we make this
single-threaded
program concurrent, then the resources become shared, and we have to handle contention.
After
all, both cooks cannot use the knife at the same time! We also have to handle ordering of
operations, to ensure the ingredients have been prepped before they are used. Let's
reframe this program using this table below:
|
Onion |
Pepper |
Eggs |
Cheese |
Knife |
Whisk |
Grater |
Pan |
| Dice Onion |
|
|
|
|
|
|
|
|
| Chop Pepper |
|
|
|
|
|
|
|
|
| Beat Eggs |
|
|
|
|
|
|
|
|
| Grate cheese |
|
|
|
|
|
|
|
|
| Cook Omelette |
|
|
|
|
|
|
|
|
Ideally, we want our cooks to work in parallel, so as to reduce time to breakfast,
something
like
this:
| Cook 1 |
Dice Onion |
Grate Cheese |
|
| Cook 2 |
Beat Eggs |
Chop Pepper |
Cook Omelette |
If we were to code this using traditional threads and locks, we may write it like this:
def cook1():
with onion.lock:
with knife.lock:
knife.dice(onion)
with cheese.lock:
with grater.lock:
grater.grate(cheese)
def wait_until_ready(ingredient: Ingredient,
state: str):
with ingredient.lock:
if ingredient.state == state:
return
with ingredient.condition:
while ingredient.state != state:
ingredient.condition.wait()
def cook2():
with eggs.lock:
with whisk.lock:
whisk.beat(eggs)
with pepper.lock:
with knife.lock:
knife.chop(pepper)
wait_until_ready(onion, "diced")
wait_until_ready(cheese, "grated")
with onion.lock:
with pepper.lock:
with eggs.lock:
with cheese.lock:
with pan.lock:
pan.cook(omelette,
(onion,
pepper,
eggs,
cheese))
There is a lot going on all of a sudden! First and foremost we see the need to explicitly
split
up
tasks between worker threads. There are ways to do this automatically of course
(e.g., producer/consumer queues) but for the sake of simplification we have
divvied up the work optimally between the two cooks. Secondly, we have the need to acquire
and
release locks. This is fairly straightforward for cook 1, but because cook 2 has to cook the
omelette they have the additional task of waiting until the other ingredients have been
prepared
by cook 1. This requires an additional Condition variable on each ingredient to
allow
cook 1 to be notified of changes to the ingredient's state. This example is presented in
full
working
form on Github.
It all works, but we believe there is a simpler and more intuitive way to code this program
cowns and behaviors
cowns
A concurrent-owned variable (cown) ensures that only one thread of execution can
access
its contents at a time. In particular, this is enforced at the level of Python's GIL,
meaning a
cown can only be accessed by one interpreter at a time. However, the reference to a cown can
be
shared between multiple processes. Under the hood, cowns use
Python's
cross-interpreter
data API to allow data to safely move across interpreter boundaries. Take the
scenario
below, for example, in which you have a cown that is referenced by two different
interpreters.
At this stage, the cown contains the data in cross-interpreter data, or XIData,
form.
.
When Interpreter A calls acquire, the cown changes ownership and its
contents
are used to create a new object which can be manipulated normally.
If Interpreter B attempts to acquire the cown while it is owned by Interpreter A, this will
result
in an exception being thrown. Only one interpreter is allowed temporal ownership of the
cown.
Interpreter A changes the data in the cown and then releases it.
Now that Interpreter A has released it, the cown is ready to be acquired again.
Behaviors
A behavior is a block of code which requires zero or more cowns to be acquired before it can
be
run. In Python using the boc library, you define this using the
@when
decorator:
@when(knife, onion)
def dice_onion(knife: Cown, onion: Cown):
knife.value.dice(onion.value)
Note a couple of things here. First, we have the decorator, which lists the cowns that the
upon which the behavior depends. Next, we have a normal function declaration, which has
the same number of arguments as were passed to the decorator. Finally, note how we need to
access the value inside the cown using the value attribute. Once declared, the
behavior is automatically scheduled to be run on the next available worker once its cowns
are available. Behaviors have some other interesting properties. One thing you can do is
have
a behavior which spawns another behavior:
@when(knife, onion)
def dice_onion(knife: Cown, onion: Cown):
knife.value.dice(onion.value)
@when()
def _():
print("The onion is diced")
Here we have a behavior which spawns another behavior to report on the status of the onion
in a way that doesn't block the execution of the main behavior. Since this report does not
need to read or modify the state of the cowns, it can be scheduled without any dependencies
and will run once there is a free worker, but after the currently acquired cowns have been
released. What if a behavior throws an exception?
@when(knife, onion)
def grate_onion(knife: Cown, onion: Cown):
knife.value.grate(onion.value)
@when(grate_onion)
def _(result):
if isinstance(result.value, Exception):
print("Exception while grating onion:",
result.value)
result.value = None
Here we show how we can schedule work over the result of a behavior. Here we use it to
handle
an exception, but we could also use it to get the result of a calculation and use it to
schedule additional work (in combination with the results of other calculations). We will
see
this mechanism in use as we look at the cooking example rewritten to use cowns and
behaviors.
Cooking with BOC
Now that we've explained the concepts behind cowns and behaviors, let's see how we put them
together
to rewrite the cooking example:
# set up our resources, this time as cowns
onion = Cown(Ingredient("onion"))
pepper = Cown(Ingredient("pepper"))
eggs = Cown(Ingredient("egg", 3))
cheese = Cown(Ingredient("cheese"))
knife = Cown(Utensil("knife"))
whisk = Cown(Utensil("whisk"))
grater = Cown(Utensil("grater"))
pan = Cown(Cookware("pan"))
# ...our recipe is immutable, thus
# no need for coordination
omelette = Recipe("omelette", {
"onion": "diced",
"pepper": "chopped",
"egg": "beaten",
"cheese": "grated"
})
# ...declare each step as a behavior
@when(knife, onion)
def dice_onion(knife, onion):
knife.value.dice(onion.value)
@when(knife, pepper)
def chop_pepper(knife, pepper):
knife.value.chop(pepper.value)
@when(whisk, eggs)
def beat_eggs(whisk, eggs):
whisk.value.beat(eggs.value)
@when(grater, cheese)
def grate_cheese(grater, cheese):
grater.value.grate(cheese.value)
@when(onion, pepper, eggs, cheese, pan)
def cook_omelette(onion, pepper,
eggs, cheese, pan):
pan.value.cook(omelette, (onion.value,
pepper.value,
eggs.value,
cheese.value))
# ...then wait for all behaviors to complete
wait()
First, we can see that the program becomes much simpler to express: no locks.
Instead, each resource is wrapped in a cown. Note that the recipe does not
need to be wrapped in a Cown. As it is immutable, it can be safely used and
does not require coordination. The second change is that every
action we need to take that depends on resources explicitly declares which
resources it needs (using the @when decorator). Furthermore,
the ordering of the behaviors as they are declared determines the scheduling
of the behaviors. In a situation like this, it means that all the ingredient
behaviors will execute before cook_omelette. The result is a
clean program that is easy to read and reason about. You can view the full
example
on Github.
In fact, you can view many more examples of what you can do with BOC,
in the examples section of our
Github repository
Lock-free Messaging
One of the enabling technologies for BOC is Erlang-style send and
selective receive. These functions are implemented under the hood
using a fast, non-blocking concurrent queue implementation in C. We
expose them to end users for general use as they provide a convenient
means of communication across threads and subinterpreters.
The send function is always non-blocking, and involves tag,
which acts like a mailbox where messages will be sent, and contents,
which can be any object that can safely be sent between interpreters using
the cross-interpreter API (with a pickle fallback). Here is an
example of a send message:
send("calculator", ("+", 5))
Each of these messages can be received by a receive command.
What may be different if you have not encountered selective
receive before is that the receive command can specify which
tags it is willing to receive, for example:
match receive("calculator"):
case [_, ("+", x)]:
num_operations += 1
value += x
case [_, ("-", x)]:
num_operations += 1
value -= x
case [_, ("*", x)]:
num_operations += 1
value *= x
case [_, ("/", x)]:
num_operations += 1
value /= x
The match statement provides a particular well-suited way
to handle the results of a call to receive, as you can see here.
In addition to a simple blocking call, receive also accepts a
timeout, in which case a message will be returned with a special tag, for
example:
def after():
return "calculator", ("print", True)
while running:
match receive("calculator", timeout, after):
# other cases...
case [_, ("print", timeout)]:
if timeout:
print("Timed out")
print("Total operations:", num_operations)
print("Final value:", value)
running = False
The after callback gives you the ability to specify what the return
value of receive should be when it times out.
If you want to learn more about how send and receive
can be used, please take a look at the
calculator example.
Matrix
The boc library includes a built-in Matrix class:
a dense 2-D matrix of double-precision floats backed entirely by C.
Because the underlying data lives outside the Python heap, a
Matrix can be placed inside a Cown and safely shared
between interpreters with zero-copy overhead.
Creating matrices
from bocpy import Matrix
# 2 x 3 zero-filled
a = Matrix(2, 3)
# from a flat list (row-major order)
b = Matrix(2, 3, [1, 2, 3, 4, 5, 6])
# convenience constructors
zeros = Matrix.zeros((3, 3))
ones = Matrix.ones((2, 4))
rnd = Matrix.uniform(0, 1, (3, 3))
vec = Matrix.vector([1, 2, 3])
Arithmetic
Matrix supports the familiar element-wise operators
(+, -, *, /)
as well as matrix multiplication with @:
c = a + b # element-wise add
d = a * 2 # scalar broadcast
e = a @ b.T # matrix multiply
# (b transposed)
a += ones # in-place add
Indexing and slicing
You can index with integers, slices, or (row, col) tuples:
row = b[0] # first row as a 1 x 3 Matrix
elem = b[1, 2] # element at row 1, column 2
b[0] = [7, 8, 9] # replace an entire row
Reductions and transforms
total = b.sum() # scalar sum of all elements
col_means = b.mean(axis=0) # column-wise means
t = b.T # transpose (property)
clamped = b.clip(0, 1) # clamp every element to [0, 1]
Using Matrix with cowns
Because a Matrix is cross-interpreter-safe, it works
naturally with Cown and @when:
from bocpy import Cown, when, wait
positions = Cown(Matrix.uniform(0, 100, (50, 2)))
velocities = Cown(Matrix.normal(0, 1, (50, 2)))
@when(positions, velocities)
def step(pos, vel):
pos.value += vel.value
wait()
This pattern is used extensively in the
boids
flocking simulation, where hundreds of agents update their
positions and velocities concurrently across multiple interpreters.
Full Matrix API reference