C2N3 – Le module Task
Mix.install([
{:kino, "~> 0.16.1"},
{:req, "~> 0.5.15"},
{:merquery, "~> 0.3.0"},
{:image, "~> 0.62.0"}
])
Motivation
Dans les notebooks précédents, nous avons découvert les bases de la concurrence en Elixir à travers les processus légers (processus BEAM), la fonction spawn/1
et l’envoi/réception de messages.
Désormais, il est temps de construire sur ces notions fondamentales et d’explorer un outil haut niveau fourni par Elixir pour simplifier l’exécution concurrente : le module Task
. Ce module offre des fonctions prêtes à l’emploi pour lancer des tâches asynchrones et en récupérer facilement les résultats, sans avoir à gérer explicitement les messages entre processus.
Exemple : Utilisation de Task.async et Task.await
Comparons une utilisation simple du module Task
par rapport à l’approche vue précédemment:
# Lance une tâche
task =
Task.async(fn ->
# Simule un calcul long de 1 seconde (1000ms)
Process.sleep(1000)
5 * 5
end)
IO.puts("Tâche lancée pour calculer 5^2 ...")
# Ici, on pourrait faire d'autres opérations pendant que la tâche s'exécute...
# Récupérer le résultat de la tâche (cela bloque jusqu'à ce que ce soit prêt)
result = Task.await(task)
IO.puts("Résultat obtenu : #{result}")
On remarque immédiatement:
-
que l’appel à
Task.async/1
a remplacé les appels àspawn/1
/spawn_link/1
-
qu’il n’y a plus de bloc
receive do ... end
: c’est désormaisTask.await/1
qui se charge de récupérer le résultat de la commande lancée plus tôt
💡 Lorsqu’on utilise Task.async/1
, la tâche lancée est liée au processus appelant !
On pourrait d’ailleurs lancer plusieurs tâches en parallèle, sans perturber le fonctionnement du processus parent:
sleep_and_square = fn ->
n = :rand.uniform(5) # nombre alératoire entre 1 et 5
Process.sleep(n * 1000)
n * n
end
# lancement des tâches
task1 = Task.async(sleep_and_square)
task2 = Task.async(sleep_and_square)
# Récupération des résultats une fois prêts
result1 = Task.await(task1)
result2 = Task.await(task2)
IO.puts("Résultat obtenu (tâche n°1) : #{result1}")
IO.puts("Résultat obtenu (tâche n°1) : #{result2}")
Le fonctionnement global est illustré dans le schéma ci-dessous:
sequenceDiagram
participant Principal
participant Tâche1
participant Tâche2
Note over Principal,Tâche1: Démarrage de la première tâche
Principal->>Tâche1: Task.async(fn1)
Note right of Tâche1: Tâche1 démarre en arrière-plan
Note over Principal,Tâche2: Démarrage de la deuxième tâche
Principal->>Tâche2: Task.async(fn2)
Note right of Tâche2: Tâche2 démarre en arrière-plan
Note over Principal: (Le processus principal continue son exécution...)
Tâche1-->>Principal: renvoie le résultat1 (terminé)
Tâche2-->>Principal: renvoie le résultat2 (terminé)
Principal->>Principal: Task.await(resultat1)
Task.await(resultat2)
Note over Principal: Récupération des résultats une fois prêts
💡 il existe également une fonction Task.await_many/1
qui permet d’attendre le résultat de plusieurs tâches en une seule commande (ici avec Task.await_many([task1, task2])
).
Pourquoi organiser son code ainsi ?
Toujours dans un scénario où nous lançons des tâches “longues” – par ex. des calculs ou des requêtes sur internet – il devient rapidement intéressant de “paralléliser” l’exécution de notre code afin de gagner en performance.
Comparons l’approche avec parallélisation et l’approche synchrone:
# Nos tâches longue durée
tache_a = fn -> Process.sleep(2000); "Opération 1 terminée" end
tache_b = fn -> Process.sleep(2000); "Opération 2 terminée" end
# exécution synchrone
temps_depart_ms = System.monotonic_time(:millisecond)
tache_a.()
tache_b.()
duree_ms = System.monotonic_time(:millisecond) - temps_depart_ms
IO.puts "Durée d'exécution: #{duree_ms}ms"
# exécution concurrente
temps_depart_ms = System.monotonic_time(:millisecond)
# Lancement des tâches
task1 = Task.async(tache_a)
task2 = Task.async(tache_b)
# Attente des résultats
Task.await_many([task1, task2])
duree_ms = System.monotonic_time(:millisecond) - temps_depart_ms
IO.puts "Durée d'exécution: #{duree_ms}ms"
On comprend immédiatement l’intérêt de la seconde approche: le temps d’exécution n’est plus la somme des temps individuels des différents tâches, mais devient (à quelques ms près) la durée de la tâche la plus longue.
On peut l’illustrer par le schéma:
gantt
dateFormat ss
axisFormat %Ss
section Séquentiel
Tâche_A :done, seq1, 00, 2s
Tâche_B :done, seq2, after seq1, 2s
section Parallèle
Tâche_A :done, par1, 00, 2s
Tâche_B :done, par2, 00, 2s
Nous allons maintenant illustrer tout ça sur un exemple plus concret:
Faire des requêtes HTTP (internet) avec Elixir
Nous allons maintenant charger des données depuis internet: il vous faudra évidemment une connexion fonctionnelle pour continuer ce TP, même si nous ne chargerons que peu de données en preatique !
Nous allons utiliser deux ressources externes:
-
la librairie
Req
d’Elixir, qui est la façon standard (“haut niveau”) de faire des requêtes sur internet - l’API publique “PokéAPI“ qui propose gratuitement une base de données sur les personnages des jeux Pokemon
Exercice: chargement d’informations sur un Pokemon
Nous allons charger les informations sur le pokemon pikachu
en effectuant:
- un recherche de type “GET”
-
à l’adresse
https://pokeapi.co/api/v2/pokemon/pikachu
Complétez les paramètre dans l’interface ci-dessous pour effectuer votre appel:
req = Req.new(method: :get, url: "", headers: %{}, params: %{})
{req, resp} = Req.request(req)
resp
Vous devriez obtenir un objet de type “Map” dans la variables resp
contenant toute une série de paramètres.,
- arrivez-vous à comprendre les différentes informations chargée depuis internet
- en particulier, arrivez-vous à retrouver les informations sur les images tirées du jeu ?
Votre mission est désormais de compléter le code ci-après, afin d’extraire une liste de toutes les adresses de toutes les images du pokemon. Le résutat de l’appel devrait être une liste d’adresses internet pour les différentes images:
à vous de jouer:
defmodule SpriteExtractor do
def get_sprites(resp) do
[] # TODO
end
end
Pour analyser la valeur retourner par votre fonction, exécutez:
sprites = SpriteExtractor.get_sprites(resp)
Si votre code fonctionne correctement, vous devriez pouvoir exécuter la cellule suivante pour charger toutes les images et les afficher.
Le code fourni va prendre les adresses des images que vous avez extraites, et créer une “page web” avec des éléments “image” pour afficher le résutat. Notez bien que pour l’instant nous ne chargeons pas les images une à une: ce sera l’objet de l’étape suivante de l’exercice.
À vous de jouer: exécutez la cellule suivante une fois le code complété 👇
# Construit un document "markdown" avec toutes nos images
images = for x <- sprites do
Kino.Markdown.new("")
end
Kino.Layout.grid(images, columns: 6)
⚠️ Attention, comme mentionné, le code ci-dessus “triche”: nous avons extrait des addreses des images et nous laissons le navigateur les afficher.
Dans la seconde étape ci-dessous, nous allons réellement charger toutes ces images en parallèle:
Deuxième partie de l’exercice: télécharger toutes les images entrantes
Il est temps de charger les image “pour de vrai” et pas seulement les adresses internet de ces images.
Pour se faire, nous allons:
-
repartir de notre liste d’adresses
sprites
obtenue à l’exercice précédent -
lancer des tâches pour de chargement des données de l’image avec
Req.get!/1
et récupérer le “body” de la réponse qui contient les données de l’image -
Charger les images avec
Image.from_binary!/1
puis les transformer en images “affichables” avecImage.Kino.show/1
à vous de jouer:
# Première tâche: charger les images en parallèle
tasks = [
# 👈 créez une série de tâches avec Task.async() pour télécharger les images
]
raw_images = Task.await_many(tasks)
# Deuxième tâche: transformer les données "brutes" en images
images = [] # 👈 transformez les raw_images en images affichables, en suivant les instructions du TP