Erlang, programmation distribuée et modèle acteur

Spécialité(s)


Résumé

Quel est le point commun entre RabbitMQ, ejabberd, CouchDB, WhatsApp et Heroku ? Ces outils et services ont la particularité d’utiliser le même langage de programmation : Erlang. Ce dernier, encore trop peu connu du grand public, a pourtant réussi à maintes reprises à faire parler de lui. Il offre une approche nouvelle dans le monde du développement, où le paradigme orienté objet domine largement le marché, en offrant une implémentation très haut niveau du modèle acteur, facilitant ainsi la mise en place ainsi que l’utilisation de systèmes complexes et distribués.


Body

Erlang est probablement plus qu’un langage, du moins pour sa communauté, qui le voit comme une philosophie, un chemin à suivre pour produire des programmes résilients et évolutifs. La syntaxe, certes peu orthodoxe pour ceux qui viennent de langages classiques tels que C, Python ou Java, rappelle pour certains les heures de sommeil troublées par l’apprentissage de Prolog, durant leurs cours sur les systèmes experts ou sur l’intelligence artificielle. Malheureusement, trop de développeurs restent bloqués sur la forme que prend le langage, et par conséquent, ne voient pas le fond et surtout l’étendue de ses fonctionnalités.

Erlang a été conçu pour solutionner les problèmes rencontrés par l’industrie de pointe, des difficultés que nous rencontrons aujourd’hui telles que la haute disponibilité, les systèmes distribués, concurrents et évolutifs. Erlang fait partie de ces révolutions silencieuses, qui fonctionnent sans accroche, avec une maintenance peu contraignante. Sans le savoir, vous avez probablement déjà utilisé les services de ce langage, que ce soit au travers de RabbitMQ, CouchDB ou encore Riak dans le monde de l’Open Source. Vous l’avez probablement rencontré sans vous en apercevoir en regardant vos messages sur WhatsApp, ou lors du déploiement de vos services chez Heroku. Il est présent chez Google, Amazon ou encore Facebook, qui l’utilisent en interne pour leurs différents services. Il sert à faire transiter les paquets réseau sur les équipements Cisco, ce dernier l’utilisant sur une vaste gamme de switchs et de routeurs...

Vous l’aurez donc compris, vous avez affaire ici à un langage utilisé par les grands noms de l’industrie, qui recherchent un environnement fiable et de qualité. Je vous propose donc dans cet article de vous présenter la base du langage, vous offrir un aperçu des possibilités qu’il a à offrir, vous en présenter la syntaxe et vous expliquer l’aventure d’Erlang, un langage pas comme les autres.

Joe Armstrong, créateur et premier artisan d'Erlang, nous quittait à l'âge de 68 ans le 20 avril 2019. Nous espérons que cette suite d'articles sur Erlang fera honneur à tout son travail. Merci à lui, pour tout ce qu'il nous a offert et su nous partager avec autant d'humilité et d'humour. Ô Capitaine ! Mon Capitaine !

1. Histoire

La chronologie complète d’Erlang peut se retrouver dans plusieurs endroits, tout d’abord dans la thèse [6] de Joe Armstrong datée de 2003 et ayant pour titre « Making reliable distributed systems in the presence of software errors », mais aussi dans le papier nommé « A History of Erlang » [7] du même auteur, datant de 2009. L’histoire d’Erlang commence au début des années 80, en Suède, dans les bureaux de la société de télécommunication Ericsson. Cette dernière décide de créer un laboratoire, nommé CS-Lab (Computer Science Laboratory), ayant pour but de suggérer de nouvelles architectures et concepts pour les systèmes de calcul.

La première version d’Erlang sera livrée quelques années plus tard, en 1987. Cette première version fut développée en Prolog et embarquait déjà des fonctions pour les systèmes de télécommunication (PABX). En 1989 apparaît pour la première fois la machine virtuelle Erlang, JAM pour Joe’s Abstract Machine, une implémentation partielle de la WAM (Warren’s Abstract Machine). Cette VM supportait alors la création de processus en parallèle ainsi que les envois de messages et la détection d’erreur. La syntaxe actuelle d’Erlang apparaît réellement en 1990 et n’est plus qu’un simple dialecte de Prolog, mais devient un langage à part entière avec sa spécification, ses normes et ses conventions.

En 1993, la société Erlang Systems AB voit le jour pour promouvoir le langage Erlang en interne, mais aussi en externe de la société Ericsson. Cette même année, la BEAM est créée et la première version commerciale est disponible, prête à être vendue. De plus, le langage Erlang est choisi pour une refonte d’un projet de switch nouvelle génération interne à Ericsson, AXE-N. Le projet final est livré en 1998, composé de 1,7 million de lignes de code entièrement écrites en Erlang. Peu de temps après, Erlang est mis de côté chez Ericsson pour la mise en place de nouveaux produits de développement, la société désirant être plus consommatrice de logiciel que productrice. En décembre 1998, le langage passe officiellement en Open Source et est librement disponible sur Internet. Une partie du code passe dans le domaine public, ou sous une licence publique Erlang qui deviendra dans l’avenir une licence Apache-2.0.

