312 votes

Si async-await ne crée pas de threads supplémentaires, alors comment rend-il les applications réactives ?

À maintes reprises, je vois qu'il est dit qu'utiliser async - await ne crée pas de fils supplémentaires. Cela n'a pas de sens, car les seules façons dont un ordinateur peut sembler faire plus d'une chose à la fois sont les suivantes

  • Faire plus d'une chose à la fois (exécution en parallèle, utilisation de plusieurs processeurs).
  • La simuler en planifiant des tâches et en passant de l'une à l'autre (faire un peu de A, un peu de B, un peu de A, etc.)

Donc si async - await ne fait ni l'un ni l'autre, alors comment peut-il rendre une application réactive ? S'il n'y a qu'un seul thread, l'appel d'une méthode implique d'attendre la fin de la méthode avant de faire quoi que ce soit d'autre, et les méthodes à l'intérieur de cette méthode doivent attendre le résultat avant de poursuivre, et ainsi de suite.

22 votes

Les tâches IO ne sont pas liées au CPU et ne nécessitent donc pas de thread. L'objectif principal de l'asynchronisme est de ne pas bloquer les threads pendant les tâches liées à l'IO.

0 votes

Je suis d'accord avec vous. Un nouveau processus doit être lancé.

27 votes

@jdweng : Non, pas du tout. Même si cela créait de nouvelles fils c'est très différent de la création d'un nouveau processus.

364voto

Lasse V. Karlsen Points 148037

En fait, async/await n'est pas si magique. Le sujet est assez vaste, mais pour une réponse rapide mais suffisamment complète à votre question, je pense que nous pouvons nous débrouiller.

Abordons un simple événement de clic de bouton dans une application Windows Forms :

public async void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before awaiting");
    await GetSomethingAsync();
    Console.WriteLine("after awaiting");
}

Je vais explicitement pas parler de tout ce qui est GetSomethingAsync est de retour pour le moment. Disons que c'est quelque chose qui se terminera après, disons, 2 secondes.

Dans un monde traditionnel, non asynchrone, votre gestionnaire d'événement de clic de bouton ressemblerait à quelque chose comme ceci :

public void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before waiting");
    DoSomethingThatTakes2Seconds();
    Console.WriteLine("after waiting");
}

Lorsque vous cliquez sur le bouton du formulaire, l'application semble se figer pendant environ 2 secondes, le temps que la méthode se termine. Ce qui se passe, c'est que la "pompe à messages", qui est en fait une boucle, est bloquée.

Cette boucle demande continuellement à Windows "Quelqu'un a-t-il fait quelque chose, comme déplacer la souris, cliquer sur quelque chose ? Est-ce que je dois repeindre quelque chose ? Si oui, dites-le moi", puis traite ce "quelque chose". Cette boucle a reçu un message indiquant que l'utilisateur a cliqué sur le "bouton1" (ou le type de message équivalent de Windows), et a fini par appeler notre module button1_Click méthode ci-dessus. Jusqu'à ce que cette méthode revienne, cette boucle est maintenant bloquée en attente. Cela prend 2 secondes et pendant ce temps, aucun message n'est traité.

La plupart des choses qui concernent Windows se font à l'aide de messages, ce qui signifie que si la boucle de messages cesse de pomper des messages, ne serait-ce qu'une seconde, l'utilisateur le remarque rapidement. Par exemple, si vous déplacez le bloc-notes ou tout autre programme au-dessus de votre propre programme, puis l'en éloignez à nouveau, une rafale de messages de peinture est envoyée à votre programme pour indiquer quelle région de la fenêtre est soudainement redevenue visible. Si la boucle de messages qui traite ces messages est en attente de quelque chose, bloquée, alors aucune peinture n'est effectuée.

Donc, si dans le premier exemple, async/await ne crée pas de nouveaux fils, comment fait-il ?

Eh bien, ce qui se passe, c'est que votre méthode est divisée en deux. Comme il s'agit d'un sujet très vaste, je ne vais pas entrer dans les détails, mais il suffit de dire que la méthode est divisée en deux :

  1. Tout le code menant à await y compris l'appel à GetSomethingAsync
  2. Tout le code suivant await

Illustration :

code... code... code... await X(); ... code... code... code...

Réarrangé :

code... code... code... var x = X(); await X; code... code... code...
^                                  ^          ^                     ^
+---- portion 1 -------------------+          +---- portion 2 ------+

