Wenn async-await keine zusätzlichen Threads erstellt, wie reagiert es dann auf Anwendungen?


241

Immer und immer wieder, ich sehe es so , dass mit async- awaitschafft keine zusätzlichen Threads. Das ist nicht sinnvoll, da ein Computer nur dann mehr als eine Aufgabe gleichzeitig ausführen kann

  • Tatsächlich mehr als eine Sache gleichzeitig ausführen (parallel ausführen, mehrere Prozessoren verwenden)
  • Simulieren Sie es, indem Sie Aufgaben planen und zwischen ihnen wechseln (machen Sie ein bisschen A, ein bisschen B, ein bisschen A usw.)

Wenn async- awaitbeides nicht, wie kann dann eine Anwendung darauf reagieren? Wenn nur ein Thread vorhanden ist, bedeutet das Aufrufen einer Methode, dass Sie warten müssen, bis die Methode abgeschlossen ist, bevor Sie etwas anderes tun. Die Methoden in dieser Methode müssen auf das Ergebnis warten, bevor Sie fortfahren, und so weiter.


17
E / A-Aufgaben sind nicht CPU-gebunden und erfordern daher keinen Thread. Der Hauptpunkt von Async besteht darin, Threads während E / A-gebundener Aufgaben nicht zu blockieren.
Juharr

24
@jdweng: Nein, überhaupt nicht. Selbst wenn neue Threads erstellt wurden, unterscheidet sich dies stark vom Erstellen eines neuen Prozesses.
Jon Skeet

8
Wenn Sie sich mit Callback-basierter asynchroner Programmierung auskennen, wissen Sie, wie await/ asyncfunktioniert, ohne Threads zu erstellen.
user253751

6
Es macht eine Anwendung nicht gerade reaktionsschneller , hält Sie jedoch davon ab, Ihre Threads zu blockieren, was eine häufige Ursache für nicht reagierende Anwendungen ist.
Owen

6
@RubberDuck: Ja, möglicherweise wird ein Thread aus dem Thread-Pool für die Fortsetzung verwendet. Aber es startet einen Thread nicht so, wie es sich das OP hier vorstellt - es ist nicht so, als würde es sagen: "Nehmen Sie diese gewöhnliche Methode, führen Sie sie jetzt in einem separaten Thread aus - dort ist das asynchron." Es ist viel subtiler als das.
Jon Skeet

Antworten:


299

Eigentlich ist Async / Warten nicht so magisch. Das vollständige Thema ist ziemlich weit gefasst, aber für eine schnelle und dennoch vollständige Antwort auf Ihre Frage denke ich, dass wir es schaffen können.

Lassen Sie uns ein einfaches Ereignis zum Klicken auf eine Schaltfläche in einer Windows Forms-Anwendung angehen:

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

Ich werde explizit nicht darüber sprechen, was es GetSomethingAsyncgerade zurückgibt. Sagen wir einfach, dies ist etwas, das nach beispielsweise 2 Sekunden abgeschlossen sein wird.

In einer traditionellen, nicht asynchronen Welt würde Ihr Ereignishandler für Schaltflächenklicks ungefähr so ​​aussehen:

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

Wenn Sie auf die Schaltfläche im Formular klicken, scheint die Anwendung etwa 2 Sekunden lang einzufrieren, während wir auf den Abschluss dieser Methode warten. Was passiert ist, dass die "Nachrichtenpumpe", im Grunde eine Schleife, blockiert ist.

Diese Schleife fragt Windows ständig: "Hat jemand etwas getan, wie die Maus bewegt, auf etwas geklickt? Muss ich etwas neu streichen? Wenn ja, sagen Sie es mir!" und verarbeitet dann das "etwas". Diese Schleife erhielt eine Nachricht, dass der Benutzer auf "button1" (oder den entsprechenden Nachrichtentyp von Windows) geklickt hat, und rief schließlich unsere anbutton1_Click oben beschriebene Methode aufgerufen hat. Bis diese Methode zurückkehrt, wartet diese Schleife nicht mehr. Dies dauert 2 Sekunden und währenddessen werden keine Nachrichten verarbeitet.

Die meisten Dinge, die sich mit Fenstern befassen, werden mithilfe von Nachrichten ausgeführt. Wenn die Nachrichtenschleife auch nur für eine Sekunde keine Nachrichten mehr pumpt, fällt dies dem Benutzer schnell auf. Wenn Sie beispielsweise den Notizblock oder ein anderes Programm über Ihr eigenes Programm bewegen und dann wieder entfernen, wird eine Reihe von Malmeldungen an Ihr Programm gesendet, die angeben, welcher Bereich des Fensters jetzt plötzlich wieder sichtbar wurde. Wenn die Nachrichtenschleife, die diese Nachrichten verarbeitet, auf etwas wartet, das blockiert ist, wird kein Malvorgang ausgeführt.

Wenn im ersten Beispiel async/awaitkeine neuen Threads erstellt werden, wie funktioniert das?

