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/2se 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 :
- Si la fonction
log/2plante, la fonctionmultiply/2plante. Pour tester, ajoutez unraise "uh oh"dans la fonctionlog/2. - Si la fonction
log/2prend du temps, la fonctionmultiply/2prendra du temps. Pour tester, ajoutez unProcess.sleep(10_000)dans la fonctionlog/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.
- La fonction devient un
behaviour. On l’appelle comme ça, car un acteur peut changer de comportement n’importe quand. - Les inputs synchrones deviennent des
messagesasynchrones. - L’output disparaît pour laisser place à des messages vers d’autres acteurs.
- Les messages arrivent dans la
mailboxde l’acteur qui est une queue FIFO. - Le behaviour peut créer (
spawn) d’autres acteurs. - Le behaviour peut faire des effets de bord (
I/O). - L’acteur possède un
statepour maintenir en mémoire des données. - 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:
- La fonction
multiply/2fait la multiplication - La fonction
multiply/2appelle la fonctionlog_async/2 - La fonction
log_async/2crée un acteur et retourne immédiatement avec la paire{:ok, <0.133.7>} - La fonction
multiply/2retourne avec le résultat de la multiplication - L’acteur exécute la fonction
log/2 - 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 :
- Un acteur permet d’isoler les erreurs
- 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.