Qu'est-ce que GenServer et pourquoi devriez-vous vous en soucier?

Dans cet article, vous allez apprendre les bases de la simultanéité dans Elixir et voir comment générer des processus, envoyer et recevoir des messages et créer des processus de longue durée. Vous allez également apprendre à connaître GenServer, voir comment il peut être utilisé dans votre application et découvrir quelques avantages qu'il vous fournit..

Comme vous le savez probablement, Elixir est un langage fonctionnel utilisé pour créer des systèmes simultanés tolérants aux pannes, qui gèrent de nombreuses demandes simultanées. BEAM (machine virtuelle Erlang) utilise les processus exécuter plusieurs tâches simultanément, ce qui signifie, par exemple, que le traitement d’une demande ne bloque pas une autre. Les processus sont légers et isolés, ce qui signifie qu'ils ne partagent aucune mémoire et que même si un processus se bloque, d'autres peuvent continuer à s'exécuter..

Les processus BEAM sont très différents des Processus OS. BEAM s’exécute dans un processus d’exploitation et utilise son propre ordonnanceurs. Chaque planificateur occupe un noyau CPU, s'exécute dans un thread séparé et peut gérer des milliers de processus simultanément (qui s'exécutent à tour de rôle). Vous pouvez en savoir plus sur BEAM et le multithreading sur StackOverflow.

Donc, comme vous le voyez, les processus BEAM (je dirai simplement "processus" à partir de maintenant) sont très importants dans Elixir. Le langage vous fournit des outils de bas niveau pour générer manuellement des processus, gérer l'état et gérer les demandes. Cependant, peu de gens les utilisent - il est plus courant de s’en remettre au Open Telecom Platform (OTP) cadre pour le faire. 

À l'heure actuelle, OTP n'a rien à voir avec les téléphones. Il s'agit d'un cadre généraliste permettant de créer des systèmes simultanés complexes. Il définit la manière dont vos applications doivent être structurées et fournit une base de données ainsi qu'un ensemble d'outils très utiles pour créer des processus serveur, récupérer des erreurs, effectuer une journalisation, etc. Dans cet article, nous allons comportement du serveur appelé GenServer qui est fourni par OTP.  

Vous pouvez considérer GenServer comme une abstraction ou une aide simplifiant le travail avec les processus du serveur. Tout d'abord, vous verrez comment générer des processus à l'aide de fonctions de bas niveau. Ensuite, nous allons passer à GenServer et voir comment cela nous simplifie les choses en supprimant le besoin d'écrire du code fastidieux (et plutôt générique) à chaque fois. Commençons!

Tout commence avec Spawn

Si vous me demandiez comment créer un processus dans Elixir, je répondrais: frayer il! spawn / 1 est une fonction définie à l'intérieur du Noyau module qui renvoie un nouveau processus. Cette fonction accepte un lambda qui sera exécuté dans le processus créé. Dès que l'exécution est terminée, le processus se termine également:

spawn (fn -> IO.puts ("hi") end) |> IO.inspect # => hi # => #PID<0.72.0>

Alors, ici frayer a renvoyé un nouvel identifiant de processus. Si vous ajoutez un délai au lambda, la chaîne "hi" sera imprimée après un certain temps:

spawn (fn ->: timer.sleep (5000) IO.puts ("hi") end) |> IO.inspect # => #PID<0.82.0> # => (après 5 secondes) "salut"

Maintenant, nous pouvons générer autant de processus que nous le souhaitons et ils seront exécutés simultanément:

spawn_it = fn (num) -> spawn (fn ->: timer.sleep (5000) IO.puts ("hi # num") end) end Enum.each (1 ... 10, fn (_) -> spawn_it . (: rand.uniform (100)) end) # => (tous imprimés en même temps, après 5 secondes) # => hi 5 # => hi 10 etc… 

Nous créons ici dix processus et imprimons une chaîne de test avec un nombre aléatoire. :rand est un module fourni par Erlang, son nom est donc un atome. Ce qui est bien, c'est que tous les messages seront imprimés en même temps, après cinq secondes. Cela se produit parce que les dix processus sont exécutés simultanément.

Comparez-le à l'exemple suivant qui effectue la même tâche mais sans utiliser frai / 1:

dont_spawn_it = fn (num) ->: timer.sleep (5000) IO.puts ("hi # num") et Enum.each (1… 10, fn (_) -> dont_spawn_it. (: rand.uniform ( 100)) end) # => (après 5 secondes) hi 70 # => (après encore 5 secondes) hi 45 # => etc… 

