Chamagne Bastien
Développeur indépendant à Pau

Un acteur transitoire qui produit un effet

Modèle Acteur Elixir

Disclaimer

Je ne suis pas très doué pour l’écriture ni même très pédagogue, donc je ne vais même pas essayer de parler de théorie. On a des IA pour ça qui feront bien mieux ce travail que moi. Par contre, je pense que le meilleur moyen d’apprendre est de se salir les mains, donc je vous invite à suivre cet article comme si l’on faisait du pair programming ensemble.

J’utilise délibérément des termes anglais car leur traduction ne ferait que créer de la confusion. La terminologie du modèle d’acteur n’est pas vraiment fixe, suivant les implémentations on n’utilisera pas les mêmes mots. Par exemple en Erlang/Elixir, on va souvent parler de process au lieu d’acteur.

Les exemples ci-dessous sont en Elixir, commencez donc par installer Elixir et lancer le mode interactif : Elixir School - Bases Vous pouvez aussi passer par Livebook afin d’avoir une belle UI pour vos expérimentations.

Objectif

Nous allons créer un acteur transitoire (temporaire) à partir d’une fonction et comprendre pourquoi il est intéressant d’utiliser un acteur plutôt que de faire un appel de fonction.

Un acteur n’est qu’une fonction !

L’acteur le plus simple n’est en fait qu’une fonction. Commençons donc par créer une fonction !

  ---
config:
  theme: dark