Nun, was passiert ist, dass Ihre Methode in zwei Teile geteilt ist. Dies ist eine dieser allgemeinen Themen, daher werde ich nicht zu sehr ins Detail gehen, aber es reicht zu sagen, dass die Methode in diese beiden Dinge unterteilt ist:

  1. Der gesamte Code, der dazu führt await, einschließlich des Aufrufs vonGetSomethingAsync
  2. Der gesamte Code folgt await

Illustration:

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

Neu arrangiert:

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

Grundsätzlich läuft die Methode folgendermaßen ab:

  1. Es führt alles bis zu await
  2. Es ruft die GetSomethingAsyncMethode auf, die ihre Sache macht, und gibt etwas zurück, das in Zukunft 2 Sekunden dauern wird

    Bisher befinden wir uns noch im ursprünglichen Aufruf von button1_Click, der im Hauptthread stattfindet und von der Nachrichtenschleife aufgerufen wird. Wenn der Code im Vorfeld awaitviel Zeit in Anspruch nimmt, friert die Benutzeroberfläche immer noch ein. In unserem Beispiel nicht so sehr

  3. Was das awaitSchlüsselwort zusammen mit etwas cleverer Compiler-Magie bewirkt, ist, dass es im Grunde so etwas wie "Ok, weißt du was ? Ich werde einfach vom Button-Click-Event-Handler hier zurückkehren. Wenn du (wie in, das, was wir ' Warten Sie auf), um fertig zu werden. Lassen Sie es mich wissen, da ich noch Code zum Ausführen habe. "

    Tatsächlich wird die SynchronizationContext-Klasse darüber informiert, dass dies abgeschlossen ist. Abhängig vom aktuellen Synchronisierungskontext, der gerade im Spiel ist, wird die Warteschlange für die Ausführung anstehen. Die in einem Windows Forms-Programm verwendete Kontextklasse stellt sie mithilfe der Warteschlange in die Warteschlange, die die Nachrichtenschleife pumpt.

  4. Es kehrt also zur Nachrichtenschleife zurück, die nun frei ist, Nachrichten weiter zu pumpen, z. B. das Fenster zu verschieben, die Größe zu ändern oder auf andere Schaltflächen zu klicken.

    Für den Benutzer reagiert die Benutzeroberfläche jetzt wieder, verarbeitet andere Schaltflächenklicks, ändert die Größe und vor allem zeichnet sie neu , sodass sie nicht einzufrieren scheint.

  5. 2 Sekunden später ist das, worauf wir warten, abgeschlossen und was jetzt passiert, ist, dass es (nun, der Synchronisationskontext) eine Nachricht in die Warteschlange stellt, die die Nachrichtenschleife betrachtet, und sagt: "Hey, ich habe noch etwas Code für Sie ausführen ", und dieser Code ist der gesamte Code nach dem Warten.
  6. Wenn die Nachrichtenschleife zu dieser Nachricht gelangt, wird sie die Methode, an der sie aufgehört hat, unmittelbar danach "erneut eingeben" awaitund den Rest der Methode weiter ausführen. Beachten Sie, dass dieser Code erneut aus der Nachrichtenschleife aufgerufen wird. Wenn dieser Code also etwas Langes tut, ohne ihn async/awaitordnungsgemäß zu verwenden, blockiert er die Nachrichtenschleife erneut

Es gibt hier viele bewegliche Teile unter der Haube, daher hier einige Links zu weiteren Informationen. Ich wollte sagen, "falls Sie es brauchen", aber dieses Thema ist ziemlich weit gefasst und es ist ziemlich wichtig, einige dieser beweglichen Teile zu kennen . Sie werden immer verstehen, dass Async / Warten immer noch ein undichtes Konzept ist. Einige der zugrunde liegenden Einschränkungen und Probleme fließen immer noch in den umgebenden Code ein. Wenn dies nicht der Fall ist, müssen Sie normalerweise eine Anwendung debuggen, die ohne ersichtlichen Grund zufällig unterbrochen wird.


OK, was ist, wenn GetSomethingAsyncein Thread hochgefahren wird, der in 2 Sekunden abgeschlossen ist? Ja, dann ist offensichtlich ein neuer Thread im Spiel. Dieser Thread ist jedoch nicht , weil die Async-heit dieses Verfahrens ist es, weil der Programmierer dieses Verfahrens ein Gewinde wählt asynchronen Code zu implementieren. Fast alle asynchronen E / A verwenden keinen Thread, sondern unterschiedliche Dinge. async/await selbst drehen keine neuen Threads hoch, aber offensichtlich können die "Dinge, auf die wir warten" mithilfe von Threads implementiert werden.

Es gibt viele Dinge in .NET, die nicht unbedingt einen eigenen Thread starten, aber dennoch asynchron sind:

  • Webanfragen (und viele andere netzwerkbezogene Dinge, die Zeit brauchen)
  • Asynchrones Lesen und Schreiben von Dateien
  • und vieles mehr ist ein gutes Zeichen, wenn die betreffende Klasse / Schnittstelle Methoden mit dem Namen SomethingSomethingAsyncoder BeginSomethingund hat EndSomethingund eine IAsyncResultbeteiligt ist.

Normalerweise verwenden diese Dinge keinen Faden unter der Haube.


