AP · Elixir · AP7

Qui garde le nombre entre deux appels ?

Un tableau de contributions, dans notre première application.

AP7 · un projet Mix, trois paliers

Une application qui conserve un état
AP · Elixir · AP7

Comment suivre la séance

Ouvrez compteur-local/ dans votre éditeur, à la racine de mix.exs.
Lancez mix test --only palier_1 : les tests échouent d'abord avec « à compléter ».

  1. Un client, un serveur : lire et ajouter · palier 1
  2. Plusieurs clients en même temps · palier 2
  3. Qui relance le compteur ? · palier 3

Chaque partie : un court exposé, un palier du README, une reprise.

Une application qui conserve un état
AP · Elixir · AP7

En AP6, une boucle gardait l'état

defp loop(value) do
  receive do
    {:get, client, ref} ->
      send(client, {:result, ref, value})
      loop(value)
  end
end

Le processus qui exécute cette boucle est le propriétaire de l'état : lui seul lit et remplace value.

Une application qui conserve un état
AP · Elixir · AP7

Agent garde une valeur pour nous

{:ok, counter} = Agent.start_link(fn -> 0 end)
Agent.update(counter, fn count -> count + 1 end)
Agent.get(counter, fn count -> count end)   # 1
Agent.stop(counter)

La fonction passée à update s'exécute dans le processus de l'Agent, qui garde le résultat.

Une application qui conserve un état
AP · Elixir · AP7

01 · Un client, un serveur, un protocole

Le serveur possède l'état et traite les demandes une par une.
Le client envoie une demande, puis attend la réponse.
Le protocole fixe les demandes permises et leurs réponses.

Une application qui conserve un état
AP · Elixir · AP7

GenServer écrit la boucle à notre place

En AP6, à la main Avec GenServer
spawn(fn -> loop(initial) end) GenServer.start_link, qui appelle init/1
make_ref, send, puis Replies.await GenServer.call(server, requête)
une branche du receive une clause de handle_call/3
répondre, puis rappeler loop(next) rendre {:reply, réponse, next}
Une application qui conserve un état
AP · Elixir · AP7

GenServer.call traverse deux processus

Le client appelle GenServer.call et attend ; le serveur exécute handle_call avec son état 5 et rend reply 5, 5

Le même module contient le code du client et celui du serveur.

Une application qui conserve un état
AP · Elixir · AP7

Une transition rend une réponse et un état

defmodule Ticket do
  use GenServer

  def start_link(first), do: GenServer.start_link(__MODULE__, first)
  def take(server), do: GenServer.call(server, :take)

  @impl true
  def init(first), do: {:ok, first}

  @impl true
  def handle_call(:take, _from, number), do: {:reply, number, number + 1}
end

Après start_link(1), take rend 1, puis 2 : le client reçoit number, le serveur garde number + 1.

Une application qui conserve un état
AP · Elixir · AP7

Elixir pour cet atelier · 01

  • GenServer.call(server, requête) s'exécute chez le client et rend la réponse.
  • handle_call(requête, _from, état) rend {:reply, réponse, nouvel_état}.
  • server est un PID ou un nom enregistré, comme CounterLocal.Counter.
  • def increment(server, amount \\ 1) : \\ donne une valeur par défaut à amount.
  • mix test --only palier_1, puis iex -S mix ; recompile() recharge le code modifié.
Une application qui conserve un état
AP · Elixir · AP7

À vous · 01 · Lire et ajouter

Projet compteur-local, palier 1. 40 min.

Complétez value/1 et increment/2 avec GenServer.call.
Écrivez la clause d'ajout de handle_call/3 : la réponse, puis l'état.
Essayez l'API dans IEx avec le compteur nommé Counter.

Une application qui conserve un état
AP · Elixir · AP7

Reprise · La réponse ou l'état ?

Une clause peut répondre le bon total et garder l'ancien état.
Quel test le détecte, et à quelle ligne ?
Qui exécute value/1 ? Qui exécute handle_call/3 ?

