Notes
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de modifier des répertoires.
Programmation asynchrone simplifiée avec le nouveau Visual Studio Async CTP
Eric Lippert
Imaginez ce que serait le monde si les gens travaillaient de la même façon que les programmes informatiques :
void ServeBreakfast(Customer diner)
{
var order = ObtainOrder(diner);
var ingredients = ObtainIngredients(order);
var recipe = ObtainRecipe(order);
var meal = recipe.Prepare(ingredients);
diner.Give(meal);
}
Bien évidemment, chaque sous-routine peut encore être scindée, au même titre que vous pouvez par exemple faire chauffer la poêle, cuire les omelettes et faire griller du pain pour préparer un repas. Si les êtres humains devaient effectuer ce type de tâche comme des programmes informatiques, ils écriraient tout attentivement comme des séquences de tâches hiérarchiques dans une liste de contrôle et s'assureraient comme des maniaques que chaque tâche est terminée avant de passer à la suivante.
Une approche reposant sur les sous-routines semble raisonnable (vous ne pouvez pas cuire les œufs avant d'avoir reçu la commande), mais elle fait perdre du temps et donne l'impression que l'application ne répond pas. Elle fait perdre du temps parce qu'il est préférable de faire griller le pain pendant que les œufs cuisent et non une fois qu'ils sont cuits et froids. L'application semble ne pas répondre parce que si un autre client arrive pendant que la commande est en cours de préparation, il est préférable de prendre sa commande et non de le faire attendre à la porte jusqu'à ce que le petit déjeuner ait été servi au client actuel. Un serveur qui suit servilement une liste de contrôle ne peut en aucun cas répondre à temps à des événements inattendus.
Première solution : embaucher plus de personnel en créant davantage de threads
L'exemple de la préparation du petit déjeuner est saugrenu, mais la réalité est bien évidemment tout sauf saugrenue. Chaque fois que vous transférez le contrôle dans une sous-routine longue sur le thread d'interface utilisateur, cette dernière ne répond plus du tout jusqu'à la fin de la sous-routine. Comment pourrait-il en être autrement ? Les applications répondent aux événements d'interface utilisateur en exécutant le code sur le thread d'interface utilisateur qui est extrêmement occupé à autre chose. C'est uniquement lorsque chaque tâche de la liste est terminée qu'il s'occupe des commandes en attente de l'utilisateur frustré. En règle générale, la solution à ce problème consiste à utiliser la simultanéité pour faire deux ou plusieurs choses « simultanément ». Si les deux threads se trouvent sur deux processeurs indépendants, il est possible qu'ils soient réellement exécutés simultanément. Dans un monde où il y a plus de threads que de processeurs à leur dédier, le système d'exploitation simule la simultanéité en planifiant périodiquement une tranche horaire pour chaque thread afin de contrôler un processeur.
Une solution simultanée pourrait consister à créer un pool de threads et à attribuer à chaque nouveau client un thread spécifique pour gérer ses requêtes. Dans le cadre de notre analogie, nous pourrions embaucher un groupe de serveurs. Lorsqu'une nouvelle personne arrive pour dîner, un serveur inactif lui est attribué. Chaque serveur travaille ensuite indépendamment pour prendre la commande, rechercher les ingrédients, faire cuire la nourriture et la servir.
La difficulté avec cette approche est que les événements d'interface utilisateur arrivent généralement sur le même thread et s'attendent à être totalement traités sur ce thread. La plupart des composants d'interface utilisateur créent des requêtes sur le thread d'interface utilisateur et s'attendent à ce que l'on communique avec eux uniquement sur ce thread. Il y a peu de chances pour que le fait de dédier un nouveau thread à chaque tâche associée à l'interface utilisateur fonctionne bien.
Pour régler ce problème, vous pourriez avoir un seul thread de premier plan pour écouter les événements d'interface utilisateur. Ce thread ne ferait rien d'autre que prendre les commandes et les envoyer vers un ou plusieurs threads de travail d'arrière-plan. Dans cette analogie, il y a un seul serveur qui interagit avec les clients et une cuisine remplie de cuisiniers qui font le travail demandé. Le thread d'interface utilisateur et les threads de travail sont ensuite chargés de coordonner leurs communications. Les cuisiniers ne parlent jamais directement aux personnes qui dînent, mais la nourriture est tout de même servie d'une façon ou d'une autre.
Cela résout certainement le problème de réponse à temps aux événements d'interface utilisateur, mais pas l'absence d'efficacité. En effet, le code exécuté sur le thread de travail attend toujours de façon synchrone que les œufs soient cuits avant de mettre le pain dans le grille-pain. Ce problème pourrait à son tour être résolu en ajoutant encore plus de simultanéité : vous pourriez avoir deux cuisiniers par commande, un pour les œufs et un pour les toasts. Mais cela pourrait devenir plutôt onéreux. De combien de cuisiniers aurez-vous besoin et que se passera-t-il lorsqu'ils devront coordonner leur travail ?
Ce type de simultanéité apporte de nombreuses difficultés bien connues. Tout d'abord, les threads sont notoirement lourds. Par défaut, un thread consomme un million d'octets de mémoire virtuelle pour sa pile et de nombreuses autres ressources système. Deuxièmement, les objets d'interface utilisateur ont des affinités avec le thread d'interface utilisateur et ne peuvent pas être appelés depuis des threads de travail. Le thread de travail et le thread d'interface utilisateur doivent passer un accord complexe selon lequel le thread d'interface utilisateur peut envoyer les informations nécessaires des éléments d'interface utilisateur au thread de travail, et ce dernier peut renvoyer des mises à jour au thread d'interface utilisateur plutôt qu'aux éléments d'interface utilisateur directement. De tels contrats sont difficiles à coder et risquent de provoquer des conditions de concurrence, des blocages et d'autres problèmes de thread. Troisièmement, une grande partie des fictions agréables sur lesquelles nous nous reposons dans le monde à thread unique (par exemple, les lectures et écritures de mémoire se produisant dans une séquence prévisible et cohérente) ne sont plus fiables. Cela mène aux pires types de bogues difficiles à reproduire.
Cela ne semble tout simplement pas juste d'avoir à utiliser le gros marteau de la simultanéité reposant sur les threads pour créer des programmes simples qui restent réactifs et sont exécutés efficacement. D'une manière ou d'une autre, les gens parviennent à résoudre des problèmes complexes tout en continuant à répondre aux événements. Dans le monde réel, vous n'avez pas besoin d'assigner un serveur par table ou deux cuisiniers par commande pour qu'ils s'occupent de dizaines de demandes clients qui sont toutes en attente au même moment. L'utilisation des threads pour résoudre ce problème implique le recours à un trop grand nombre de cuisiniers. Il y a nécessairement une meilleure solution qui n'implique pas autant de simultanéité.
Deuxième solution : développer le trouble du déficit de l'attention avec DoEvents
Une « solution » de non-simultanéité courante au problème d'absence de réponse de l'interface utilisateur pendant les opérations longues consiste à saupoudrer délibérément les mots magiques Application.DoEvents autour d'un programme jusqu'à ce que le problème disparaisse. Bien que cela soit une solution pragmatique, elle n'est pas très bien conçue :
void ServeBreakfast(Customer diner)
{
var order = ObtainOrder(diner);
Application.DoEvents();
var ingredients = ObtainIngredients(order);
Application.DoEvents();
var recipe = ObtainRecipe(order);
Application.DoEvents();
var meal = recipe.Prepare(ingredients);
Application.DoEvents();
diner.Give(meal);
}
En fait, l'utilisation de DoEvents signifie « voir si quelque chose d'intéressant s'est produit pendant que j'étais occupé à faire la dernière chose. S'il s'est produit quelque chose auquel je dois répondre, me souvenir de ce que je viens de faire, gérer la nouvelle situation, puis revenir où je me suis arrêté ». Votre programme se comporte alors comme s'il souffrait de troubles du déficit de l'attention : tout ce qui est nouveau fait immédiatement l'objet de son attention. Cette solution semble plausible pour améliorer la réactivité, et elle fonctionne même parfois, mais cette approche présente un certain nombre de problèmes.
Tout d'abord, les événements DoEvents fonctionnent le mieux lorsque le délai est dû à une boucle qui doit être exécutée un trop grand nombre de fois, chaque exécution étant elle-même de courte durée. En recherchant les événements en attente régulièrement au cours de la boucle, vous pouvez maintenir la réactivité même si l'exécution de la boucle est longue. Toutefois, ce schéma n'est généralement pas à l'origine du problème de réactivité. Celui-ci est en effet plus souvent dû à une opération dont l'exécution est longue par nature et prend donc beaucoup de temps, par exemple une tentative d'accès synchrone à un fichier sur un réseau à latence élevée. Dans notre exemple, la tâche dont l'exécution est longue pourrait être la préparation du repas et il n'y a pas la place d'insérer la fonction DoEvents qui facilite les choses. Ou il pourrait y avoir la place d'insérer cette fonction DoEvents, mais ce serait dans une méthode pour laquelle vous n'avez pas le code source.
Deuxièmement, avec l'appel de DoEvents, le programme tente de servir totalement tous les événements plus récents avant de terminer le travail associé aux événements précédents. Imaginez ce qui se produirait si personne ne pouvait avoir son repas tant que chaque client qui est arrivé n'a pas été servi ! Si les clients ne cessent d'arriver, il est possible que le premier client n'ait jamais son repas et meure de faim. En fait, il pourrait même arriver qu'aucun client n'obtienne son repas. La fin du travail associé aux événements précédents peut être repoussée loin dans le futur arbitrairement puisque les nouveaux événements continuent à interrompre le travail fait pour les événements précédents.
Troisièmement, DoEvents entraîne véritablement un danger de réentrance inattendue. Cela signifie que pendant que vous servez un client, vous vérifiez si des événements d'interface utilisateur récents et intéressants ont eu lieu et vous commencez par inadvertance à servir à nouveau le même dîner, bien qu'il ait déjà été servi. La plupart des développeurs ne conçoivent pas leur code pour détecter ce type de réentrance. Il est ainsi possible de se retrouver dans des états de programme vraiment très étranges lorsqu'un algorithme qui n'aurait jamais dû être récursif finit par s'appeler lui-même de façon inattendue via DoEvents.
En bref, la fonction DoEvents devrait uniquement être utilisée pour résoudre un problème de réactivité dans les cas les plus simples. Cette solution n'est pas adaptée pour gérer la réactivité de l'interface utilisateur dans les programmes complexes.
Troisième solution : inverser la liste de contrôle avec les rappels
La nature non simultanée de la technique DoEvents est attirante, mais n'est clairement pas la bonne solution pour un programme complexe. Il est plus judicieux de diviser les éléments de la liste de contrôle en une série de tâches courtes, chacune d'entre elles pouvant être effectuée suffisamment rapidement pour que l'application semble être réactive aux événements.
Cette idée est loin d'être nouvelle : la division d'un problème complexe en plusieurs petites parties est la raison pour laquelle les sous-routines ont été créées en premier lieu. Le petit changement intéressant ici est qu'au lieu de parcourir une liste de contrôle pour déterminer ce qui a déjà été fait et ce qui doit être fait ensuite, et de rendre uniquement le contrôle à l'appelant une fois tout terminé, chaque nouvelle tâche reçoit la liste du travail qui doit être effectué ensuite. Le travail qui doit venir après la fin d'une tâche spécifique se nomme la « continuation » de la tâche.
Lorsqu'une tâche est terminée, elle peut examiner la continuation et la terminer immédiatement à ce moment-là. Elle peut également planifier l'exécution ultérieure de la planification. Si la continuation a besoin d'informations calculées par la tâche précédente, cette dernière peut les passer en tant qu'argument à l'appel de la continuation.
Avec cette approche, tout le corps du travail est divisé en petites sections, chacune d'entre elles pouvant être exécutée rapidement. Le système semble réactif parce que les événements en attente peuvent être détectés et gérés entre les exécutions de deux petites sections de travail. Mais étant donné que les activités associées à ces nouveaux événements peuvent également être divisées en petites parties et mises dans une file d'attente en vue d'une exécution ultérieure, il n'existe pas de problème de « faim » dans lequel les nouvelles tâches empêchent l'exécution des tâches plus anciennes. Les nouvelles tâches longues ne sont pas gérées immédiatement, mais elles sont mises dans une file d'attente en vue d'un traitement futur.
L'idée est excellente, mais l'implémentation de ce type de solution n'est pas tout à fait claire. La difficulté essentielle est de déterminer comment expliquer à chaque petite unité de travail ce qu'est la continuation, c'est-à-dire quel est le travail à effectuer ensuite.
Dans le code asynchrone traditionnel, cette opération est généralement effectuée en enregistrant la fonction de « rappel ». Supposons que nous ayons une version asynchrone de « Préparer » qui prenne une fonction de rappel indiquant ce qui doit être effectué ensuite, c'est-à-dire servir le repas :
void ServeBreakfast(Diner diner)
{
var order = ObtainOrder(diner);
var ingredients = ObtainIngredients(order);
var recipe = ObtainRecipe(order);
recipe.PrepareAsync(ingredients, meal =>
{
diner.Give(meal);
});
}
Maintenant, ServeBreakfast est retourné immédiatement après le retour de PrepareAsync. Le code qui a appelé ServeBreakfast est ensuite libre de servir les autres événements qui se produisent. PrepareAsync ne fait pas de travail « réel ». Il fait plutôt rapidement ce qui est nécessaire pour s'assurer que le repas sera préparé par la suite. PrepareAsync s'assure également que la méthode de rappel sera appelée avec le repas préparé lorsque son argument après la tâche de préparation du repas sera terminé. Ainsi, le dîner sera finalement servi même si le client devra peut-être attendre brièvement si un événement requiert de l'attention entre la fin de la préparation et le service du repas.
Notez que rien de tout cela n'implique nécessairement un deuxième thread. Il est possible que PrepareAsync entraîne la réalisation du travail de préparation des repas sur un thread distinct ou qu'il entraîne la mise en file d'attente d'une série de courtes tâches associées à la préparation du repas sur le thread d'interface utilisateur à exécuter plus tard. Cela n'a pas grande importance, tout ce que nous savons c'est que PrepareAsync garantit deux choses, d'une façon ou d'une autre : les repas seront préparés d'une façon qui ne bloque pas le thread d'interface utilisateur avec une opération à latence élevée et le rappel sera appelé après le travail de préparation du repas demandé.
Mais supposons que l'une des méthodes permettant d'obtenir la commande, d'obtenir les ingrédients, d'obtenir la recette ou de préparer le repas soit ce qui ralentit l'interface utilisateur. Nous pourrions résoudre ce problème plus important si nous avions une version asynchrone de chacune de ces méthodes. À quoi ressemblerait le programme final ? N'oubliez pas que chaque méthode doit avoir un rappel qui lui indique quoi faire une fois l'unité de travail terminée :
void ServeBreakfast(Diner diner)
{
ObtainOrderAsync(diner, order =>
{
ObtainIngredientsAsync(order, ingredients =>
{
ObtainRecipeAsync(order, recipe =>
{
recipe.PrepareAsync(ingredients, meal =>
{
diner.Give(meal);
})})})});
}
Bien que cela puisse ressembler à une énorme pagaille, ce n'est rien comparé au mauvais état des véritables programmes lorsqu'ils sont réécrits à l'aide de l'asynchronie reposant sur les rappels. Pensez à la façon dont vous géreriez la définition d'une boucle asynchrone ou des exceptions, des blocs try-finally ou d'autres formes complexes du flux de contrôle. Vous finissez par mettre votre programme sens dessus dessous. Le code met maintenant l'accent sur la façon dont tous les rappels sont reliés et non sur ce que devrait être le workflow logique du programme.
Quatrième solution : laisser le compilateur résoudre le problème avec l'asynchronie reposant sur les tâches
L'asynchronie reposant sur les tâches garde le thread d'interface utilisateur réactif et réduit au minimum le temps perdu en attendant de façon synchrone la fin d'un long travail. Cependant, le remède semble pire que la maladie. Le prix à payer pour la réactivité et les performances est que vous devez écrire du code mettant en évidence la façon dont les mécanismes de l'asynchronie fonctionnent tout en obscurcissant la signification et l'objectif du code.
Les prochaines versions de C# et Visual Basic vous permettent plutôt d'écrire du code qui met en évidence sa signification et son objectif, tout en donnant suffisamment d'indices aux compilateurs pour créer les mécanismes qui vous sont nécessaires en arrière-plan. La solution est composée de deux parties : la première dans le système de type et la seconde dans le langage.
La version du CLR 4 a défini le type Task<T> (le type fiable de la bibliothèque parallèle de tâches, ou TPL) pour représenter le concept suivant : « un travail qui produira un résultat de type T dans le futur ». Le concept de « travail qui sera effectué dans le futur mais ne retourne aucun résultat » est représenté par le type de tâche non générique.
La façon exacte dont le résultat du type T sera produit dans le futur est un détail d'implémentation d'une tâche donnée. Le travail peut être confié complètement à un autre ordinateur, à un autre processus de cet ordinateur, à un autre thread, ou il consiste simplement à lire un résultat précédemment mis en cache auquel il est possible d'accéder à partir du thread actuel à moindre coût. Les tâches TPL sont généralement confiées aux threads de travail à partir d'un pool de threads du processus actuel, mais ce détail d'implémentation n'est pas essentiel pour le type Task<T>. En revanche, ce type peut représenter une opération de latence élevée qui produit un T.
La moitié de la solution liée au langage est le nouveau mot clé await. Un appel de méthode standard signifie « souvenez-vous de ce que vous faites, exécutez cette méthode jusqu'à ce qu'elle soit totalement terminée, puis revenez là où vous vous étiez arrêté en connaissant maintenant le résultat de la méthode ». Une expression await, en revanche, signifie « évaluez cette expression pour obtenir un objet représentant un travail qui produira un résultat dans le futur. Inscrivez le reste de la méthode actuelle comme rappel associé à la continuation de cette tâche. Une fois la tâche produite et le rappel inscrit, rendez immédiatement le contrôle à l'appelant ».
Notre petit exemple réécrit avec le nouveau style est nettement mieux :
async void ServeBreakfast(Diner diner)
{
var order = await ObtainOrderAsync(diner);
var ingredients = await ObtainIngredientsAsync(order);
var recipe = await ObtainRecipeAsync(order);
var meal = await recipe.PrepareAsync(ingredients);
diner.Give(meal);
}
Dans cette ébauche, chaque version asynchrone retourne un Task<Order>, Task<List<Ingredient>>, etc. Chaque fois qu'un await est rencontré, la méthode en cours d'exécution inscrit le reste de la méthode comme la chose à faire une fois la tâche en cours terminée, puis elle retourne immédiatement à ce qu'elle faisait. Chaque tâche va en quelque sorte se terminer, soit en étant planifiée pour une exécution comme événement sur le thread actuel, soit parce qu'elle a utilisé un thread de réalisation E/S ou un thread de travail, puis elle demandera à sa continuation de reprendre là où elle s'était arrêtée pour exécuter le reste de la méthode.
Remarquez que la méthode est désormais marquée avec le nouveau mot clé async. Celui-ci indique simplement au compilateur que dans le contexte de cette méthode, le mot clé await doit être traité comme un point où le workflow rend le contrôle à son appelant et le reprend une fois la tâche associée terminée. Remarquez également que les exemples de cet article utilisent du code C#. Le Visual Basic proposera une fonctionnalité similaire avec une syntaxe similaire. La conception de ces fonctionnalités en C# et en Visual Basic a été largement influencée par les workflows asynchrones F#, une fonctionnalité qui est proposée par F# depuis quelque temps.
Où en apprendre plus
Cette brève introduction motive à peine, puis gratte la surface de la nouvelle fonctionnalité d'asynchronie en C# et Visual Basic. Pour une explication plus détaillée de la façon dont cela fonctionne en arrière-plan et de la réflexion en matière de caractéristiques de performances du code asynchrone, consultez les autres articles de cette édition rédigés par mes collègues Mads Torgersen et Stephen Toub.
Pour obtenir une version préliminaire de cette fonctionnalité, ainsi que des exemples et des livres blancs, et pour accéder à un forum de la communauté sur lequel échanger des questions, des discussions et des commentaires constructifs, rendez-vous sur msdn.com/async. Ces fonctionnalités de langage et les bibliothèques qui les prennent en charge sont encore en développement. L'équipe de conception aimerait recevoir autant de commentaires que possible.
Eric Lippert est développeur principal dans l'équipe Compilateur C# chez Microsoft.
Merci aux experts techniques suivants d'avoir relu cet article : *Mads Torgersen, *Stephen Toub *et *Lucian Wischik