OK, möchten Sie etwas von diesem "breiten Thema"?

Fragen wir Try Roslyn nach unserem Button-Klick:

Versuchen Sie es mit Roslyn

Ich werde hier nicht in der vollständig generierten Klasse verlinken, aber es ist ziemlich blutiges Zeug.


10
Es ist also im Grunde das, was das OP als " Simulieren der parallelen Ausführung durch Planen von Aufgaben und Wechseln zwischen ihnen " beschrieben hat, nicht wahr ?
Bergi

4
@Bergi Nicht ganz. Die Ausführung ist wirklich parallel - die asynchrone E / A-Aufgabe wird ausgeführt und erfordert keine Threads, um fortzufahren (dies wurde lange vor Windows verwendet - MS DOS verwendete auch asynchrone E / A, obwohl dies nicht der Fall war Multithreading haben!). await Kann natürlich auch so verwendet werden, wie Sie es beschreiben, ist es aber im Allgemeinen nicht. Es werden nur die Rückrufe geplant (im Thread-Pool) - zwischen dem Rückruf und der Anforderung wird kein Thread benötigt.
Luaan

3
Aus diesem Grund wollte ich ausdrücklich vermeiden, zu viel darüber zu sprechen, was diese Methode bewirkt, da es sich um eine Frage handelt, die speziell auf async / await abzielt und keine eigenen Threads erstellt. Offensichtlich können sie verwendet werden, um auf den Abschluss von Threads zu warten .
Lasse V. Karlsen

5
@ LasseV.Karlsen - Ich nehme Ihre großartige Antwort auf, aber ich bin immer noch auf ein Detail fixiert. Ich verstehe, dass der Ereignishandler wie in Schritt 4 vorhanden ist, der es der Nachrichtenpumpe ermöglicht, weiter zu pumpen, aber wann und wo wird das "Ding, das zwei Sekunden dauert" weiterhin ausgeführt, wenn nicht in einem separaten Thread? Wenn es auf dem UI-Thread ausgeführt würde, würde es die Nachrichtenpumpe trotzdem blockieren, während es ausgeführt wird, da es einige Zeit auf demselben Thread ausgeführt werden muss. [Fortsetzung] ...
rory.ap

3
Ich mag deine Erklärung mit der Nachrichtenpumpe. Wie unterscheidet sich Ihre Erklärung, wenn es keine Nachrichtenpumpe wie in einer Konsolenanwendung oder einem Webserver gibt? Wie wird der Wiedereintritt einer Methode erreicht?
Puchacz

94

Ich erkläre es vollständig in meinem Blog-Beitrag Es gibt keinen Thread .

Zusammenfassend lässt sich sagen, dass moderne E / A-Systeme DMA (Direct Memory Access) stark nutzen. Es gibt spezielle dedizierte Prozessoren für Netzwerkkarten, Grafikkarten, Festplattencontroller, serielle / parallele Anschlüsse usw. Diese Prozessoren haben direkten Zugriff auf den Speicherbus und übernehmen das Lesen / Schreiben völlig unabhängig von der CPU. Die CPU muss das Gerät nur über den Speicherort im Speicher informieren, der die Daten enthält, und kann dann ihre eigene Aktion ausführen, bis das Gerät einen Interrupt auslöst, der die CPU darüber informiert, dass das Lesen / Schreiben abgeschlossen ist.

Sobald der Vorgang ausgeführt wird, muss die CPU keine Arbeit mehr ausführen und somit kein Thread mehr.


Nur um es klar zu machen. Ich verstehe das hohe Niveau dessen, was passiert, wenn async-await verwendet wird. In Bezug auf die Erstellung ohne Thread - gibt es keinen Thread nur in E / A-Anforderungen an Geräte, die, wie Sie sagten, über eigene Prozessoren verfügen, die die Anforderung selbst verarbeiten? Können wir davon ausgehen, dass ALLE E / A-Anforderungen auf solchen unabhängigen Prozessoren verarbeitet werden, was bedeutet, dass Task.NUR für CPU-gebundene Aktionen ausgeführt wird?
Yonatan Nir

@YonatanNir: Es geht nicht nur um separate Prozessoren. Jede Art von ereignisgesteuerter Antwort ist natürlich asynchron. Task.Runist am besten für CPU-gebundene Aktionen geeignet , hat aber auch eine Handvoll anderer Verwendungszwecke.
Stephen Cleary

1
Ich habe Ihren Artikel gelesen und es gibt immer noch etwas Grundlegendes, das ich nicht verstehe, da ich mit der Implementierung des Betriebssystems auf niedrigerer Ebene nicht wirklich vertraut bin. Ich habe das, was Sie geschrieben haben, bis zu dem Punkt geschrieben, an dem Sie geschrieben haben: "Der Schreibvorgang ist jetzt" im Flug ". Wie viele Threads verarbeiten ihn? Keine." . Wenn es also keine Threads gibt, wie wird dann die Operation selbst ausgeführt, wenn nicht ein Thread?
Yonatan Nir