Une application qui conserve un état
AP · Elixir · AP7

Pause · 10 min

Au retour : cent clients pour un seul compteur.

Une application qui conserve un état
AP · Elixir · AP7

02 · Plusieurs clients en même temps

Le serveur traite une requête jusqu'au bout avant de prendre la suivante.
Chaque clause de handle_call est donc une transition atomique.

Une application qui conserve un état
AP · Elixir · AP7

Lire, puis écrire : deux requêtes

Alice et Bob lisent 0, puis écrivent chacun 1 ; l'état final vaut 1 au lieu de 2

Chaque requête est atomique, mais leur suite ne l'est pas.

Une application qui conserve un état
AP · Elixir · AP7

Cent clients, cent tâches

clients = for _ <- 1..100, do: Task.async(fn -> Counter.increment(counter) end)
replies = Task.await_many(clients)

Task.async lance un processus qui calcule un résultat.
Task.await_many attend tous les résultats et les range dans l'ordre de la liste.

Une application qui conserve un état
AP · Elixir · AP7

Elixir pour cet atelier · 02

  • test "ce qui est vérifié" do ... end, précédé de @tag :palier_2, dans test/counter_test.exs.
  • start_supervised!({Counter, initial: 7}) démarre un compteur propre au test, arrêté à la fin.
  • Supervisor.child_spec(Counter, id: :alice) distingue deux compteurs dans un même test.
  • mix test --include palier_2 ajoute les tests du deuxième palier.
Une application qui conserve un état
AP · Elixir · AP7

À vous · 02 · Compter toutes les contributions

Projet compteur-local, palier 2. 28 min.

Écrivez la remise à zéro, côté client puis côté serveur.
Prédisez les réponses des cent clients, puis lancez les tests du palier.
Ajoutez un test de votre choix et expliquez ce qu'il vérifie.

Une application qui conserve un état
AP · Elixir · AP7

Reprise · Concurrent ne veut pas dire plus rapide

Cent clients avancent en même temps ; le compteur traite leurs requêtes une par une.

Si handle_call faisait un long calcul, qu'arriverait-il aux lectures ?
Où placer ce calcul pour que le compteur continue de répondre ?

Une application qui conserve un état
AP · Elixir · AP7

03 · Qui relance le compteur ?

children = [{CounterLocal.Counter, name: CounterLocal.Counter}]
Supervisor.start_link(children,
  strategy: :one_for_one, name: CounterLocal.Supervisor)

Le superviseur appellera start_link avec ces options, et le compteur sera joignable par son nom.

Une application qui conserve un état
AP · Elixir · AP7

Un nom survit au redémarrage, pas le total

Avant l'arrêt Après le redémarrage
un PID, le nom Counter, un total un nouveau PID, le même nom, le total 0

Le superviseur rétablit le service ; il ne sauvegarde pas les données.

Une application qui conserve un état
AP · Elixir · AP7

À vous · 03 · Arrêter le compteur, puis le relire

Projet compteur-local, palier 3. 22 min.

Lancez les tests des trois paliers et lisez celui du redémarrage.
Dans IEx, tuez le compteur de l'application, puis relisez son PID et son total.
Expliquez ce que le superviseur a reconstruit, et ce qu'il n'a pas pu reconstruire.

Une application qui conserve un état
AP · Elixir · AP7

Où en sommes-nous ?

Nous savons confier un état à un processus propriétaire, écrire ses transitions et le faire relancer.

Tous les clients doivent pourtant joindre ce serveur.
Comment continuer à compter s'il devient inaccessible ?

Une application qui conserve un état

