Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Les fondamentaux de la programmation asynchrone : Async, Await, Futures et Streams

De nombreuses opérations que nous demandons à l’ordinateur d’effectuer peuvent prendre un certain temps. Ce serait bien de pouvoir faire autre chose en attendant que ces processus longs se terminent. Les ordinateurs modernes offrent deux techniques pour travailler sur plusieurs opérations à la fois : le parallélisme et la concurrence. Cependant, la logique de nos programmes est écrite de manière essentiellement linéaire. Nous aimerions pouvoir spécifier les opérations qu’un programme doit effectuer et les points auxquels une fonction pourrait se mettre en pause pour laisser une autre partie du programme s’exécuter à la place, sans avoir besoin de spécifier à l’avance l’ordre exact et la manière dont chaque morceau de code doit s’exécuter. La programmation asynchrone est une abstraction qui nous permet d’exprimer notre code en termes de points de pause potentiels et de résultats finaux, tout en prenant en charge les détails de la coordination pour nous.

Ce chapitre s’appuie sur l’utilisation des threads du chapitre 16 pour le parallélisme et la concurrence, en présentant une approche alternative pour écrire du code : les futures, les streams, et la syntaxe async et await de Rust qui nous permettent d’exprimer comment les opérations pourraient être asynchrones, ainsi que les crates tierces qui implémentent des runtimes asynchrones : du code qui gère et coordonne l’exécution des opérations asynchrones.

Prenons un exemple. Imaginons que vous exportez une vidéo d’une fête de famille, une opération qui peut prendre de quelques minutes à plusieurs heures. L’exportation vidéo utilisera autant de puissance CPU et GPU que possible. Si vous n’aviez qu’un seul cœur CPU et que votre système d’exploitation ne mettait pas en pause cette exportation avant qu’elle ne soit terminée — c’est-à-dire s’il exécutait l’exportation de manière synchrone — vous ne pourriez rien faire d’autre sur votre ordinateur pendant l’exécution de cette tâche. Ce serait une expérience assez frustrante. Heureusement, le système d’exploitation de votre ordinateur peut, et le fait, interrompre invisiblement l’exportation assez souvent pour vous permettre de faire d’autres choses en même temps.

Maintenant, imaginons que vous téléchargez une vidéo partagée par quelqu’un d’autre, ce qui peut aussi prendre du temps mais ne consomme pas autant de temps CPU. Dans ce cas, le CPU doit attendre que les données arrivent du réseau. Bien que vous puissiez commencer à lire les données dès qu’elles commencent à arriver, il peut falloir un certain temps pour que toutes les données soient disponibles. Même une fois que toutes les données sont présentes, si la vidéo est assez volumineuse, il pourrait falloir au moins une seconde ou deux pour tout charger. Cela peut sembler peu, mais c’est très long pour un processeur moderne qui peut effectuer des milliards d’opérations par seconde. Là encore, votre système d’exploitation interrompra invisiblement votre programme pour permettre au CPU d’effectuer d’autres tâches en attendant que l’appel réseau se terminé.

L’exportation vidéo est un exemple d’opération limitée par le CPU (CPU-bound) ou limitée par le calcul (compute-bound). Elle est limitée par la vitesse potentielle de traitement des données du CPU ou du GPU, et par la part de cette vitesse qu’il peut consacrer à l’opération. Le téléchargement vidéo est un exemple d’opération limitée par les E/S (I/O-bound), car elle est limitée par la vitesse des entrées et sorties de l’ordinateur ; elle ne peut aller que aussi vite que les données peuvent être envoyées à travers le réseau.

Dans ces deux exemples, les interruptions invisibles du système d’exploitation fournissent une forme de concurrence. Cette concurrence se produit cependant uniquement au niveau du programme entier : le système d’exploitation interrompt un programme pour permettre à d’autres programmes de travailler. Dans de nombreux cas, comme nous comprenons nos programmes à un niveau bien plus granulaire que le système d’exploitation, nous pouvons repérer des opportunités de concurrence que le système d’exploitation ne peut pas voir.

Par exemple, si nous construisons un outil pour gérer les téléchargements de fichiers, nous devrions pouvoir écrire notre programme de sorte que le démarrage d’un téléchargement ne bloque pas l’interface utilisateur, et les utilisateurs devraient pouvoir démarrer plusieurs téléchargements en même temps. Cependant, de nombreuses API de systèmes d’exploitation pour interagir avec le réseau sont bloquantes ; c’est-à-dire qu’elles bloquent la progression du programme jusqu’à ce que les données qu’elles traitent soient complètement prêtes.

Remarque : c’est ainsi que fonctionnent la plupart des appels de fonctions, si vous y réfléchissez. Cependant, le terme bloquant est généralement réservé aux appels de fonctions qui interagissent avec des fichiers, le réseau ou d’autres ressources de l’ordinateur, car ce sont les cas où un programme individuel bénéficierait d’une opération non bloquante.

Nous pourrions éviter de bloquer notre thread principal en créant un thread dédié pour télécharger chaque fichier. Cependant, le surcoût des ressources système utilisées par ces threads finirait par devenir un problème. Il serait préférable que l’appel ne soit pas bloquant en premier lieu, et qu’à la place nous puissions définir un certain nombre de tâches que nous aimerions que notre programme accomplisse et laisser le runtime choisir le meilleur ordre et la meilleure manière de les exécuter.