5
Dies ist das fehlende Stück in Tausenden von Erklärungen !!! Es gibt tatsächlich jemanden, der die Arbeit im Hintergrund mit E / A-Operationen erledigt. Es ist kein Thread, sondern eine andere dedizierte Hardwarekomponente, die ihre Arbeit erledigt!
the_dark_destructor

2
@PrabuWeerasinghe: Der Compiler erstellt eine Struktur, die den Status und die lokalen Variablen enthält. Wenn ein Warten nachgeben muss (dh zu seinem Aufrufer zurückkehren muss), wird diese Struktur eingerahmt und lebt auf dem Heap.
Stephen Cleary

87

Die einzige Möglichkeit, dass ein Computer mehr als eine Aufgabe gleichzeitig ausführt, besteht darin, (1) tatsächlich mehr als eine Aufgabe gleichzeitig auszuführen, (2) sie zu simulieren, indem Aufgaben geplant und zwischen ihnen gewechselt werden. Also, wenn Async-Warten keine von beiden

Es ist nicht so, dass keiner von ihnen auf etwas wartet . Denken Sie daran, dass der Zweck von awaitnicht darin besteht , synchronen Code magisch asynchron zu machen . Dies soll die Verwendung der gleichen Techniken ermöglichen , die wir zum Schreiben von synchronem Code beim Aufrufen von asynchronem Code verwenden . Beim Warten geht es darum, dass der Code, der Operationen mit hoher Latenz verwendet, wie Code aussieht, der Operationen mit niedriger Latenz verwendet . Diese Operationen mit hoher Latenz können sich auf Threads befinden, sie können sich auf Spezialhardware befinden, sie können ihre Arbeit in kleine Teile zerreißen und sie in die Nachrichtenwarteschlange stellen, um sie später vom UI-Thread zu verarbeiten. Sie tun etwas , um Asynchronität zu erreichen, aber siesind diejenigen, die es tun. Mit Await können Sie diese Asynchronität nur nutzen.

Ich denke auch, dass Ihnen eine dritte Option fehlt. Wir alten Leute - Kinder von heute mit ihrer Rap-Musik sollten von meinem Rasen verschwinden usw. - erinnern uns an die Welt von Windows in den frühen neunziger Jahren. Es gab keine Multi-CPU-Maschinen und keine Thread-Scheduler. Sie wollten zwei Windows-Apps gleichzeitig ausführen, Sie mussten nachgeben . Multitasking war kooperativ . Das Betriebssystem teilt einem Prozess mit, dass er ausgeführt werden soll, und wenn er sich schlecht verhält, werden alle anderen Prozesse nicht bedient. Es läuft, bis es nachgibt, und irgendwie muss es wissen, wie es dort weitermachen soll, wo es wenn das Betriebssystem das nächste Mal die Kontrolle zurückgibt. Asynchroner Code mit einem Thread ist sehr ähnlich, mit "Warten" anstelle von "Ertrag". Warten bedeutet: "Ich werde mich daran erinnern, wo ich hier aufgehört habe, und jemand anderen eine Weile laufen lassen. Rufen Sie mich zurück, wenn die Aufgabe, auf die ich warte, abgeschlossen ist, und ich werde dort weitermachen, wo ich aufgehört habe." Ich denke, Sie können sehen, wie Apps dadurch reaktionsfähiger werden, genau wie in den Windows 3-Tagen.

Wenn Sie eine Methode aufrufen, müssen Sie warten, bis die Methode abgeschlossen ist

Es gibt den Schlüssel, den Sie vermissen. Eine Methode kann zurückkehren, bevor ihre Arbeit abgeschlossen ist . Das ist genau dort die Essenz der Asynchronität. Eine Methode gibt eine Aufgabe zurück, die bedeutet, dass "diese Arbeit ausgeführt wird; sagen Sie mir, was zu tun ist, wenn sie abgeschlossen ist". Die Arbeit der Methode ist nicht erledigt, obwohl sie zurückgekehrt ist .

Vor dem Operator "Warten" mussten Sie Code schreiben, der aussah wie Spaghetti, die durch Schweizer Käse gefädelt wurden, um die Tatsache zu bewältigen, dass wir nach Abschluss noch etwas zu tun haben , aber die Rückgabe und die Fertigstellung desynchronisiert sind . Mit Await können Sie Code schreiben, der so aussieht, als ob die Rückgabe und der Abschluss synchronisiert sind, ohne dass sie tatsächlich synchronisiert werden.


Andere moderne Hochsprachen unterstützen ebenfalls ein ähnliches explizit kooperatives Verhalten (dh die Funktion erledigt einige Dinge, ergibt [möglicherweise einen Wert / ein Objekt an den Aufrufer senden] und setzt dort fort, wo sie aufgehört hat, wenn die Steuerung zurückgegeben wird [möglicherweise mit zusätzlichen Eingaben] ). Zum einen sind Generatoren in Python sehr groß.
JAB

