45 votes

Pourquoi les applications web deviennent-elles folles avec await / async de nos jours ?

Je viens d'un arrière-plan / client lourd, donc peut-être que je manque quelque chose... mais j'ai récemment regardé la source d'un serveur de jetons JWT open source et les auteurs sont devenus fous avec await / async. Comme sur chaque méthode et chaque ligne.

Je comprends à quoi sert ce modèle... pour exécuter des tâches longues dans un thread séparé. À l'époque où j'étais un client lourd, je l'utilisais si une méthode pouvait prendre quelques secondes, afin de ne pas bloquer le thread de l'interface graphique... mais certainement pas pour une méthode qui prend quelques ms.

Cette utilisation excessive de await / async est-elle nécessaire pour le développement web ou pour quelque chose comme Angular ? C'était dans un serveur de jetons JWT, donc je ne vois même pas ce que ça a à voir avec tout ça. C'est juste un point de terminaison REST.

Comment le fait de rendre chaque ligne asynchrone va-t-il améliorer les performances ? Pour moi, cela va réduire les performances en faisant tourner tous ces threads, non ?

133voto

Eric Lippert Points 300275

Je comprends à quoi sert le modèle... pour exécuter des tâches longues dans un fil séparé.

Ce n'est absolument pas le but de ce modèle. .

Attendre fait pas mettez l'opération sur un nouveau fil. Assurez-vous que c'est très claire pour vous. Attendre planifie le travail restant comme la continuation de l'opération à latence élevée.

Attendre fait pas transformer une opération synchrone en une opération concurrente asynchrone. Await permet aux programmeurs qui travaillent avec un modèle déjà asynchrone d'écrire leur logique pour ressembler à des flux de travail synchrones. . Await ne crée pas et ne détruit pas l'asynchronisme. gère l'asynchronie existante.

Créer un nouveau fil de discussion, c'est comme embaucher un ouvrier. Lorsque vous attendez une tâche, vous n'engagez pas un travailleur pour effectuer cette tâche. Vous demandez "est-ce que cette tâche est déjà faite ? Si ce n'est pas le cas, rappelez-moi quand elle sera terminée pour que je puisse continuer à faire le travail qui dépend de cette tâche. En attendant, je vais aller travailler sur cette autre chose ici..."

Si vous êtes en train de faire vos impôts et que vous vous rendez compte que vous avez besoin d'un numéro de votre travail, et que le courrier n'est pas encore arrivé, vous n'allez pas engager un travailleur pour attendre près de la boîte aux lettres . Vous notez où vous en êtes dans vos impôts, vous allez faire d'autres choses, et quand le courrier arrive, vous reprenez là où vous vous êtes arrêté. C'est attendre . C'est attente asynchrone d'un résultat .

Cette utilisation excessive de await / async est-elle nécessaire pour le développement web ou pour quelque chose comme Angular ?

C'est pour gérer la latence.

En quoi le fait de rendre chaque ligne asynchrone va-t-il améliorer les performances ?

De deux façons. Tout d'abord, en garantissant que les applications restent réactives dans un monde où les opérations à forte latence sont nombreuses. Ce type de performance est important pour les utilisateurs qui ne veulent pas que leurs applications se bloquent. Deuxièmement, en fournissant aux développeurs des outils pour exprimer les relations de dépendance des données dans les flux de travail asynchrones. En évitant de bloquer les opérations à forte latence, les ressources du système sont libérées pour travailler sur les opérations non bloquées.

Pour moi, ça va tuer les performances en faisant tourner tous ces fils, non ?

Il n'y a pas de fil conducteur. La concurence est un mécanisme permettant de réaliser l'asynchronie, mais ce n'est pas le seul.

Ok, donc si j'écris du code comme : await someMethod1() ; await someMethod2() ; await someMethod3() ; cela va magiquement rendre l'application plus réactive ?

Plus réactif par rapport à quoi ? Par rapport au fait d'appeler ces méthodes sans les attendre ? Non, bien sûr que non. Par rapport à l'attente synchrone de la fin des tâches ? Absolument, oui.