À dire : En AP6, nous avons écrit à la main un compteur qui vivait dans un processus et répondait à des messages. Aujourd'hui, nous en faisons une vraie application : un tableau de contributions auquel de nombreux clients ajoutent leur part, démarré par un superviseur. Nous ne réécrirons plus la boucle de réception : OTP la fournit, sous le nom de GenServer, et nous écrirons seulement ce qui est propre à notre service. Nous verrons aussi qu'un serveur unique protège bien son état, à condition que les clients lui demandent les bonnes choses. Ouvrez le projet compteur-local pendant que je vous montre comment la séance s'organise. Repères : avant le lancement, quiz d'ouverture sur AP6 (quiz/quiz-7-processus, chapitre 9 du poly), dix minutes de réponse et cinq de correction, pris sur la marge du créneau. Le parcours tient en 180 min, pause comprise ; l'ouverture, la reprise d'AP6 et l'essai Agent prennent 20 min en tout.

À dire : Le projet est un squelette : l'infrastructure et les tests sont fournis, et vous écrivez les fonctions qui manquent. Au départ, les tests du premier palier échouent avec « à compléter » : c'est normal, ils décrivent ce que vous allez écrire. Le README guide chaque palier, avec ses commandes et ses questions ; je présente chacun en quelques minutes, vous travaillez, puis nous reprenons ensemble. Prenez l'habitude de prédire le résultat d'un test avant de le lancer. Commençons par retrouver ce que nous savons déjà. Repères : 3 min. Vérifier que mix test --only palier_1 compile chez tout le monde ; le projet n'a aucune dépendance. Les paliers suivants restent désactivés tant qu'on ne les inclut pas avec --include.

À dire : Voici la branche de lecture fournie dans le compteur d'AP6. L'état, value, est l'argument de la boucle ; la réponse part vers le client, puis la boucle se rappelle avec le même état. Le processus qui exécute cette boucle est le propriétaire de l'état : les autres ne peuvent que lui envoyer des demandes. Pour obtenir ce service, nous avions écrit beaucoup de choses à la main : la boucle, la référence, l'attente bornée, l'envoi de la réponse. Avant de voir comment OTP s'en charge, essayons la brique la plus simple qu'il fournit. Repères : 5 min. Code à lire, pas à exécuter seul : c'est un extrait du module Counter d'AP6. Faire désigner l'état courant et l'argument du prochain appel, et demander où s'exécute ce code : dans le processus propriétaire, pas chez l'expéditeur.

À dire : Un Agent est un processus fourni par OTP qui garde une seule valeur ; c'est lui que nous avons vu renaître à zéro sous un superviseur à la fin d'AP6. On lui envoie des fonctions : update applique la fonction à la valeur courante, dans le processus de l'Agent, et garde le résultat ; get applique la fonction et nous rend son résultat. Avant d'évaluer get, quelle valeur attendez-vous ? Essayez ces quatre lignes dans IEx. L'Agent suffit pour garder une valeur, mais chaque client peut lui envoyer n'importe quelle fonction ; pour un service, nous préférerons fixer la liste des demandes permises. Repères : 10 min, essai dans IEx compris. Résultat : 1 ; aucune variable n'a changé chez l'appelant. La variante get_and_update du README lit et modifie en une seule requête : la garder pour les rapides. Pas de dictionnaire de processus.

À dire : Ce motif est partout : votre navigateur est client d'un serveur web, une application est cliente d'une base de données, et dans notre projet, chaque contributeur sera client du compteur. Le serveur est le seul à connaître l'état ; il reçoit les demandes dans sa boîte et les traite l'une après l'autre. Le protocole est la liste des demandes qu'il accepte, avec la réponse de chacune, exactement comme le tableau du compteur d'AP6. Ce qui change d'un serveur à l'autre, ce sont les demandes et l'état ; le reste, la boucle, les références, les délais, est toujours le même. C'est ce reste qu'OTP a écrit une fois pour toutes. Repères : exposé de la partie 1 : 15 min en tout, dont 2 pour cette slide. Client et serveur sont ici deux processus de la même machine virtuelle ; aucun réseau.