2
@ JAB: Auf jeden Fall. Generatoren werden in C # als "Iteratorblöcke" bezeichnet und verwenden das yieldSchlüsselwort. Sowohl asyncMethoden als auch Iteratoren in C # sind eine Form der Coroutine. Dies ist der allgemeine Begriff für eine Funktion, die weiß, wie ihre aktuelle Operation für eine spätere Wiederaufnahme angehalten werden kann. Eine Reihe von Sprachen haben heutzutage Coroutinen oder koroutinenähnliche Kontrollflüsse.
Eric Lippert

1
Die Analogie zum Ertrag ist gut - es ist kooperatives Multitasking innerhalb eines Prozesses. (und damit die Systemstabilitätsprobleme des systemweiten kooperativen Multitasking zu vermeiden)
user253751

3
Ich denke, das Konzept der "CPU-Interrupts", die für E / A verwendet werden, kennt nicht viele moderne "Programmierer", daher denken sie, dass ein Thread auf jedes Bit von E / A warten muss.
Ian Ringrose

@EricLippert Asynchrone Methode von WebClient erstellt tatsächlich zusätzlichen Thread, siehe hier stackoverflow.com/questions/48366871/…
KevinBui

27

Ich bin wirklich froh, dass jemand diese Frage gestellt hat, denn ich habe lange Zeit auch geglaubt, dass Threads für die Parallelität notwendig sind. Als ich zum ersten Mal Event-Loops sah , dachte ich, sie wären eine Lüge. Ich dachte mir: "Es gibt keine Möglichkeit, dass dieser Code gleichzeitig ausgeführt wird, wenn er in einem einzelnen Thread ausgeführt wird." Denken Sie daran, dies ist, nachdem ich bereits den Kampf durchgemacht hatte, den Unterschied zwischen Parallelität und Parallelität zu verstehen.

Nach eigenen Recherchen habe ich endlich das fehlende Stück gefunden : select(). Insbesondere IO Multiplexing, implementiert durch verschiedene Kerne unter verschiedenen Namen: select(), poll(), epoll(), kqueue(). Hierbei handelt es sich um Systemaufrufe , bei denen Sie, obwohl sich die Implementierungsdetails unterscheiden, eine Reihe von Dateideskriptoren zur Überwachung übergeben können. Anschließend können Sie einen weiteren Aufruf tätigen, der blockiert, bis sich einer der überwachten Dateideskriptoren ändert.

Auf diese Weise kann man auf eine Reihe von E / A-Ereignissen (die Hauptereignisschleife) warten, das erste abgeschlossene Ereignis verarbeiten und dann die Steuerung an die Ereignisschleife zurückgeben. Spülen und wiederholen.

Wie funktioniert das? Nun, die kurze Antwort ist, dass es sich um Magie auf Kernel- und Hardware-Ebene handelt. Neben der CPU befinden sich viele Komponenten in einem Computer, und diese Komponenten können parallel arbeiten. Der Kernel kann diese Geräte steuern und direkt mit ihnen kommunizieren, um bestimmte Signale zu empfangen.

Diese IO-Multiplexing-Systemaufrufe sind der grundlegende Baustein für Single-Threaded-Ereignisschleifen wie node.js oder Tornado. Wenn Sie awaiteine Funktion ausführen, suchen Sie nach einem bestimmten Ereignis (dem Abschluss dieser Funktion) und geben dann die Kontrolle an die Hauptereignisschleife zurück. Wenn das Ereignis, das Sie gerade ansehen, abgeschlossen ist, wird die Funktion (eventuell) dort fortgesetzt, wo sie aufgehört hat. Funktionen, mit denen Sie die Berechnung wie folgt anhalten und fortsetzen können, werden als Coroutinen bezeichnet .


25

awaitund asyncverwenden Sie Aufgaben nicht Threads.

Das Framework verfügt über einen Thread-Pool, der bereit ist, einige Arbeiten in Form von Task- Objekten auszuführen . Wenn Sie eine Aufgabe an den Pool senden, wählen Sie einen freien, bereits vorhandenen 1- Thread aus, um die Aufgabenaktionsmethode aufzurufen.
Beim Erstellen einer Aufgabe geht es darum, ein neues Objekt zu erstellen, viel schneller als beim Erstellen eines neuen Threads.

Wenn eine Aufgabe eine Fortsetzung anhängen kann, handelt es sich um ein neues Aufgabenobjekt , das ausgeführt wird, sobald der Thread endet.

Seit der async/awaitVerwendung von Aufgaben erstellen sie keinen neuen Thread.


Während Interrupt-Programmiertechniken in jedem modernen Betriebssystem weit verbreitet sind, halte ich sie hier nicht für relevant.
Sie können zwei CPU-gebundene Tasks gleichzeitig (tatsächlich verschachtelt) in einer einzelnen CPU ausführen lassen aysnc/await. Dies
konnte nicht einfach damit erklärt werden, dass das Betriebssystem die Warteschlange für IORP unterstützt .


Als ich das letzte Mal die vom Compiler transformierten asyncMethoden in DFA überprüft habe , ist die Arbeit in Schritte unterteilt, die jeweils mit einer awaitAnweisung enden .
Der awaitstartet seine Aufgabe und fügt ihm eine Fortsetzung hinzu, um den nächsten Schritt auszuführen.