C'est ce que je ne comprends pas, je suppose. Si vous attendez les 3 à la fin, alors oui, vous utilisez les 3 méthodes en parallèle.

Non non non. Arrêtez de penser au parallélisme. Il n'y a pas besoin de parallélisme.

Pensez-y de cette façon. Vous souhaitez préparer un sandwich aux œufs au plat. Vous avez les tâches suivantes :

  • Faire frire un œuf
  • Faire griller du pain
  • Assembler un sandwich

Trois tâches. La troisième tâche dépend des résultats des deux premières, mais les deux premières tâches ne dépendent pas l'une de l'autre. Voici donc des flux de travail :

  • Mettez un œuf dans la poêle. Pendant que l'œuf est en train de frire, fixez-le.
  • Une fois l'œuf cuit, mettez des toasts dans le grille-pain. Fixez le grille-pain.
  • Une fois que le pain grillé est cuit, mettez l'œuf sur le pain grillé.

Le problème est que vous pourriez mettre le toast dans le grille-pain pendant que l'œuf cuit. Flux de travail alternatif :

  • Mettez un œuf dans la casserole. Réglez une alarme qui sonne lorsque l'œuf est cuit.
  • Mets les toasts dans le grille-pain. Réglez une alarme qui sonne lorsque le toast est prêt.
  • Vérifiez votre courrier. Faites vos impôts. Polir l'argenterie. Tout ce que vous devez faire.
  • Lorsque les deux alarmes ont sonné, prenez l'œuf et le toast, mettez-les ensemble, et vous avez un sandwich.

Vous voyez pourquoi le flux de travail asynchrone est bien plus efficace ? Vous faites beaucoup de choses pendant que vous attendez que l'opération à forte latence se termine. Mais vous n'avez pas engagé un chef des œufs et un chef des toasts. . Il n'y a pas de nouveaux fils !

Le flux de travail que j'ai proposé serait le suivant :

eggtask = FryEggAsync();
toasttask = MakeToastAsync();
egg = await eggtask;
toast = await toasttask;
return MakeSandwich(egg, toast);

Maintenant, comparez ça à :

eggtask = FryEggAsync();
egg = await eggtask;
toasttask = MakeToastAsync();
toast = await toasttask;
return MakeSandwich(egg, toast);

Vous voyez comment ce flux de travail diffère ? Ce flux de travail est :

  • Mettez un œuf dans la casserole et mettez une alarme.
  • Va faire un autre travail jusqu'à ce que l'alarme se déclenche.
  • Sortez l'œuf de la casserole, mettez le pain dans le grille-pain. Mettez une alarme...
  • Va faire un autre travail jusqu'à ce que l'alarme se déclenche.
  • Quand l'alarme se déclenche, assemblez le sandwich.

Ce flux de travail est moins efficace parce que nous n'avons pas réussi à capturer le fait que les tâches du toast et de l'œuf ont une latence élevée et sont indépendantes. . Mais c'est sûrement une utilisation plus efficace des ressources que de faire rien pendant que tu attends que l'oeuf cuise.

Le point de tout cela est : les fils sont follement chers, donc Ne le fais pas. créer de nouveaux fils. Plutôt, utiliser plus efficacement le fil de discussion dont vous disposez en le faisant travailler pendant que vous effectuez des opérations à forte latence. . Attendre est pas Il s'agit de réaliser plus de travail sur un seul fil dans un monde où la latence des calculs est élevée.

Peut-être que ce calcul est effectué sur un autre thread, peut-être qu'il est bloqué sur le disque, peu importe. Cela n'a pas d'importance. Le point est, attendre est pour gérer que l'asynchronie, et non créer il.

J'ai du mal à comprendre comment la programmation asynchrone peut être possible sans utiliser le parallélisme quelque part. Par exemple, comment dire au programme de commencer à préparer les toasts en attendant les œufs sans que DoEggs() ne s'exécute simultanément, du moins en interne ?