Pendant que ce code fonctionne, vous pouvez vous rendre à la cuisine et préparer une autre tasse de café, car cela prend presque une minute. Chaque message est affiché séquentiellement, ce qui n'est bien sûr pas optimal!

Vous pourriez demander: "Combien de mémoire un processus consomme-t-il?" Cela dépend, mais au départ, il occupe quelques kilo-octets, ce qui est un très petit nombre (même mon ancien ordinateur portable a 8 Go de mémoire, sans parler des serveurs modernes et cool)..

Jusqu'ici tout va bien. Avant de commencer à travailler avec GenServer, discutons encore d’une autre chose importante: passer et recevoir messages.

Travailler avec des messages

Il n’est pas surprenant que les processus (qui sont isolés, vous vous en souvenez) ont besoin de communiquer d’une manière ou d’une autre, en particulier pour créer des systèmes plus ou moins complexes. Pour ce faire, nous pouvons utiliser des messages.

Un message peut être envoyé en utilisant une fonction avec un nom assez évident: send / 2. Il accepte une destination (port, ID de processus ou un nom de processus) et le message réel. Une fois le message envoyé, il apparaît dans la liste boites aux lettres d'un processus et peut être traité. Comme vous le voyez, l’idée générale est très similaire à notre activité quotidienne d’échange de courriels..

Une boîte aux lettres est essentiellement une file d'attente "premier entré premier sorti" (FIFO). Une fois le message traité, il est supprimé de la file d'attente. Pour commencer à recevoir des messages, vous devez deviner quoi! -A recevoir une macro. Cette macro contient une ou plusieurs clauses auxquelles un message est associé. Si une correspondance est trouvée, le message est traité. Sinon, le message est remis dans la boîte aux lettres. En plus de cela, vous pouvez définir une option après clause qui s'exécute si un message n'a pas été reçu dans le temps imparti. Vous pouvez en savoir plus sur envoyer / 2 et recevoir dans les docs officiels.

Ok, assez avec la théorie, essayons de travailler avec les messages. Tout d’abord, envoyez quelque chose au processus en cours:

envoyer (self (), "bonjour!")

La macro self / 0 renvoie un pid du processus appelant, ce qui est exactement ce dont nous avons besoin. N'omettez pas les parenthèses rondes après la fonction car vous obtiendrez un avertissement concernant la correspondance d'ambiguïté..

Maintenant, recevez le message tout en réglant la après clause:

receive do msg -> IO.puts "Oui, un message: # msg" msg après 1000 -> IO.puts: stderr, "Je veux des messages!" end |> IO.puts # => Yay, un message: bonjour! # => bonjour!

Notez que la clause renvoie le résultat de l’évaluation de la dernière ligne, nous obtenons donc le "bonjour!" chaîne.

Rappelez-vous que vous pouvez introduire autant de clauses que nécessaire:

send (self (), : ok, "hello!") reçoit : ok, msg -> IO.puts "Oui, un message: # msg" msg : erreur, msg -> IO .puts: stderr, "Oh non, quelque chose de grave est arrivé: # msg" _ -> IO.puts "Je ne sais pas ce que ce message est…" après 1000 -> IO.puts: stderr, "Je veux des messages!" fin |> IO.puts

Nous avons ici quatre clauses: une pour gérer un message de succès, une autre pour gérer les erreurs, puis une clause "fallback" et un délai d'attente.