Als Konzeptbeispiel hier ein Pseudocode-Beispiel.
Die Dinge werden aus Gründen der Klarheit und weil ich mich nicht an alle Details genau erinnere, vereinfacht.

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

Es wird in so etwas verwandelt

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 Tatsächlich kann ein Pool seine Richtlinie zur Aufgabenerstellung haben.


16

Ich werde nicht mit Eric Lippert oder Lasse V. Karlsen und anderen konkurrieren, ich möchte nur auf eine andere Facette dieser Frage aufmerksam machen, die meiner Meinung nach nicht ausdrücklich erwähnt wurde.

Wenn Sie awaites alleine verwenden, reagiert Ihre App nicht auf magische Weise. Wenn Sie in der Methode, auf die Sie warten, aus den UI-Thread-Blöcken etwas tun, wird Ihre UI trotzdem genauso blockiert wie die nicht erwartete Version .

Sie müssen Ihre erwartete Methode speziell schreiben, damit sie entweder einen neuen Thread erzeugt oder einen Abschlussport verwendet (der die Ausführung im aktuellen Thread zurückgibt und etwas anderes zur Fortsetzung aufruft, wenn der Abschlussport signalisiert wird). Aber dieser Teil wird in anderen Antworten gut erklärt.


3
Es ist überhaupt kein Wettbewerb; Es ist eine Zusammenarbeit!
Eric Lippert

16

So sehe ich das alles, es ist vielleicht nicht besonders technisch korrekt, aber es hilft mir zumindest :).

Grundsätzlich gibt es zwei Arten der Verarbeitung (Berechnung), die auf einer Maschine ausgeführt werden:

  • Verarbeitung, die auf der CPU stattfindet
  • Die Verarbeitung, die auf anderen Prozessoren (GPU, Netzwerkkarte usw.) erfolgt, wird als E / A bezeichnet.

Wenn wir also nach dem Kompilieren einen Quellcode schreiben, ist die Verarbeitung abhängig vom verwendeten Objekt (und dies ist sehr wichtig) CPU-gebunden oder E / A-gebunden , und tatsächlich kann sie an eine Kombination von gebunden werden beide.

Einige Beispiele:

  • wenn ich die Write-Methode der verwende FileStream Objekts (das ein Stream ist) verwende, wird die Verarbeitung beispielsweise mit 1% CPU-Bindung und 99% E / A-Bindung ausgeführt.
  • wenn ich die Write-Methode der verwende NetworkStream Objekts (das ein Stream ist) verwende, wird die Verarbeitung beispielsweise mit 1% CPU-Bindung und 99% E / A-Bindung ausgeführt.
  • Wenn ich die Write-Methode des MemorystreamObjekts (das ein Stream ist) verwende, ist die Verarbeitung zu 100% CPU-gebunden.

Wie Sie sehen, aus objektorientierter Sicht eines Programmierers, obwohl ich immer auf a zugreife Stream Objekt , das, was darunter passiert, stark vom endgültigen Typ des Objekts abhängen.

Um die Dinge zu optimieren, ist es manchmal nützlich, Code parallel ausführen zu können (beachten Sie, dass ich das Wort nicht asynchron verwende), wenn dies möglich und / oder erforderlich ist.

Einige Beispiele:

  • In einer Desktop-App möchte ich ein Dokument drucken, aber nicht darauf warten.
  • Mein Webserver bedient viele Clients gleichzeitig, wobei jeder seine Seiten parallel erhält (nicht serialisiert).

Vor async / await hatten wir im Wesentlichen zwei Lösungen dafür:

  • Themen . Mit den Klassen Thread und ThreadPool war die Verwendung relativ einfach. Threads sind nur CPU-gebunden .
  • Das "alte" asynchrone Programmiermodell Begin / End / AsyncCallback . Es ist nur ein Modell, es sagt Ihnen nicht, ob Sie CPU- oder IO-gebunden sind. Wenn Sie sich die Socket- oder FileStream-Klassen ansehen, ist sie an E / A gebunden, was cool ist, aber wir verwenden sie selten.

Das asynchrone / Warten ist nur ein allgemeines Programmiermodell, das auf dem Task-Konzept basiert . Es ist etwas einfacher zu verwenden als Threads oder Thread-Pools für CPU-gebundene Aufgaben und viel einfacher zu verwenden als das alte Begin / End-Modell. Undercovers ist jedoch "nur" ein hochentwickelter Wrapper mit allen Funktionen.

So ist der eigentliche Gewinn meist auf IO Bound Aufgaben , Aufgabe, die die CPU nicht verwenden, aber async / await ist nach wie vor nur ein Programmiermodell, ist es nicht Sie zu bestimmen , hilft wie / wo Verarbeitung am Ende passieren wird.

Dies bedeutet nicht, dass eine Klasse eine Methode "DoSomethingAsync" hat, die ein Task-Objekt zurückgibt, von dem Sie annehmen können, dass es CPU-gebunden ist (was bedeutet, dass es möglicherweise ziemlich nutzlos ist , insbesondere wenn es keinen Abbruch-Token-Parameter hat) oder IO-gebunden (was bedeutet, dass es wahrscheinlich ein Muss ist ) oder eine Kombination aus beiden (da das Modell ziemlich viral ist, können Bindungen und potenzielle Vorteile am Ende sehr gemischt und nicht so offensichtlich sein).