En gros, la méthode s'exécute comme suit :

  1. Il exécute tout jusqu'à await

  2. Il appelle le GetSomethingAsync qui fait son travail et renvoie quelque chose qui se terminera 2 secondes dans le futur

    Jusqu'à présent, nous sommes toujours dans l'appel original à button1_Click, qui se passe sur le fil principal, appelé depuis la boucle de messages. Si le code menant à await prend beaucoup de temps, l'interface utilisateur se figera quand même. Dans notre exemple, pas tant que ça

  3. Qu'est-ce que le await ainsi qu'une astuce de compilation, fait qu'il fait quelque chose comme "Ok, vous savez quoi, je vais simplement retourner du gestionnaire d'événement de clic de bouton ici. Lorsque vous (c'est-à-dire la chose que nous attendons) aurez terminé, faites-le moi savoir car il me reste encore du code à exécuter".

    En fait, cela permettra au Classe SynchronizationContext sait qu'elle est terminée, ce qui, en fonction du contexte de synchronisation en cours, fera la queue pour l'exécution. La classe de contexte utilisée dans un programme Windows Forms la mettra en file d'attente en utilisant la file d'attente que la boucle de messages pompe.

  4. Il retourne donc à la boucle de messages, qui est maintenant libre de continuer à pomper des messages, comme déplacer la fenêtre, la redimensionner ou cliquer sur d'autres boutons.

    Pour l'utilisateur, l'interface utilisateur est à nouveau réactive, elle traite les autres clics de bouton, les redimensionnements et, surtout, le plus important, redessiner pour qu'il ne semble pas geler.

  5. 2 secondes plus tard, la chose que nous attendons se termine et ce qui se passe maintenant, c'est qu'il (enfin, le contexte de synchronisation) place un message dans la file d'attente que la boucle de message regarde, en disant "Hé, j'ai encore du code à exécuter", et ce code est tout le code après l'attendre.

  6. Lorsque la boucle de messages arrive à ce message, elle "réintègre" la méthode là où elle s'est arrêtée, juste après le message suivant await et continuer à exécuter le reste de la méthode. Notez que ce code est à nouveau appelé depuis la boucle de messages, donc si ce code fait quelque chose de long sans utiliser la méthode async/await correctement, il bloquera à nouveau la boucle de messages

Il y a beaucoup de pièces mobiles sous le capot ici, alors voici quelques liens vers plus d'informations, j'allais dire "si vous en avez besoin", mais ce sujet est assez large et il est assez important de savoir certaines de ces pièces mobiles . Invariablement, vous comprendrez qu'async/await reste un concept peu fiable. Certaines des limitations et des problèmes sous-jacents s'infiltrent toujours dans le code environnant, et si ce n'est pas le cas, vous finissez généralement par devoir déboguer une application qui s'arrête de manière aléatoire sans raison valable.


OK, et si GetSomethingAsync lance un fil de discussion qui se termine en 2 secondes ? Oui, alors il est évident qu'il y a un nouveau fil en jeu. Ce fil, cependant, n'est pas parce que de l'asynchronisme de cette méthode, c'est parce que le programmeur de cette méthode a choisi un thread pour implémenter le code asynchrone. Presque toutes les E/S asynchrones Ne le fais pas. utilisent un fil, ils utilisent des choses différentes. async/await par eux-mêmes ne créent pas de nouveaux threads, mais il est évident que les "choses que nous attendons" peuvent être mises en œuvre à l'aide de threads.

Il y a beaucoup de choses dans .NET qui ne font pas nécessairement tourner un thread par elles-mêmes mais qui sont quand même asynchrones :

  • Requêtes web (et bien d'autres choses liées au réseau qui prennent du temps)
  • Lecture et écriture asynchrones de fichiers
  • et bien d'autres encore, un bon signe est que la classe/interface en question possède des méthodes nommées SomethingSomethingAsync o BeginSomething y EndSomething et il y a un IAsyncResult impliqué.

Habituellement, ces choses n'utilisent pas de fil sous le capot.


OK, donc tu veux un peu de ces "trucs à large sujet" ?

Eh bien, demandons Essayez Roslyn sur notre bouton clic :

Essayez Roslyn

Je ne vais pas mettre le lien vers le cours complet généré ici, mais c'est assez gore.

16 votes

Donc, c'est essentiellement ce que l'OP décrit comme " Simuler l'exécution parallèle en programmant des tâches et en passant de l'une à l'autre ", n'est-ce pas ?

0 votes

C'est ce qu'il semble, mais au lieu de programmer et de faire des allers-retours pour voir s'il peut continuer, il attend d'être signalé et sait qu'il peut continuer.

5 votes

@Bergi Pas tout à fait. L'exécution est vraiment parallèle - la tâche d'E/S asynchrone est en cours et ne nécessite pas de threads pour se poursuivre (c'est quelque chose qui a été utilisé bien avant l'arrivée de Windows - MS DOS utilisait également l'E/S asynchrone, même s'il n'avait pas de multithreading !) Bien sûr, await peut peut également être utilisé de la manière que vous décrivez, mais ne l'est généralement pas. Seuls les callbacks sont planifiés (sur le pool de threads) - entre le callback et la demande, aucun thread n'est nécessaire.

132voto

Stephen Cleary Points 91731

Je l'explique en détail dans mon billet de blog Il n'y a pas de fil conducteur .

En résumé, les systèmes d'E/S modernes font un usage intensif du DMA (Direct Memory Access). Il existe des processeurs spéciaux et dédiés sur les cartes réseau, les cartes vidéo, les contrôleurs de disque dur, les ports série/parallèle, etc. Ces processeurs ont un accès direct au bus mémoire et gèrent la lecture/écriture de manière totalement indépendante de l'unité centrale. Ces processeurs ont un accès direct au bus mémoire et gèrent la lecture/écriture de manière totalement indépendante de l'unité centrale. L'unité centrale doit simplement indiquer à l'appareil l'emplacement de la mémoire contenant les données, puis elle peut faire ce qu'elle veut jusqu'à ce que l'appareil déclenche une interruption pour lui signaler que la lecture/écriture est terminée.

Une fois l'opération en cours, il n'y a plus de travail à faire pour l'unité centrale, et donc plus de thread.

0 votes

Juste pour que ce soit clair.. Je comprends le haut niveau de ce qui se passe quand on utilise async-await. En ce qui concerne l'absence de création de threads - il n'y a pas de threads uniquement dans les demandes d'E/S vers les périphériques qui, comme vous l'avez dit, ont leurs propres processeurs qui gèrent la demande elle-même ? Pouvons-nous supposer que TOUTES les demandes d'E/S sont traitées par ces processeurs indépendants, ce qui signifie que Task.Run n'est utilisé que pour les actions liées au CPU ?

1 votes

@YonatanNir : Il ne s'agit pas seulement de processeurs séparés ; tout type de réponse événementielle est naturellement asynchrone. Task.Run est plus approprié pour les actions liées au CPU mais il a aussi une poignée d'autres usages.

2 votes

J'ai fini de lire votre article et il y a encore quelque chose de fondamental que je ne comprends pas, car je ne suis pas vraiment familier avec l'implémentation de bas niveau du système d'exploitation. J'ai compris ce que tu as écrit jusqu'au moment où tu as écrit : "L'opération d'écriture est maintenant "en vol". Combien de threads sont en train de la traiter ? Aucun." . Donc, s'il n'y a pas de threads, alors comment l'opération elle-même est effectuée si ce n'est pas sur un thread ?

93voto

Eric Lippert Points 300275

les seules façons dont un ordinateur peut sembler faire plus d'une chose à la fois est (1) de faire réellement plus d'une chose à la fois, (2) de le simuler en programmant des tâches et en passant de l'une à l'autre. Donc, si async-await ne fait rien de tout cela

Ce n'est pas qu'on attend ni de ceux-là. Rappelez-vous, le but de await n'est pas de rendre le code synchrone magiquement asynchrone . C'est pour permettre utiliser les mêmes techniques que celles utilisées pour écrire du code synchrone lors de l'appel à du code asynchrone. . Await est à propos de faire en sorte que le code qui utilise des opérations à forte latence ressemble au code qui utilise des opérations à faible latence. . Ces opérations à forte latence peuvent être effectuées sur des threads, sur du matériel spécialisé, elles peuvent déchirer leur travail en petits morceaux et le placer dans la file d'attente des messages pour être traité ultérieurement par le thread de l'interface utilisateur. Ils sont en train de faire quelque chose pour réaliser l'asynchronie, mais ils sont ceux qui le font. Await vous permet simplement de profiter de cette asynchronie.

De plus, je pense qu'il vous manque une troisième option. Nous, les vieux -- les jeunes d'aujourd'hui avec leur musique rap devraient dégager de ma pelouse, etc -- nous nous souvenons du monde de Windows au début des années 1990. Il n'y avait pas de machines multi-CPU ni de planificateurs de threads. Si vous vouliez exécuter deux applications Windows en même temps, vous deviez rendement . Le multitâche était coopérative . Le système d'exploitation indique à un processus qu'il peut s'exécuter, et s'il se comporte mal, il empêche tous les autres processus d'être servis. Il fonctionne jusqu'à ce qu'il cède, et d'une manière ou d'une autre, il doit savoir comment reprendre là où il s'est arrêté la prochaine fois que le système d'exploitation lui rendra le contrôle. . Le code asynchrone monofilaire ressemble beaucoup à cela, avec "await" au lieu de "yield". Attendre signifie "Je vais me rappeler où j'en suis resté et laisser quelqu'un d'autre s'exécuter pendant un moment ; rappelez-moi quand la tâche que j'attends sera terminée et je reprendrai là où j'en étais resté". Je pense que vous pouvez voir comment cela rend les applications plus réactives, tout comme à l'époque de Windows 3.

l'appel d'une méthode signifie qu'il faut attendre que la méthode se termine

Voilà la clé qui vous manque. Une méthode peut revenir avant que son travail ne soit terminé . C'est l'essence même de l'asynchronie. Une méthode renvoie, elle renvoie une tâche qui signifie "ce travail est en cours ; dites-moi quoi faire quand il sera terminé". Le travail de la méthode n'est pas terminé, même si elle est revenue .

Avant l'opérateur await, vous deviez écrire du code qui ressemblait à des spaghettis enfilés dans du fromage suisse pour faire face au fait que nous avons du travail à faire. après l'achèvement, mais avec le retour et l'achèvement désynchronisés. . Await vous permet d'écrire du code qui regarde comme le retour et l'achèvement sont synchronisés, sans eux en fait en cours de synchronisation.

0 votes

D'autres langages de haut niveau modernes prennent également en charge des comportements coopératifs explicites similaires (c'est-à-dire que la fonction fait quelque chose, donne quelque chose [éventuellement en envoyant une valeur/un objet à l'appelant], et continue là où elle s'est arrêtée lorsque le contrôle est rendu [éventuellement avec une entrée supplémentaire fournie]). Les générateurs sont très importants en Python, pour commencer.

2 votes

@JAB : Absolument. Les générateurs sont appelés "blocs d'itérateurs" en C# et utilisent la fonction yield mot-clé. Les deux sites async Les méthodes et les itérateurs en C# sont une forme de coroutine Il s'agit du terme général pour désigner une fonction qui sait suspendre son opération en cours pour la reprendre plus tard. Un certain nombre de langages disposent aujourd'hui de coroutines ou de flux de contrôle de type coroutine.

1 votes

L'analogie avec le rendement est bonne - c'est le multitâche coopératif. au sein d'un même processus. (et ainsi éviter les problèmes de stabilité du système liés au multitâche coopératif à l'échelle du système)

31voto

gardenhead Points 738

Je suis vraiment heureux que quelqu'un ait posé cette question, car pendant longtemps, j'ai également cru que les threads étaient nécessaires à la concurrence. Quand j'ai vu pour la première fois boucles d'événements je pensais que c'était un mensonge. Je me suis dit "il n'y a pas moyen que ce code soit concurrent s'il s'exécute dans un seul thread". Gardez à l'esprit que c'est après J'avais déjà eu du mal à comprendre la différence entre la concurrence et le parallélisme.

Après avoir fait mes propres recherches, j'ai finalement trouvé la pièce manquante : select() . Plus précisément, le multiplexage des E/S, mis en œuvre par divers noyaux sous différents noms : select() , poll() , epoll() , kqueue() . Il s'agit de appels de système qui, bien que les détails d'implémentation diffèrent, vous permettent de transmettre un ensemble de descripteurs de fichiers pour regarder. Ensuite, vous pouvez faire un autre appel qui bloque jusqu'à ce que l'un des descripteurs de fichiers surveillés change.

Ainsi, on peut attendre un ensemble d'événements d'E/S (la boucle d'événements principale), traiter le premier événement qui se termine, puis rendre le contrôle à la boucle d'événements. Rincer et répéter.

Comment cela fonctionne-t-il ? Eh bien, la réponse courte est que c'est une magie au niveau du noyau et du matériel. Il existe de nombreux composants dans un ordinateur en plus du CPU, et ces composants peuvent travailler en parallèle. Le noyau peut contrôler ces périphériques et communiquer directement avec eux pour recevoir certains signaux.

Ces appels système de multiplexage des entrées/sorties sont la composante fondamentale des boucles d'événements monofilaires comme node.js ou Tornado. Lorsque vous await une fonction, vous attendez un certain événement (l'achèvement de cette fonction), puis vous rendez le contrôle à la boucle d'événement principale. Lorsque l'événement que vous surveillez se termine, la fonction reprend (éventuellement) là où elle s'est arrêtée. Les fonctions qui vous permettent de suspendre et de reprendre le calcul de cette manière sont appelées coroutines .

27voto

Margaret Bloom Points 3177

await y async utiliser Tâches pas les fils.

Le cadre dispose d'un pool de threads prêts à exécuter un travail sous la forme de Tâche objets ; soumettre un Tâche à la piscine signifie sélectionner un libre, déjà existant 1 un thread pour appeler la tâche méthode d'action.
Créer un Tâche est une question de création d'un nouvel objet, bien plus rapide que la création d'un nouveau fil.

Étant donné un Tâche il est possible d'attacher un Continuation à elle, c'est une nouvelle Tâche objet à exécuter une fois que le fil se termine.

Depuis async/await utiliser Tâche s ils ne le font pas créer un nouveau fil.


Bien que la technique de programmation des interruptions soit largement utilisée dans tous les OS modernes, je ne pense pas qu'elle soit pertinents ici.
Vous pouvez avoir deux Tâches liées au CPU s'exécutant en parallèle (entrelacés en fait) dans un seul CPU en utilisant aysnc/await .
Cela ne peut pas être expliqué simplement par le fait que le système d'exploitation supporte la mise en file d'attente. IRP .


La dernière fois que j'ai vérifié, le compilateur a transformé async en DFAE le travail est divisé en plusieurs étapes, chacune d'elles se terminant par un await l'instruction.
Le site await commence son Tâche et lui attacher une continuation pour exécuter le prochain suivante.

À titre d'exemple de concept, voici un exemple de pseudo-code.
Les choses sont simplifiées par souci de clarté et parce que je ne me souviens pas exactement de tous les détails.

method:
   instr1                  
   instr2
   await task1
   instr3
   instr4
   await task2
   instr5
   return value

Il se transforme en quelque chose comme ceci

int state = 0;

Task nextStep()
{
  switch (state)
  {
     case 0:
        instr1;
        instr2;
        state = 1;

        task1.addContinuation(nextStep());
        task1.start();

        return task1;

     case 1:
        instr3;
        instr4;
        state = 2;

        task2.addContinuation(nextStep());
        task2.start();

        return task2;

     case 2:
        instr5;
        state = 0;

        task3 = new Task();
        task3.setResult(value);
        task3.setCompleted();

        return task3;
   }
}

method:
   nextStep();

1 En fait, un pool peut avoir sa propre politique de création de tâches.

0 votes

En cas d'attente, le contrôle revient à l'appelant. Je comprends ce point. Mais est-ce que le thread qui a appelé la fonction asynchrone est libéré dans le pool de threads ? Par exemple, dans une application Windows.

0 votes

@variable Il faudrait que je me penche sur la façon dont ça fonctionne exactement sur .NET mais oui. La fonction asynchrone qui vient d'être appelée est retournée, ce qui signifie que le compilateur a créé un awaiter et lui a ajouté une continuation (qui sera appelée par l'awaiter de la tâche lorsque l'événement attendu, qui est vraiment asynchrone, se terminera). Le thread n'a donc plus rien à faire et peut être renvoyé dans le pool, ce qui signifie qu'il peut reprendre d'autres tâches.

0 votes

Je me demandais si l'interface utilisateur est toujours assignée au même thread en raison du contexte de synchronisation, le savez-vous ? Dans ce cas, le thread ne sera pas renvoyé au pool et sera utilisé par le thread de l'IU pour exécuter le code suivant l'appel de la méthode async. Je suis un novice dans ce domaine.

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