À dire : Mettons côte à côte ce que nous écrivions en AP6 et ce que fournit GenServer. Le démarrage devient start_link, qui lance le processus et appelle notre init pour obtenir l'état initial. Côté client, GenServer.call crée la référence, envoie la demande et attend la réponse, avec un délai de cinq secondes. Côté serveur, chaque branche du receive devient une clause de handle_call ; au lieu d'envoyer la réponse puis de nous rappeler, nous rendons un tuple qui contient la réponse et l'état suivant, et OTP fait le reste. Nous n'écrivons plus que ce qui est propre à notre service : ses transitions. Repères : 3 min. Différence importante avec Replies.await : à l'expiration du délai, GenServer.call ne rend pas {:error, :timeout}, il provoque une sortie chez l'appelant, et le délai expiré ne dit toujours pas si la demande a été traitée. GenServer.cast envoie sans attendre de réponse : une autre API, pas une optimisation à imposer ; les ajouts du projet utilisent call, pour que le client sache quand sa contribution est prise en compte.

À dire : Suivons Counter.value, la lecture du squelette, sur un diagramme de séquence. À gauche, le client appelle value, qui appelle GenServer.call : ce code s'exécute dans le processus du client, qui attend ensuite. La demande :value traverse jusqu'au serveur ; là, la boucle d'OTP appelle notre handle_call avec la demande, l'expéditeur et l'état courant, 5. La clause rend {:reply, 5, 5} : le premier 5 part vers le client, le second reste l'état du serveur. Retenez que value et handle_call sont écrites dans le même fichier, mais ne s'exécutent jamais dans le même processus. Repères : 4 min. Faire nommer, pour chaque fonction du squelette, le processus qui l'exécute. Le paramètre _from sert à OTP pour répondre ; nos clauses n'en ont pas besoin. La clause :value est fournie dans le squelette : elle sert d'exemple.

À dire : Voici un autre service, un distributeur de tickets comme à l'entrée d'une préfecture. Chaque demande :take rend le numéro courant au client, et le serveur garde le numéro suivant pour la prochaine demande : la réponse et l'état suivant sont deux valeurs différentes. C'est ce qu'on appelle une transition : à partir d'un état et d'une demande, une clause calcule une réponse et l'état suivant, sans rien modifier. use GenServer déclare que le module suit ce contrat ; @impl true signale qu'une fonction est un callback, ce que le compilateur vérifie ; __MODULE__ désigne le module lui-même. Dans votre compteur, la lecture rend l'état sans le changer ; à vous de trouver la réponse et l'état suivant d'un ajout. Repères : 4 min. Exemple distinct du squelette, vérifié dans Elixir : start_link(1), puis take rend 1 et 2. Ne pas écrire au tableau la clause d'ajout du compteur.

À dire : Ces formes suffisent pour le premier palier. Le squelette déclare déjà increment avec une valeur par défaut : increment(c) ajoute 1, increment(c, 4) ajoute 4. Dans IEx, recompile() recharge votre code après une modification, mais les processus déjà lancés gardent leur état. À vous. Repères : 2 min. Repères du README : contrats des trois fonctions publiques et des clauses de handle_call. La fiche GenServer se consulte quand une forme manque, pas comme prérequis.

À dire : Vous avez quarante minutes pour le premier palier, puis nous reprendrons. Commencez par value, dont la clause serveur est fournie : un premier test doit passer très vite. Pour l'ajout, demandez-vous avant d'écrire : que reçoit le client, que garde le serveur ? Puis jouez la séquence du README dans IEx, en prédisant chaque valeur. Repères : 40 min d'atelier, puis 5 min de reprise. Résultat attendu : mix test --only palier_1 passe (trois tests). Piège principal : répondre le bon total mais rendre l'ancien état ; le test « le total conserve les contributions successives » le détecte à la lecture suivante. Pour les rapides : la remise à zéro du palier 2, ou le détour Agent du README. Si le groupe prend du retard, terminer un callback ensemble au tableau.