Zurück zu meinen Beispielen: Wenn ich meine Schreibvorgänge mit async / await in MemoryStream ausführe, bleibt die CPU gebunden (ich werde wahrscheinlich nicht davon profitieren), obwohl ich sicherlich von Dateien und Netzwerkströmen profitieren werde.


1
Dies ist eine recht gute Antwort. Die Verwendung des Theadpools für CPU-gebundene Arbeiten ist insofern schlecht, als TP-Threads zum Auslagern von E / A-Vorgängen verwendet werden sollten. CPU-gebundene Arbeit imo sollte natürlich mit Einschränkungen blockiert werden und nichts schließt die Verwendung mehrerer Threads aus.
Davidcarr

3

Weitere Antworten zusammenfassen:

Async / await wird hauptsächlich für E / A-gebundene Aufgaben erstellt, da durch deren Verwendung vermieden werden kann, dass der aufrufende Thread blockiert wird. Ihre Hauptverwendung liegt bei UI-Threads, bei denen es nicht erwünscht ist, dass der Thread bei einer E / A-gebundenen Operation blockiert wird.

Async erstellt keinen eigenen Thread. Der Thread der aufrufenden Methode wird verwendet, um die asynchrone Methode auszuführen, bis eine erwartete gefunden wird. Der gleiche Thread führt dann den Rest der aufrufenden Methode über den asynchronen Methodenaufruf hinaus weiter aus. Innerhalb der aufgerufenen asynchronen Methode kann die Fortsetzung nach der Rückkehr vom Warten auf einen Thread aus dem Thread-Pool ausgeführt werden - der einzige Ort, an dem ein separater Thread ins Bild kommt.


Gute Zusammenfassung, aber ich denke, es sollte 2 weitere Fragen beantworten, um das vollständige Bild zu erhalten: 1. Auf welchem ​​Thread wird der erwartete Code ausgeführt? 2. Wer steuert / konfiguriert den genannten Thread-Pool - den Entwickler oder die Laufzeitumgebung?
Stojke

1. In diesem Fall handelt es sich bei dem erwarteten Code hauptsächlich um eine E / A-gebundene Operation, bei der keine CPU-Threads verwendet werden. Wenn wait für den CPU-gebundenen Betrieb verwendet werden soll, kann eine separate Task erzeugt werden. 2. Der Thread im Thread-Pool wird vom Task-Scheduler verwaltet, der Teil des TPL-Frameworks ist.
Vaibhav Kumar

2

Ich versuche es von unten nach oben zu erklären. Vielleicht findet es jemand hilfreich. Ich war dort, habe das getan, es neu erfunden, als ich einfache Spiele unter DOS in Pascal gemacht habe (gute alte Zeiten ...)

Also ... In jeder ereignisgesteuerten Anwendung befindet sich eine Ereignisschleife, die ungefähr so ​​aussieht:

while (getMessage(out message)) // pseudo-code
{
   dispatchMessage(message); // pseudo-code
}

Frameworks verbergen dieses Detail normalerweise vor Ihnen, aber es ist da. Die Funktion getMessage liest das nächste Ereignis aus der Ereigniswarteschlange oder wartet, bis ein Ereignis eintritt: Maus bewegen, Tastendruck, Tastendruck, Klicken usw. Anschließend sendet dispatchMessage das Ereignis an den entsprechenden Ereignishandler. Wartet dann auf das nächste Ereignis und so weiter, bis ein Beendigungsereignis eintritt, das die Schleifen verlässt und die Anwendung beendet.

Ereignishandler sollten schnell ausgeführt werden, damit die Ereignisschleife mehr Ereignisse abfragen kann und die Benutzeroberfläche weiterhin reagiert. Was passiert, wenn ein Knopfdruck einen teuren Vorgang wie diesen auslöst?

void expensiveOperation()
{
    for (int i = 0; i < 1000; i++)
    {
        Thread.Sleep(10);
    }
}

Nun, die Benutzeroberfläche reagiert nicht mehr, bis der 10-Sekunden-Vorgang abgeschlossen ist, da die Steuerung innerhalb der Funktion bleibt. Um dieses Problem zu lösen, müssen Sie die Aufgabe in kleine Teile aufteilen, die schnell ausgeführt werden können. Dies bedeutet, dass Sie nicht das Ganze in einem einzigen Ereignis behandeln können. Sie müssen einen kleinen Teil der Arbeit erledigen und dann ein anderes Ereignis veröffentlichen in die Ereigniswarteschlange stellen, um die Fortsetzung anzufordern.

Sie würden dies also ändern in:

void expensiveOperation()
{
    doIteration(0);
}

void doIteration(int i)
{
    if (i >= 1000) return;
    Thread.Sleep(10); // Do a piece of work.
    postFunctionCallMessage(() => {doIteration(i + 1);}); // Pseudo code. 
}