Le début « des années turbulentes » commence pour Erlang. Après la rupture au sein d’Ericsson, la société Bluetail AB voit le jour et permettra de faciliter l’ouverture du langage dans le monde de l’Open Source, mais aussi de designer et d’implémenter le framework OTP (Open Telecom Platform). Après ces quelques années, le langage est considéré comme stable et la majorité des modifications se font sans altérer la syntaxe ou les interfaces utilisées par les développeurs. L’histoire continue et le langage se voit utiliser par de grands groupes et autres sociétés, tel que WhatsApp, qui a été l’une des plus grandes réussites financières, et dont l’intégralité du back-end fut écrite en Erlang.

L’écosystème évolue et voit l’apparition de langages adaptés pour les principes utilisés par Erlang. En 2008, LFE (pour Lisp Flavored Erlang) est créé. En 2011 apparaissent Joxa et Elixir, le premier est un langage inspiré de Lisp et le second est basé sur une syntaxe proche de Ruby. En 2012, Luerl implémente Lua en Erlang. En 2015, Clojerl est créé, et rend la BEAM compatible avec le langage Clojure. À noter que de nombreux projets tels que Scala, Akka ou Distributed Haskell se sont fortement inspirés d’Erlang lors de leur création et conception. En décembre 2019, OpenErlang soufflait ses 20 bougies, le langage avait été créé 34 ans plus tôt, et l’aventure continue.

2. Installation

Étant donné la vaste étendue des différents systèmes d’exploitation disponibles aujourd’hui, nous allons voir comment installer Erlang en le compilant depuis ses sources, librement disponibles sur GitHub [3]. Sachez tout de même que la quasi-totalité des distributions Linux et des autres systèmes libres ou non libres propose des versions précompilées/prépackagées d’Erlang.

Le site officiel d’Erlang [1] fournit aussi les sources, la documentation et des versions précompilées pour Windows. Finalement, si vous ne trouvez pas votre bonheur, vous pouvez toujours vérifier les différentes versions d’Erlang disponible sur le site d’Erlang Solutions [4] qui prépare généralement de nombreux paquets pour pratiquement toutes les plateformes disponibles sur le marché.

L’installation des sources, dans notre cas, sera faite via votre utilisateur courant. Un compilateur C tel que GCC ou LLVM/Clang ainsi qu’Autoconf, GNUMake et Git sont des prérequis pour la compilation. La variable d’environnement AUTOCONF_VERSION doit être configurée avec la version d’Autoconf qui est installée. Voici un bout de code permettant d’extraire automatiquement ce numéro de version (que vous pourrez insérer dans votre .profile ou .bashrc).

$ export AUTOCONF_VERSION=$(autoconf -V \
  | sed -Ee '/^autoconf/{ s/.*([0-9]+\.[0-9]+)$/\1/; p; };d;')
$ cd ${HOME}
$ mkdir src
$ cd src

Comme dit précédemment, les sources d’Erlang sont disponibles sur GitHub [3]. Vous pouvez essayer de compiler la branche master du projet, mais il est vivement recommandé d’utiliser une branche stable qui se trouve dans les tags, à récupérer avec la commande git tag. Pour cet article, nous prendrons la version 21.2.7 [9].

$ git https://github.com/erlang/otp
$ cd otp
$ git checkout OTP-21.2.7

Erlang intègre un script shell nommé otp_build présent à la racine des sources et permettant d’amorcer la configuration et l’installation des différentes sous-librairies. Le reste de la compilation reste classique, et si vous êtes habitué à compiler vos propres logiciels, il n’y a ici que très peu de surprises.

$ ./otp_build setup
$ ./configure
$ make

Après la compilation, un répertoire bin devrait être présent contenant les exécutables de base d’Erlang telle que la commande erl donnant accès au REPL, erlc donnant accès au compilateur ou encore escript, exécutable permettant d’utiliser Erlang comme un langage scripté. Lors des prochains exemples, nous utiliserons erl qui nous donnera accès à un shell interactif. Notez que le code suivant vous permettra d’exécuter ces commandes sans faire une installation globale d’Erlang sur votre système, donc, parfait si vous n’avez pas les droits d’administrateur.

$ cd bin
$ export PATH=${PATH}:$(pwd)
$ erl

Dans le cas où vous voudriez installer Erlang sur votre système ou que vous ayez un accès administrateur, vous pouvez réexécuter la commande make suivie de la cible install. Par défaut, la distribution s’installera dans /usr/local, mais cette option est configurable via le script de configuration.

$ make install

3. Utilisation du REPL

Erlang utilise une machine virtuelle pour fonctionner, la BEAM [8]. Cette dernière a été fortement optimisée pour les tâches concurrentes, parallèles et distribuées. Lors du développement d’un logiciel en Erlang, vos meilleurs amis seront la documentation [5], mais aussi le REPL. Cette interface en ligne de commande vous permettra d’interagir avec l’environnement d’Erlang et plus particulièrement, la machine virtuelle. L’invocation de cet outil se fait via la commande erl, qui doit être normalement présente dans votre PATH.

$ erl
Erlang/OTP 21 [erts-10.2] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1]
 
Eshell V10.2 (abort with ^G)
1>

