Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Module 10: Concurrency

elixir-intro/module10.livemd

Module 10: Concurrency

Mix.install([
  {:vega_lite, "~> 0.1.10"},
  {:kino_vega_lite, "~> 0.1.13"}
])

Preparation

The first step is to read up on GenServer-based concurrency in Elixir. Do so by following these instructions:

  1. Check out the GIT repository at https://github.com/aslakjohansen/livebook-demos) or download the contents as a zip file.
  2. Open index.livemd from this repository in Livebook and evaluate the last cell if necessary.
  3. Scroll down to the demo labeled “Generic servers, registries and supervision” and click your way through it. Note that this is more advanced than the contents of last modules “Primitive concurrency” notebook.

Starting Point

In this exercise we will attempt to model a bakery. This bakery has a number of bakers who each know how to bake only a single product, and they bake on order. There is also a number of customers who – for the sake of simplicity – keeps ordering the same product.

Both roles (baker and customer) are implemented as genservers, and two registers (one for each role) are used to keep track of the individual instances.

Lets first take a look at the code for the baker role:

{:ok, baker_registry_pid} = Registry.start_link(name: BakerRegistry, keys: :duplicate)
defmodule Baker do
  use GenServer
  
  # (client) interface
  
  def start(product, name) do
    GenServer.start(__MODULE__, [product: product, name: name])
  end
  
  def order(product) do
    case Registry.lookup(BakerRegistry, product) do
      [] ->
        {:error, "No provider for '#{product}'"}
      workers ->
        {pid, _} = Enum.random(workers)
        GenServer.cast(pid, {:order, self()})
        :ok
    end
  end
  
  # callbacks
  
  @impl true
  def init([product: product, name: _] = state) do
    Registry.register(BakerRegistry, product, _value = nil)
    {:ok, state}
  end
  
  @impl true
  def handle_cast({:order, client}, [product: product, name: name] = state) do
    :timer.sleep(Enum.random(1600..3200))
    GenServer.cast(client, {:cake, product, name})
    {:noreply, state}
  end
end

And then the code for the customer role:

{:ok, customer_registry_pid} = Registry.start_link(name: CustomerRegistry, keys: :duplicate)
defmodule Customer do
  use GenServer
  
  # (client) interface
  
  def start(product, name) do
    GenServer.start(__MODULE__, [product: product, name: "Customer #{name}"])
  end
  
  # callbacks
  
  @impl true
  def init([product: product, name: _] = state) do
    Baker.order(product)
    {:ok, state}
  end

  @impl true
  def handle_cast({:cake, product, name}, [product: p, name: n] = state) do
    IO.puts("[#{n}] Got #{product} from #{name}: Yum! Lets have one more ...")
    Baker.order(p)
    {:noreply, state}
  end
end

At this point we can start some bakers:

{:ok, baker_1_pid} = Baker.start("Red Velvet Cheesecake", "John")
{:ok, baker_2_pid} = Baker.start("Red Velvet Cheesecake", "Alberte")
{:ok, baker_3_pid} = Baker.start("Red Velvet Cheesecake", "Sofie")
{:ok, baker_4_pid} = Baker.start("Lemon Pie", "Freja")
{:ok, baker_5_pid} = Baker.start("Lemon Pie", "Birger")
{:ok, baker_6_pid} = Baker.start("Carrot Cake", "Hans")
{:ok, baker_7_pid} = Baker.start("Carrot Cake", "Anders")
{:ok, baker_8_pid} = Baker.start("Carrot Cake", "Signe")
bakers = [baker_1_pid, baker_2_pid, baker_3_pid, baker_4_pid, baker_5_pid, baker_6_pid, baker_7_pid, baker_8_pid]

As well as some customers:

products = ["Red Velvet Cheesecake", "Lemon Pie", "Carrot Cake"]

1..20
|> Enum.map(fn name -> Customer.start(Enum.random(products), name) end)

Exercise

Step through the code we have covered so far, and use the mouse-over tooltips to find the documentation that explains the elements you don’t understand. There is probably a few.

The next step is to introduce a Clerk genserver for representing a clerk. Clerks are intermediaries between the customers and the bakers. It looks a bit like this:

graph LR;
  Customer1-->Clerk1;
  Customer1-->Clerk2;
  Customer2-->Clerk1;
  Customer2-->Clerk2;
  Customer3-->Clerk1;
  Customer3-->Clerk2;
  Customer4-->Clerk1;
  Customer4-->Clerk2;
  Clerk1-->Baker1;
  Clerk2-->Baker1;
  Clerk1-->Baker2;
  Clerk2-->Baker2;
  Clerk1-->Baker3;
  Clerk2-->Baker3;

A customer needs to be able to ask a clerk which products can be ordered. After receiving this list, a customer will order a random product from the list of offered products. Upon receiving an order, the clerk will then order a baker with the right skills (they only know how to bake a single product, remember?) to fulfill the order. The clerks keeps tract of how many orders they have completed of each product. That part is done in a separate genserver.

If done right, no customer will care how many clerks you have (beyond the first one), and no clerk will care how many bakers you have. The more of each set you have, the better the system will scale.

The exercise is to make the changes necessary in order to make this new model work, and quite a few changes are necessary. A good strategy is to pick a manageable subset of this functionality and implement it. Then you can pick a new manageable subset of the remaining functionality and implement that. And you keep doing so until everything is implemented.

Note: When doing larger changes like this, it is a good practice to frequently back up the file.

Next step …

This is the end of the planned part of the workshop and where our paths separate. Your next step is to come up with an exercise of your own, and then solve it. Best of luck!