---
flowchart LR
    f["Function"]
    o@{ shape: braces, label: "output" }
    io1@{ shape: lin-cyl, label: "I/O" }
    io2@{ shape: cloud, label: "I/O" }
    i@{ shape: braces, label: "input1
    ...
    inputN" }

    i-->f
    f-->o
    f-.->io1
    f-.->io2

Figure 1. Zoom sur une fonction

Une fonction prend des paramètres en entrée et produit un résultat. Elle peut éventuellement faire des effets de bord (appel réseau, écriture disque, etc.).

Créons une fonction de log qui écrit des choses sur le disque :

defmodule FileLogger do
    # La fonction prend en paramètre 2 strings : le path d'un fichier et du texte
    # Elle crée ensuite une date human-readable qu'elle vient préfixer au texte
    # Et elle écrit tout ça à la fin du fichier.
    def log(filepath, text) do
        timestamp = Calendar.strftime(DateTime.utc_now(), "%Y-%m-%d %H:%M:%S")
        File.write(filepath,"[#{timestamp}] #{text}\n",[:append])
    end
end

Info : Vous pouvez coller directement le code dans le terminal iex

Voici un exemple d’utilisation de cette fonction :

defmodule Math do
    # La fonction prend 2 nombres en paramètres
    # Elle retourne le résultat après avoir appelé la fonction de log ci-dessus
    def multiply(a, b) do
        result = a * b
        FileLogger.log("/tmp/math.log", "#{a} times #{b} equals #{result}" )
        result
    end
end

et pour tester la fonction multiply/2 dans iex :

Math.multiply(6, 7)

Info : multiply/2 se lit comme ceci : la fonction multiply d’arité 2 (l’arité étant le nombre de paramètres)

Vous devriez voir une nouvelle ligne dans le fichier /tmp/math.log.

[2026-01-06 10:41:08] 6 times 7 equals 42

Super. Maintenant trouvons les inconvénients d’utiliser une fonction dans ce cas :

  1. Si la fonction log/2 plante, la fonction multiply/2 plante. Pour tester, ajoutez un raise "uh oh" dans la fonction log/2.
  2. Si la fonction log/2 prend du temps, la fonction multiply/2 prendra du temps. Pour tester, ajoutez un Process.sleep(10_000) dans la fonction log/2.

Évidemment, vous me voyez venir… Ces inconvénients disparaissent en transformant cette fonction en acteur ! Un acteur est exactement comme un processus système qui tourne en arrière-plan.

En utilisant un acteur pour la fonction log/2, si celle-ci plante ou prend beaucoup de temps, cela n’impactera pas la fonction multiply/2. En effet, celle-ci n’attend pas de retour de log/2.

  ---
config:
    theme: dark
---
flowchart LR
    i@{ shape: braces, label: "in_msg1
    ...
    in_msgN" }

    io1@{ shape: lin-cyl, label: "I/O" }
    io2@{ shape: cloud, label: "I/O" }

    subgraph Actor
        f["Behaviour"]
        mb["Mailbox"]@{shape: docs}
        state["State"]@{shape: cyl}
    end

    f-.->io1
    f-.->io2
    f-.->|spawn|actorX
    f-.->|send|actorY
    i-->mb--  receive -->f
    f-.->state

Figure 2. Zoom sur un acteur.

  1. La fonction devient un behaviour. On l’appelle comme ça, car un acteur peut changer de comportement n’importe quand.
  2. Les inputs synchrones deviennent des messages asynchrones.
  3. L’output disparaît pour laisser place à des messages vers d’autres acteurs.
  4. Les messages arrivent dans la mailbox de l’acteur qui est une queue FIFO.
  5. Le behaviour peut créer (spawn) d’autres acteurs.
  6. Le behaviour peut faire des effets de bord (I/O).
  7. L’acteur possède un state pour maintenir en mémoire des données.
  8. L’acteur peut s’arrêter.

Ça fait beaucoup d’un coup mais pour cet article, on ne verra que la création/destruction d’un acteur avec un seul behaviour.

Créons un acteur pour cette fonction :

defmodule FileLogger do
    # [..]

    # spawn/3 prend 3 paramètres : le module, le nom et les arguments de la fonction
    def log_async(filepath, text) do
        spawn(__MODULE__, :log, [filepath, text])
    end
end

Voilà, il suffit de changer log(..) par log_async(..) dans multiply/2 pour utiliser l’acteur. Maintenant réessayez d’ajouter le Process.sleep(10_000) ou le raise "uh oh" dans la fonction log/2 (dans la fonction anonyme) et testez la fonction multiply/2. Celle-ci n’est plus impactée par la lenteur ou les crashs de log/2 !

Concrètement, voici ce qu’il se passe à l’appel de la fonction multiply/2 :

  ---
    config:
        theme: dark
---
sequenceDiagram
    participant iex@{ "type": "actor"}
    participant multiply/2
    participant log_async/2

    iex-->multiply/2: multiply(6,7)
    activate multiply/2
    multiply/2->>multiply/2: calc
    multiply/2->>log_async/2: log_async(path, txt)
    activate log_async/2


    create participant 0.133.7@{ "type" : "actor" }
    participant log/2
    log_async/2->>0.133.7: spawn

    0.133.7-->>log_async/2: {:ok, <0.133.7>}

    log_async/2-->>multiply/2: {:ok, <0.133.7>}
    deactivate log_async/2
    multiply/2-->>iex: 42
    deactivate multiply/2
    participant Disk@{ "type" : "database" }
    0.133.7->>log/2: log(path, txt)
    activate 0.133.7
    log/2->>Disk: File.write(path, txt, flags)
    activate Disk
    Disk-->>log/2: :ok
    deactivate Disk
    log/2-->>0.133.7: :ok
    deactivate 0.133.7
    destroy 0.133.7
    0.133.7-x0.133.7:
  1. La fonction multiply/2 fait la multiplication
  2. La fonction multiply/2 appelle la fonction log_async/2
  3. La fonction log_async/2 crée un acteur et retourne immédiatement avec la paire {:ok, <0.133.7>}
  4. La fonction multiply/2 retourne avec le résultat de la multiplication
  5. L’acteur exécute la fonction log/2
  6. L’acteur s’arrête car il n’a plus rien à faire

Info : <0.133.7> est un process identifier. Il est nécessaire pour interagir avec l’acteur (envoyer des messages par exemple). Dans cet exemple, nous l’ignorons complètement.

Repository

J’ai mis le code de cet article sur : github/bchamagne/simple. Il est un peu différent car j’ai utilisé de l’injection de dépendances au lieu d’utiliser log/2 ou log_async/2.

Conclusion

Grâce à ce petit exemple nous comprenons 2 propriétés importantes des acteurs :

  1. Un acteur permet d’isoler les erreurs
  2. Un acteur a son propre cycle de vie et tourne en parallèle

Cet exemple fait ce qu’on appelle du “Fire and Forget” : la fonction spawn un acteur et ne s’en soucie plus.

Certains se diront qu’il y a un overhead de passer par un acteur plutôt que de faire l’appel à la fonction directement. C’est vrai, mais le jeu en vaut probablement la chandelle. En effet l’overhead est très faible comme le dit par exemple la documentation d’Erlang (le papa d’Elixir) :

Documentation : Erlang is designed for massive concurrency. Erlang processes are lightweight (grow and shrink dynamically) with small memory footprint, fast to create and terminate, and the scheduling overhead is low. — Erlang

Dans un prochain article nous aborderons un exemple d’un acteur à peine plus compliqué, mais il produira un résultat pour son appelant.