Une présentation rapide du REPL ou shell s’impose. Tout d’abord, si vous êtes habitué aux systèmes Unix/Linux, vous devriez savoir que tout est fichier. En Erlang, tout est processus. Le REPL ne fait pas exception à la règle, et est un processus comme les autres. En pressant les touches <CTRL> + <j>, un nouveau shell apparaît, et vous permettra de contrôler les différentes actions que vous avez pu faire.

User switch command
--> h
  c [nn]            - connect to job
  i [nn]            - interrupt job
  k [nn]            - kill job
  j                 - list all jobs
  s [shell]         - start local shell
  r [node [shell]]  - start remote shell
  q                 - quit erlang
  ? | h             - this message

Via cette interface, il est possible de gérer plusieurs shells en même temps, mais aussi de vous connecter à des nœuds distants. Ces fonctionnalités sont intéressantes dans le cas où vous voudriez prendre le contrôle d’un système en fonctionnement, mais aussi en cas de problème lors de tests (boucle infinie par exemple).

--> j  
  1* {shell,start,[init]}
--> s
--> j   
  1* {shell,start,[init]}   
  2 {shell,start,[]}
--> c 2
 
1>

Le REPL intègre aussi de nombreuses fonctions permettant de se simplifier la vie. Tout d’abord, parlons de la notation des fonctions, ces dernières sont représentées par le nom du module, puis le nom de la fonction suivi de l’arité (nombre d’arguments). Par exemple, décomposons la référence module:nom/1 qui représente la fonction d’arité 1 nommée nom présente dans le module ayant pour nom module. Le REPL supporte donc de nombreuses fonctions d’aides, affichées via la fonction help/0, vous pouvez voir une sélection des commandes fréquemment utilisées ci-dessous.

1> help().** shell internal commands **
h()        -- history
history(N) -- set how many previous commands to keep
** commands in module c **
c(Mod)     -- compile and load module or file <Mod>
cd(Dir)    -- change working directory
flush()    -- flush any messages sent to the shell
i()        -- information about the system
ni()       -- information about the networked system
i(X,Y,Z)   -- information about pid <X,Y,Z>
ls()       -- list files in the current directory
ls(Dir)    -- list files in directory <Dir>
m()        -- which modules are loaded
memory()   -- memory allocation information
pid(X,Y,Z) -- convert X,Y,Z to a Pid
pwd()      -- print working directory
q()        -- quit - shorthand for init:stop()
uptime()   -- print node uptime

La documentation complète est disponible sur le site officiel, pour ceux qui seraient intéressés de voir toutes les fonctionnalités.

4. Présentation de la syntaxe

La syntaxe d’Erlang est fortement héritée de Prolog et en conserve, du moins en partie, sa structure. Les variables commencent par une lettre en majuscule ou un _ (underscore). Par convention, une variable commençant par un underscore est une variable qui ne sera pas utilisée dans la suite du code. L’omission de ce caractère sur les variables non utilisées affichera un warning lors de la compilation, que nous verrons plus tard dans l’article. L’attribution d’une valeur à une variable se fait via le caractère = (égal). Le terme de gauche étant le nom de la variable, et le terme de droite étant la valeur de la variable. Il est aussi à noter que chaque instruction se termine par un . (point), tout comme en Prolog. Chaque instruction en Erlang doit se terminer par ce caractère, comme une phrase écrite dans notre langue.

1> Variable = 1.
1
2> _Variable = 2.
2

Erlang est un langage typé dynamique qui possède un certain nombre défini de types. Commençons par les structures de données statiques, que vous devriez être habitué à rencontrer dans d’autres langages. Les nombres, par exemple. Dans le langage sont directement intégrées les fonctions classiques, telles que l’addition avec la fonction erlang:’+’/2, la soustraction avec la fonction erlang:’-’/2, la multiplication avec la fonction erlang:’*’/2 ou encore la division avec la fonction erlang:’/’/2.

3> NombreEntier = 1.
1
4> NombreAVirgule = 1.234.
1.234
5> erlang:’+’(1, 2).
3
6> 1 + 2.
3

Vient ensuite le type atom. Les atomes sont des valeurs uniques généralement utilisées pour marquer des messages entre les différents processus ou lors d’un code de retour. Les atomes sont stockés dans une table et sont limités au nombre de 1 048 576 atomes par défaut, cette valeur étant configurable lors du lancement de la BEAM. Lors de la création de code, vous verrez fréquemment les atomes ok ou error qui serviront à identifier le résultat d’une fonction et de son succès ou de son échec. Les atomes sont créés de 2 façons : ou en utilisant un terme commençant par une lettre minuscule, ou par l’utilisation de ' (simple apostrophe).

5> Atom = test.
test

Le tuple est une structure de donnée composée et statique, qui possède donc un nombre d’éléments fixes lors de sa création. Vous la rencontrerez fréquemment en association avec un atom, le premier élément étant alors ok suivi de la valeur de retour ou error, suivi de la cause ou de la raison de l’erreur. Cette structure est équivalente au tuple en Python.

6> Tuple = {ok, "my data"}.
{ok, "my data"}

Les listes et les chaînes de caractères sont extrêmement liées. Une liste peut-être composée de n’importe quel type de données. Une chaîne de caractères, ou string, est une liste composée uniquement de nombres entiers représentant des caractères imprimables. Les modules lists et string permettent de manipuler les listes et les chaînes de caractères. Voici quelques exemples de fonctions disponibles dans ces modules respectifs.