Reprenons l'analogie. Vous êtes en train de préparer un sandwich aux œufs, les œufs et les toasts sont en train de cuire, et vous commencez à lire votre courrier. Vous en êtes à la moitié du courrier lorsque les œufs sont cuits, alors vous mettez le courrier de côté et retirez l'œuf du feu. Puis vous retournez à votre courrier. Puis les toasts sont cuits et vous faites le sandwich. Puis vous finissez de lire votre courrier après avoir fait le sandwich. Comment avez-vous fait tout cela sans embaucher du personnel, une personne pour lire le courrier, une personne pour faire cuire l'œuf, une pour faire les toasts et une pour assembler le sandwich ? Vous avez fait tout cela avec un seul ouvrier.

Comment avez-vous fait cela ? En divisant les tâches en petits morceaux, en notant quels morceaux doivent être faits dans quel ordre, et puis coopérativement multitâche les pièces.

Les jeunes d'aujourd'hui, avec leurs grands modèles de mémoire virtuelle plate et leurs processus multithreads, pensent qu'il en a toujours été ainsi, mais ma mémoire remonte à l'époque de Windows 3, qui n'avait rien de tout cela. Si vous vouliez que deux choses se produisent "en parallèle", c'est ce que vous faisiez : vous divisiez les tâches en petites parties et vous vous relayiez pour les exécuter. L'ensemble du système d'exploitation était basé sur ce concept.

Maintenant, vous pourriez regarder l'analogie et dire "OK, mais une partie du travail, comme faire griller les toasts, est effectuée par une machine", et que est la source du parallélisme. Bien sûr, je n'ai pas eu besoin d'engager un ouvrier pour griller le pain, mais j'ai obtenu le parallélisme dans le matériel. Et c'est la bonne façon de voir les choses. Le parallélisme matériel et le parallélisme des fils sont différents. . Lorsque vous faites une demande asynchrone au sous-système réseau pour aller chercher un enregistrement dans une base de données, il y a pas de fil qui reste là à attendre le résultat. Le matériel atteint un parallélisme à un niveau très, très inférieur à celui des threads du système d'exploitation.

Si vous voulez une explication plus détaillée de la façon dont le matériel fonctionne avec le système d'exploitation pour obtenir l'asynchronie, lisez " Il n'y a pas de fil conducteur " par Stephen Cleary.

Ainsi, lorsque vous voyez "asynchrone", ne pensez pas "parallèle". Pensez plutôt à une opération à forte latence divisée en petits morceaux. beaucoup de de telles opérations dont les pièces ne dépendent pas les unes des autres, alors vous pouvez s'entrelacer de manière coopérative l'exécution de ces pièces sur un seul fil.

Comme vous pouvez l'imaginer, c'est très difficile d'écrire des flux de contrôle qui vous permettent d'abandonner ce que vous êtes en train de faire, de faire autre chose, et de reprendre sans problème là où vous vous êtes arrêté. C'est pourquoi nous faisons faire ce travail par le compilateur ! L'intérêt de "await" est qu'il vous permet de gérer ces flux de travail asynchrones en les décrivant comme des flux de travail synchrones. Chaque fois qu'il y a un point où vous pouvez mettre cette tâche de côté et y revenir plus tard, écrivez "await". Le compilateur se chargera de transformer votre code en de nombreux petits morceaux qui pourront chacun être planifiés dans un flux de travail asynchrone.

UPDATE :

Dans votre dernier exemple, quelle serait la différence entre


eggtask = FryEggAsync(); 
egg = await eggtask; 
toasttask = MakeToastAsync(); 
toast = await toasttask; 

egg = await FryEggAsync(); 
toast = await MakeToastAsync();?

Je suppose qu'il les appelle de manière synchrone mais les exécute de manière asynchrone ? Je dois admettre que je n'ai jamais pris la peine d'attendre les tâches séparément auparavant.

Il n'y a pas de différence.

Lorsque FryEggAsync est appelé, il est appelé que ce soit await apparaît devant elle ou non. await est un opérateur . Il opère sur la chose a retourné de l'appel à FryEggAsync . C'est comme n'importe quel autre opérateur.