C’est exactement ce que l’abstraction async (abréviation d’asynchrone) de Rust nous offre. Dans ce chapitre, vous apprendrez tout sur l’async en couvrant les sujets suivants :

  • Comment utiliser la syntaxe async et await de Rust et exécuter des fonctions asynchrones avec un runtime
  • Comment utiliser le modèle async pour résoudre certains des mêmes défis que nous avons vus au chapitre 16
  • Comment le multithreading et l’async fournissent des solutions complémentaires que vous pouvez combiner dans de nombreux cas

Avant de voir comment l’async fonctionne en pratique, nous devons cependant faire un petit détour pour discuter des différences entre le parallélisme et la concurrence.

Parallélisme et concurrence

Nous avons traité le parallélisme et la concurrence comme étant largement interchangeables jusqu’à présent. Maintenant, nous devons les distinguer plus précisément, car les différences vont apparaître lorsque nous commencerons à travailler.

Considérez les différentes manières dont une équipe pourrait répartir le travail sur un projet logiciel. Vous pourriez assigner à un seul membre plusieurs tâches, assigner à chaque membre une seule tâche, ou utiliser un mélange des deux approches.

Quand un individu travaille sur plusieurs tâches différentes avant qu’aucune d’entre elles ne soit terminée, c’est de la concurrence. Une façon d’implémenter la concurrence est similaire au fait d’avoir deux projets différents ouverts sur votre ordinateur, et quand vous vous ennuyez ou êtes bloqué sur un projet, vous passez à l’autre. Vous n’êtes qu’une seule personne, donc vous ne pouvez pas progresser sur les deux tâches exactement en même temps, mais vous pouvez faire du multitâche, en progressant sur l’une à la fois en alternant entre elles (voir la figure 17-1).

Un diagramme avec des boîtes empilées étiquetées Tâche A et Tâche B, contenant des losanges qui représentent les sous-tâches. Des flèches vont de A1 vers B1, B1 vers A2, A2 vers B2, B2 vers A3, A3 vers A4 et A4 vers B3. Les flèches entre les sous-tâches traversent les boîtes entre la Tâche A et la Tâche B.
Figure 17-1 : Un flux de travail concurrent, alternant entre la Tâche A et la Tâche B

Quand l’équipe répartit un groupe de tâches en faisant prendre une tâche à chaque membre pour y travailler seul, c’est du parallélisme. Chaque personne de l’équipe peut progresser exactement en même temps (voir la figure 17-2).

Un diagramme avec des boîtes empilées étiquetées Tâche A et Tâche B, contenant des losanges qui représentent les sous-tâches. Des flèches vont de A1 vers A2, A2 vers A3, A3 vers A4, B1 vers B2 et B2 vers B3. Aucune flèche ne traverse entre les boîtes de la Tâche A et de la Tâche B.
Figure 17-2 : Un flux de travail parallèle, où le travail avance sur la Tâche A et la Tâche B indépendamment

Dans ces deux flux de travail, vous pourriez devoir coordonner différentes tâches. Peut-être pensiez-vous que la tâche assignée à une personne était totalement indépendante du travail de tout le monde, mais elle nécessite en fait qu’une autre personne de l’équipe terminé d’abord sa tâche. Une partie du travail pouvait être fait en parallèle, mais une autre partie était en réalité sérielle : elle ne pouvait se faire qu’en série, une tâche après l’autre, comme dans la figure 17-3.

Un diagramme avec des boîtes empilées étiquetées Tâche A et Tâche B, contenant des losanges qui représentent les sous-tâches. Dans la Tâche A, des flèches vont de A1 vers A2, de A2 vers une paire de lignes verticales épaisses ressemblant à un symbole de « pause », et de ce symbole vers A3. Dans la Tâche B, des flèches vont de B1 vers B2, de B2 vers B3, de B3 vers A3, et de B3 vers B4.
Figure 17-3 : Un flux de travail partiellement parallèle, où le travail avance sur la Tâche A et la Tâche B indépendamment jusqu’à ce que la Tâche A3 soit bloquée par les résultats de la Tâche B3.

De même, vous pourriez réaliser qu’une de vos propres tâches dépend d’une autre de vos tâches. Votre travail concurrent est alors également devenu sériel.

Le parallélisme et la concurrence peuvent également se croiser. Si vous apprenez qu’un collègue est bloqué tant que vous n’avez pas terminé une de vos tâches, vous concentrerez probablement tous vos efforts sur cette tâche pour « débloquer » votre collègue. Vous et votre collègue n’êtes plus en mesure de travailler en parallèle, et vous n’êtes plus non plus en mesure de travailler de manière concurrente sur vos propres tâches.

Les mêmes dynamiques fondamentales entrent en jeu avec les logiciels et le matériel. Sur une machine avec un seul cœur CPU, le CPU ne peut effectuer qu’une seule opération à la fois, mais il peut quand même travailler de manière concurrente. En utilisant des outils comme les threads, les processus et l’async, l’ordinateur peut mettre en pause une activité et passer à d’autres avant de revenir éventuellement à la première activité. Sur une machine avec plusieurs cœurs CPU, il peut également travailler en parallèle. Un cœur peut exécuter une tâche pendant qu’un autre cœur en exécute une complètement différente, et ces opérations se produisent réellement en même temps.

L’exécution de code async en Rust se fait généralement de manière concurrente. En fonction du matériel, du système d’exploitation et du runtime async que nous utilisons (nous en parlerons bientôt), cette concurrence peut également utiliser le parallélisme en coulisses.

Maintenant, plongeons dans le fonctionnement réel de la programmation async en Rust.