Version en ligne

Tutoriel : Erlang, partie 4 : la programmation concurrente

Table des matières

Erlang, partie 4 : la programmation concurrente
La programmation concurrente
Les processus et les messages
Un peu plus de contrôle sur les processus
Un autre exemple

Erlang, partie 4 : la programmation concurrente

La programmation concurrente

Nous allons maintenant aborder l'un des points les plus intéressants d'Erlang : la programmation concurrente. Dans cette partie du cours, nous allons voir comment Erlang permet facilement de construire des programmes concurrents.

La programmation concurrente

Les processus et les messages

En informatique comme dans la vraie vie, les ressources (puits de pétrole ou base de données…) sont limitées, et de nombreux "acteurs" (c'est le terme que nous emploierons généralement) peuvent vouloir y accéder simultanément. C'est particulièrement vrai pour un serveur web comme celui du Site du Zéro, par exemple. De nombreux Zéros envoient des requêtes qui arrivent en même temps, et qui cherchent toutes à accéder à la base de données. Seulement, ces requêtes sont envoyées autant pour lire le contenu de la base de données que pour y écrire. Que se passe-t-il si quelqu'un lit la base de données pendant que vous y écrivez ?

C'est en partie pour régler ces problèmes qu'Erlang a été conçu. Naturellement, ils ne se limitent pas aux bases de données : il y a des tas de situations dans lesquelles ce que l'on appelle des accès concurrents peuvent se produire. Dès que plusieurs acteurs veulent modifier la même ressource, cela arrive (comme dans une banque en ligne, dans un jeu vidéo, etc.). La programmation concurrente traite donc de ces situations.

Mais elle ne se limite pas à ces cas de figure négatifs. Pour rester efficaces, les solutions informatiques ont parfois besoin de beaucoup de puissance de calcul, et les ingénieurs ont très rapidement compris que cette puissance pouvait être obtenue en combinant beaucoup d'unités traitantes. Les algorithmes sont alors distribués au niveau de ces unités pour une plus grande efficacité. De même, on peut chercher à multiplier les unités de stockage, ou bien la bande passante, etc. en distribuant un programme sur plusieurs postes, qui devront alors collaborer.

Cette façon de faire est appelée "calcul parallèle" ou "calcul distribué", et relève également de la programmation concurrente. On peut penser à des systèmes comme le P2P (où les données sont distribuées sur des centaines de postes au moins), ou bien les systèmes de calcul réparti pour la recherche comme SETI@Home. La problématique devient alors de diviser un problème entre les unités traitantes, et de savoir recombiner les résultats efficacement. Et Erlang est également intéressant à utiliser pour étudier ce genre de problèmes.


Les processus et les messages

Les processus et les messages

La programmation concurrente Un peu plus de contrôle sur les processus

Le concept

Pour aborder ces problèmes, il faut les modéliser au moyen de nouveaux concepts. Erlang pose à cette fin deux nouvelles définitions : les processus et les messages. Un processus est un bout de code tournant en parallèle des autres. Par exemple, si l'on crée deux processus A et B en leur attribuant deux séquences de code différentes, les deux s'exécuteront simultanément.

En Erlang, les processus tournent indépendamment les uns des autres. La seule interaction possible entre eux est l'envoi de messages, qui peuvent être constitué de n'importe quelle objet Erlang. L'envoi d'un message suit la syntaxe identifiant ! message.

Une caractéristique intéressante d'Erlang est qu'il n'est pas nécessaire que cet envoi soit synchronisé avec la réception : si le processus qui reçoit le message ne le lit pas immédiatement, il sera conservé, placé "en attente". L'envoi de message n'est donc pas bloquant : une fois que le message est envoyé, le processus expéditeur va continuer son exécution personnelle.

En revanche, la réception de message est, bien sûr, bloquante : les processus sont figés jusqu'à en recevoir un qui correspond à leurs attentes (il est tout à fait possible qu'ils reçoivent des messages d'une forme qu'ils n'ont pas prévue de lire, et que ces messages soient donc ignorés). Toutefois, il est possible de spécifier un temps au-delà duquel le processus arrête d'attendre et poursuit son exécution.

Recevoir un message

La syntaxe de réception de messages va vous sembler familière… elle utilise la correspondance de motifs ! Lors de l'utilisation de receive, le mot clef de réception des messages, on cherche à faire correspondre un motif à chaque message reçu. Notez que l'ordre a une importance : les motifs placés le plus haut sont susceptibles d'être exécutés en premier, puisque leur correspondance est testée avant ceux qui suivent. Cela nous permet de définir facilement des messages prioritaires sur les autres.

Voici l'allure d'un code attendant un message :

receive
    {foo, Bar} -> 
        %% Si on reçoit un doublet dont le premier élément est l'atome foo
        instruction 1,
        instruction 2;
    foo -> 
        %% Si on reçoit foo tout seul
        instruction 1,
        instruction 2;
    Bar ->
        %% Souvenez-vous que ce motif correspondra à n'importe quoi !
        instruction 1,
        instruction 2
after   %% Rien reçu après X milisecondes ?
    X -> 
        instruction 1,
        instruction 2
end

La partie concernant after est optionnelle, elle sert juste à préciser un délai après lequel on fera autre chose. Le bloc receive s'utilise donc à l'intérieur du code d'un processus. Mais au juste, comment créer ces processus ? C'est le rôle de la fonction spawn.

La fonction prédéfinie spawn (de même que ses éventuelles variantes) sert à lancer un nouveau processus. Elle attend trois arguments : un module, une fonction dans ce module, et une liste d'arguments, qui représentent le code à lancer en parallèle. Elle retourne un identifiant de processus, qui est représenté en Erlang par un objet de la forme <a.b.c> où a, b et c sont des entiers naturels. Un tel objet est appelé PID, c'est un identifiant de processus unique. Les trois nombres qu'il contient servent en effet à repérer le processus sur un nœud erlang donné - nous ne nous y intéresserons pas pour le moment.

Un exemple

Nous allons écrire un petit exemple de deux processus qui s'envoient des messages : l'un des deux jouera en quelque sorte un rôle de serveur, auquel on confiera d'ailleurs le rôle de stocker le nombre de messages reçus pour le renvoyer à l'autre, qui sera le client. Les messages seront élémentaires : le client se contentera d'envoyer un atome ping au serveur, qui lui renverra un tuple {pong, N} où N est le nombre stocké. Naturellement, il faudra penser à ajouter 1 à N à chaque fois.

Vous ne le voyez peut-être pas pour l'instant, mais tel quel notre projet est compromis : comment le client et le serveur vont-ils savoir à qui s'adresser, c'est à dire quel est le PID auquel ils doivent envoyer leurs messages ? On pourra passer le PID du serveur au client lors de la création du processus (comme s'il en connaissais déjà l'adresse). En revanche, pour que la réponse du serveur soit possible, le client devra également envoyer son propre PID au serveur ; le message ne sera donc pas un simple atome, mais plutôt {ping, PID}. Pour connaître son propre PID, un processus peut utiliser la fonction self.

La notation ?MODULE correspond à une macro. Rien de bien sorcier à comprendre pour l'instant : ?MODULE est automatiquement remplacé par le nom du module courant.

-module(pingpong).

-export([start/0, pingueur/1, pongueur/1]).

%% Fonction qui va lancer nos deux processus avec le nombre initial
start() ->
    PID = spawn(?MODULE, pongueur, [0]),
    spawn(?MODULE, pingueur, [PID]). 
    
pingueur(PID) -> 
    PID ! {ping, self()},  
    receive 
        {pong, N} ->
            io:format("Reçu ~w~n", [N])
    end,
    pingueur(PID).  %% On boucle
    
pongueur(N) ->
    timer:sleep(2000),   %% Sert à attendre deux secondes (pour que les choses n'aillent pas trop vite)
    receive
        {ping, PID} ->   %% Un ping envoyé par PID
            M = N + 1,   %% Un message de plus
            PID ! {pong, M},
            pongueur(M)  %% On boucle avec le nouveau nombre de messages reçus.
    after
        5000 -> io:format("Je ne reçois rien, donc je m'arrête.~n")
    end.

Notez que start renvoie au passage le PID du processus pingueur, on peut donc le récupérer en utilisant le module :

1> c(pingpong).
{ok,pingpong}
2> Foo = pingpong:start().
<0.39.0>
Reçu 1
Reçu 2
3> Foo ! {pong, 42}.    %% On envoie un message au processus pingueur !
Reçu 42
{pong,42}
Reçu 3
Reçu 4
4>

Pour stopper Erlang, faites un ctrl-c ;) .

Vous pouvez expérimenter plusieurs choses rigolotes sur ce code. Par exemple, modifiez le code pour que le processus de ping ne soit pas lancé, afin de voir ce que fait after. Vous pouvez aussi lancez un deuxième client, et rajouter le code nécessaire à l'affichage par la fonction pingueur du numéro du client lancé. Que se passe-t-il si les deux clients tournent en même temps ?


La programmation concurrente Un peu plus de contrôle sur les processus

Un peu plus de contrôle sur les processus

Les processus et les messages Un autre exemple

À côté de ces concepts élémentaires pour la programmation parallèle, Erlang met à notre disposition quelques outils pour nous faciliter la tâche. Le premier est plus ou moins un gadget qui permet de nommer des processus.

Nommer les processus

Erlang nous laisse associer un nom de notre choix à un PID, qui nous permettra de rendre plus lisible notre code. La fonction standard register/2, qui prend en argument un atome et un PID, rend global un identifiant qui peut être utilisé à la place du PID du processus, et ce dans n'importe quelle partie du code.

Vous pouvez facilement adapter le code précédent pour qu'il ne soit plus nécessaire de passer en argument du client l'identifiant du processus : les deux premières fonctions sont maintenant

start() ->
    PID = spawn(?MODULE, pongueur, [0]),
    register(serveur, PID),
    spawn(?MODULE, pingueur, []). 
    
pingueur() -> 
    serveur ! {ping, self()},
    receive 
        {pong, N} ->
            io:format("Reçu ~w~n", [N])
    end,
    pingueur().  %% On boucle

Si vous lancez le code depuis la ligne de commande, vous pourrez cette fois-ci envoyer un message au processus serveur en écrivant server ! {ping, self()}.. Vous n'aurez pas la réponse du serveur, mais un délai supplémentaire de deux secondes entre deux affichages du message du client, car le serveur aura bien été occupé momentanément par ce nouveau client :) .

Vous pouvez utiliser registered/0 pour récupérer la liste des processus nommés localement. Un nom est oublié à l'aide de unregister/1, ou lorsque le processus se termine. Enfin, whereis/1 fait correspondre au nom passé en argument l'éventuel PID auquel il correspond, ou undefined sinon.

Les liens

Comme je l'ai dit, si jamais spawn ne réussit pas à exécuter un processus, le code se déroule quand même comme si de rien n'était (si vous essayez à l'aide de la ligne de commande, vous aurez un rapport d'erreur, mais pas pour autant de véritable erreur, au sens de celles que nous verrons au chapitre 5). En fait, le processus fils est lancé indépendamment du processus père, il vit en quelques sortes sa vie. Mais on peut s'arranger pour que les deux gardent contact, à l'aide de la fonction spawn_link/3, qui prend les mêmes arguments que spawn, ou bien de la fonction link, qui elle, utilisée à l'intérieur d'un processus X, va relier X au processus précisé en argument.

Lier des processus en Erlang nous permettra par la suite de traiter efficacement les erreurs des processus fils. Pour l'instant, je ne vais présenter que l'interception du message de sortie qui est produit lorsqu'un processus se termine. Il est bien sûr inutile que le processus serveur tourne indéfiniment alors que le processus client est arrêté. Notre première solution pour éviter cette situation a été d'utiliser after, mais on peut à la place utiliser un lien, qui présente l'avantage de ne pas placer de délai arbitraire dans le code.

On va donc, au lancement du processus de ping, relier les deux processus entre eux. On va également rajouter la possibilité pour le client de recevoir en message l'atome exit que nous enverrons depuis la ligne de commande, ce qui modifie un peu le début de notre code. La nouvelle fonction peut surprendre, c'est juste un détail : pingueur est exécutée plusieurs fois, il est inutile de recréer à chaque fois un lien à l'aide de link, donc on sépare l'utilisation de link du code répété.

-module(pingpong3).

-export([start/0, pingueur_start/0, pongueur/1]).

%% Fonction qui va lancer nos deux processus avec le nombre initial
start() ->
    PID = spawn(?MODULE, pongueur, [0]),
    register(serveur, PID),
    spawn(?MODULE, pingueur_start, []). 
    
pingueur_start() ->          
    link(whereis(serveur)),   %% On utilie whereis car link attend un PID.
    pingueur().
  
pingueur() -> 
    serveur ! {ping, self()},
    receive 
        {pong, N} ->
            io:format("Reçu ~w~n", [N]),
            pingueur(); %% On boucle
        exit -> 
            ok   %% Si on reçoit un message 'exit', on arrête tout
    end.

Que se passe-t-il lorsqu'un processus A se termine ? Selon qu'il termine sur une erreur ou sur un autre résultat (nous en reparlerons au prochain chapitre), la situation n'est pas la même. Dans le premier cas, un message d'erreur est propagé à tous les processus auxquels A est relié, et ces processus terminent également, sauf si une certaine option (propre à chaque processus) est réglée à true ; cette option, qui s'appelle trap_exit, intervient dans le deuxième cas : si le processus A termine sur autre chose qu'une erreur, alors les processus liés à A ne reçoivent un message que si l'option trap_exit est réglée sur true.

C'est peut-être un peu flou pour l'instant, mais nous en reparlerons très prochainement. Pour l'instant, contentons nous de comprendre que si nous réglons l'option trap_exit, alors lorsque le processus de ping terminera, un message particulier sera envoyé au processus de pong. Sans plus de commentaires, voici le code correct :

pongueur(N) ->
    process_flag(trap_exit, true),
    timer:sleep(2000),   %% Sert à attendre deux secondes (pour que les choses n'aillent pas trop vite)
    receive
        {ping, PID} ->   %% Un ping envoyé par PID
            M = N + 1,   %% Un message de plus
            PID ! {pong, M},
            pongueur(M); %% On boucle avec le nouveau nombre de messages reçus.
        {'EXIT', PID, Raison} -> 
            io:format("Le serveur s'arrête !~n")
    end.

Notez le réglage de l'option, ainsi que le motif correspondant au message spécial de fin de processus. C'est un mécanisme utile qui nous permet de synchroniser un minimum nos processus, en leur donnant la possibilité de connaître l'état (vivant ou mort) de leurs voisins.

Lorsque Erlang a commencé à être utilisé sur des réseaux de grande taille, pour de la production industrielle, il devenait important de distinguer le code de gestion des erreurs du reste. Afin de surveiller l'état du réseau, le code de gestion devait avoir conscience des problèmes qui pouvaient survenir. Le mécanisme de liens nous donne l'opportunité de propager facilement des informations en cas de pépin : c'est une notion très importante en Erlang. Nous serons donc amené à la recroiser.


Les processus et les messages Un autre exemple

Un autre exemple

Un peu plus de contrôle sur les processus

Nous allons encore utiliser un peu les processus pour modéliser un "faux problème réel". Joe est loueur de vélos en bord de mer. L'été, la ville dans laquelle il travaille se remplie de touristes. Comme il n'y a rien de plus agréable que de faire du vélo au bord de l'eau, ces derniers foncent tous chez Joe pour lui en louer un. Naturellement, Joe doit tenir le compte de ses vélos, gérer les demandes aussi bien que les retours, et tout ça sans personne pour l'aider : comme son commerce est récent, Joe n'a pas encore les moyens d'embaucher un employé !

Voici un premier code, sa compréhension ne devrait pas vous poser de problème car je n'ai utilisé que des concepts que nous avions déjà vus :

-module(joe).

-export([start/2, clients_start/1, client/0, serveur/1]).

start(Velos, ClientsMax) -> 
    register(joe, spawn(?MODULE, serveur, [Velos])),
    spawn(?MODULE, clients_start, [ClientsMax]).
  
clients_start(0) -> 
    ok;
clients_start(N) -> 
    timer:sleep(mon_random()), %% On attend un petit peu avant de lancer un nouveau processus
    spawn(?MODULE, client, []),
    clients_start(N-1).

client() ->
    joe ! {emprunte, self()},
    receive 
        {velo, N} ->                        %% Si on reçoit le vélo n°N
            timer:sleep(1500),               %% Le temps d'une petite balade?
            joe ! {restitue, N, self()};    %% et on rend le vélo !
        non ->                              %% Pas de vélo disponible ?
            io:format("Un client s'en va, furieux !~n")
    end,
    timer:sleep(1500), %% Attente avant que le client ne revienne
    client().
    
serveur(N) ->
    receive
        {emprunte, PID} ->  
            if
                N > 0 -> 
                    io:format("On confie un vélo?~n"),
                    PID ! {velo, N},
                    serveur(N-1);
                true ->
                    PID ! non,
                    serveur(0)
            end;
        {restitue, _, _} ->     %% Voir plus bas
            io:format("Vélo récupéré, merci de votre visite !~n"),
            serveur(N+1) 
    end.
    
mon_random() ->
    random:uniform(3000) + 2000. %% Pour attendre entre 2 et 5 secondes, au hasard?

Ce code est sujet à plusieurs améliorations. On pourrait s'amuser à rajouter des clients de différents types, certains étant capable d'attendre un petit peu avant de partir, en colère, parce qu'il n'y a plus de vélo disponible. Cette attente, vous pourriez la coder à l'aide de l'instruction after ;) . On pourrait également mettre en place un système de prix, ne plus se contenter de numéroter les vélos selon leur ordre de retour mais en gardant une numérotation fixe (c'est à ça que pourrait servir le N passé dans le message {velo, N}, ou dans le message de restitution).

On pourrait également considérer que Joe, après un été ou deux, a suffisamment de clients pour embaucher quelqu'un (ce qui se traduira par un deuxième processus jouant le rôle de Joe). Le problème est maintenant plus compliqué : comment faire pour que les vélos puissent être prêtés aussi bien par Joe que par son employé ?

À vous de voir, si vous avez envie de vous entraîner je vous recommande de plancher sur ces éventuels développements :) .

Son intégration profonde des éléments de la programmation concurrente valent à Erlang la qualification de "langage concurrent". Il a été créé pour ça, alors pourquoi ne pas profiter de sa simplicité et de sa souplesse pour découvrir cet important paradigme, qui n'a certainement pas fini de faire réfléchir les ingénieurs ?


Un peu plus de contrôle sur les processus