7> List = [1,2,3].
[1,2,3]
8> lists:sort([2,3,7,1]).
[1,2,3,7]
9> lists:reverse([a,b,c]).
[c,b,a]
10> erlang:hd([1,2,3]).
1
11> erlang:tl([1,2,3]).
[2,3]
7> ChaineDeCaractere = "abcde".
"abcde"

La map permet de créer un tableau associatif, composé d’une clé et d’une valeur. Cette structure de données est similaire aux hashes en Perl et aux dictionnaires en Python. Le module maps permet de manipuler cette structure de données.

8> Map = #{ cle => valeur }.
#{ cle => valeur }
9> maps:get(cle, Map).
valeur
10> maps:keys(Map).    
[cle]
11> maps:values(Map).
[valeur]
12> maps:put(cle2, valeur2, Map).
#{cle => valeur,cle2 => valeur2}
13> maps:remove(cle, Map).
#{}

Le type bitstring, ou binary, est une liste optimisée contenant des données brutes au format binaire. Cette structure de données est particulièrement efficace pour décomposer ou composer des données binaires en utilisant le pattern matching, fonctionnalité que nous verrons un peu plus tard dans l’article. Le module binary permet de manipuler ce type de données, mais une grande partie de la manipulation pourra se faire directement via la syntaxe Erlang, qui offre un panel de sucres syntaxiques très puissant. Cette dernière fonctionnalité est montrée rapidement en exemple ci-après :

14> Bitstring = <<"abcde">>.
<<"abcde">>
15> binary:replace(Bitstring, <<"a">>, <<"z">>).
<<"zbcde">>
16> <<Char:8, Rest/bitstring>> = Bitstring.
<<"abcde">>
17> Char.
97
16> Rest.
<<"bcde">>

Les fonctions anonymes ou fonctions lambda sont définies avec le mot-clé fun et terminées par le mot-clé end. Celles-ci vous permettront de créer des fonctions à partir de variables. Ce type de données est souvent utilisé pour lancer un processus via la fonction erlang:spawn/1 ou pour solutionner un problème unique au sein d’une fonction particulière. À noter qu’il est possible de voir les informations concernant la fonction créée via erlang:fun_info/1. Notez l’avant-dernier tuple ayant pour première valeur env, et une liste en contenu. Une partie de ces éléments représente l’arbre de la syntaxe abstraite (ou AST pour Abstract Syntax Tree). Pour faire simple, c’est une représentation standard du code avant sa transformation en code machine. Ce type de contenu peut-être utilisé pour faire de la métaprogrammation, c’est-à-dire donner la possibilité à la machine de modifier son propre code lors de l’exécution, technique courante et utilisée dans de nombreux autres langages tels que Lisp, Scheme, Prolog ou encore Ocaml.

