Zuletzt haben wir bereits
darüber gesprochen, ob Equals und GetHashCode beim Programmieren in C # überschrieben werden sollen. Heute werden wir uns mit den Leistungsparametern asynchroner Methoden befassen. Jetzt mitmachen!

In den letzten beiden Artikeln im msdn-Blog haben wir uns mit der
internen Struktur asynchroner Methoden in C # und
den Erweiterungspunkten befasst , die der C # -Compiler zur Steuerung des Verhaltens asynchroner Methoden bereitstellt.
Basierend auf den Informationen im ersten Artikel führt der Compiler viele Transformationen durch, um die asynchrone Programmierung der synchronen so ähnlich wie möglich zu machen. Zu diesem Zweck erstellt er eine Instanz der Zustandsmaschine und übergibt sie an den Builder der asynchronen Methode, die das Warteobjekt für die Aufgabe usw. aufruft. Natürlich hat eine solche Logik einen Preis, aber wie viel kostet sie uns?
Bis zum Erscheinen der TPL-Bibliothek wurden asynchrone Operationen nicht in so großer Menge verwendet, daher waren die Kosten nicht hoch. Aber heute kann selbst eine relativ einfache Anwendung Hunderte, wenn nicht Tausende von asynchronen Operationen pro Sekunde ausführen. Die TPL-Bibliothek für parallele Aufgaben wurde unter Berücksichtigung dieser Arbeitsbelastung erstellt, aber hier gibt es keine Magie und Sie müssen für alles bezahlen.
Um die Kosten asynchroner Methoden abzuschätzen, verwenden wir ein leicht modifiziertes Beispiel aus dem ersten Artikel.
public class StockPrices { private const int Count = 100; private List<(string name, decimal price)> _stockPricesCache;
Die
StockPrices
Klasse
StockPrices
Aktienkurse von einer externen Quelle zwischen und ermöglicht es Ihnen, sie über die API anzufordern. Der Hauptunterschied zum Beispiel im ersten Artikel ist der Übergang von einem Wörterbuch zu einer Preisliste. Um die Kosten verschiedener asynchroner Methoden im Vergleich zu synchronen Methoden abzuschätzen, muss die Operation selbst eine bestimmte Aufgabe erfüllen. In unserem Fall handelt es sich um eine lineare Suche nach Aktienkursen.
Die
GetPricesFromCache
Methode
GetPricesFromCache
absichtlich auf einer einfachen Schleife, um eine Ressourcenzuweisung zu vermeiden.
Vergleich von synchronen Methoden und aufgabenbasierten asynchronen Methoden
Im ersten Leistungstest vergleichen wir die asynchrone Methode, die die asynchrone Initialisierungsmethode (
GetStockPriceForAsync
)
GetStockPriceForAsync
, die synchrone Methode, die die asynchrone Initialisierungsmethode (
GetStockPriceFor
)
GetStockPriceFor
, und die synchrone Methode, die die synchrone Initialisierungsmethode aufruft.
private readonly StockPrices _stockPrices = new StockPrices(); public SyncVsAsyncBenchmark() {
Die Ergebnisse sind unten gezeigt:

Bereits zu diesem Zeitpunkt haben wir interessante Daten erhalten:
- Die asynchrone Methode ist ziemlich schnell.
GetPricesForAsync
in diesem Test synchron ausgeführt und ist ungefähr 15% (*) langsamer als die rein synchrone Methode. - Die synchrone
GetPricesFor
Methode, die die asynchrone InitializeMapIfNeededAsync
Methode aufruft, hat noch geringere Kosten, weist jedoch überraschenderweise überhaupt keine Ressourcen zu (in der Spalte GetPricesDirectlyFromCache
in der obigen Tabelle kostet sie sowohl für GetPricesDirectlyFromCache
als auch für GetPricesDirectlyFromCache
GetStockPriceFor
).
(*) Natürlich kann nicht gesagt werden, dass die Kosten für die synchrone Ausführung der asynchronen Methode in allen möglichen Fällen 15% betragen. Dieser Wert hängt direkt von der von der Methode ausgeführten Arbeitslast ab. Der Unterschied zwischen dem Overhead eines reinen Aufrufs einer asynchronen Methode (die nichts tut) und einer synchronen Methode (die nichts tut) ist enorm. Die Idee dieses Vergleichstests ist es zu zeigen, dass die Kosten der asynchronen Methode, die einen relativ geringen Arbeitsaufwand leistet, relativ gering sind.Wie kommt es, dass beim Aufrufen von
InitializeMapIfNeededAsync
überhaupt keine Ressourcen zugewiesen werden? Im ersten Artikel dieser Reihe habe ich erwähnt, dass eine asynchrone Methode mindestens ein Objekt im verwalteten Header zuordnen sollte - die Taskinstanz selbst. Lassen Sie uns diesen Punkt genauer diskutieren.
Optimierung Nr. 1: Zwischenspeichern von Aufgabeninstanzen, wenn möglich
Die Antwort auf die obige Frage ist sehr einfach:
AsyncMethodBuilder
verwendet eine Instanz der Aufgabe für jede erfolgreich abgeschlossene asynchrone Operation . Die von
Task
asynchrone Methode verwendet
AsyncMethodBuilder
mit der folgenden Logik in der
SetResult
Methode:
Die
SetResult
Methode
SetResult
nur für erfolgreich abgeschlossene asynchrone Methoden aufgerufen, und ein
erfolgreiches Ergebnis für jede Task
Methode kann frei zusammen verwendet werden . Wir können dieses Verhalten sogar mit dem folgenden Test verfolgen:
[Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } }
Dies ist jedoch nicht die einzig mögliche Optimierung.
AsyncTaskMethodBuilder<T>
optimiert die Arbeit auf ähnliche Weise: Es werden Aufgaben für
Task<bool>
und einige andere einfache Typen zwischengespeichert. Beispielsweise werden alle Standardwerte für eine Gruppe von Ganzzahltypen zwischengespeichert und ein spezieller Cache für
Task<int>
, wobei Werte aus dem Bereich [-1; 9] (Weitere Informationen finden Sie unter
AsyncTaskMethodBuilder<T>.GetTaskForResult()
).
Dies wird durch folgenden Test bestätigt:
[Test] public void AsyncTaskBuilderCachesResultingTask() {
Verlassen Sie sich nicht übermäßig auf ein solches Verhalten , aber es ist immer schön zu erkennen, dass die Entwickler der Sprache und Plattform alles tun, um die Produktivität auf alle verfügbaren Arten zu steigern. Das Zwischenspeichern von Aufgaben ist eine beliebte Optimierungsmethode, die auch in anderen Bereichen verwendet wird. Beispielsweise
nutzt eine neue Implementierung von
Socket
im
corefx-Repo- Repository diese Methode in
großem Umfang und wendet nach Möglichkeit
zwischengespeicherte Aufgaben an .
Optimierung Nr. 2: Verwenden von ValueTask
Die oben beschriebene Optimierungsmethode funktioniert nur in wenigen Fällen. Daher können wir stattdessen
ValueTask<T>
(**) verwenden, einen speziellen
ValueTask<T>
, der der Aufgabe ähnlich ist. Es werden keine Ressourcen zugewiesen, wenn die Methode synchron ausgeführt wird.
ValueTask<T>
ist eine unterscheidbare Kombination von
T
und
Task<T>
: Wenn die "Value-Task" abgeschlossen ist, wird der Basiswert verwendet. Wenn die Grundzuordnung noch nicht ausgeschöpft ist, werden Ressourcen für die Aufgabe zugewiesen.
Dieser spezielle Typ verhindert eine übermäßige Heap-Bereitstellung, wenn eine Operation synchron ausgeführt wird. Um
ValueTask<T>
, müssen Sie den Rückgabetyp für
GetStockPriceForAsync
: Anstelle von
Task<decimal>
ValueTask<decimal>
angeben:
public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); }
Jetzt können wir den Unterschied mit einem zusätzlichen Vergleichstest bewerten:
[Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }

Wie Sie sehen, ist die Version mit
ValueTask
nur geringfügig schneller als die Version mit Task. Der Hauptunterschied besteht darin, dass die Heap-Zuordnung verhindert wird. In einer Minute werden wir die Machbarkeit eines solchen Übergangs diskutieren, aber vorher möchte ich über eine knifflige Optimierung sprechen.
Optimierung Nr. 3: Verzicht auf asynchrone Methoden innerhalb eines gemeinsamen Pfades
Wenn Sie sehr häufig eine asynchrone Methode verwenden und die Kosten noch weiter senken möchten, empfehle ich Ihnen die folgende Optimierung: Entfernen Sie den Async-Modifikator, überprüfen Sie den Status der Aufgabe innerhalb der Methode und führen Sie den gesamten Vorgang synchron aus, wobei Sie asynchrone Ansätze vollständig aufgeben.
Sieht kompliziert aus? Betrachten Sie ein Beispiel.
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var task = InitializeMapIfNeededAsync();
In diesem Fall wird der
async
Modifikator in der
GetStockPriceWithValueTaskAsync_Optimized
Methode nicht verwendet. Wenn er also eine Aufgabe von der
InitializeMapIfNeededAsync
Methode empfängt, überprüft er seinen Ausführungsstatus. Wenn die Aufgabe abgeschlossen ist, verwendet die Methode einfach
DoGetPriceFromCache
um sofort das Ergebnis zu erhalten. Wenn die Initialisierungsaufgabe noch ausgeführt wird, ruft die Methode eine lokale Funktion auf und wartet auf Ergebnisse.
Die Verwendung einer lokalen Funktion ist nicht die einzige, sondern eine der einfachsten Möglichkeiten. Aber es gibt eine Einschränkung. Während der natürlichsten Implementierung erhält die lokale Funktion einen externen Status (lokale Variable und Argument):
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized2(string companyId) {
Leider generiert dieser Code aufgrund
eines Compilerfehlers einen Abschluss, selbst wenn die Methode innerhalb des gemeinsamen Pfads ausgeführt wird. So sieht diese Methode von innen aus:
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var closure = new __DisplayClass0_0() { __this = this, companyId = companyId, task = InitializeMapIfNeededAsync() }; if (closure.task.IsCompleted) { return ... }
Wie im Artikel
Zerlegen der lokalen Funktionen in C # erläutert, verwendet der Compiler eine gemeinsame Abschlussinstanz für alle lokalen Variablen und Argumente in einem bestimmten Bereich. Folglich macht eine solche Codegenerierung einen gewissen Sinn, aber es macht den ganzen Kampf mit der Zuweisung von Haufen nutzlos.
TIPP . Eine solche Optimierung ist eine sehr heimtückische Sache. Die Vorteile sind vernachlässigbar, und selbst wenn Sie die
richtige ursprüngliche lokale Funktion schreiben, können Sie versehentlich einen externen Status erhalten, der dazu führt, dass der Heap zugewiesen wird. Sie können weiterhin auf die Optimierung zurückgreifen, wenn Sie mit einer häufig verwendeten Bibliothek (z. B. BCL) in einer Methode arbeiten, die definitiv für einen geladenen Codeabschnitt verwendet wird.
Kosten für das Warten auf eine Aufgabe
Im Moment haben wir nur einen bestimmten Fall betrachtet: den Overhead einer asynchronen Methode, die synchron ausgeführt wird. Dies geschieht absichtlich. Je kleiner die asynchrone Methode ist, desto deutlicher sind die Kosten für die Gesamtleistung. Detailliertere asynchrone Methoden werden in der Regel synchron ausgeführt und führen eine geringere Arbeitslast aus. Und wir nennen sie normalerweise öfter.
Wir müssen uns jedoch der Kosten des asynchronen Mechanismus bewusst sein, wenn die Methode auf die Fertigstellung einer ausstehenden Aufgabe „wartet“. Um diese Kosten abzuschätzen, nehmen wir Änderungen an
InitializeMapIfNeededAsync
und rufen
Task.Yield()
auch wenn der Cache initialisiert wird:
private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; }
Wir fügen unserem Benchmark-Paket die folgenden Methoden für Vergleichstests hinzu:
[Benchmark] public decimal GetStockPriceFor_Await() { return _stockPricesThatYield.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync_Await() { return _stockPricesThatYield.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }

Wie Sie sehen, ist der Unterschied spürbar - sowohl in Bezug auf die Geschwindigkeit als auch in Bezug auf die Speichernutzung. Erklären Sie kurz die Ergebnisse.
- Jede Warteoperation für eine nicht abgeschlossene Aufgabe dauert ungefähr 4 Mikrosekunden und weist jedem Aufruf fast 300 Bytes (**) zu. Aus diesem Grund läuft GetStockPriceFor fast doppelt so schnell wie GetStockPriceForAsync und weist weniger Speicher zu.
- Eine auf ValueTask basierende asynchrone Methode dauert etwas länger als die Variante mit Task, wenn diese Methode nicht synchron ausgeführt wird. Eine Zustandsmaschine einer auf ValueTask <T> basierenden Methode sollte mehr Daten speichern als eine Zustandsmaschine einer auf Task <T> basierenden Methode.
(**) Dies hängt von der Plattform (x64 oder x86) und einer Reihe lokaler Variablen und Argumente der asynchronen Methode ab.Leistung asynchroner Methoden 101
- Wenn die asynchrone Methode synchron ausgeführt wird, ist der Overhead ziemlich gering.
- Wenn die asynchrone Methode synchron ausgeführt wird, tritt der folgende Speicher-Overhead auf: Für asynchrone Task-Methoden gibt es keinen Overhead, und für asynchrone Task <T> -Methoden beträgt der Überlauf 88 Byte pro Operation (für x64-Plattformen).
- ValueTask <T> eliminiert den oben genannten Overhead für synchron ausgeführte asynchrone Methoden.
- Wenn eine auf ValueTask <T> basierende asynchrone Methode synchron ausgeführt wird, dauert es etwas kürzer als die Methode mit Task <T>, andernfalls gibt es geringfügige Unterschiede zugunsten der zweiten Option.
- Der Leistungsaufwand für asynchrone Methoden, die darauf warten, eine nicht abgeschlossene Aufgabe abzuschließen, ist erheblich höher (ungefähr 300 Byte pro Vorgang für x64-Plattformen).
Messungen sind natürlich unser Alles. Wenn Sie feststellen, dass eine asynchrone Operation Leistungsprobleme verursacht, können Sie von
Task<T>
zu
ValueTask<T>
wechseln, die Task zwischenspeichern oder den gesamten Ausführungspfad nach Möglichkeit synchronisieren. Sie können auch versuchen, Ihre asynchronen Vorgänge zusammenzufassen. Dies wird dazu beitragen, die Leistung zu verbessern, das Debuggen und die Code-Analyse im Allgemeinen zu vereinfachen.
Nicht jeder kleine Code sollte asynchron sein.