Si le message ne correspond à aucune des clauses, il est conservé dans la boîte aux lettres, ce qui n'est pas toujours souhaitable. Pourquoi? Parce que chaque fois qu'un nouveau message arrive, les anciens sont traités dans la première tête (car la boîte aux lettres est une file d'attente FIFO), ce qui ralentit le programme. Par conséquent, une clause de "repli" peut être utile.

Maintenant que vous savez créer des processus, envoyer et recevoir des messages, examinons un exemple un peu plus complexe qui implique la création d'un serveur simple répondant à divers messages..

Travailler avec un processus serveur

Dans l'exemple précédent, nous n'avons envoyé qu'un seul message, l'avons reçu et effectué un travail. C'est bien, mais pas très fonctionnel. Habituellement, nous avons un serveur capable de répondre à divers messages. Par "serveur", j'entends un processus de longue durée construit avec une fonction récurrente. Par exemple, créons un serveur pour effectuer certaines équations mathématiques. Il va recevoir un message contenant l'opération demandée et quelques arguments.

Commencez par créer le serveur et la fonction de bouclage:

defmodule MathServer ne démarre pas spawn & listen / 0 fin defp listen reçoit : sqrt, appelant, arg -> IO.puts arg _ -> IO.puts: stderr, "Non implémenté." end listen () end end

Nous créons donc un processus qui continue à écouter les messages entrants. Une fois le message reçu, le écouter / 0 la fonction est appelée à nouveau, créant ainsi une boucle sans fin. À l'intérieur de écouter / 0 fonction, nous ajoutons un support pour la : sqrt message, qui calculera la racine carrée d’un nombre. le se disputer contiendra le numéro réel pour effectuer l'opération. En outre, nous définissons une clause de secours.

Vous pouvez maintenant démarrer le serveur et affecter son identifiant de processus à une variable:

math_server = MathServer.start IO.inspect math_server # => #PID<0.85.0>

Brillant! Ajoutons maintenant un fonction d'implémentation pour effectuer le calcul:

defmodule MathServer do #… def sqrt (serveur, argument) ne pas envoyer (: nom_some, : sqrt, self (), argument) end end

Utilisez cette fonction maintenant:

MathServer.sqrt (math_server, 3) # => 3

Pour l'instant, il affiche simplement l'argument passé. Modifiez votre code comme suit pour effectuer l'opération mathématique:

defmodule MathServer do #… defp listen reçoit do: sr: appelant, argument -> send (: un_nom_de, : résultat, do_sqrt (argument)) _ -> IO.puts: stderr, "Non implémenté." listen () end defp do_sqrt (arg) faire: math.sqrt (arg) end end

Maintenant, un autre message est envoyé au serveur contenant le résultat du calcul. 

Ce qui est intéressant est que le sqrt / 2 Cette fonction envoie simplement un message au serveur lui demandant d’effectuer une opération sans attendre le résultat. Donc, fondamentalement, il effectue une appel asynchrone.

Évidemment, nous voulons récupérer le résultat à un moment donné, donc codez une autre fonction publique:

def grab_result ne reçoit que do : résultat, résultat -> résultat après 5000 -> IO.puts: stderr, "Timeout" end end

Maintenant, utilisez-le:

math_server = MathServer.start MathServer.sqrt (math_server, 3) MathServer.grab_result |> IO.puts # => 1.7320508075688772

Ça marche! Bien sûr, vous pouvez même créer un pool de serveurs et répartir les tâches entre eux, en obtenant une concurrence simultanée. C'est pratique quand les requêtes ne sont pas liées les unes aux autres.

Rencontrez GenServer

Très bien, nous avons couvert une poignée de fonctions nous permettant de créer des processus serveur de longue durée et d’envoyer et recevoir des messages. C’est génial, mais nous devons écrire trop de code standard qui démarre une boucle de serveur (début / 0), répond aux messages (écouter / 0 fonction privée), et retourne un résultat (grab_result / 0). Dans des situations plus complexes, il peut également être nécessaire de gérer un état partagé ou de gérer les erreurs..

Comme je l'ai dit au début de l'article, il n'est pas nécessaire de réinventer le vélo. Au lieu de cela, nous pouvons utiliser le comportement GenServer qui fournit déjà tout le code standard et supporte parfaitement les processus de serveur (comme nous l'avons vu dans la section précédente)..

Comportement Elixir est un code qui implémente un modèle commun. Pour utiliser GenServer, vous devez définir un programme spécial. module de rappel qui satisfait au contrat dicté par le comportement. Spécifiquement, il devrait implémenter des fonctions de rappel, et l’implémentation réelle est à vous. Une fois les rappels écrits, le module de comportement peut les utiliser.

Comme indiqué par la documentation, GenServer nécessite l'implémentation de six rappels, bien qu'ils aient également une implémentation par défaut. Cela signifie que vous ne pouvez redéfinir que ceux qui nécessitent une logique personnalisée.

Commençons par le début: nous devons démarrer le serveur avant de faire quoi que ce soit, passez à la section suivante.!

Démarrer le serveur

Pour démontrer l’utilisation de GenServer, écrivons un CalcServer cela permettra aux utilisateurs d'appliquer diverses opérations à un argument. Le résultat de l'opération sera stocké dans un état du serveur, et puis une autre opération peut également être appliquée. Ou un utilisateur peut obtenir un résultat final des calculs.

Tout d’abord, utilisez la macro d’utilisation pour connecter GenServer:

defmodule CalcServer utilise GenServer fin

Maintenant, nous devrons redéfinir certains rappels.

Le premier est init / 1, qui est appelé au démarrage d’un serveur. L'argument passé est utilisé pour définir l'état d'un serveur initial. Dans le cas le plus simple, ce rappel doit renvoyer le : ok, initial_state tuple, bien qu’il y ait d’autres valeurs de retour possibles comme : stop, raison, ce qui provoque l'arrêt immédiat du serveur.

Je pense que nous pouvons permettre aux utilisateurs de définir l'état initial de notre serveur. Cependant, nous devons vérifier que l'argument passé est un nombre. Utilisez donc une clause de garde pour cela:

defmodule CalcServer utilise GenServer def init (valeur_initial) quand is_number (valeur_initial) do : ok, valeur_initial end def (_) do : stop, "La valeur doit être un entier!" end end

Maintenant, démarrez simplement le serveur en utilisant la fonction start / 3 et fournissez votre CalcServer en tant que module de rappel (le premier argument). Le deuxième argument sera l'état initial:

GenServer.start (CalcServer, 5.1) |> IO.inspect # => : ok, #PID<0.85.0>

Si vous essayez de passer un non-nombre comme second argument, le serveur ne sera pas démarré, ce qui est exactement ce dont nous avons besoin..

Génial! Maintenant que notre serveur est en marche, nous pouvons commencer à coder des opérations mathématiques.

Traitement des demandes asynchrones

Les demandes asynchrones sont appelées jette selon les termes de GenServer. Pour effectuer une telle requête, utilisez la fonction cast / 2, qui accepte un serveur et la requête réelle. C'est semblable à la sqrt / 2 fonction que nous avons codée lorsque nous parlons de processus de serveur. Il utilise également l'approche "feu et oublie", ce qui signifie que nous n'attendons pas que la demande se termine.

Pour traiter les messages asynchrones, un rappel handle_cast / 2 est utilisé. Il accepte une requête et un état et devrait répondre par un tuple : noreply, new_state dans le cas le plus simple (ou : stop, raison, new_state arrêter la boucle du serveur). Par exemple, traitons un asynchrone : sqrt jeter:

def handle_cast (: sqrt, state) do : noreply,: math.sqrt (state) end 

C'est ainsi que nous maintenons l'état de notre serveur. Initialement, le numéro (transmis au démarrage du serveur) était 5.1. Maintenant, nous mettons à jour l'état et le mettons à : math.sqrt (5.1).

Coder la fonction d'interface qui utilise casting / 2:

def sqrt (pid) do GenServer.cast (pid,: sqrt) end

Pour moi, cela ressemble à un sorcier maléfique qui jette un sort mais se fiche de l'impact qu'il provoque..

Notez que nous avons besoin d'un identifiant de processus pour effectuer la conversion. Rappelez-vous que quand un serveur est démarré avec succès, un tuple : ok, pid est retourné. Par conséquent, utilisons la correspondance de modèle pour extraire l'ID de processus:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid)

Agréable! La même approche peut être utilisée pour implémenter, par exemple, la multiplication. Le code sera un peu plus complexe car nous devrons passer le deuxième argument, un multiplicateur:

def multiply (pid, multiplier) do GenServer.cast (pid, : multiply, multiplier) end

le jeter fonction ne supporte que deux arguments, il me faut donc construire un tuple et y passer un argument supplémentaire.

Maintenant le rappel:

def handle_cast (: multiply, multiplier, state) fait : noreply, state * multiplier end

Nous pouvons aussi écrire un seul handle_cast rappel qui prend en charge l'opération et arrête le serveur si l'opération est inconnue:

def handle_cast (opération, état) ne cas opération que faire: sqrt -> : noreply,: math.sqrt (état) : multiply, multiplier -> : noreply, state * multiplicateur _ -> : stop, "Non implémenté", état end end

Maintenant, utilisez la nouvelle fonction d'interface:

CalcServer.multiply (pid, 2)

Génial, mais il n’existe actuellement aucun moyen d’obtenir un résultat des calculs. Par conséquent, il est temps de définir un autre rappel.

Traitement des demandes synchrones