17> Fun = fun(X) -> X+1 end.
#Fun<erl_eval.20.128620087>
18> Fun(1).
2
19> erlang:fun_info(Fun).
[{pid,<0.87.0>},
   {module,erl_eval},
   {new_index,6},
   {new_uniq,<<245,82,198,227,120,209,152,67,80,234,138,144,
               123,165,151,196>>},
   {index,6},
   {uniq,128620087},
   {name,'-expr/5-fun-4-'},
   {arity,1},
   {env,[{[],
        {eval,#Fun<shell.21.103068397>},
        {value,#Fun<shell.5.103068397>},
        [{clause,1,
                 [{var,1,'X'}],
                 [],
                 [{op,1,'+',{var,1,...},{integer,...}}]}]}]},
{type,local}]

Erlang est un langage fonctionnel et ne possède pas les classiques while, until ou for comme dans de nombreux langages. La création de boucles se fait via la construction de fonctions récursives. Ne vous inquiétez pas, Erlang a été designé pour optimiser l’exécution de ce type de fonctions, aucun risque de faire un débordement de stack comme en C.

Le code suivant définit une fonction anonyme nommée L (dans le cadre de son exécution) et stockée sur la variable Loop. Si le premier argument passé à la fonction est égal à 0, alors on retourne la valeur ok. Si l’argument est équivalent à autre chose, alors on l’affiche à l’écran via la fonction io:format/2, puis on rappelle la fonction L avec le premier argument moins 1. À noter qu’il n’y a pas de vérification de type, si vous passez une donnée non compatible, la fonction retournera une erreur au niveau de l’appel à la soustraction. Si vous passez une valeur négative, vous donnerez naissance à une boucle infinie que vous devrez arrêter en tuant le processus du shell.

20> Loop = fun L(0) -> ok;               
               L(X) -> io:format("~p~n",[X]),                 
               L(X-1)  
    end.
#Fun<erl_eval.30.128620087>
21> Loop(3).
3
2
1
ok
23> Loop(a).
a
** exception error: an error occurred when evaluating an arithmetic expression
     in operator -/2
        called as a - 1

Après vous avoir fait découvrir rapidement la syntaxe d’Erlang et son utilisation au sein du REPL, plusieurs fonctionnalités essentielles restent à découvrir : le pattern matching et les guards. Le pattern matching permet d’exécuter une action en fonction d’une entrée déjà définie, un peu comme un switch/case en C, mais aussi de décomposer les éléments d’une structure de données. Cette fonctionnalité est présente dans l’exemple de la boucle précédemment créée. Voici d’autres exemples un peu plus concrets, basés sur les variables que nous avons définies tout au long de la présentation :

24> [Head|Tail] = List.
[1,2,3]
25> Head.
1
26> Tail.
[2,3]

Les guards sont généralement utilisés pour valider le type de données passé à une fonction et changer son comportement. Effectivement, l’action d’une fonction sur un type de données n’aura pas forcément le même résultat. Les guards sont définis par le mot-clé when après la définition des arguments de la fonction. L’exemple suivant permet de montrer le changement de comportement de la fonction anonyme, retournant un atom en fonction du type de données en entrée.

27> MyCase = fun(X) when is_integer(X) -> integer;
             (X) when is_atom(X) -> atom;
             (X) when is_list(X) -> list;
             (_) -> other_type
    end.#
Fun<erl_eval.6.128620087>
28> MyCase(1).
integer
29> MyCase(test).
atom
30> MyCase([]).
list
31> MyCase(<<>>).
other_type

La syntaxe générale d’Erlang est définie dans le manuel de référence disponible sur le Web, mais aussi dans les sources que vous avez précédemment téléchargées. N’hésitez pas à le consulter pour comprendre et voir les subtilités du langage.

5. Programmation concurrente et parallèle

La création de processus en Erlang se fait via la fonction erlang:spawn/2. Tout d’abord, un processus Erlang n’est pas un thread au sens strict du terme, mais partage un pool de threads créé par la machine virtuelle et géré par un ordonnanceur. Un processus Erlang est l’équivalent d’un processus sous Unix, un « objet » instancié et isolé du reste des autres processus, il possède son propre stack, sa propre heap et sa propre mailbox lui permettant de recevoir des messages lus via le mot clé receive. Du point de vue de l'équivalence, les goroutines en Go ou les sparks en Haskell permettent de faire grossièrement la même chose.

12> WaitMessage = fun (Message, Timeout) ->
12>   timer:sleep(Timeout),
12>   io:format(“~p~n”, [Message])
12> end.
#Fun<erl_eval.12.128620087>
13> erlang:spawn(fun() -> WaitMessage(“hello”, 1000) end).
<0.122.0>
"hello"

La fonction timer:sleep/1 bloque le process courant pendant un temps donné en millisecondes. Si vous exécutez la fonction WaitMessage directement dans votre console, cette dernière sera donc bloquée pendant le temps passé défini dans le second argument. La fonction erlang:spawn/1, quant à elle, attend une fonction anonyme en argument et retourne un identifiant de processus unique (Process ID ou PID).

14> Wait = fun() ->
14>   receive
14>     Pattern ->
14>       io:format("~p~n", [Pattern])
14>   end
14> end.
#Fun<erl_eval.20.128620087>

La variable Wait fait référence à une fonction anonyme. Cette dernière lit le contenu de la mailbox puis affiche le contenu du message à l’écran via la fonction io:format/2. Finalement, le processus n’aura plus d’action à exécuter : il meurt.

15> Pid = erlang:spawn(Wait).
<0.81.0>

erlang:spawn/1 retourne un PID, que nous allons stocker dans la variable Pid. Tout d’abord, assurons-nous que le processus ait été correctement lancé, pour cela, nous pouvons utiliser la fonction erlang:process_info/1 qui retournera les informations liées au processus.

16> erlang:process_info(Pid).
[{current_function,{prim_eval,'receive',2}},
{initial_call,{erlang,apply,2}},
{status,waiting},
{message_queue_len,0},
{links,[]},
{dictionary,[]},
{trap_exit,false},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.63.0>},
{total_heap_size,233},
{heap_size,233},
{stack_size,9},
{reductions,4005},
{garbage_collection,[{max_heap_size,#{error_logger => true,kill => true,size => 0}},
                      {min_bin_vheap_size,46422},
                      {min_heap_size,233},
                      {fullsweep_after,65535},
                      {minor_gcs,0}]},
{suspending,[]}]

Notre processus est bien présent. La fonction nous retourne une liste de tuple/2, généralement nommé proplist, qui contient toutes les informations relatives à notre process. Pour envoyer un message à notre nouveau processus créé, nous pouvons utiliser soit le sucre syntaxique ! (point d’exclamation) ou utiliser la fonction erlang:send/2. L’envoi du message se fait en passant le Pid suivi du message que nous désirons envoyer, dans notre cas, la chaîne de caractères contenant « hello ».

17> Pid ! "hello".
"hello""hello"

Les deux fonctions d’envoi de messages retournent par défaut la valeur qui a été envoyée (le premier "hello" imprimé). Le deuxième "hello" provient directement du process que nous avons lancé basé sur la fonction Wait. Après cette action, le processus <0.80.0> ne devrait plus être présent sur la machine virtuelle.

18> erlang:process_info(Pid).
undefined

Nous savons maintenant lancer des processus Erlang, ceux-ci peuvent émettre des signaux lors d’un changement d’état, par exemple, un crash. Reprenons notre exemple, mais au lieu d’utiliser erlang:spawn/1, utilisons erlang:spawn_monitor/1.

18> f(Pid).
19> {Pid, Ref} = erlang:spawn_monitor(Wait).
{<0.99.0>,#Ref<0.2152525467.872415233.79687>}
20> Pid ! “hello”.
"hello"
"hello"
21> flush().
Shell got {'DOWN',#Ref<0.2152525467.872415233.79687>,process,<0.99.0>,normal}
ok

La fonction flush/0 permet de voir le contenu de la boîte aux lettres du processus, dans notre cas, celle de notre shell. Nous voyons bien que nous venons de recevoir un message nous alertant que notre processus s’est arrêté ('DOWN') avec la raison de son arrêt (normal).

6. Programmation distribuée

Distributed Erlang est un mode de fonctionnement d’Erlang permettant d’interconnecter plusieurs nœuds entre eux et de partager des ressources entre ces différents systèmes. Cette fonctionnalité est présente dans toutes les releases. Par défaut, Erlang utilise un réseau décentralisé et chaque nœud peut communiquer vers chacun des autres nœuds. Dans le cadre de cette démonstration, il sera nécessaire de démarrer 2 instances de machines virtuelles Erlang, pour les besoins du test, sur notre machine de test.

Le principe de fonctionnement est le suivant : chaque nœud démarre avec un nom spécifique, qu’il est possible de configurer avec les flags -sname (pour short name) ou -name lors du lancement de la machine virtuelle ou de configurer via la fonction net_kernel:start/1. Chaque nœud doit aussi posséder un cookie permettant d’identifier les machines virtuelles entre elles, cette information peut-être configurée avec le flag -cookie, mais dans notre exemple, nous utiliserons directement les bibliothèques fournies avec Erlang et plus particulièrement, la fonction erlang:set_cookie/2. Démarrons notre premier nœud, que nous appellerons alice. Vérifions que notre nœud possède le bon nom avec la fonction erlang:node/0.

$ erl -sname alice
Erlang/OTP 21 [erts-10.2] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1]
 
Eshell V10.2 (abort with ^G)
(alice@kin)1> erlang:node().
alice@kin

Maintenant, nous pouvons configurer notre cookie via la commande erlang:set_cookie/2, et vérifier si l’état du nœud a été correctement configuré avec la fonction erlang:get_cookie/0. Attention tout de même, dans le cadre d’une mise en production, il est nécessaire de choisir un cookie suffisamment sécurisé.

(alice@kin)2> erlang:set_cookie(erlang:node(), 'mycookie').
True
(alice@kin)3> erlang:get_cookie().
mycookie

Assurons-nous qu’aucun nœud ne soit connecté avec la fonction erlang:nodes/0. Une liste vide nous est retournée, qui nous indique que le nœud alice n’est connecté à aucun autre nœud.

(alice@kin)4> erlang:nodes().
[]

Parfait ! Notre premier nœud semble prêt à accueillir bob, notre second nœud, qui viendra se connecter à alice après avoir été configuré par nos bons soins. La fonction net_kernel:connect_node/1 initialise la connexion avec le nœud distant et retourne true en cas de succès, ce qui est normalement notre cas.

$ erl -sname bob
Erlang/OTP 21 [erts-10.2] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1]
 
Eshell V10.2 (abort with ^G)
(bob@kin)1> erlang:set_cookie(erlang:node(), 'mycookie').
true
(bob@kin)2> net_kernel:connect_node('alice@kin').
true

Les deux nœuds sont désormais connectés entre eux ! Comment le prouver ? En réutilisant la fonction erlang:nodes/0 qui permet d’afficher la liste des nœuds connectés. Tout d’abord sur alice :

(alice@kin)5> erlang:nodes().     
[bob@kin]

Puis maintenant, sur bob :

(bob@kin)3> erlang:nodes().
[alice@kin]

Nos deux nœuds peuvent se voir et partagent (au moins) leurs noms respectifs, et nous utilisons maintenant officiellement Distributed Erlang. Ce mode de fonctionnement nous permet d’utiliser des fonctionnalités assez intéressantes, comme les appels de fonctions distantes ou Remote Procedure Call (RPC). Faisons l’expérience de suite avec la fonction rpc:call/4.

(alice@kin)6> rpc:call('bob@kin', erlang, node, []).
bob@kin

Cette fonction permet d’exécuter une commande sur le nœud distant, le premier argument contient le nom du nœud distant (bob dans notre cas), le second argument contient le nom du module (erlang), le troisième argument le nom de la fonction (node) et finalement, le dernier argument contient une liste de valeurs, utilisées pour représenter les arguments pour la fonction à appeler ([] ou /0). Nous voyons bien que dans notre exemple, la fonction appelée nous retourne 'bob@kin', le code a donc été correctement exécuté à distance. Évidemment, si nous pouvons utiliser des procédures sur un autre nœud, nous pouvons aussi exécuter notre propre code. Pour se faire, un processus doit être enregistré (registered) via la fonction erlang:register/2 sur le nœud. Faisons l’expérience avec un simple processus :

(alice@kin)7> Process = fun () ->
   receive   
      Message -> io:format("~p~n", [Message])
   end
end.
#Fun<erl_eval.44.128620087>
(alice@kin)8> Pid = erlang:spawn(Process).
<0.100.0>
(alice@kin)9> erlang:register(talktome, Pid).
True
(alice@kin)10> erlang:whereis(talktome).
<0.100.0>

Notre processus est démarré et enregistré avec le nom talktome. Nous pouvons maintenant aller sur bob et essayer de contacter ce processus.

(bob@kin)7> erlang:send({talktome, 'alice@kin'}, "hello alice!").
"hello alice!"

Vous devriez voir maintenant le même message imprimé sur le nœud alice, et le processus talktome a dû s’arrêter.

"hello alice!"
(alice@kin)11> erlang:process_info(Pid).
undefined

7. Création d’un système de cache

Maintenant que nous avons vu comment utiliser le REPL, nous allons créer nos premiers modules en Erlang. Le REPL sera dans tous les cas utilisé pour compiler, tester et/ou valider le fonctionnement d’un module. Pour le premier exercice, nous allons créer un système de cache simpliste. Le terme de cache ici est plus présent pour définir un système de stockage clés/valeurs, comme vous pourriez le retrouver sur différents projets comme Redis.

Erlang possède déjà des solutions existantes pour gérer le cache, nommées ETS et DETS. Ces deux solutions donneront naissance plus tard à Mnesia, une base de données relationnelle distribuée. Dans notre cas, nous utiliserons quelque chose de plus simple en utilisant un map. Essayons tout d’abord de définir le comportement de notre futur programme. Des processus différents veulent stocker des informations dans un autre processus en provenance de la même machine ou de nœuds différents. Pour ce faire, ces processus auront besoin d’une interface de communication qui permettra de modifier l’état du processus. Résumons les étapes :

  1. Nous démarrons un processus utilisant notre système de cache ;
  2. Un processus distant a besoin de stocker une information, et possède le PID du processus en question ;
  3. Il utilise l’interface présente dans le code permettant d’envoyer la clé et la valeur via le PID

Commençons par créer un fichier nommé cache.erl. Une fois ce dossier créé, ouvrez-le avec votre éditeur de fichiers préféré, à noter que chaque distribution d’Erlang est livrée avec les outils nécessaires à l’intégration du langage dans Emacs.

$ touch cache.erl
$ emacs cache.erl

Le nom du module doit être explicitement écrit dans le fichier et doit correspondre au niveau du fichier sans l’extension. Dans notre exemple, le fichier s’appelle cache.erl, donc le module aura pour nom cache.

-module(cache).

Exporter des fonctions permet de séparer les fonctions utilisables par l’utilisateur des fonctions qui n’ont pas vocation à être utilisées. Pour faire simple, nous définissons l’API qui nous permettra d’interagir avec le processus. Décrivons les fonctions qui seront exportées et donc exécutables. Un processus doit être démarré, nous offrons donc 2 fonctions pour le faire, start/0 et start/1 ; la première démarre un processus avec les options par défaut. La seconde démarre le processus avec un argument correspondant aux options du processus, que nous pourrons faire passer par une liste. La fonction add/3 permettra de rajouter une clé associée à une valeur. La fonction update/3 permettra de mettre à jour une clé avec une nouvelle valeur associée. La fonction delete/2 permettra de supprimer une clé ainsi que sa valeur associée. La fonction get/2 permettra de récupérer les données qui ont été stockées via une clé.

-export([start/0, start/1]).
-export([add/3, update/3, delete/2]).
-export([get/2]).

Créons nos premières fonctions, start/0 et start/1. La fonction start/0 fait appel à start/1 en envoyant une liste vide (donc, aucune option). La fonction start/1 crée, quant à elle, une fonction anonyme faisant appel à la fonction init/1, puis crée un processus basé sur la fonction anonyme précédemment créée. Ces 2 fonctions retournent un identifiant de processus qui sera alors utilisé comme référence pour communiquer avec ledit processus et avoir accès à l’API en question.

start() ->
    start([]).
 
start(Args) ->
    Start = fun() -> init(Args) end,
    erlang:spawn(Start).

La fonction init/1 va permettre d’initialiser le processus qui aura dû être précédemment lancé par erlang:spawn/1. Cette fonction n’est pas exportée et donc, n’est pas accessible par les utilisateurs. Généralement, une fonction d’initialisation permet de changer le comportement du processus. init/1 fait appel à loop/1 qui est la boucle infinie qui contiendra le cœur de notre processus.

init(_Args) ->
    loop(#{}).

loop/1 est une fonction relativement complexe, elle permet de lire la boîte mail du processus et en fonction des données présentes, agir sur son état. Pour pouvoir boucler à la fin de chaque nouveau message lu, la fonction loop/1 s’appelle elle-même avec un état potentiellement modifié. Chaque message qui arrive dans la boîte aux lettres est testé au niveau des différents pattern matching présents dans receive.

loop(State) ->
    receive
        {Pid, get, values} ->
            Answer = maps:keys(State),
            erlang:send(Pid, Answer),
            loop(State);
        {Pid, get, keys} ->
            Answer = maps:values(State),
            erlang:send(Pid, Answer),
            loop(State);
        {Pid, get, state} ->
            erlang:send(Pid, State),
            loop(State);
        {add, Key, Value} ->
            NewState = maps:put(Key, Value, State),
            loop(NewState);
        {update, Key, Value} ->
            NewState = maps:update(Key, Value, State),
            loop(NewState);
        {delete, Key} ->
            NewState = maps:remove(Key, State),
            loop(NewState);
        Else ->
            io:format("got ~p~n", [Else]),
            loop(State)
    end.

La fonction add/3 est la première fonction de l’API. Son premier argument correspond à l’identifiant du processus, suivi de la clé, puis de la valeur associée à la clé.

add(Pid, Key, Value) ->
    erlang:send(Pid, {add, Key, Value}).

La fonction update/3 permet de mettre à jour une valeur déjà présente. Son premier argument est l’identifiant du processus, puis la clé que nous voulons mettre à jour, ainsi que sa valeur associée.

update(Pid, Key, Value) ->
    erlang:send(Pid, {update, Key, Value}).

La fonction delete/2 permet de supprimer une clé déjà présente, le premier argument est la référence au processus, suivi de la clé que nous voulons supprimer.

delete(Pid, Key) ->
    erlang:send(Pid, {delete, Key}).

La fonction get/2 permet de récupérer les informations contenues dans l’état du processus, en récupérant la liste des clés, des valeurs ou l’état complet géré par le processus en cours de fonctionnement. Cette fonction est particulière ici, car elle attend une réponse du processus en lisant dans la boîte mail du processus qui appelle. Le terme after permet de gérer un timeout, si le processus distant ne répond pas au bout de 1000 ms, alors get/2 retourne une erreur.

get(Pid, Message) when is_atom(Message) ->
    erlang:send(Pid, {self(), get, Message}),
    receive
        Data -> {ok, Data}
    after
        1000 -> {error, timeout}
    end.
 

Après avoir enregistré ce fichier, nous allons lancer un REPL Erlang dans le même répertoire et compiler notre module cache. Il est tout à fait possible de compiler le fichier avec erlc, sans pour autant passer par la fonction c/1.

$ erlc cache.erl
$ ls cache*
cache.erl cache.beam
$ erl
Erlang/OTP 21 [erts-10.2] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1]
 
Eshell V10.2 (abort with ^G)
1> c(cache)
{ok, cache}
2> Pid = cache:start().
<0.83.0>
3> cache:add(Pid, cle, value).
{add,cle,value}
4> cache:add(Pid, 1, 2).      
{add,1,2}
5> cache:get(Pid, values).
{ok,[1,cle]}
6> cache:get(Pid, keys).
{ok,[2,value]}
7> cache:get(Pid, state).
{ok,#{1 => 2,cle => value}}
8>

Nos données sont correctement stockées dans notre processus, et notre système de cache est fonctionnel. Évidemment, ce code ne fait pas grand-chose… Mais il permet de vous montrer comment, en quelques lignes de code, il est possible de créer un système de cache interne à Erlang et potentiellement joignable par d’autres nœuds. Essayez d’enregistrer le processus précédemment créé et d’envoyer des messages depuis un autre nœud. Vous serez alors étonné de voir arriver les données dans votre nouveau système de cache !

Conclusion

Erlang est un langage portable, stable et éprouvé. L’environnement fourni avec Erlang/OTP permet de répondre aux problèmes d’aujourd’hui, sans avoir besoin d’utiliser des dépendances externes dans la grande majorité des cas. La communauté est active, passionnée, donnant accès à des outils très complets et performants. Rentrer dans le monde d’Erlang, c’est prendre le risque de ne plus en ressortir et de voir le monde différemment…

Il est parfois dit que la philosophie d’Erlang se rapproche du bouddhisme par sa simplicité et sa sobriété. Certains disent aussi que les choses complexes sont facilement réalisables en Erlang, mais que les choses simples sont parfois complexes à obtenir. Le fait est qu’à une époque où de plus en plus de nouveaux langages apparaissent, désirant « révolutionner » le monde de l’informatique, il semblerait que cette révolution se soit déjà passé 30 ans auparavant.

Références

[1] Site officiel d’Erlang : https://www.erlang.org

[2] Page de téléchargement officielle d’Erlang : https://www.erlang.org/downloads

[3] Dépôt Erlang officiel sur GitHub : https://github.com/erlang/otp

[4] Page de téléchargement d’Erlang Solutions : https://frama.link/ErlangSolutionsDownload

[5] Documentation officielle Erlang : http://erlang.org/doc/

[6] J. ARMSTRONG, « Making reliable distributed systems in the presence of software errors », 2003 : https://frama.link/JoeArmstrongThesis

[7] J. ARMSTRONG, « A History of Erlang », 2009 : https://frama.link/JoeArmstrongErlangHistory

[8] Spécification de la BEAM, CS-Lab, 1997 : http://www.cs-lab.org/historical_beam_instruction_set.html

[9] Note de la release officielle d’Erlang 21.2 : https://www.erlang.org/downloads/21.2

Pour aller plus loin

Cet article ne fut qu’une mise en bouche, une introduction sommaire à un langage complet et surtout complexe. De nombreux cours se trouvent maintenant librement sur la Toile, mais aussi de nombreux ouvrages sur le sujet, que vous pourrez trouver recensés sur le site officiel du langage. N’oubliez pas de regarder aussi du côté de la communauté active sur IRC et Slack, qui vous permettra d’avancer plus facilement.



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous