November 2015
Band 30, Nummer 12
Asynchrone Programmierung – Asynchron von Anfang an
Von Mark Sowul
Mit den jüngsten Versionen von Microsoft .NET ist es einfacher als je zuvor, mithilfe der Schlüsselwörter "async" und "await" reaktionsfähige Hochleistungsanwendungen zu schreiben. Es lässt sich ohne Übertreibung sagen, dass Microsoft dafür gesorgt hat, dass wir .NET-Entwickler Software nun auf andere Weise programmieren. Asynchroner Code, für den bislang ein undurchdringbares Geflecht geschachtelter Rückrufe erforderlich war, kann nun ebenso einfach wie sequenzieller, synchroner Code geschrieben (und verstanden!) werden.
Es gibt bereits sehr viel Informationen zum Erstellen und Nutzen asynchroner Methoden, weshalb ich davon ausgehe, dass Sie mit den Grundlagen vertraut sind. Falls nicht, können Sie sich auf der Seite msdn.com/async der Visual Studio-Dokumentation rasch auf den neuesten Stand bringen.
In einem Großteil der Dokumentation zur Asynchronität werden Sie gewarnt, dass Sie asynchrone Methoden vorhandenem Code nicht einfach hinzufügen können, da der Aufrufer selbst asynchron sein muss. Laut Lucian Wischik, einem Entwickler im Microsoft Language-Team, ist "Asynchronität wie der Zombie-Virus". Wie also integrieren Sie Asynchronität in alle Strukturen Ihrer Anwendung gleich von Anfang an, ohne auf "async void" zurückzugreifen? Das werde ich Ihnen im Verlauf mehrerer Umgestaltungen des standardmäßigen UI-Startcodes sowohl für Windows Forms als auch Windows Presentation Foundation (WPF) zeigen und die UI-Codebausteine in ein objektorientiertes Design umwandeln und Unterstützung für "async/await" hinzufügen. Währenddessen werde ich erläutern, wann es sinnvoll ist, "async void" zu verwenden und wann nicht.
In diesem Artikel liegen die Hauptschwerpunkte der exemplarischen Vorgehensweise auf Windows Forms. Für WPF sind weitere Änderungen erforderlich, die ablenkend sein können. Bei jedem Schritt erläutere ich die Änderungen mithilfe einer Windows Forms-Anwendung und anschließend die für die WPF-Version erforderlichen Unterschiede. Ich zeige in diesem Artikel alle grundlegenden Codeänderungen. Zum begleitenden herunterladbaren Onlinecode gehören aber vollständige Beispiele (und die Zwischenbearbeitungen) für beide Umgebungen.
Erste Schritte
Die Visual Studio-Vorlagen für Windows Forms- und WPF-Anwendungen eignen sich nicht wirklich für das Verwenden von "async" während des Starts (oder allgemein zum Anpassen des Startprozesses). Obwohl C# anstrebt, eine objektorientierte Sprache zu sein (bei der sich der gesamte Code in Klassen befindet), drängt der standardmäßige Startcode Entwickler zum Platzieren von Logik in statischen Methoden neben "Main" oder in einen allzu komplizierten Konstruktor für das Hauptformular. (Nein, es ist keine gute Idee, auf die Datenbank im "MainForm"-Konstruktor zuzugreifen. Und, ja, das habe ich schon erlebt.) Diese Situation war schon immer problematisch. Doch mit "async" bedeutet dies nun auch, dass es keine klare Möglichkeit gibt, dass sich die Anwendung selbst asynchron initialisiert.
Zum Einstieg habe ich ein neues Projekt mithilfe der Windows Forms Application-Vorlage in Visual Studio erstellt. Abbildung 1 zeigt ihren Standardstartcode in "Program.cs".
Abbildung 1: Der standardmäßige Windows Forms-Startcode
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
Bei WPF ist das nicht ganz so einfach. Der WPF-Standardstartcode ist recht undurchsichtig, und selbst das Finden von Code zu seiner Anpassung ist schwierig. Sie können "Application.OnStartup" etwas Initialisierungscode hinzufügen, doch wie soll das Anzeigen der Benutzeroberfläche verzögert werden, bis Sie die benötigten Daten geladen haben? Der erste notwendige Schritt bei WPF besteht darin, den Startprozess als Code verfügbar zu machen, den ich bearbeiten kann. Ich bringe WPF zum selben Ausgangspunkt wie Windows Forms, und anschließend sind alle Schritte für beide Plattformen ähnlich.
Nach Erstellen einer neuen WPF-Anwendung in Visual Studio erstelle ich mit dem Code in Abbildung 2 eine neue Klasse mit dem Namen "Program". Öffnen Sie zum Ändern der standardmäßigen Startsequenz die Projekteigenschaften, und ändern Sie das Startobjekt von "App" in das neu erstellte "Program".
Abbildung 2: Der entsprechende Windows Presentation Foundation-Startcode
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
App app = new App();
// This applies the XAML, e.g. StartupUri, Application.Resources
app.InitializeComponent();
// Shows the Window specified by StartupUri
app.Run();
}
}
Wenn Sie für den Aufruf von "InitializeComponent" in Abbildung 2 "Gehe zu Definition" wählen, erkennen Sie, dass der Compiler den dazugehörigen "Main"-Code so erstellt, als würden Sie "App" als Startobjekt verwenden (womit ich die "Blackbox" für Sie hier öffne).
Schritte zu einem objektorientierten Start
Zuerst nehme ich eine kleine Umgestaltung am Standardstartcode vor, um ihn in eine objektorientierte Richtung zu bringen: Ich entnehme die Logik aus "Main" und verschiebe sie in eine Klasse. Dafür richte ich "Program" als nicht statische Klasse ein (wie schon gesagt, weisen Sie die Standardeinstellungen in die falsche Richtung) und ordne ihr einen Konstruktor zu. Dann verschiebe ich den Einrichtungscode in den Konstruktor und füge eine "Start"-Methode hinzu, mit der mein Formular ausgeführt wird.
Wie Sie in Abbildung 3 sehen, habe ich die neue Version "Program1" genannt. Dieses Gerüst veranschaulicht den Kern der Idee, denn zum Ausführen des Programms erstellt "Main" nun ein Objekt, für das wie bei einem herkömmlichen objektorientierten Szenario Methoden aufgerufen werden.
Abbildung 3: "Program1", der Anfang eines objektorientierten Starts
[STAThread]
static void Main()
{
Program1 p = new Program1();
p.Start();
}
private readonly Form1 m_mainForm;
private Program1()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
m_mainForm = new Form1();
}
public void Start()
{
Application.Run(m_mainForm);
}
Entkoppeln der Anwendung vom Formular
Nichtsdestotrotz ergeben sich durch diesen Aufruf von "Application.Run", der (am Ende in meiner "Start"-Methode) eine Formularinstanz verwendet, einige Probleme. Eines davon ist allgemein auf die Architektur bezogen: Mir gefällt nicht, dass die Lebensdauer meiner Anwendung an das Anzeigen dieses Formulars gebunden ist. Dies wäre für viele Anwendungen in Ordnung, doch es gibt im Hintergrund ausgeführte Anwendungen, die beim Start keine UI anzeigen sollen, außer vielleicht ein Symbol auf der Taskleiste oder im Infobereich. Ich kenne auch einige, die kurz einen Bildschirm einblenden, wenn sie gestartet werden, ehe sie ausgeblendet werden. Ich wette, dass ihr Startcode einen ähnlichen Prozess befolgt und sie sich sobald wie möglich ausblenden, wenn das Laden des Formulars abgeschlossen ist. Dieses spezifische Problem muss zugegebenermaßen hier nicht gelöst werden, doch die Trennung ist für eine asynchrone Initialisierung besonders wichtig.
Anstatt "Application.Run(m_mainForm)" verwende ich die Überladung von "Run", die kein Argument verwendet: Die Methode startet die UI-Infrastruktur, ohne sie an ein bestimmtes Formular zu binden. Diese Entkopplung bedeutet, dass ich selbst das Formular anzeigen muss, und bedeutet auch, dass bei Schließen des Formulars die Anwendung nicht mehr beendet wird. Deshalb muss ich auch dies explizit schreiben (siehe Abbildung 4). Ich nutze diese Gelegenheit auch, um meinen ersten Hook für die Initialisierung hinzuzufügen. "Initialize" ist eine Methode, die ich für meine Formularklasse erstelle, um ihr Logik für ihre Initialisierung hinzuzufügen, wie z. B. zum Abrufen von Daten aus einer Datenbank oder Website.
Abbildung 4: In "Program2" ist die Nachrichtenschleife nun von vom Hauptformular getrennt
private Program2()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
m_mainForm = new Form1();
m_mainForm.FormClosed += m_mainForm_FormClosed;
}
void m_mainForm_FormClosed(object sender, FormClosedEventArgs e)
{
Application.ExitThread();
}
public void Start()
{
m_mainForm.Initialize();
m_mainForm.Show();
Application.Run();
}
In der WPF-Version bestimmt der "StartupUri" der App, welches Fenster angezeigt wird, wenn "Run" aufgerufen wird. Die Definition finden Sie in der Markupdatei "App.xaml". Nicht überraschend ist, dass die für "Application" standardmäßige "ShutdownMode"-Einstellung "OnLastWindowClose" die Anwendung herunterfährt, nachdem alle WPF-Fenster geschlossen wurden, wodurch die Lebensdauern miteinander verbunden werden. (Dies ist ein Unterschied zu Windows Forms. Wenn in Windows Forms Ihr Hauptfenster als untergeordnetes Fenster geöffnet wird und Sie bloß das erste Fenster schließen, wird die Anwendung beendet. In WPF wird sie erst beendet, nachdem beide Fenster geschlossen wurden.)
Um dieselbe Trennung in WPF zu erreichen, entferne ich zunächst den "StartupUri" aus "App.xaml". Stattdessen erstelle ich das Fenster selbst, initialisiere es und zeige es an, bevor der Aufruf an "App.Run" erfolgt:
public void Start()
{
MainWindow mainForm = new MainWindow();
mainForm.Initialize();
mainForm.Show();
m_app.Run();
}
Wenn ich die Anwendung erstelle, lege ich "app.ShutdownMode" auf "ShutdownMode.OnExplicitShutdown" fest, wodurch die Lebensdauer der Anwendung von der der Fenster entkoppelt wird:
m_app = new App();
m_app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
m_app.InitializeComponent();
Um dieses explizite Herunterfahren zu erreichen, füge ich einen Ereignishandler für "MainWindow.Closed" an.
Freilich ist bei WPF das Trennen von Belangen einfacher zu bewerkstelligen, weshalb es sinnvoller ist, ein Ansichtsmodell anstelle des Fensters selbst zu initialisieren: Ich erstelle eine "MainViewModel"-Klasse, in der ich meine "Initialize"-Methode erstelle. Ebenso muss die Anforderung zum Schließen der App auch das Ansichtsmodell durchlaufen, weshalb ich dem Ansichtsmodell ein "CloseRequested"-Ereignis und eine entsprechende "RequestClose"-Methode hinzufüge. Die resultierende WPF-Version von "Program2" ist in Abbildung 5 zu sehen ("Main" bleibt unverändert und wird deshalb hier nicht gezeigt).
Abbildung 5: Die "Program2"-Klasse, Windows Presentation Foundation-Version
private readonly App m_app;
private Program2()
{
m_app = new App();
m_app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
m_app.InitializeComponent();
}
public void Start()
{
MainViewModel viewModel = new MainViewModel();
viewModel.CloseRequested += viewModel_CloseRequested;
viewModel.Initialize();
MainWindow mainForm = new MainWindow();
mainForm.Closed += (sender, e) =>
{
viewModel.RequestClose();
};
mainForm.DataContext = viewModel;
mainForm.Show();
m_app.Run();
}
void viewModel_CloseRequested(object sender, EventArgs e)
{
m_app.Shutdown();
}
Herausabstrahieren der Hostumgebung
Nachdem ich "Application.Run" von meinem Formular getrennt habe, möchte ich einen weiteren Architekturaspekt in den Griff bekommen. Derzeit ist "Application" umfassend in die "Program"-Klasse eingebettet. Diese Hostumgebung möchte ich nun gewissermaßen "herausabstrahieren". Ich entferne hierzu die verschiedenen Windows Forms-Methoden für "Application" aus meiner "Program"-Klasse, und belasse nur die Funktionalität, die sich auf das Programm selbst bezieht (siehe "Program3" in Abbildung 6). Ein letzter Schritt ist das Hinzufügen eines Ereignisses zur "Program"-Klasse, sodass die Verknüpfung zwischen dem Schließen des Formulars und Herunterfahren der Anwendung weniger direkt ist. Beachten Sie, dass "Program3" als Klasse keine Interaktion mit "Application" aufweist!
Abbildung 6: "Program3" kann nun mühelos an anderer Stelle eingefügt werden
private readonly Form1 m_mainForm;
private Program3()
{
m_mainForm = new Form1();
m_mainForm.FormClosed += m_mainForm_FormClosed;
}
public void Start()
{
m_mainForm.Initialize();
m_mainForm.Show();
}
public event EventHandler<EventArgs> ExitRequested;
void m_mainForm_FormClosed(object sender, FormClosedEventArgs e)
{
OnExitRequested(EventArgs.Empty);
}
protected virtual void OnExitRequested(EventArgs e)
{
if (ExitRequested != null)
ExitRequested(this, e);
}
Das Trennen der Hostumgebung hat mehrere Vorteile. Zum einen wird das Testen vereinfacht (Sie können nun "Program3" zu einem begrenzten Grad testen). Außerdem kann der Code an anderer Stelle leichter wiederverwendet und ggf. in eine größere Anwendung oder einen "Startprogramm"-Bildschirm eingebettet werden.
Die entkoppelte "Main"-Methode wird in Abbildung 7 gezeigt. Die "Application"-Logik habe ich wieder zurückverschoben. Dieses Design vereinfacht die Integration von WPF und Windows Forms und ggf. das schrittweise Ersetzen von Windows Forms durch WPF. Dieser Aspekt wird in diesem Artikel nicht behandelt, doch Sie können ein Beispiel einer gemischten Anwendung im begleitenden Onlinecode finden. Wie bei der vorherigen Umgestaltung ist dies zwar alles ganz nett, aber nicht unbedingt notwendig: Die Relevanz für die eigentliche Aufgabe besteht darin, dass der Ablauf der asynchronen Version natürlicher sein wird, was Sie bald sehen werden.
Abbildung 7: "Main" kann nun ein beliebiges Programm hosten
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Program3 p = new Program3();
p.ExitRequested += p_ExitRequested;
p.Start();
Application.Run();
}
static void p_ExitRequested(object sender, EventArgs e)
{
Application.ExitThread();
}
Die lang erwartete Asynchronität
Nun möchte ich Ihnen das Ergebnis präsentieren. Ich kann die "Start"-Methode asynchron gestalten, wodurch ich "await" nutzen und meine Initialisierungslogik asynchron machen kann. Gemäß Benennungskonvention habe ich "Start" in "StartAsync" und "Initialize" in "InitializeAsync" umbenannt. Ich habe auch den Rückgabetyp in einen asynchron "Task" geändert:
public async Task StartAsync()
{
await m_mainForm.InitializeAsync();
m_mainForm.Show();
}
Um diesen zu nutzen, wird "Main" wie folgt geändert:
static void Main()
{
...
p.ExitRequested += p_ExitRequested;
Task programStart = p.StartAsync();
Application.Run();
}
Um die Funktionsweise zu erläutern und ein kniffliges, aber wichtiges Problem zu lösen, muss ich detailliert untersuchen, was es mit "async/await" auf sich hat.
Die wahre Bedeutung von "await": Sehen Sie sich die "StartAsync"-Methode an, die ich präsentiert habe. Wichtig ist der Hinweis, dass wenn eine asynchrone Methode das Schlüsselwort "await" erreicht, ihre Rückkehr erfolgt. Der ausgeführte Thread wird wie bei Rückkehr einer jeden anderen Methode fortgesetzt. In diesem Fall erreicht die "StartAsync"-Methode "await m_mainForm.InitializeAsync" und kehrt zur "Main"-Methode zurück, die mit dem Aufruf von "Application.Run" fortgesetzt wird. Dies führt zu dem der Intuition widersprechenden Ergebnis, dass "Application.Run" wahrscheinlich vor "m_mainForm.Show" ausgeführt wird, obgleich es sequenziell nach "m_mainForm.Show" erfolgt. "async" und "await" machen die asynchrone Programmierung zwar einfacher, aber immer noch nicht leicht.
Aus diesem Grund geben asynchrone Methoden "Tasks" zurück. Es ist die Ausführung dieses Tasks, die das "Zurückkehren" der asynchronen Methode im intuitiven Sinne darstellt, nämlich wenn ihr gesamter Code ausgeführt wurde. Bei "StartAsync" bedeutet dies, dass sowohl "InitializeAsync" als auch "m_mainForm.Show" abgeschlossen sind. Und dies ist das erste Problem beim Verwenden von "async void": Ohne "Task"-Objekt gibt es für den Aufrufer einer "async void"-Methode keine Möglichkeit herauszufinden, wann die Ausführung abgeschlossen ist.
Wie und wann wird der Rest des Codes ausgeführt, wenn der Thread sich weiterbewegt hat und "StartAsync" bereits zum Aufrufer zurückgekehrt ist? Hier kommt "Application.Run" ins Spiel. "Application.Run" ist eine unendliche Schleife, die auf zu erledigende Aufgaben wartet, meist auf die Verarbeitung von UI-Ereignissen. Wenn Sie beispielsweise den Mauszeiger über das Fenster bewegen oder auf eine Schaltfläche klicken, wird das Ergebnis aus der "Application.Run"-Nachrichtenwarteschlange entfernt und als Antwort der entsprechende Code verteilt. Dann wird auf das nächste eingehende Ereignis gewartet. Es gibt aber keine strenge Begrenzung auf die Benutzeroberfläche: Sehen Sie sich "Control.Invoke" an, das eine Funktion im UI-Thread ausführt. "Application.Run" verarbeitet auch diese Anforderungen.
In diesem Fall wird nach Abschluss von "InitializeAsync" der Rest der "StartAsync"-Methode in diese Nachrichtenschleife gestellt. Bei Verwenden von "await" führt "Application.Run" den Rest der Methode im UI-Thread so aus, als hätten sie einen Rückruf mithilfe von "Control.Invoke" geschrieben. (Ob die Fortsetzung im UI-Thread erfolgt, wird von "ConfigureAwait" gesteuert. Mehr dazu erfahren Sie im Artikel von Stephen Cleary vom März 2013 zu bewährten Methoden bei der asynchronen Programmierung unter msdn.com/magazine/jj991977).
Das ist der Grund, warum das Trennen von "Application.Run" von "m_mainForm" so wichtig ist. "Application.Run" macht die ganze Arbeit: die Methode muss ausgeführt werden, um den Code hinter "await" zu verarbeiten, sogar ehe Sie tatsächlich etwas von der Benutzeroberfläche anzeigen. Wenn Sie beispielsweise versuchen, "Application.Run" aus "Main" heraus und zurück in "StartAsync" zu verschieben, wird das Programm sofort beendet: Sobald die Ausführung auf "await InitializeAsync" trifft, wird die Steuerung an "Main" zurückgegeben, und es gibt keinen weiteren auszuführenden Code, weshalb dies das Ende von "Main" ist.
Die erklärt auch, warum die Verwendung von "async" ganz von unten beginnen muss. Ein gängiges, aber nicht empfehlenswertes Antimuster ist das Aufrufen von "Task.Wait" anstatt von "await", da der Aufrufer keine asynchrone Methode ist, was aber höchstwahrscheinlich in einem Deadlock endet. Das Problem ist, dass der UI-Thread von diesem Aufruf von "Wait" blockiert wird und die Fortsetzung nicht verarbeiten kann. Ohne die Fortsetzung, wird der Task nicht abgeschlossen, weshalb der Aufruf von "Wait" nicht zurückkehrt. Die Folge: Deadlock!
"Await" und "Application.Run" – ein Huhn-und-Ei-Problem: Zuvor habe ich erwähnt, dass es ein kniffliges Problem gab. Ich habe erläutert, dass wenn Sie "await" aufrufen, das Standardverhalten das Fortsetzen der Ausführung im UI-Thread ist, also genau das, was ich hier brauche. Allerdings ist die Infrastruktur dafür noch nicht eingerichtet, wenn ich "await" erstmals aufrufe, da der dazugehörige Code noch nicht ausgeführt wurde!
"SynchronizationContext.Current" ist der Schlüssel zu diesem Verhalten: Beim Aufrufen von "await" erfasst die Infrastruktur den Wert von "SynchronizationContext.Current", mit dessen Hilfe die Fortsetzung im UI-Thread erfolgt. Der Synchronisierungskontext wird von Windows Forms oder WPF eingerichtet, wenn die Ausführung der Nachrichtenschleife begonnen wird. Innerhalb von "StartAsync" ist das noch nicht passiert: Wenn Sie "SynchronizationContext.Current" während des Beginns von "StartAsync" untersuchen, sehen Sie, dass der Wert NULL ist. Wenn es keinen Synchronisierungskontext gibt, übermittelt "await" die Fortsetzung stattdessen an den Threadpool, und da dies nicht der UI-Thread sein wird, funktioniert das Ganze nicht.
Die WPF-Version hängt sich gleich auf, doch wie sich herausstellt, funktioniert die Windows Forms-Version "zufälligerweise". Standardmäßig richtet Windows Forms den Synchronisierungskontext ein, nachdem das erste Steuerelement erstellt wurde, in diesem Fall wenn ich "m_mainForm" konstruiere (dieses Verhalten wird von "WindowsFormsSynchronizationContext.AutoInstall" gesteuert). Da "await InitializeAsync" nach dem Erstellen des Formulars erfolgt, ist alles in Ordnung. Müsste ich allerdings einen Aufruf von "await" vor der Erstellung von "m_mainForm" platzieren, hätte ich dasselbe Problem. Die Lösung besteht darin, dass ich den Synchronisierungskontext zu Beginn selbst wie folgt erstelle:
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
SynchronizationContext.SetSynchronizationContext(
new WindowsFormsSynchronizationContext());
Program4 p = new Program4();
... as before
}
Für WPF lautet der entsprechende Aufruf so:
SynchronizationContext.SetSynchronizationContext(
new DispatcherSynchronizationContext());
Ausnahmebehandlung
Fast geschafft! Doch am Stamm der Anwendung habe ich ein weiteres fortbestehendes Problem: Wenn "InitializeAsync" eine Ausnahme auslöst, kann das Programm diese nicht behandeln. Das Taskobjekt "programStart" enthält die Informationen zur Ausnahme, ohne dass eine entsprechende Behandlung erfolgt, weshalb meine Anwendung in einer Art Fegefeuer feststeckt. Wenn ich "await StartAsync" aufrufen könnte, ließe sich die Ausnahme in "Main" abfangen. Ich kann aber "await" nicht verwenden, da "Main" nicht asynchron ist.
Dies verdeutlicht das zweite Problem mit "async void": Es gibt keine Möglichkeit, von einer "async void"-Methode ausgelöste Ausnahmen ordnungsgemäß abzufangen, da der Aufrufer keinen Zugriff auf das Taskobjekt hat. (Wann also sollte ich "async void" verwenden? Herkömmliche Leitlinien besagen, dass "async void" hauptsächlich auf Ereignishandler begrenzt werden sollte. In dem zuvor erwähnten Artikel vom März 2013 wird dies auch erörtert. Ich empfehle die Lektüre, damit Sie "async/await" optimal einsetzen können.)
Unter normalen Umständen beschäftigt sich "TaskScheduler.UnobservedException" mit Tasks, die Ausnahmen auslösen, die anschließend nicht behandelt werden. Das Problem ist, dass die Ausführung nicht gewährleistet ist. In dieser Situation ist dies nahezu sicher nicht der Fall: der Taskplaner erkennt unbeobachtete Ausnahmen, wenn ein solcher Task abgeschlossen wurde. Der Abschluss erfolgt nur, wenn der Garbage Collector ausgeführt wird. Der Garbage Collector wird nur ausgeführt, wenn eine Anforderung nach mehr Arbeitsspeicher erfüllt werden muss.
Sie ahnen vielleicht schon, wohin das führt: In diesem Fall führt eine Ausnahme dazu, dass die Anwendung inaktiv bleibt, sodass sie nicht mehr Arbeitsspeicher anfordert, weshalb der Garbage Collector nicht ausgeführt wird. Schlussendlich hängt sich die Anwendung auf. Dies ist auch der Grund, warum sich die WPF-Version aufhängt, wenn Sie den Synchronisierungskontext nicht angeben: der WPF-Fensterkonstruktur löst eine Ausnahme aus, da ein Fenster für einen Nicht-UI-Thread erstellt wird, woraufhin diese Ausnahme unbehandelt bleibt. Ein letzter Punkt ist der Umgang mit dem "programStart"-Task und das Hinzufügen einer Fortsetzung, die bei einem Fehler ausgeführt wird. In diesem Fall ist das Beenden sinnvoll, wenn sich die Anwendung nicht selbst initialisieren kann.
Ich kann "await" nicht in "Main" verwenden, da es nicht asynchron ist. Aber ich kann eine neue asynchrone Methode allein zum Zweck der Verfügbarmachung (und Behandlung) von Ausnahmen erstellen, die während des asynchronen Starts ausgelöst werden: Die Methode besteht nur aus der Angabe von "try" und "catch" um das Schlüsselwort "await" herum. Da diese Methode alle Ausnahmen behandelt und keine neuen auslöst, handelt es um einen weiteren der begrenzten Fälle, bei denen "async void" sinnvoll ist:
private static async void HandleExceptions(Task task)
{
try
{
await task;
}
catch (Exception ex)
{
...log the exception, show an error to the user, etc.
Application.Exit();
}
}
In "Main" sieht die Verwendung so aus:
Task programStart = p.StartAsync();
HandleExceptions(programStart);
Application.Run();
Freilich gibt es wie gewöhnlich ein kniffliges Problem (wenn "async/await" die Dinge vereinfacht, können Sie sich vorstellen, wie schwierig es vorher war). Zuvor habe ich erwähnt, dass wenn eine asynchrone Methode einen Aufruf an "await" richtet, sie üblicherweise zurückkehrt, und dass der Rest dieser Methode als Fortsetzung ausgeführt wird. Mitunter kann der Task jedoch synchron abgeschlossen werden. Ist das der Fall, wird die Ausführung des Codes nicht unterbrochen, was ein Leistungsvorteil ist. Falls dies hier geschieht, bedeutet dies allerdings, dass die "HandleExceptions"-Methode vollständig ausgeführt wird und anschließend zurückkehrt. "Application.Run" folgt dann im Anschluss: Wenn in diesem Fall eine Ausnahme auftritt, erfolgt der Aufruf von "Application.Exit" nun vor dem Aufruf von "Application.Run", und zwar ohne jegliche Auswirkung.
Was ich möchte, ist "HandleExceptions" zur Ausführung als Fortsetzung zu zwingen: Ich muss sicherstellen, dass ich bis "Application.Run" fortfahre, ehe andere Schritte erfolgen. Wenn anschließend eine Ausnahme auftritt, weiß ich, dass "Application.Run" bereits ausgeführt wird und dass "Application.Exit" für eine ordnungsgemäße Unterbrechung sorgt. "Task.Yield" macht genau das: Der aktuelle asynchrone Codepfad wird gezwungen, seinem Aufruf Vorfahrt zu gewähren, und dann als Fortsetzung wiederaufgenommen zu werden.
Hier die Korrektur an "HandleExceptions":
private static async void HandleExceptions(Task task)
{
try
{
// Force this to yield to the caller, so Application.Run will be executing
await Task.Yield();
await task;
}
...as before
Wenn ich in diesem Fall "await Task.Yield" aufrufe, kehrt "HandleExceptions" zurück, und "Application.Run" wird ausgeführt. Der Rest von "HandleExceptions" wird dann als Fortsetzung an den aktuellen "SynchronizationContext" übermittelt, was bedeutet, dass er von "Application.Run" ausgewählt wird.
Im Übrigen denke ich, dass "Task.Yield" ein guter Lackmustest für das Verständnis von "async/await" ist: Wenn Sie den Zweck von "Task.Yield" verstehen, sind Sie wahrscheinlich auch umfassend mit der Funktionsweise von "async/await" vertraut.
Der Nutzen
Nun da alles läuft, wollen wir uns ein bisschen Spaß gönnen: Ich zeige Ihnen, wie einfach es ist, einen reaktionsfähigen Begrüßungsbildschirm hinzuzufügen, ohne diesen in einem getrennten Thread auszuführen. Spaß hin oder her, ein Begrüßungsbildschirm ist relativ wichtig, wenn Ihre Anwendung nicht gleich "startet": Wenn der Benutzer Ihre Anwendung startet und mehrere Sekunden nichts passiert, ist von guter Benutzererfahrung keine Rede.
Das Starten eines getrennten Threads für einen Begrüßungsbildschirm ist ineffizient und außerdem ungeschickt, da Sie die Aufrufe zwischen Threads ordnungsgemäß marshallen müssen. Das Anbieten von Statusinformationen auf dem Begrüßungsbildschirm ist deshalb schwierig, und selbst für das Schließen ist ein Aufruf von "Invoke" o. ä. erforderlich. Wenn der Begrüßungsbildschirm schließlich geschlossen wird, wird der Eingabefokus darüber hinaus meist nicht ordnungsgemäß zum Hauptformular verschoben, denn es ist nicht möglich, den Besitz zwischen dem Begrüßungsbildschirm und Hauptformular festzulegen, wenn sich diese in verschiedenen Threads befinden. Vergleichen Sie dies mit der Einfachheit der asynchronen Version in Abbildung 8.
Abbildung 8: Hinzufügen eines Begrüßungsbildschirms zu "StartAsync"
public async Task StartAsync()
{
using (SplashScreen splashScreen = new SplashScreen())
{
// If user closes splash screen, quit; that would also
// be a good opportunity to set a cancellation token
splashScreen.FormClosed += m_mainForm_FormClosed;
splashScreen.Show();
m_mainForm = new Form1();
m_mainForm.FormClosed += m_mainForm_FormClosed;
await m_mainForm.InitializeAsync();
// This ensures the activation works so when the
// splash screen goes away, the main form is activated
splashScreen.Owner = m_mainForm;
m_mainForm.Show();
splashScreen.FormClosed -= m_mainForm_FormClosed;
splashScreen.Close();
}
}
Zusammenfassung
Ich habe erläutert, wie Sie ein objektorientiertes Design auf den Startcode Ihrer Anwendung (entweder Windows Forms oder WPF) anwenden, damit diese mühelos die asynchrone Initialisierung unterstützen kann. Ich habe außerdem gezeigt, wie Sie einige knifflige Probleme in den Griff bekommen, die von einem asynchronen Startprozess verursacht werden können. Damit Ihre Initialisierung tatsächlich asynchron erfolgt, müssen Sie leider ohne meine Hilfe auskommen, doch unter msdn.com/async finden Sie Anleitungen.
Das Ermöglichen der Verwendung von "async" und "await" ist nur der Anfang. Da die "Program"-Klasse nun objektorientierter ist, können andere Features einfacher implementiert werden. Ich kann Befehlszeilenargumenten durch Aufrufen einer entsprechenden Methode für die "Program"-Klasse verarbeiten. Ich kann veranlassen, dass sich der Benutzer anmeldet, ehe das Hauptfenster angezeigt wird. Ich kann die App im Infobereich starten, ohne beim Start ein Fenster anzuzeigen. Wie üblich bietet ein objektorientiertes Design die Gelegenheit, Funktionalität in Ihrem Code zu erweitern und wiederzuverwenden.
Mark Sowulist vielleicht sogar eine in C# geschriebene Softwaresimulation (worüber spekuliert wird). Als begeisterter .NET-Entwickler seit den Anfängen teilt Sowul sein breites Wissen zur Architektur und Leistung von .NET und Microsoft SQL Server über sein in New York ansässiges Beratungsunternehmen SolSoft Solutions. Sie können Ihn unter mark@solsoftsolutions.com erreichen und sich für seine gelegentlichen E-Mails unter dem Motto "Software Insights" unter eepurl.com/_K7YD registrieren.
Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Stephen Cleary und James McCaffrey