À dire : Une clause qui répond le bon total mais rend l'ancien état passe la première assertion d'un test, puis échoue à la lecture suivante : la réponse est partie, mais rien n'a été gardé. C'est le même souci qu'en AP6, quand la boucle se rappelait avec l'ancienne valeur. Quant aux deux fonctions, value s'exécute chez le client, et handle_call dans le serveur, même si elles sont écrites côte à côte. Après la pause, nous lâcherons cent clients sur ce compteur. Repères : 5 min, comptées avec l'atelier. Faire montrer une trace IEx par un élève ; pas de corrigé projeté.

À dire : Dix minutes de pause. Si un test du premier palier échoue encore, notez lequel avant de partir. Au retour, nous verrons si notre compteur résiste à de nombreux clients en même temps. Repères : 10 min.

À dire : Votre compteur va maintenant recevoir les demandes de cent clients à la fois. Leurs messages s'accumulent dans la boîte du serveur, qui les traite l'un après l'autre : pendant qu'une clause s'exécute, aucune autre demande ne peut s'intercaler. On dit que chaque transition est atomique, c'est-à-dire indivisible vue de l'extérieur. Est-ce suffisant pour ne perdre aucune contribution ? Cela dépend de la manière dont les clients découpent leurs demandes. Repères : exposé de la partie 2 : 12 min en tout, dont 2 pour cette slide.

À dire : Imaginons un compteur qui offrirait une lecture et une écriture, et deux clients qui veulent ajouter 1. Alice lit 0, Bob lit 0, puis chacun écrit 1 : le serveur a traité quatre requêtes atomiques, l'une après l'autre, et pourtant une contribution est perdue. Reconnaissez-vous le tableau des deux fils d'AP6 ? Les processus ont supprimé la mémoire partagée, mais pas les courses entre messages : l'atomicité vaut pour une requête, pas pour une suite de requêtes. Le remède est de mettre toute la décision dans une seule requête, que le serveur calcule à partir de son état courant : « ajoute 1 », et non « écris 1 ». Repères : 4 min. Contre-exemple déterministe : ne pas le faire dépendre d'une course réelle ni d'un Process.sleep. Notre squelette n'offre justement pas d'écriture séparée. Même idée que Agent.get_and_update, et que la règle « vérifier et réserver dans la même requête » du projet réservations-concurrentes d'AP9.

À dire : Pour éprouver le compteur, le test du palier 2 lance cent clients. Task.async démarre un petit processus qui exécute la fonction et garde son résultat ; il est lié à son lanceur, si bien qu'une erreur dans un client fait échouer le test. Task.await_many attend que les cent tâches aient fini et rend leurs résultats dans l'ordre de la liste des tâches, pas dans l'ordre où elles ont fini. Avant de lancer ce test, prédisez : quelles réponses le compteur peut-il faire ? La liste des réponses est-elle forcément croissante ? Repères : 4 min. Attendu : chaque client reçoit une valeur différente, de 1 à 100 ; la liste n'est pas croissante en général, d'où le Enum.sort du test. Ce test montre que rien n'est perdu, pas une accélération : le compteur traite les cent requêtes en série.

À dire : La remise à zéro s'écrit comme l'ajout : une fonction cliente, puis une clause de handle_call. Pour votre test personnel, recopiez la forme des tests existants : chaque test démarre son propre compteur avec start_supervised!, si bien que les tests ne se gênent pas entre eux ni avec le compteur de l'application. Choisissez un cas limite et écrivez en une phrase ce qu'il vérifie. Repères : 2 min. Les tests démarrent leurs compteurs sous le superviseur de test d'ExUnit, indépendants de l'application.

