Notions fondamentales de programmation asynchrone: Async, Await, Futures et Streams
De nombreuses opérations que nous demandons à l’ordinateur d’effectuer peuvent prendre bien du temps. Ce serait bien si nous pouvions faire autre chose pendant que nous attendons que ces processus longs se terminent. Les ordinateurs modernes comportent deux techniques pour travailler sur plus d’une opération à la fois : le parallélisme et la concurrence. La logique de nos programmes, cependant, est écrite d’une manière essentiellement linéaire. Nous aimerions pouvoir préciser les opérations qu’un programme doit effectuer et les points où une fonction pourrait s’interrompre pour laisser une autre partie du programme se dérouler, sans pour autant avoir à préciser en avance l’ordre et la manière exacts dont chaque portion du code devrait s’exécuter. La programmation asynchrone est une abstraction qui nous permet d’exprimer notre code en termes de points d’interruption potentiels et de résultats finaux, et qui se charge à notre place des détails de la coordination.
Ce chapitre s’appuie sur l’utilisation des tâches pour le parallélisme et la concurrence, abordée dans le chapitre 16, pour présenter une autre approche de l’écriture de code : les futures et les flux de Rust, ainsi que la syntaxe de async et await, qui permettent d’exprimer comment les opérations peuvent être asynchrones, et les crates tierces qui implémentent des environnements d’exécution asynchrones, soit du code qui gère et coordonne l’exécution d’opérations asynchrones.
Prenons un exemple. Mettons que vous exportiez une vidéo que vous avez réalisée lors d’une fête familiale, une opération qui peut prendre de quelques minutes à quelques heures. L’export de la vidéo va utiliser autant de puissance du CPU et du GPU que possible. Si vous n’aviez qu’un seul cœur de processeur et que votre système d’exploitation ne mettait pas cet export en pause avant qu’il n’ait terminé — c’est à dire s’il exécutait l’export de manière synchrone —, vous ne pourriez rien faire d’autre sur votre ordinateur pendant que cette tâche s’exécute. Ce serait assez frustrant. Heureusement, votre système d’exploitation peut interrompre de manière invisible l’export, et il fait cela de manière suffisamment fréquemment pour vous permettre de faire d’autres tâches en même temps.
Maintenant, mettons que vous téléchargiez une vidéo partagée par quelqu’un d’autre, ce qui peut aussi prendre un certain temps mais ne consomme pas autant de ressources du processeur. Dans ce cas, le processeur doit attendre que les données arrivent via le réseau. Même si vous pouvez commencer à lire les données dès qu’elles commencent à arriver, il peut s’écouler du temps avant qu’elles ne soient toutes disponibles. Même une fois que toutes les données sont arrivées, si la vidéo est assez volumineuse, il faudra au moins une ou deux secondes pour la charger complètement. Cela peut sembler minime, mais c’est un très long laps de temps pour un processeur moderne, qui peut effectuer des milliards d’opérations par seconde. De même, votre système d’exploitation va interrompre de manière invisible votre programme pour permettre au processeur d’effectuer d’autres tâches en attendant la fin de la communication réseau.
L’export de vidéo est un exemple d’opération liée au processeur (NdT : CPU-bound) ou liée au calcul (NdT : compute-bound). Elle est limitée par la vitesse potentielle de traitement de l’ordinateur dans son CPU ou GPU, et par la quantité de cette vitesse qui peut être dédiée à l’opération. Le téléchargement de vidéo est un exemple d’opération liée aux entrées/sorties (NdT : I/O-bound), car elle est limitée par la vitesse des entrées et sorties de l’ordinateur ; elle ne pourra jamais aller plus vite que la vitesse à laquelle les données peuvent être envoyées sur le réseau.
Dans ces deux exemples, les interruptions invisibles du système d’exploitation fournissent une espèce de concurrence. Cependant, cette concurrence ne se trouve qu’au niveau du programme entier : le système d’exploitation interrompt un programme pour laisser un autre programme travailler. Dans de nombreux cas de figures, comme nous comprenons nos programmes avec un bien meilleur niveau de granularité que ne le fait le système d’exploitation, nous pouvons repérer des possibilités de concurrence que le système d’exploitation ne peut pas voir.
Par exemple, si nous développons un outil gérant les téléchargements de fichiers, nous devrions pouvoir écrire notre programme de manière à ce que le démarrage d’un téléchargement ne monopolise pas l’interface utilisateur, et les utilisateurs devraient pouvoir démarrer plusieurs téléchargements simultanément. Toutefois, de nombreuses interfaces de programmations (NdT : APIs) de systèmes d’exploitation permettant d’interagir avec le réseau sont blocantes : en d’autres termes, elles bloquent l’exécution du programme jusqu’à ce que les données qu’elles traitent soient entièrement prêtes.
Remarque : c’est ainsi que la plupart des appels systèmes fonctionnent, si on y pense bien. Cependant, le terme bloquant est généralement réservé aux appels systèmes qui interagissent avec des fichiers, le réseau, ou d’autres ressources de l’ordinateur, car il s’agit là des cas où un programme pourrait profiter du caractère non-bloquant de l’opération.
Nous pourrions éviter de bloquer notre tâche principale main en créant une tâche dédiée pour télécharger chaque fichier. Cependant, la consommation des ressources utilisées par ces tâches deviendra, à terme, un problème. Il serait préférable que l’appel ne bloque pas d’emblée, et que nous puissions définir un certain nombre de tâches que notre programme devrait accomplir, en permettant au système d’exécution de choisir le meilleur ordre et la meilleure manière de les exécuter.
C’est exactement là ce que nous offre l’abstraction async (abrégé d’_asynchrone) de Rust. Dans ce chapitre, vous apprendrez tout sur async dans les thèmes suivants :
- comment utiliser la syntaxe de
asyncetawaitde Rust et exécuter des fonctions asynchrones avec un moteur d’exécution ; - comment utiliser le modèle async pour résoudre quelques des mêmes défis que nous avons vus dans le chapitre 16 ;
- comment le multitâches et async fournissent des solutions complémentaires que vous pouvez combiner dans de nombreux cas.
Cependant, avant de voir comment async fonctionne en pratique, nous devons faire un petit détour pour discuter les différences entre parallélisme et concurrence.
Parallélisme et concurrence
Nous avons traité du parallélisme et de la concurrence de manière pratiquement interchangeable, jusqu’ici. Nous devons maintenant faire la distinction plus précisément, car les différences apparaîtront dès que nous commencerons à travailler.
Considérons les différentes manières dont une équipe pourrait se répartir le travail sur un projet de développement logiciel. Vous pourriez attribuer à un membre de multiples tâches, attribuer à chaque membre une tâche, ou encore panacher ces deux approches.
Quand une personne travaille sur différentes tâches avant qu’aucune d’elles ne soit terminée, il s’agit de concurrence. Une manière d’implémenter la concurrence est analogue à avoir deux projets différents ouverts sur votre ordinateur : quand vous vous ennuyez ou bien que vous coincez sur un projet, vous basculez sur l’autre. Vous n’êtes qu’un individu, vous ne pouvez donc pas avancer sur les deux tâches au même instant, mais vous pouvez être multitâches, en avançant sur un projet à la fois en alternant parmi les projets (voir figure 17-1).
Quand l’équipe se répartit un groupe de tâches avec chaque membre qui prend une tâche et travaille dessus seul, c’est du parallélisme. Chaque personne de l’équipe peut avancer au même instant (voir figure 17-2).
Dans ces deux schémas, il vous faudra coordonner entre les différentes tâches. Peut-être pensiez-vous que la tâche assignée à une personne était totalement indépendantes du travail des autres, mais il s’avère qu’elle nécessite qu’une autre personne de l’équipe termine d’abord sa tâche. Une partie du travail peut être fait en parallèle, mais une partie est en fait en série : elle ne peut advenir qu’en série, une tâche après l’autre, comme dans la figure 17-3.
De même, vous pourriez vous rendre compte qu’une de vos tâches dépend d’une autre de vos tâches. Votre travail en concurrence vient aussi de se devenir en série.
Également, le parallélisme et la concurrence peuvent se recouper mutuellement. Si vous apprenez qu’un collègue est bloqué jusqu’à ce que vous finissiez une de vos tâches, vous allez probablement recentrer tous vos efforts sur cette tâche afin de débloquer votre collègue. Vous et votre collègue ne pouvez alors plus travailler en parallèle, et vous ne pouvez plus travailler en concurrence sur vos propres tâches.
La même dynamique intervient avec les logiciels et le matériel. Sur une machine avec un processeur à simple cœur, le processeur ne peut faire qu’une seule opération à la fois, mais il peut toujours travailler de manière concurrente. En utilisant des outils comme les tâches, les process et async, l’ordinateur peut mettre une activité en pause et basculer sur d’autres activités avant de finalement revenir sur la première activité. Une machine avec un processeur à plusieurs cœurs pourra aussi travailler en parallèle. Un cœur peut effectuer une tâche pendant qu’un autre cœur effectue une tâche sans aucun rapport avec la première, et ces opérations se déroulent effectivement en même temps.
Faire tourner du code asynchrone en Rust se fait généralement de manière concurrente. En fonction du matériel, du système d’exploitation et du moteur d’exécution utilisés (nous en verrons bientôt plus sur les moteurs d’exécution (NdT : runtimes)), cette concurrence peut aussi utiliser du parallélisme sous le capot.
Plongeons-nous maintenant dans la manière dont la programmation asynchrone fonctionne réellement en Rust.