In diesem Fall wird nur die erste Iteration ausgeführt, dann wird eine Nachricht an die Ereigniswarteschlange gesendet, um die nächste Iteration auszuführen, und es wird zurückgegeben. Es ist unser BeispielpostFunctionCallMessage Pseudofunktion wird ein Ereignis "Diese Funktion aufrufen" in die Warteschlange gestellt, sodass der Ereignis-Dispatcher es aufruft, wenn es es erreicht. Auf diese Weise können alle anderen GUI-Ereignisse verarbeitet werden, während auch Teile einer lang laufenden Arbeit kontinuierlich ausgeführt werden.

Solange diese lange laufende Aufgabe ausgeführt wird, befindet sich ihr Fortsetzungsereignis immer in der Ereigniswarteschlange. Sie haben also im Grunde Ihren eigenen Taskplaner erfunden. Wobei die Fortsetzungsereignisse in der Warteschlange "Prozesse" sind, die ausgeführt werden. Genau das tun Betriebssysteme, außer dass das Senden der Fortsetzungsereignisse und das Zurückkehren zur Scheduler-Schleife über den Timer-Interrupt der CPU erfolgt, bei dem das Betriebssystem den Kontextumschaltcode registriert hat, sodass Sie sich nicht darum kümmern müssen. Aber hier schreiben Sie Ihren eigenen Planer, also müssen Sie sich darum kümmern - bis jetzt.

So können wir Aufgaben mit langer Laufzeit in einem einzigen Thread parallel zur GUI ausführen, indem wir sie in kleine Teile aufteilen und Fortsetzungsereignisse senden. Dies ist die allgemeine Idee der TaskKlasse. Es stellt ein Stück Arbeit dar und wenn Sie es aufrufen .ContinueWith, definieren Sie, welche Funktion als nächstes Stück aufgerufen werden soll, wenn das aktuelle Stück fertig ist (und sein Rückgabewert an die Fortsetzung übergeben wird). Die TaskKlasse verwendet einen Thread-Pool, in dem sich in jedem Thread eine Ereignisschleife befindet, die darauf wartet, ähnliche Arbeiten auszuführen, wie ich sie zu Beginn gezeigt habe. Auf diese Weise können Millionen von Aufgaben parallel ausgeführt werden, aber nur wenige Threads, um sie auszuführen. Aber es würde genauso gut mit einem einzelnen Thread funktionieren - solange Ihre Aufgaben ordnungsgemäß in kleine Teile aufgeteilt sind, scheint jeder von ihnen parallel zu laufen.

Aber all diese Verkettungen manuell in kleine Teile aufzuteilen, ist eine mühsame Arbeit und bringt das Layout der Logik völlig durcheinander, da der gesamte Hintergrundaufgabencode im Grunde genommen ein .ContinueWithChaos ist. Hier hilft Ihnen der Compiler. Es erledigt all diese Verkettung und Fortsetzung für Sie im Hintergrund. Wenn Sie sagen, dass awaitSie dem Compiler mitteilen, dass "hier anhalten, den Rest der Funktion als Fortsetzungsaufgabe hinzufügen". Der Compiler kümmert sich um den Rest, so dass Sie nicht müssen.


0

Tatsächlich sind async awaitKetten Zustandsmaschinen, die vom CLR-Compiler generiert werden.

async await Verwendet jedoch Threads, die TPL zum Ausführen von Aufgaben im Thread-Pool verwendet.

Der Grund, warum die Anwendung nicht blockiert wird, besteht darin, dass die Zustandsmaschine entscheiden kann, welche Co-Routine ausgeführt, wiederholt, überprüft und erneut entschieden werden soll.

Weiterführende Literatur:

Was generiert async & await?

Async Await und die generierte StateMachine

Asynchrones C # und F # (III.): Wie funktioniert es? - Tomas Petricek

Bearbeiten :

In Ordnung. Es scheint, als ob meine Ausarbeitung falsch ist. Ich muss jedoch darauf hinweisen, dass Zustandsautomaten wichtige Vermögenswerte für async awaits sind. Selbst wenn Sie asynchrone E / A aufnehmen, benötigen Sie einen Helfer, um zu überprüfen, ob der Vorgang abgeschlossen ist. Daher benötigen wir weiterhin eine Zustandsmaschine und bestimmen, welche Routine zusammen asychron ausgeführt werden kann.


0

Dies beantwortet die Frage nicht direkt, aber ich denke, es ist eine interessante zusätzliche Information:

Async and await erstellt selbst keine neuen Threads. ABER je nachdem, wo Sie asynchrones Warten verwenden, wird der synchrone Teil VOR dem Warten möglicherweise auf einem anderen Thread ausgeführt als der synchrone Teil NACH dem Warten (z. B. verhalten sich ASP.NET und ASP.NET-Kern unterschiedlich).

In UI-Thread-basierten Anwendungen (WinForms, WPF) befinden Sie sich vorher und nachher im selben Thread. Wenn Sie jedoch async away für einen Thread-Pool-Thread verwenden, ist der Thread vor und nach dem Warten möglicherweise nicht identisch.

Ein tolles Video zu diesem Thema

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.