À dire : Vous avez près d'une demi-heure pour le deuxième palier. La remise à zéro est courte ; l'essentiel est de prédire les cent réponses et d'expliquer pourquoi aucune contribution n'est perdue. Écrivez votre explication dans un commentaire ou un fichier de notes : elle compte autant que le code. Repères : 28 min d'atelier, puis 5 min de reprise. Résultat attendu : mix test --include palier_2 passe. Ne pas affirmer un ordre global de réponse entre clients : seul le tri des réponses est prévisible. Pour les rapides : comparer avec Agent.get_and_update, puis lire le palier 3.

À dire : Nos cent clients sont concurrents, mais le compteur traite leurs cent requêtes l'une après l'autre : ce test montre qu'aucune contribution n'est perdue, pas que le programme va plus vite. Si l'une des clauses faisait un long calcul, toutes les demandes suivantes, même les simples lectures, attendraient sa fin. Il faudrait alors confier ce calcul à d'autres processus, des travailleurs, et ne garder dans le serveur que la mise à jour de l'état. C'est exactement le sujet du projet ferme de rendu d'AP9 ; le projet réservations concurrentes, lui, reprend l'idée d'une requête qui vérifie et décide d'un seul tenant. Repères : 5 min, comptées avec l'atelier. Ces ouvertures ne demandent aucun code pendant AP7.

À dire : Ouvrez application.ex : c'est l'arbre que vous avez construit à la fin d'AP6, avec cette fois notre compteur comme enfant. La paire module et options est une recette abrégée, une child spec : le superviseur appellera start_link de Counter avec ces options, et le compteur sera enregistré sous le nom CounterLocal.Counter. Ce nom évite de transporter un PID : dans IEx, Counter.value(Counter) trouve le processus par son nom. Mais que devient ce nom si le processus est tué ? Repères : exposé de la partie 3 : 8 min en tout, dont 4 pour cette slide. Fichier fourni, à lire seulement. Les tests démarrent leurs propres compteurs avec start_supervised! ; ils ne touchent pas celui de l'application.

À dire : Le superviseur relance un nouveau processus avec la même recette : il porte le même nom, si bien que les clients le retrouvent, mais son état est celui que rend init, c'est-à-dire zéro. Vous l'aviez vu en AP6 avec un Agent ; ici, c'est votre propre serveur. Le redémarrage prend un peu de temps : un appel fait juste après l'arrêt peut échouer, et le test du palier 3 attend la notification de démarrage plutôt que de dormir. Prédisez le total avant de le lire ! Repères : 4 min. Ne jamais promettre la conservation de l'état. Le test du palier 3 tue un compteur qui appartient au test, pas celui de l'application.

À dire : Pour ce dernier palier, il n'y a presque rien à écrire : il s'agit d'observer et d'expliquer. Suivez l'expérience du README : gardez le PID du compteur, tuez-le avec Process.exit, puis, au prompt suivant, relisez son PID et sa valeur. Écrivez votre prédiction avant chaque ligne. Repères : 22 min d'atelier, puis 5 min de reprise. Commande : mix test --include palier_2 --include palier_3. Résultat attendu : un PID différent sous le même nom, et la valeur 0. Si le groupe est en retard, faire l'expérience au tableau et garder la trace « lecture séparée contre incrément atomique » pour tous.

À dire : Récapitulons : un seul processus possède l'état, chaque demande est une transition atomique, et un superviseur relance le service sans en sauvegarder les données. Ce serveur unique a cependant un prix : tous les clients doivent pouvoir le joindre. Imaginez que la moitié de la classe travaille dans un train, sans réseau : comment continuer à compter ? Faites-moi vos propositions, je les note, et nous les mettrons à l'épreuve en AP8, où chaque poste gardera sa propre copie du tableau. Le quiz d'ouverture d'AP8 portera sur cette séance : relisez le chapitre 10 du poly. Repères : 10 min de bilan et d'annonce d'AP8. Noter au tableau deux ou trois propositions (additionner les totaux, garder le plus grand) : AP8 commence par les éprouver. Les rendus seront précisés en cours ; garder le projet et une courte explication des essais, sans reprendre les anciennes dates de blockchain.