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
asyncetawaitde 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).
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).
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.
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.