Laissez-moi le dire encore une fois : await est un opérateur et son opérande est une tâche. C'est un opérateur très inhabituel, certes, mais grammaticalement c'est un opérateur, et il opère sur un valeur comme n'importe quel autre opérateur.

Laissez-moi le dire encore une fois : await n'est pas une poussière magique que l'on met sur un site d'appel et qui, soudainement, est déplacé vers un autre thread. L'appel se produit quand l'appel se produit, l'appel renvoie un valeur et cette valeur est une référence à un objet qui est un opérande légal de la fonction await opérateur .

Alors oui,

var x = Foo();
var y = await x;

et

var y = await Foo();

sont la même chose, la même chose que

var x = Foo();
var y = 1 + x;

et

var y = 1 + Foo();

sont la même chose.

Alors revoyons tout ça encore une fois, parce que vous semblez croire au mythe selon lequel await causes asynchronie. Ce n'est pas le cas.

async Task M() { 
   var eggtask = FryEggAsync(); 

Supposons que M() s'appelle. FryEggAsync est appelé. De manière synchrone. Il n'existe pas d'appel asynchrone ; vous voyez un appel, le contrôle passe à l'appelé jusqu'à ce que l'appelé revienne. Le destinataire retourne une tâche qui représente un œuf à mettre à disposition dans le futur .

Comment FryEggAsync faire ça ? Je ne sais pas et je m'en fiche. Tout ce que je sais, c'est que je l'appelle, et que je reçois en retour un objet qui représente une valeur future. Peut-être que cette valeur est produite sur un autre thread. Peut-être qu'elle est produite sur ce fil mais à l'avenir . Il est peut-être produit par un matériel à usage spécifique, comme un contrôleur de disque ou une carte réseau. Je m'en moque. Ce qui m'importe, c'est que je récupère une tâche.

  egg = await eggtask; 

Maintenant, nous prenons cette tâche et await lui demande "avez-vous terminé ?" Si la réponse est oui, alors egg se voit attribuer la valeur produite par la tâche. Si la réponse est négative, alors M() renvoie un Task représentant "le travail de M sera achevé dans le futur". Le reste de M() est signé comme la suite de eggtask donc lorsque eggtask est terminée, elle appelle M() encore une fois et le ramasser pas depuis le début mais de l'affectation à egg . M() est un reprenable à tout moment méthode. Le compilateur fait la magie nécessaire pour que cela se produise.

Alors maintenant, nous sommes de retour. Le fil continue à faire ce qu'il fait. A un moment donné, l'oeuf est prêt, donc la suite de l'action de eggtask est invoqué, ce qui entraîne M() pour être appelé à nouveau. Il reprend au point où il s'est arrêté : il attribue l'œuf qui vient d'être produit à egg . Et maintenant, nous continuons notre route :

toasttask = MakeToastAsync(); 

Encore une fois, l'appel renvoie une tâche, et nous :

toast = await toasttask; 

vérifier si la tâche est terminée. Si oui, nous attribuons toast . Si non, alors nous revenons de M() à nouveau et le continuation de toasttask est *le reste de M().

Et ainsi de suite.

Élimination de la task Les variables ne font rien d'utile. Le stockage des valeurs est alloué ; on ne lui donne simplement pas de nom.

UNE AUTRE MISE À JOUR :

Est-il possible d'appeler les méthodes retournant des tâches le plus tôt possible et de les attendre le plus tard possible ?

L'exemple donné est le suivant :

var task = FooAsync();
DoSomethingElse();
var foo = await task;
...

Il y a un peu de Il y a des arguments à faire valoir pour cela. Mais prenons un peu de recul. Le but de la await L'opérateur est de construire un flux de travail asynchrone en utilisant les conventions de codage d'un flux de travail synchrone . Donc la chose à laquelle il faut penser est quel est ce flux de travail ? A flux de travail impose un ordre à un ensemble de tâches connexes.

La manière la plus simple de voir l'ordre requis dans un flux de travail est d'examiner le fichier dépendance des données . Vous ne pouvez pas faire le sandwich avant que le pain grillé ne sorte du grille-pain, donc vous devez obtenir le pain grillé. quelque part . Puisque await extrait la valeur de la tâche terminée, il doit y avoir un await quelque part entre la création de la tâche du grille-pain et la création du sandwich.

Vous pouvez également représenter les dépendances sur les effets secondaires. Par exemple, l'utilisateur appuie sur le bouton, vous voulez donc faire jouer le son de la sirène, puis attendre trois secondes, puis ouvrir la porte, puis attendre trois secondes, puis fermer la porte :

DisableButton();
PlaySiren();
await Task.Delay(3000);
OpenDoor();
await Task.Delay(3000);
CloseDoor();
EnableButton();

Cela n'aurait aucun sens de dire

DisableButton();
PlaySiren();
var delay1 = Task.Delay(3000);
OpenDoor();
var delay2 = Task.Delay(3000);
CloseDoor();
EnableButton();
await delay1;
await delay2;

Parce que ce n'est pas le flux de travail souhaité.

Ainsi, la réponse à votre question est la suivante : différer l'attente jusqu'au moment où la valeur est réellement nécessaire est une assez bonne pratique, car cela augmente les possibilités de programmer le travail de manière efficace. Mais vous pouvez aller trop loin ; assurez-vous que le flux de travail qui est mis en œuvre est celui que vous voulez.

3voto

Kirlac Points 402

En général, c'est parce qu'une fois que les fonctions asynchrones jouent mieux avec d'autres fonctions asynchrones, sinon vous commencez à perdre les avantages de l'asynchronisme. Par conséquent, les fonctions qui appellent des fonctions asynchrones finissent par être elles-mêmes asynchrones et cela se propage dans toute l'application. Par exemple, si vous avez rendu asynchrones vos interactions avec un magasin de données, les éléments qui utilisent cette fonctionnalité ont tendance à être rendus asynchrones également.

En convertissant du code synchrone en code asynchrone, vous constaterez qu'il fonctionne mieux si le code asynchrone appelle et est appelé par d'autres codes asynchrones - de haut en bas (ou "en haut", si vous préférez). D'autres ont également remarqué le comportement de propagation de la programmation asynchrone et l'ont qualifié de "contagieux" ou l'ont comparé à un virus zombie. Qu'il s'agisse de tortues ou de zombies, il est certain que le code asynchrone a tendance à pousser le code environnant à être également asynchrone. Ce comportement est inhérent à tous les types de programmation asynchrone, et pas seulement aux nouveaux mots-clés async/await.

Source : Async/Await - Meilleures pratiques en matière de programmation asynchrone

1voto

bazza Points 1828

C'est un monde de modèles d'acteurs, vraiment...

Mon point de vue est que l'asynchronisme et les attentes sont simplement une façon d'habiller les systèmes logiciels pour éviter d'avoir à concéder que, en réalité, beaucoup de systèmes (en particulier ceux avec beaucoup de communications réseau) sont mieux vus comme des systèmes de modèle Acteur (ou mieux encore, Processus séquentiel communicant).

Dans les deux cas, il s'agit d'attendre que l'une des choses devienne réalisable, de prendre les mesures nécessaires lorsqu'elle l'est, puis de recommencer à attendre. Plus précisément, vous attendez qu'un message arrive d'un autre endroit, vous le lisez et vous agissez en fonction de son contenu. Dans *nix, l'attente est généralement effectuée par un appel à epoll() ou select().

L'utilisation de await / async est simplement une façon de prétendre que votre système est toujours une sorte d'appel de méthode synchrone (et donc familier), tout en rendant difficile la gestion efficace des choses qui ne se terminent pas toujours dans le même ordre à chaque fois.

Cependant, une fois que l'on s'est fait à l'idée que l'on n'appelle plus des méthodes mais que l'on fait simplement passer des messages, tout devient très naturel. C'est vraiment un truc du genre "faites ceci", "bien sûr, voici la réponse", avec de nombreuses interactions de ce type entrelacées. Le fait de conclure avec un gros appel WaitForLotsOfThings() en haut d'une boucle est simplement une reconnaissance explicite que votre programme attendra jusqu'à ce qu'il ait quelque chose à faire en réponse à de nombreux autres programmes communiquant avec lui.

Comment Windows rend les choses difficiles

Malheureusement, Windows rend très difficile la mise en œuvre d'un système proactif ("si vous lisez ce message maintenant, vous l'aurez"). Windows est réacteur ("ce message que tu m'as demandé de lire, il a été lu maintenant"). C'est une distinction importante.

Avec la première méthode, un message (ou même un délai d'attente) qui signifie "arrêtez d'écouter cet autre acteur" est facilement géré - il suffit d'exclure cet autre acteur de la liste que vous écouterez la prochaine fois que vous attendrez.

Avec un réacteur, c'est beaucoup plus difficile. Comment honorer un message "arrêtez d'écouter cet autre acteur" lorsque la lecture a déjà commencé avec une sorte d'appel asynchrone, et ne se terminera pas tant que quelque chose n'aura pas été lu, un résultat douteux étant donné l'instruction récemment reçue ?

Je pinaille dans une certaine mesure. Proactor est très utile dans les systèmes avec une connectivité dynamique, des acteurs qui entrent dans le système, qui en sortent à nouveau. Reactor est parfait si vous avez une population fixe d'acteurs avec des liens de communication qui ne disparaîtront jamais. Néanmoins, étant donné qu'un système reactor est facilement implémenté dans une plateforme proactor, mais qu'un système proactor ne peut pas être facilement implémenté sur une plateforme reactor (le temps ne reviendra pas en arrière), je trouve l'approche de Window particulièrement irritante.

Donc, d'une manière ou d'une autre, async / await sont définitivement encore au pays des réacteurs.

Frapper sur l'impact

Ce phénomène a contaminé de nombreuses autres bibliothèques.

L'asio Boost du C++ est également reactor, même sur *nix, en grande partie, semble-t-il, parce qu'ils voulaient avoir une implémentation Windows.

ZeroMQ, qui est un cadre proactif, est limité dans une certaine mesure sous Windows car il repose sur un appel à select() (qui, sous Windows, ne fonctionne que sur les sockets).

Pour la famille cygwin de runtimes POSIX sous Windows, ils ont dû implémenter select(), epoll(), etc. en ayant un thread par descripteur de fichier. sondage (oui, sondage ! !!!) le socket / port série / pipe sous-jacent pour les données entrantes afin de recréer les routines POSIX. Yeurk ! Les commentaires sur les listes de diffusion des développeurs de cygwin datant de l'époque où ils mettaient en œuvre cette partie sont une lecture amusante.

Acteur n'est pas nécessairement lent

Il convient de noter que l'expression "faire passer des messages" ne signifie pas nécessairement faire passer des copies - il existe de nombreuses formulations du modèle de l'acteur où l'on se contente de faire passer la propriété des références aux messages (par exemple, Dataflow, qui fait partie de la bibliothèque Task Parallel en C#). Cela le rend rapide. Je n'ai pas encore eu l'occasion d'examiner la bibliothèque Dataflow, mais elle ne fait pas vraiment de Windows un proacteur tout d'un coup. Elle ne vous donne pas un système proacteur de modèle d'acteur travaillant sur toutes sortes de supports de données comme les sockets, les pipes, les files d'attente, etc.

Le runtime Linux de Windows 10

Alors que nous venons de dénoncer Windows et son architecture de réacteur inférieure, un point intrigant est que Windows 10 exécute maintenant des binaires Linux. Comment, j'aimerais bien le savoir, Microsoft a-t-il implémenté l'appel système qui sous-tend select(), epoll() étant donné qu'il doit fonctionner sur des sockets, des ports série, des pipes et tout ce qui, au pays de POSIX, est un descripteur de fichier, alors que tout le reste de Windows ne le peut pas ? Je donnerais mes dents de derrière pour connaître la réponse à cette question.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X