Asynchrone Programmierung
Asynchrone Leistung: Leistungseinbußen aufgrund von „async“ und „await“
Stephen Toub
Die asynchrone Programmierung galt lange Zeit als das Reich talentierter, aber auch selbstquälerisch veranlagter Entwickler – die über die Muße, den Wunsch und die mentale Kapazität verfügten, über Callback nach Callback bei einer nichtlinearen Ablaufsteuerung zu grübeln. Mit Microsoft .NET Framework 4.5 stellen C# und Visual Basic die Asynchronität für den Rest der Entwicklergarde bereit, sodass auch Normalsterbliche asynchrone Methoden fast ebenso einfach schreiben können wie synchrone Methoden. Nie wieder Callbacks. Nie wieder explizites Code-Marshalling von einem Synchronisierungskontext zum nächsten. Keine Gedanken mehr über die Ausgabe von Ergebnissen oder Ausnahmen. Keine weiteren Tricks, um bestehende Sprachfeatures zur Vereinfachung der asynchronen Entwicklung zu umgehen. Anders gesagt: kein Ärger mehr.
Die ersten Schritte zum Schreiben asynchroner Methoden sind deutlich vereinfacht worden (siehe Artikel von Eric Lippert und Mads Torgersen in dieser Ausgabe von MSDN Magazine). Um es richtig gut zu machen, ist dennoch ein gewisses Verständnis der Abläufe und Hintergründe erforderlich. Jedes Mal, wenn durch eine Sprache oder ein Framework die Abstraktionsebene erhöht wird, auf der ein Entwickler programmieren kann, entstehen dadurch auch immer versteckte Leistungseinbußen. In vielen Fällen sind diese Einbußen vernachlässigbar und werden von den unzähligen Entwicklern, die diese endlose Anzahl an Szenarios implementieren, ignoriert. Jedoch ziemt es sich für Entwicklungsexperten, zu wissen, welche Art von Leistungsminderung damit einhergeht. Nur so können sie die erforderlichen Schritte unternehmen, um diese Einbußen – sollten sie doch einmal spürbar sein – zu vermeiden. Und genau das ist der Fall mit dem Feature der asynchronen Methoden in C# und Visual Basic.
In diesem Artikel möchte ich die Vor- und Nachteile von asynchronen Methoden darlegen, um Ihnen ein solides Verständnis von der Implementierung dieser Methoden zu vermitteln. Zudem möchte ich einige differenzierte Nachteile aufzeigen. Damit sollen Sie allerdings nicht dazu aufgefordert werden, im Namen der Micro-Optimierung und Leistung nun lesbaren Code in etwas zu verwandeln, das nicht mehr verwaltet und gewartet werden kann. Es geht nur darum, Ihnen Informationen an die Hand zu geben, mit denen die Diagnose eventuell auftretender Probleme vereinfacht wird, und Ihnen einige Tools zu zeigen, mit denen Sie potenzielle Probleme beheben können. Dieser Artikel basiert auf einer Vorveröffentlichung von .NET Framework 4.5, sodass bestimmte Implementierungsdetails vor der endgültigen Produktveröffentlichung sicherlich noch geändert werden.
Richtiges Begriffsverständnis
Jahrzehntelang haben Entwickler hoch entwickelte Sprachen wie C#, Visual Basic, F# und C++ zum Entwickeln effizienter Anwendungen genutzt. Aufgrund dieser Erfahrung wissen solche Entwickler um die entsprechenden Leistungseinbußen bei verschiedenen Rechenoperationen, und dieses Wissen wiederum hat zur Ausprägung von Best Practices im Entwicklungsbereich geführt. Beispielsweise zieht das Aufrufen einer synchronen Methode in den meisten Fällen keine Leistungseinbußen nach sich. Das gilt umso mehr, wenn der Compiler in der Lage ist, die aufgerufene Methode inline an die Aufrufsite zu leiten. Folglich haben Entwickler gelernt, Code per Refactoring in kleine, verwaltbare Methoden umzugestalten. In der Regel brauchten sie dabei mögliche negative Konsequenzen aufgrund der erhöhten Anzahl an Methodenaufrufen nicht zu berücksichtigen. Diese Entwickler haben ein Begriffsverständnis davon, was es bedeutet, eine Methode aufzurufen.
Mit Einführung der asynchronen Methoden wird ein neues Begriffsverständnis benötigt. Die C#- und Visual Basic-Sprachen und -Compiler erwecken zwar den Eindruck, dass eine asynchrone Methode genauso funktioniert wie ihr synchrones Gegenstück, aber das entspricht nicht den Tatsachen. Am Ende generiert der Compiler viel Code für den Entwickler; und dieser Code hat erstaunlich viel Ähnlichkeit mit den Codebausteinen, die Entwickler einstmals beim Implementieren von Asynchronität selbst schreiben und manuell verwalten mussten. Des Weiteren ruft vom Compiler generierter Code in .NET Framework Bibliothekscode auf, was erneut die im Namen des Entwicklers auszuführende Rechenleistung erhöht. Um das richtige Begriffsverständnis zu entwickeln und dann auf dessen Basis fundierte Entwicklungsentscheidungen zu treffen, sollten Sie wissen, was der Compiler in Ihrem Namen generiert.
Nicht detailliert, sondern grob
Bei der Nutzung von synchronem Code verursachen Methoden ohne Text praktisch keinerlei Leistungseinbußen. Das gilt nicht für asynchrone Methoden. Betrachten Sie die folgende asynchrone Methode, deren Text über eine einzige Anweisung verfügt (und die aufgrund fehlender „awaits“ am Ende synchron ausgeführt wird):
public static async Task SimpleBodyAsync() {
Console.WriteLine("Hello, Async World!");
}
Ein Intermediate Language (IL)-Decompiler bringt nach der Kompilierung die wahre Natur dieser Funktion ans Licht: Das Ergebnis sehen Sie in Abbildung 1. Aus einem einfachen Einzeiler sind zwei Methoden entstanden, eine von ihnen befindet sich in einer Zustandsautomaten-Hilfsklasse. Zunächst ist eine Stub-Methode vorhanden, die dieselbe Grundsignatur wie die vom Entwickler geschriebene Methode enthält. Sie hat den gleichen Namen, weist eine identische Sichtbarkeit auf, akzeptiert dieselben Parameter und behält den Rückgabetyp bei. Aber diese Stub-Methode enthält keinen vom Entwickler geschriebenen Code. Stattdessen besteht sie aus „vorgefertigten“ Setup-Codebausteinen. Dieser Setup-Code initialisiert den Zustandsautomaten, mit dem die asynchrone Methode dargestellt werden soll, und startet ihn mithilfe eines Aufrufs der zweiten MoveNext-Methode auf dem Zustandsautomaten. Dieser Zustandsautomat hält den Zustand für die asynchrone Methode und lässt zu, dass dieser (falls nötig) auch über asynchrone await-Punkte hinweg gehalten wird. Auch der Methodentext ist wie vom Benutzer geschrieben enthalten, aber so verzerrt, dass Ergebnisse und Ausnahmen in den zurückgegebenen Task weitergeleitet werden, dass die aktuelle Position in der Methode gehalten wird, um die Ausführung nach einem „await“ dort fortsetzen zu können usw.
Abbildung 1 Codebausteine für asynchrone Methode
[DebuggerStepThrough]
public static Task SimpleBodyAsync() {
<SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0();
d__.<>t__builder = AsyncTaskMethodBuilder.Create();
d__.MoveNext();
return d__.<>t__builder.Task;
}
[CompilerGenerated]
[StructLayout(LayoutKind.Sequential)]
private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine {
private int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public Action <>t__MoveNextDelegate;
public void MoveNext() {
try {
if (this.<>1__state == -1) return;
Console.WriteLine("Hello, Async World!");
}
catch (Exception e) {
this.<>1__state = -1;
this.<>t__builder.SetException(e);
return;
}
this.<>1__state = -1;
this.<>t__builder.SetResult();
}
...
}
Wenn Sie darüber nachdenken, welche Leistungsminderungen durch den Aufruf asynchroner Methoden entstehen, sollten Sie diese Codebausteine berücksichtigen. Der try/catch-Block in der MoveNext-Methode verhindert vermutlich, dass sie vom JIT (Just-in-Time)-Compiler inline gesetzt wird. Damit erhalten wir zumindest die Leistungseinbußen eines Methodenaufrufs, die bei einer synchronen Methode (und einem solch kurzen Methodentext) wahrscheinlich nicht auftreten würden. Es sind mehrere Aufrufe in Framework-Routinen vorhanden (wie z. B. SetResult). Zudem werden mehrere Schreibvorgänge in Feldern auf dem Zustandsautomatentyp ausgeführt. Natürlich muss das alles mit den Leistungseinbußen aufgrund von Console.WriteLine verglichen werden, denn dieses Objekt hat vermutlich die höchsten Leistungsanforderungen (es setzt Sperren, führt E/A aus usw.). Außerdem gilt es, die im Rahmen der Infrastruktur vorgenommenen Optimierungen zu berücksichtigen. Beispielsweise handelt es sich beim Zustandsautomatentyp um eine Struktur. Diese Struktur wird nur dann in den Heap geschoben, wenn die Ausführung dieser Methode jemals wegen einer noch nicht abgeschlossenen Instanz angehalten werden muss. In dieser einfachen Methode wird sie niemals abgeschlossen. Deshalb können die Codebausteine dieser asynchronen Methode keine Zuordnungen verarbeiten. Der Compiler und die Laufzeit arbeiten gemeinsam hart daran, die Anzahl an Zuordnungen für die Infrastruktur zu minimieren.
Wann sich das Verwenden asynchroner Methoden nicht empfiehlt
In .NET Framework wird versucht, effiziente asynchrone Implementierungen für asynchrone Methoden zu generieren. Dazu werden verschiedene Optimierungen umgesetzt. Jedoch verfügen Entwickler häufig über Domänenkenntnisse, aus denen Optimierungen entstehen, deren automatische Anwendung auf Compiler und Laufzeit (wegen der allgemeinen Ausrichtung) riskant und nicht sinnvoll ist. Wenn dies berücksichtigt wird, kann es für einen Entwickler tatsächlich von Vorteil sein, in bestimmten Fällen auf die Verwendung asynchroner Methoden zu verzichten. Das gilt besonders für Bibliotheksmethoden, deren Zugriff auf feiner abgestimmte Weise erfolgt. In der Regel ist das der Fall, wenn bekannt ist, dass die Methode wirklich synchron abgeschlossen werden kann, da die Daten, auf denen sie basiert, bereits verfügbar sind.
Beim Entwickeln von asynchronen Methoden verbringen die Framework-Entwickler viel Zeit damit, Objektzuordnungen wegzuoptimieren. Der Grund dafür ist, dass Zuordnungen zu den größten Verursachern von Leistungseinbußen in der asynchronen Methodeninfrastruktur zählen. Beim Vorgang der Objektzuordnung wird nicht viel Leistung benötigt. Das Zuordnen von Objekten ist vergleichbar damit, den Einkaufswagen mit Produkten zu füllen. Es ist nicht aufwändig, Artikel in den Einkaufswagen zu legen, sondern der Aufwand entsteht erst, wenn Sie die Brieftasche herausholen und beträchtliche Ressourcen investieren müssen. Während Zuordnungen keine Leistungsminderung verursachen, ist die daraus folgende Garbage Collection in Bezug auf die Anwendungsleistung ein K.O.-Kriterium. Beim Vorgang der Garbage Collection werden aktuell zugeordnete Objekte gescannt und solche ohne Verweise ermittelt. Je mehr Objekte zugeordnet sind, desto länger dauert der Scanvorgang. Zudem gilt, je größer die zugeordneten Objekte sind und je mehr solcher Objektzuordnungen vorliegen, desto häufiger muss eine Garbage Collection ausgeführt werden. So gesehen haben Zuordnungen erhebliche Auswirkungen auf das gesamte System: Je mehr Garbage von asynchronen Methoden erzeugt wird, desto langsamer wird das übergeordnete Programm ausgeführt – auch wenn die Micro-Benchmarks der asynchronen Methoden selbst auf keine spürbaren Leistungseinbußen hindeuten.
Bei asynchronen Methoden, deren Ausführung tatsächlich ansteht (da sie auf ein Objekt warten, das noch nicht abgeschlossen ist), muss die asynchrone Methodeninfrastruktur ein Taskobjekt zuordnen, das von der Methode zurückgegeben werden soll, da dieses Taskobjekt als eindeutiger Verweis für diesen bestimmten Aufruf gilt. Viele asynchrone Methodenaufrufe können jedoch auch so abgeschlossen werden. In solchen Fällen wird von der asynchronen Methodeninfrastruktur ein zwischengespeicherter und bereits abgeschlossener Task zurückgegeben, der mehrfach verwendet werden kann. Damit wird das Zuordnen nicht benötigter Tasks vermieden. Das kann aber nur in begrenztem Umfang geschehen, zum Beispiel, wenn es sich bei der asynchronen Methode um einen nicht generischen Task, um Task<Boolean> oder Task<TResult> handelt, wobei „TResult“ ein Verweistyp und das Ergebnis der asynchronen Methode null ist. Möglicherweise wird dieses Set in Zukunft noch erweitert, aber Sie können es häufig besser machen, wenn Sie über Domänenkenntnisse des zu implementierenden Vorgangs verfügen.
Überlegen Sie, einen Typ wie MemoryStream zu implementieren. MemoryStream ist aus „Stream“ abgeleitet und kann so die in .NET 4.5 enthaltenen, neuen Stream-Methoden ReadAsync, WriteAsync und FlushAsync überschreiben, um für MemoryStream geeignete und optimierte Implementierungen bereitzustellen. Da beim Lesevorgang einfach nur gegen einen speicherinternen Puffer gelesen wird, und es sich dabei nur um eine Speicherkopie handelt, ist die Leistung bei synchroner Ausführung von ReadAsync besser. Eine solche Implementierung mit einer asynchronen Methode würde in etwa folgendermaßen aussehen:
public override async Task<int> ReadAsync(
byte [] buffer, int offset, int count,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return this.Read(buffer, offset, count);
}
Einfach genug. Und da „Read“ ein synchroner Aufruf ist und in dieser Methode keine „awaits“ vorhanden sind, die diese Methode steuern, werden wirklich alle Aufrufe von ReadAsync synchron abgeschlossen. Im Folgenden schauen wir uns einen Kopiervorgang, also ein standardmäßiges Stream-Verwendungsschema an:
byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
await source.WriteAsync(buffer, 0, numRead);
}
Wie Sie sehen, wird ReadAsync im Quellstream für diese bestimmte Aufrufreihe immer mit demselben count-Parameter (der Länge des Puffers) aufgerufen. Demzufolge ist es sehr wahrscheinlich, dass auch der Rückgabewert (die gelesene Byteanzahl) gleich ist. Nur in ganz seltenen Fällen kann die asynchrone Methodenimplementierung von ReadAsync einen zwischengespeicherten Task für den Rückgabewert nutzen – aber Sie können das machen.
Sie können die Methode wie in Abbildung 2 dargestellt umschreiben. Wenn wir die besonderen Aspekte dieser Methode und ihre gängigen Verwendungsszenarios nutzen, sind wir in der Lage, die Zuordnungen auf eine Weise zu optimieren, die wir von der zugrunde liegenden Infrastruktur nicht erwarten würden. Da jeder Aufruf von ReadAsync dieselbe Byteanzahl abruft wie der vorherige ReadAsync-Aufruf, können wir jeglichen Zuordnungsaufwand für die ReadAsync-Methode vermeiden, indem wir denselben Task wie beim vorherigen Aufruf zurückgeben. Und bei einem solch elementaren Vorgang, der schnell ausgeführt und mehrfach aufgerufen werden muss, sorgt eine Optimierung dieser Art für den entscheidenden Unterschied, dies gilt besonders für die Anzahl der auftretenden Garbage Collections.
Abbildung 2 Optimieren von Taskzuordnungen
private Task<int> m_lastTask;
public override Task<int> ReadAsync(
byte [] buffer, int offset, int count,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) {
var tcs = new TaskCompletionSource<int>();
tcs.SetCanceled();
return tcs.Task;
}
try {
int numRead = this.Read(buffer, offset, count);
return m_lastTask != null && numRead == m_lastTask.Result ?
m_lastTask : (m_lastTask = Task.FromResult(numRead));
}
catch(Exception e) {
var tcs = new TaskCompletionSource<int>();
tcs.SetException(e);
return tcs.Task;
}
}
Eine ähnliche Optimierung zur Vermeidung der Taskzuordnung kann vorgenommen werden, wenn das Szenario eine Zwischenspeicherung zwingend vorgibt. Denken Sie an eine Methode, deren Zweck darin besteht, den Inhalt einer bestimmten Webseite herunterzuladen und dann die erfolgreich heruntergeladenen Inhalte für den zukünftigen Zugriff zwischenzuspeichern. Eine solche Funktion könnte unter Verwendung einer asynchronen Methode wie folgt geschrieben werden (mithilfe der neuen Bibliothek System.Net.Http.dll in .NET 4.5):
private static ConcurrentDictionary<string,string> s_urlToContents;
public static async Task<string> GetContentsAsync(string url)
{
string contents;
if (!s_urlToContents.TryGetValue(url, out contents))
{
var response = await new HttpClient().GetAsync(url);
contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
s_urlToContents.TryAdd(url, contents);
}
return contents;
}
Dies ist eine unkomplizierte Implementierung. Im Falle von GetContentsAsync-Aufrufen, die nicht vom Zwischenspeicher ausgeführt werden können, wird der Aufwand zur Entwicklung eines neuen Task<string>-Objekts zur Abbildung dieses Downloads im Vergleich zu den Netzwerkanforderungen vernachlässigbar sein. In anderen Fällen jedoch, in denen die Inhalte aus dem Zwischenspeicher abgefragt werden können, kann diese Objektzuordnung, die einfach bereits verfügbare Daten zusammenstellt und zurückgibt, spürbare Leistungseinbußen bedeuten.
Um diese Leistungsminderung zu vermeiden (wenn das zur Einhaltung der Leistungsziele erforderlich ist), lässt sich diese Methode wie in Abbildung 3 dargestellt abändern. Wir haben jetzt zwei Methoden: eine asynchrone öffentliche Methode und eine asynchrone private Methode, an die von der öffentlichen Methode delegiert wird. Im Wörterbuch werden nun generierte Tasks (und nicht deren Inhalte) zwischengespeichert. Zukünftige Versuche, eine bereits erfolgreich heruntergeladene Seite erneut herunterzuladen, können also mit einem einfachen Wörterbuchzugriff und der Rückgabe eines bereits vorhandenen Tasks erfüllt werden. Intern machen wir uns auch die Methode ContinueWith für einen Task zunutze, mit der wir den Task direkt nach der Ausführung im Wörterbuch speichern können (aber nur nach erfolgreichem Download). Dieser Code ist offensichtlich komplizierter und erfordert beim Schreiben und Verwalten mehr Aufwand. Wie bei allen Leistungsoptimierungen gilt: Wenden Sie beim Entwickeln nicht so viel Zeit auf, bis die Leistungstests erwiesen haben, dass sich die zusätzliche Komplexität wirklich lohnt. Ob solche Optimierungen tatsächlich einen spürbaren Unterschied bringen, hängt von den Verwendungsszenarios ab. Am besten führen Sie eine Reihe von Tests durch, die gängige Verwendungsmuster abbilden, und ermitteln anhand der Testanalyse, ob die zusätzliche Komplexität die Codeleistung erheblich verbessert.
Abbildung 3 Manuelles Zwischenspeichern von Tasks
private static ConcurrentDictionary<string,Task<string>> s_urlToContents;
public static Task<string> GetContentsAsync(string url) {
Task<string> contents;
if (!s_urlToContents.TryGetValue(url, out contents)) {
contents = GetContentsAsync(url);
contents.ContinueWith(delegate {
s_urlToContents.TryAdd(url, contents);
}, CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion |
TaskContinuatOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
return contents;
}
private static async Task<string> GetContentsAsync(string url) {
var response = await new HttpClient().GetAsync(url);
return response.EnsureSuccessStatusCode().Content.ReadAsString();
}
Eine weitere taskbezogene Optimierung, die es zu berücksichtigen gilt, ist die Frage, ob der von einer asynchronen Methode zurückgegebene Task überhaupt benötigt wird. Sowohl C# als auch Visual Basic unterstützen das Erstellen von asynchronen Methoden, die „void“ zurückgeben. Dann wird auch niemals ein Task für die Methode zugeordnet. Öffentlich aus Bibliotheken bereitgestellte asynchrone Methoden sollten immer so geschrieben sein, dass sie entweder Task oder Task<TResult> zurückgeben, denn Sie als Bibliotheksentwickler können nicht wissen, ob der Kunde auf den Abschluss dieser Methode warten möchte. Bei bestimmten internen Verwendungsszenarios haben jedoch asynchrone Methoden, von denen „void“ zurückgegeben wird, ihre Berechtigung. Der Hauptgrund, aus dem „void“ zurückgebende asynchrone Methoden existieren, besteht in der Unterstützung von ereignisgesteuerten Umgebungen, wie z. B. ASP.NET und Windows Presentation Foundation (WPF). Durch das Verwenden von „async“ und „await“ vereinfachen sie beispielsweise das Implementieren von Schaltflächen-Handlern und von Ereignissen zum Laden der Seite. Wenn Sie überlegen, ob Sie eine asynchrone Methode mit „void“-Rückgabe nutzen möchten, achten Sie besonders auf die Ausnahmebehandlung: Ausnahmen, die aus der asynchronen Methode mit „void“-Rückgabe quasi „entkommen“, treten immer im zum Zeitpunkt des Methodenaufrufs aktuellen SynchronizationContext auf.
Berücksichtigen von Kontext
In .NET Framework sind viele Arten an „Kontext“ vorhanden: LogicalCallContext, SynchronizationContext, HostExecutionContext, SecurityContext, ExecutionContext und so weiter (aufgrund der schieren Anzahl könnte man annehmen, dass die Framework-Entwickler finanziell für die Einführung von neuem Kontext entlohnt werden, aber ich versichere Ihnen, das ist nicht der Fall). Einige dieser Kontextarten sind nicht nur im Hinblick auf die Funktion, sondern auch auf die Auswirkung auf die Leistung der asynchronen Methode sehr wichtig.
SynchronizationContext Der SynchronizationContext spielt eine wichtige Rolle bei asynchronen Methoden. Ein Synchronisierungskontext ist einfach eine Abstraktion der Möglichkeit, Delegataufrufe per Marshalling auf eine Weise zu verwalten, die speziell auf eine vorhandene Bibliothek oder ein Framework ausgerichtet ist. So wird beispielsweise DispatcherSynchronizationContext von WPF bereitgestellt, um einen UI-Thread für einen Dispatcher abzubilden: Wird ein Delegat für diesen Synchronisierungskontext bereitgestellt, nimmt der Dispatcher diesen in die Ausführungswarteschlange des Threads auf. In ASP.NET ist AspNetSynchronizationContext verfügbar, mit dem sichergestellt wird, dass die im Rahmen der ASP.NET-Anforderungsverarbeitung auftretenden asynchronen Vorgänge nacheinander ausgeführt und mit dem korrekten HttpContext-Status verknüpft werden. Und so weiter. Insgesamt sind in etwa 10 konkrete Implementierungen von SynchronizationContext in .NET Framework vorhanden, einige davon öffentlich, andere intern.
Beim Erwarten von Tasks und anderen erwartbaren Typen („awaitable“), die von .NET Framework bereitgestellt werden, wird der aktuelle SynchronizationContext zum Zeitpunkt der await-Ausgabe vom Erwartenden (dem „awaiter“, wie z. B. TaskAwaiter) erfasst. Wenn beim Abschließen des „awaitable“ ein aktueller SynchronizationContext erfasst worden ist, wird die Fortsetzung, die den Rest der asynchronen Methode darstellt, für diesen SynchronizationContext bereitgestellt. Damit ist seitens der Entwickler, die eine per UI-Thread aufgerufene asynchrone Methode schreiben, kein manuelles Marshalling der Aufrufe zurück an den UI-Thread mehr nötig, um die UI-Steuerung zu ändern: Diese Art Marshalling wird automatisch von der Framework-Infrastruktur ausgeführt.
Leider hat das Marshalling hohe Leistungsanforderungen. Wenn Anwendungsentwickler einen „await“ zum Implementieren der Ablaufsteuerung einsetzen, ist das automatische Marshalling fast immer die richtige Lösung. Bei Bibliotheken sieht es jedoch anders aus. Anwendungsentwickler benötigen in der Regel das Marshalling, da der Code den Inhalt berücksichtigt, unter dem er ausgeführt wird. Beispielsweise, ob der Zugriff auf UI-Steuerungen oder auf den HttpContext für die korrekte ASP.NET-Anforderung möglich ist. Bei den meisten Bibliotheken liegt eine solche Einschränkung allerdings nicht vor. Folglich führt das automatische Marshalling häufig zu unnötigen Leistungseinbußen. Im Folgenden sehen Sie erneut den bereits gezeigten Codeabschnitt zum Kopieren von Daten von einem Stream auf einen anderen:
byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
await source.WriteAsync(buffer, 0, numRead);
}
Wenn dieser Kopiervorgang über einen UI-Thread aufgerufen wird, erzwingt jeder erwartete Lese- und Schreibvorgang den Abschluss zurück zum UI-Thread. Bei 1 MB an Quelldaten und Streams, die Lese- und Schreibvorgänge asynchron ausführen (d. h. die meisten), bedeutet das mehr als 500 Hops von Hintergrundthreads zum UI-Thread. Um das zu verarbeiten, bieten die Typen Task und Task<TResult> eine ConfigureAwait-Methode. ConfigureAwait akzeptiert einen booleschen continueOnCapturedContext-Parameter, mit dem dieses Marshallingverhalten gesteuert wird. Wird der Standardwert „true“ verwendet, wird der „await“ automatisch wieder zurück auf dem erfassten SynchronizationContext abgeschlossen. Wenn der Wert „false“ verwendet wird, wird der SynchronizationContext ignoriert, und Framework versucht, die Ausführung dort fortzusetzen, wo der vorherige asynchrone Vorgang abgeschlossen worden ist. Wenn das in den Codeabschnitt zum Kopieren der Streams einbezogen wird, entsteht folgende effizientere Variante:
byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await
source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) {
await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false);
}
Für Bibliotheksentwickler ist allein diese Leistungsbeeinträchtigung der Garant dafür, dass sie immer ConfigureAwait einsetzen. Außer in dem Fall, dass die Bibliothek über Domänenkenntnisse ihrer Umgebung verfügt und den Text der Methode auch ohne Zugriff auf den korrekten Kontext ausführen kann.
Neben der Leistung gibt es noch einen weiteren Grund, ConfigureAwait in Bibliothekscode einzusetzen. Angenommen, der vorige Codeabschnitt wäre ohne ConfigureAwait in eine Methode mit der Bezeichnung CopyStreamToStreamAsync eingebunden, die über einen UI-Thread in WPF aufgerufen würde, also z. B.:
private void button1_Click(object sender, EventArgs args) {
Stream src = …, dst = …;
Task t = CopyStreamToStreamAsync(src, dst);
t.Wait(); // deadlock!
}
Hier hätte der Entwickler „button1_Click“ als asynchrone Methode schreiben und dann einen await-Task anstelle der synchronen Wait-Methode verwenden sollen. Für die Wait-Methode gibt es berechtigte Einsatzmöglichkeiten, aber es ist fast immer falsch, sie als Wartemethode in einem UI-Thread wie diesem zu nutzen. Die Wait-Methode gibt erst zurück, wenn der Task abgeschlossen ist. Im Falle von CopyStreamToStreamAsync versuchen die enthaltenen „awaits“, eine Bereitstellung zurück zum erfassten SynchronizationContext vorzunehmen, und die Methode kann erst abgeschlossen werden, wenn diese Bereitstellungen abgeschlossen sind (da diese zum Verarbeiten der restlichen Methode benötigt werden). Aber diese Bereitstellungen werden nicht abgeschlossen, da der UI-Thread, von dem sie verarbeitet werden sollen, durch den Wait-Aufruf gesperrt ist. Das ist eine kreisförmige Abhängigkeit, die in einem Deadlock endet. Wäre CopyStreamToStreamAsync stattdessen mit „ConfigureAwait(false)“ geschrieben worden, gäbe es weder die zirkuläre Abhängigkeit noch den Deadlock.
ExecutionContext Der ExecutionContext ist ein wichtiger Bestandteil von .NET Framework, dennoch wissen viele Entwickler nichts darüber. ExecutionContext ist praktisch der Großvater des Kontexts, er kapselt mehrere andere Kontexte wie SecurityContext und LogicalCallContext, und er repräsentiert alles, was automatisch über asynchrone Punkte im Code übergeben werden sollte. Jedes Mal, wenn Sie ThreadPool.QueueUserWorkItem, Task.Run, Delegate.BeginInvoke, Stream.BeginRead, WebClient.DownloadStringAsync oder einen anderen asynchronen Vorgang in Framework verwendet haben, ist im Hintergrund (sofern möglich) der ExecutionContext erfasst worden (über ExecutionContext.Capture). Mit diesem erfassten Kontext ist dann der bereitgestellte Delegat verarbeitet worden (über ExecutionContext.Run). Wenn beispielsweise der Code, mit dem ThreadPool.QueueUserWorkItem aufgerufen wird, zu diesem Zeitpunkt eine Windows-Identität annähme, würde diese angenommene Windows-Identität zum Ausführen des bereitgestellten WaitCallback-Delegaten benötigt. Und wenn der Code zum Aufrufen von Task.Run zuvor Daten in LogicalCallContext gespeichert hätte, könnte über den LogicalCallContext im bereitgestellten Aktionsdelegaten auf diese Daten zugegriffen werden. ExecutionContext wird auch über „awaits“ auf Tasks übergeben.
In Framework sind zahlreiche Optimierungen aktiv, um das Erfassen und Ausführen unter einem erfassten ExecutionContext zu verhindern, wenn dies unnötig und möglicherweise leistungsintensiv ist. Jedoch durchkreuzen Aktionen wie das Annehmen einer Windows-Identität oder das Speichern von Daten in LogicalCallContext diese Optimierungen. Das Vermeiden von Vorgängen, bei denen der ExecutionContext manipuliert wird (z. B. WindowsIdentity.Impersonate und CallContext.LogicalSetData) führt beim Verwenden von asynchronen Methoden und Asynchronität im Allgemeinen zu einer höheren Leistung.
Eine Möglichkeit ohne Garbage Collection
Wenn es um lokale Variablen geht, bieten asynchrone Methoden eine angenehme Illusion. In einer synchronen Methode sind lokale Variablen in C# und Visual Basic stapelbasiert, sodass zum Speichern dieser lokalen Variablen keine Heapzuweisungen erforderlich sind. Bei asynchronen Methoden hingegen wird der Stapel für die Methode aufgehoben, wenn die asynchrone Methode an einem await-Punkt angehalten wird. Damit die Daten nach einer await-Wiederaufnahme wieder für die Methode verfügbar sind, müssen sie irgendwo gespeichert werden. Daher werden die lokalen Variablen von C#- und Visual Basic-Compilern in eine Zustandsautomatenstruktur „geschoben“, die dann beim ersten Anhalten durch einen „await“ in den Heap geschachtelt wird. Damit können lokale Variablen auch über await-Punkte hinweg bestehen.
Etwas weiter oben in diesem Artikel habe ich erwähnt, dass die Leistungsminderung durch eine Garbage Collection sowie die Häufigkeit deren Ausführung von der Anzahl der zugeordneten Objekte beeinflusst wird. Wie häufig eine Garbage Collection ausgeführt wird, hängt aber auch von der Größe der zugeordneten Objekte ab. Je größer die zugeordneten Objekte sind, desto häufiger muss eine Garbage Collection ausgeführt werden. Folglich gilt: Je mehr lokale Variablen in einer asynchronen Methode in den Heap geschoben werden müssen, desto häufiger treten Garbage Collections auf.
Zum Zeitpunkt, an dem dieser Artikel verfasst worden ist, wird von den C#- und Visual Basic-Compilern häufiger geschoben als unbedingt nötig. Sehen Sie sich z. B. den folgenden Codeausschnitt an:
public static async Task FooAsync() {
var dto = DateTimeOffset.Now;
var dt = dto.DateTime;
await Task.Yield();
Console.WriteLine(dt);
}
Die Variable „dto“ wird nach dem await-Punkt gar nicht gelesen, und deshalb muss der vor dem await-Punkt auf die Variable geschriebene Wert diesen Punkt auch nicht überstehen. Der vom Compiler zum Speichern der lokalen Variablen generierte Zustandsautomatentyp enthält jedoch immer noch den dto-Verweis, wie in Abbildung 4 veranschaulicht.
Abbildung 4 Verschieben von lokalen Variablen
[StructLayout(LayoutKind.Sequential), CompilerGenerated]
private struct <FooAsync>d__0 : <>t__IStateMachine {
private int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public Action <>t__MoveNextDelegate;
public DateTimeOffset <dto>5__1;
public DateTime <dt>5__2;
private object <>t__stack;
private object <>t__awaiter;
public void MoveNext();
[DebuggerHidden]
public void <>t__SetMoveNextDelegate(Action param0);
}
Dadurch wird die Größe des Heapobjekts leicht über das erforderliche Maß angehoben. Wenn Sie feststellen, dass Garbage Collections häufiger als erwartet auftreten, sollten Sie überprüfen, ob Sie wirklich alle temporären Variablen benötigen, die Sie per Code in die asynchrone Methode geschrieben haben. Das Beispiel könnte wie folgt umgeschrieben werden, um das zusätzliche Feld der Zustandsautomatenklasse zu vermeiden:
public static async Task FooAsync() {
var dt = DateTimeOffset.Now.DateTime;
await Task.Yield();
Console.WriteLine(dt);
}
Zudem handelt es sich beim Garbage Collector (GC) von .NET um einen generationsbasierten Collector, das heißt, er unterteilt die Objektsätze in Gruppen, die als Generationen bezeichnet werden: Neue Objekte werden im Allgemeinen der Generation 0 zugeordnet, und alle Objekte, die eine Garbage Collection überdauern, werden auf die nächste Generation heraufgestuft (der GC von .NET nutzt derzeit die Generationen 0, 1 und 2). Das beschleunigt die Garbage Collection, weil der GC die Collection nun häufiger für eine Teilmenge des belegten Objektspeicherplatzes durchführen kann. Dieses Vorgehen basiert auf der Ansicht, dass neu zugeordnete Objekte auch schnell wieder verschwinden, wohingegen Objekte, die schon lange vorhanden sind, wahrscheinlich noch länger vorhanden sein werden. Wenn also ein Objekt die Generation 0 überdauert, wird es vermutlich noch länger vorhanden sein und den Druck auf das System für diesen Zeitraum erhöhen. Und das wiederum bedeutet, wir möchten unbedingt sicherstellen, dass nicht mehr benötigte Objekte so schnell wie möglich der Garbage Collection zugeführt werden.
Durch das erwähnte Verschieben werden lokale Variablen auf Felder einer Klasse verschoben, die während der asynchronen Methodenausführung einen Stamm besitzen (solange das erwartete Objekt einen ordnungsgemäßen Verweis auf den Delegaten pflegt, der bei Abschluss des erwarteten Vorgangs aufgerufen werden soll). Bei synchronen Methoden kann der JIT-Compiler verfolgen, wann kein Zugriff mehr auf eine lokale Variable benötigt wird. Danach ermöglicht es der GC, diese Variable als Stamm zu ignorieren, indem die referenzierten Objekte – sofern kein anderer Verweis auf diese vorliegt – für die Garbage Collection verfügbar gemacht werden. Bei asynchronen Methoden bleiben die Verweise auf diese lokalen Variablen bestehen. Das heißt, die Objekte, auf die sie verweisen, überdauern einen deutlich längeren Zeitraum, als wenn es sich um echte lokale Variablen gehandelt hätte. Wenn Sie feststellen, dass Objekte erheblich länger vorhanden sind, als diese benötigt werden, können Sie die lokalen (und ebenfalls nicht mehr erforderlichen) Variablen auf Null setzen, die auf diese Objekte verweisen. Das sollten Sie aber nur machen, wenn sich dies als Ursache der Leistungseinbußen herausgestellt hat. Andernfalls wird der Code dadurch unnötig verkompliziert. Des Weiteren sollten die C#- und Visual Basic-Compiler möglichst auf die aktuelle Version aktualisiert werden, um weitere solche Szenarios im Namen des Entwicklers auszuführen, sodass vermutlich heutzutage erstellter Code in Zukunft veraltet sein wird.
Vermeiden von Komplexität
Die C#- und Visual Basic-Compiler beeindrucken in Bezug auf die Einsetzbarkeit von „awaits“: fast überall. Await-Ausdrücke können als Teil längerer Ausdrücke verwendet, sodass Instanzen von Task<TResult> an Stellen erwartbar sind, an denen sonst ein anderer Ausdruck für die Wertrückgabe stehen würde. Im folgenden Beispielcode wird die Summe von drei Taskergebnissen zurückgegeben:
public static async Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
return Sum(await a, await b, await c);
}
private static int Sum(int a, int b, int c)
{
return a + b + c;
}
Mit dem C#-Compiler kann der Ausdruck „await b“ als Argument für die Summenfunktion verwendet werden. Hier sind mehrere „awaits“ vorhanden, deren Ergebnisse als Parameter an die Summe (Sum) übergeben werden. Aufgrund der Reihenfolge der Auswertungsregeln und der Art und Weise der asynchronen Implementierung im Compiler muss in diesem Beispiel der Compiler die temporären Ergebnisse der ersten beiden „awaits“ im Prinzip verteilen. Wie bereits gezeigt, können lokale Variablen über die await-Punkte hinweg bestehen, indem sie in Felder der Zustandsautomatenklasse geschoben werden. In diesem Fall jedoch befinden sich die Werte auf dem CLR-Auswertungsstapel und werden nicht in den Zustandsautomaten geschoben, sondern stattdessen in ein einzelnes, temporäres Objekt verteilt und dann vom Zustandsautomaten referenziert. Nachdem der „await“ für den ersten Task abgeschlossen ist und der zweite ansteht, generiert der Compiler Code, mit dem das erste Ergebnis geschachtelt und dann als geschachteltes Objekt in einem einzelnen Feld des Typs <>t__stack auf dem Zustandsautomaten hinterlegt wird. Ist auch der „await“ für den zweiten Task abgeschlossen, wird der dritte erwartet und der Compiler erzeugt Code, mit dem ein Tuple<int,int> aus den ersten beiden Werten generiert und das Tupel im gleichen Feld <>__stack gespeichert wird. Folglich könnten abhängig davon, wie Sie Ihren Code schreiben, am Ende sehr unterschiedliche Zuordnungsmuster entstehen. Ziehen Sie in Betracht, SumAsync wie folgt zu schreiben:
public static async Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
int ra = await a;
int rb = await b;
int rc = await c;
return Sum(ra, rb, rc);
}
Durch diese Änderung erzeugt der Compiler drei weitere Felder in der Zustandsautomatenklasse, um die Werte „ra“, „rb“ und „rc“ zu speichern. Eine Verteilung erfolgt dann nicht mehr. So entsteht ein Kompromiss: eine größere Zustandsautomatenklasse mit weniger Zuordnungen oder eine kleinere Zustandsautomatenklasse mit mehr Zuordnungen. Der insgesamt zugewiesene Speicherplatz ist bei der Verteilung höher, da jedes zugeordnete Objekte seinen eigenen Speicheroverhead hat, aber am Ende könnte ein Leistungstest erweisen, dass diese Variante trotzdem die bessere ist. Wie bereits erwähnt sollten diese Micro-Optimierungen erst dann in Betracht gezogen werden, wenn sich eindeutig herausgestellt hat, dass die Zuordnungen wirklich für das Problem verantwortlich sind. Aber dennoch ist es hilfreich, zu wissen, woher diese Zuordnungen stammen.
Natürlich ist in den gezeigten Beispielen eine höhere Leistungsanforderung vorhanden, die Sie kennen und berücksichtigen sollten. Der Code kann die Summe erst aufrufen, wenn alle drei „awaits“ abgeschlossen sind, und zwischen den einzelnen „awaits“ werden keine Verarbeitungsvorgänge ausgeführt. Jeder der zur Ausführung anstehenden „awaits“ erfordert eine Rechenleistung, je weniger „awaits“ also verarbeitet werden müssen, desto besser. Es wäre also angeraten, alle drei „awaits“ in einem zusammenzufassen, sodass mit Task.WhenAll gleich alle Tasks auf einmal erwartet werden:
public static async Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
int [] results = await Task.WhenAll(a, b, c);
return Sum(results[0], results[1], results[2]);
}
Hier gibt die Methode Task.WhenAll den Wert Task<TResult[]> zurück, der erst abgeschlossen wird, wenn auch alle anderen vorhandenen Tasks abgeschlossen sind. Das ist erheblich effizienter, als auf die einzelnen Tasks zu warten. Zudem sammelt sie die Ergebnisse der einzelnen Tasks und speichert sie in einem Array. Wenn Sie diesen Array vermeiden möchten, können Sie eine Bindung an die nicht generische Methode WhenAll erzwingen, die mit Task anstelle von Task<TResult> arbeitet. Die beste Leistung wird jedoch mit einem gemischten Ansatz erzielt, bei dem zunächst geprüft wird, ob alle Tasks abgeschlossen sind. Ist das der Fall, werden die Ergebnisse einzeln abgerufen; sind hingegen noch nicht alle abgeschlossen, wird ein „await“ für WhenAll für alle noch ausstehenden Tasks verwendet. Dadurch werden alle nicht erforderlichen Zuordnungen für den Aufruf von WhenAll vermieden, beispielsweise die Zuordnung des an die Methode zu übergebenden Parameter-Arrays. Und, wie bereits erwähnt, soll die Bibliotheksfunktion auch das Kontext-Marshalling unterbinden. Eine solche Lösung wird in Abbildung 5 dargestellt.
Abbildung 5 Anwenden mehrerer Optimierungen
public static Task<int> SumAsync(
Task<int> a, Task<int> b, Task<int> c)
{
return (a.Status == TaskStatus.RanToCompletion &&
b.Status == TaskStatus.RanToCompletion &&
c.Status == TaskStatus.RanToCompletion) ?
Task.FromResult(Sum(a.Result, b.Result, c.Result)) :
SumAsyncInternal(a, b, c);
}
private static async Task<int> SumAsyncInternal(
Task<int> a, Task<int> b, Task<int> c)
{
await Task.WhenAll((Task)a, b, c).ConfigureAwait(false);
return Sum(a.Result, b.Result, c.Result);
}
Asynchronität und Leistung
Asynchrone Methoden stellen ein leistungsstarkes Produktivitätstool dar, denn Sie können damit einfacher skalierbare und reaktive Bibliotheken und Anwendungen schreiben. Jedoch sollte beachtet werden, dass Asynchronität keine Leistungsoptimierung für einen einzelnen Vorgang ist. Einen synchronen Vorgang in einen asynchronen zu verwandeln, führt unweigerlich zu einer Leistungsminderung dieses Vorgangs, da immer noch dasselbe wie beim synchronen Vorgang ausgeführt werden muss, aber nun mit zusätzlichen Einschränkungen und Bedenken. In Bezug auf die Gesamtleistung sollte Asynchronität trotzdem berücksichtigt werden: Wie verhält sich die gesamte Systemleistung, wenn alles asynchron geschrieben ist, sodass E/A-Vorgänge überlappen können und sich eine bessere Systemauslastung herauskristallisiert, weil wertvolle Ressourcen nur dann belegt werden, wenn sie für die Ausführung unumgänglich sind. Die von .NET Framework bereitgestellte asynchrone Methodenimplementierung enthält gute Optimierungen. Sie bietet häufig eine ebenso gute oder sogar bessere Leistung als gut geschriebene asynchrone Implementierungen, die mit vorhandenen Mustern geschrieben sind und mehr Code umfassen. Wenn Sie planen, asynchronen Code in .NET Framework zu entwickeln, sollten von nun an asynchrone Methoden Ihr bevorzugtes Tool sein. Dennoch sollten Sie als Entwickler immer wissen, was Framework in Ihrem Namen in diesen asynchronen Methoden ausführt, damit Sie das bestmögliche Endergebnis erhalten.
Stephen Toub ist Hauptarchitekt im Parallel Computing Platform-Team bei Microsoft.
Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Joe Hoag, Eric Lippert, Danny Shih und Mads Torgersen