Si les requêtes asynchrones sont des conversions, les requêtes synchrones sont nommées appels. Pour exécuter de telles requêtes, utilisez la fonction call / 3, qui accepte un serveur, une requête et un délai d’attente facultatif qui correspond à cinq secondes par défaut..

Les requêtes synchrones sont utilisées lorsque nous voulons attendre que la réponse arrive réellement du serveur. Le cas d'utilisation typique consiste à obtenir des informations telles que le résultat de calculs, comme dans l'exemple d'aujourd'hui (rappelez-vous le grab_result / 0 fonction de l'une des sections précédentes).

Pour traiter les demandes synchrones, un handle_call / 3 le rappel est utilisé. Il accepte une requête, un tuple contenant le pid du serveur et un terme identifiant l'appel, ainsi que l'état actuel. Dans le cas le plus simple, il devrait répondre avec un tuple : reply, reply, new_state

Codez ce rappel maintenant:

def handle_call (: result, _, state) do : reply, state, state end

Comme vous le voyez, rien de complexe. le répondre et le nouvel état est égal à l'état actuel car je ne veux rien changer après le retour du résultat.

Maintenant l'interface résultat / 1 une fonction:

def result (pid) do GenServer.call (pid,: result) fin

Ça y est ...! L'utilisation finale de CalcServer est illustrée ci-dessous:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid) CalcServer.multiply (pid, 2) CalcServer.result (pid) |> IO.puts # => 4.516635916254486

Aliasing

Il devient quelque peu fastidieux de toujours fournir un identifiant de processus lors de l'appel des fonctions d'interface. Heureusement, il est possible de donner à votre processus un nom ou une alias. Ceci est fait au démarrage du serveur en configurant prénom:

GenServer.start (CalcServer, 5.1, nom:: calc) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Notez que je ne stocke pas pid maintenant, bien que vous souhaitiez peut-être effectuer une correspondance de modèle pour vous assurer que le serveur a bien été démarré.

Maintenant, les fonctions de l'interface deviennent un peu plus simples:

def sqrt do GenServer.cast (: calc,: sqrt) end def multiply (multiplicateur) do GenServer.cast (: calc, : multiply, multiplier) end def résultat do GenServer.call (: calc,: result) end

N'oubliez pas que vous ne pouvez pas démarrer deux serveurs avec le même alias.

Alternativement, vous pouvez introduire encore une autre fonction d'interface début / 1 dans votre module et tirez parti de la macro __MODULE __ / 0, qui renvoie le nom du module actuel sous forme d’atome:

defmodule CalcServer utilise GenServer def start (initial_value) de GenServer.start (CalcServer, initial_value, name: __MODULE__) end def sqrt do GenServer.cast (__ MODULE__,: sqrt) et def multiply (multiplier) do GenServer.cast (__ MODULE__,  : multiplier, multiplier) end def result faire GenServer.call (__ MODULE__,: result) end #… fin CalcServer.start (6.1) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Résiliation

Un autre rappel qui peut être redéfini dans votre module s'appelle terminate / 2. Il accepte une raison et l'état actuel et est appelé lorsqu'un serveur est sur le point de quitter. Cela peut arriver lorsque, par exemple, vous passez un argument incorrect au multiplier / 1 fonction d'interface:

#… CalcServer.multiply (2)

Le rappel peut ressembler à quelque chose comme ceci:

def terminate (_reason, _state) do IO.puts "Le serveur s'est arrêté" fin

Conclusion

Dans cet article, nous avons abordé les bases de la concurrence dans Elixir et discuté des fonctions et des macros telles que frayer, recevoir, et envoyer. Vous avez appris ce que sont les processus, comment les créer et comment envoyer et recevoir des messages. En outre, nous avons vu comment créer un processus serveur simple et long qui répond aux messages synchrones et asynchrones..

En plus de cela, nous avons discuté du comportement de GenServer et avons vu comment cela simplifie le code en introduisant divers rappels. Nous avons travaillé avec le init, mettre fin, handle_call et handle_cast rappels et créé un serveur de calcul simple. Si quelque chose vous semble incertain, n'hésitez pas à poser vos questions.!

GenServer est plus que complet et il est bien entendu impossible de tout couvrir dans un seul article. Dans mon prochain post, je vais expliquer ce que superviseurs et comment les utiliser pour surveiller vos processus et les récupérer des erreurs. Jusque-là, codage heureux!