Chamagne Bastien
Développeur indépendant à Pau

Appeler des acteurs transitoires en parallèle

Modèle Acteur Elixir

Objectif

Dans l’article précédent, nous avons fait un acteur qui prend du temps à faire un calcul. Dans celui-ci nous allons voir différentes façons pour appeler cet acteur dans une boucle.

Voici le code du précédent article :

defmodule Math do
  def fib(0), do: 0
  def fib(1), do: 1
  def fib(n), do: fib(n - 1) + fib(n - 2)

  def fib_async(n) do
    caller = self()

    spawn(fn ->
      send(caller, {:fib, n, fib(n)})
    end)

    receive do
      {:fib, ^n, value} ->
        {:ok, value}
    after 5_000 ->
      {:error, :timeout}
    end
  end
end

Admettons que nous voulons utiliser la fonction fib_async/1 sur toutes ces valeurs : [2, 4, 8, 16, 32].

En série

defmodule T do
  def fib_async_mult1(list) do
    # Pour cet exemple j'introduis les comprehensions (boucle for) afin de rendre
    # les exemples plus digestes.
    for n <- list do
      Math.fib_async(n)
    end
  end
end

En pratique : On utilise que rarement les comprehensions ; on préférera utiliser le module Enum.

  ---
    config:
        theme: dark
---
sequenceDiagram
    participant iex@{ "type" : "actor" }
    participant fib_async_mult1/1
    activate iex
    iex->>fib_async_mult1/1: fib_async_mult1([2,4])

    create participant 0.123.0@{ "type" : "actor" }
    fib_async_mult1/1->>0.123.0: fib_async(2)
    0.123.0-->>fib_async_mult1/1: {:ok, <0.123.0>}
    fib_async_mult1/1->>fib_async_mult1/1: receive
    0.123.0->>0.123.0: fib(2)
    0.123.0-->>fib_async_mult1/1: {:fib, 2, 1}
    destroy 0.123.0
    0.123.0-x0.123.0:

    create participant 0.124.0@{ "type" : "actor" }
    fib_async_mult1/1->>0.124.0: fib_async(4)
    0.124.0-->>fib_async_mult1/1: {:ok, <0.124.0>}
    fib_async_mult1/1->>fib_async_mult1/1: receive
    0.124.0->>0.124.0: fib(4)
    0.124.0-->>fib_async_mult1/1: {:fib, 4, 3}
    destroy 0.124.0
    0.124.0-x0.124.0:

    fib_async_mult1/1-->>iex: [{:ok, 1}, {:ok, 3}]
list = [2, 4, 8, 16, 32]
T.fib_async_mult1(list)
[ok: 1, ok: 3, ok: 21, ok: 987, ok: 2178309]

Syntaxe : La syntaxe du retour peut vous intriguer au premier abord mais c’est l’équivalent de : [{:ok, 1}, {:ok, 3}, {:ok, 21}, {:ok, 987}, {:ok, 2178309}]. Il s’agit de la façon dont Elixir affiche les listes de paires.

On calcule donc fib_async(2) puis fib_async(4) puis fib_async(8) et ainsi de suite. En utilisant un timer, on voit que faire tourner [42, 42, 42] prend 3 fois plus de temps que [42] (5_848_178 µs contre 1_972_955 µs).

iex(24)> :timer.tc(fn -> T.fib_async_mult1([42]) end)
{1972955, [ok: 267914296]}

iex(25)> :timer.tc(fn -> T.fib_async_mult1([42,42,42]) end)
{5848178, [ok: 267914296, ok: 267914296, ok: 267914296]}

Voyons maintenant comment faire tourner tous les calculs en même temps.

En parallèle

En enlevant le receive de la fonction fib_async/1 on décorrèle l’envoi de messages de la lecture des résultats. Cela permet au caller d’appeler plusieurs fois la fonction afin de paralléliser les calculs. Par contre, la réception et l’ordonnancement des messages deviendront la responsabilité du caller.

defmodule Math do
  # [..]

  # pid \\ self() est un argument optionnel dont la valeur par défaut est self()
  # du coup ici, nous déclarons 2 fonctions : fib_async/1 et fib_async/2
  def fib_async(n, pid \\ self()) do
    spawn(fn ->
      send(pid, {:fib, n, fib(n)})
    end)
  end
end
defmodule T do
  def fib_async_mult2(list) do
    for n <- list do
      Math.fib_async(n)
    end

    for n <- list do
      receive do
        {:fib, ^n, value} ->
          {:ok, value}
      after 5_000 ->
        {:error, :timeout}
      end
    end
  end
end
  ---
    config:
        theme: dark
---
sequenceDiagram
    participant iex@{ "type" : "actor" }
    participant fib_async_mult2/1

    iex->>fib_async_mult2/1: fib_async_mult2([2,4])


    create participant 0.123.0@{ "type" : "actor" }
    fib_async_mult2/1->>0.123.0: fib_async(2)
    0.123.0-->>fib_async_mult2/1: {:ok, <0.123.0>}

    create participant 0.124.0@{ "type" : "actor" }
    fib_async_mult2/1->>0.124.0: fib_async(4)
    0.124.0-->>fib_async_mult2/1: {:ok, <0.124.0>}

    fib_async_mult2/1->>fib_async_mult2/1: receive

    0.123.0->>0.123.0: fib(2)
    0.123.0-->>fib_async_mult2/1: {:fib, 2, 1}
    destroy 0.123.0
    0.123.0-x0.123.0:

    fib_async_mult2/1->>fib_async_mult2/1: receive

    0.124.0->>0.124.0: fib(4)
    0.124.0-->>fib_async_mult2/1: {:fib, 4, 3}
    destroy 0.124.0
    0.124.0-x0.124.0:

    fib_async_mult2/1-->>iex: [{:ok, 1}, {:ok, 3}]
list = [2, 4, 8, 16, 32]
T.fib_async_mult2(list)
[ok: 1, ok: 3, ok: 21, ok: 987, ok: 2178309]
iex(43)> :timer.tc(fn -> T.fib_async_mult2([42]) end)
{1951120, [ok: 267914296]}

iex(44)> :timer.tc(fn -> T.fib_async_mult2([42, 42, 42]) end)
{2181155, [ok: 267914296, ok: 267914296, ok: 267914296]}

Conclusion

Je pense que cet article montre bien l’exécution concurrente des acteurs. J’espère avoir réussi à montrer la flexibilité du modèle d’acteur et démontrer qu’il ne s’agit en fin de compte que de briques que l’on va pouvoir assembler comme bon nous semble.

Dans le prochain article nous évoquerons les acteurs permanents.