Eine alternative Implementierung der Klasse BackgroundWorker erlaubt es, in ein und derselben Funktion beliebig zwischen UI- und Worker-Thread hin und her zu wechseln. Die verblüffende Implementierung führt zu sehr übersichtlichem Code ganz ohne Callbacks und Delegates.
Schon seit .NET 1.0 leistet die Klasse System.ComponentModel.BackgroundWorker gute Dienste, wenn es darum geht, zeitintensive Funktionen von einem Hintergrund-Thread ausführen zu lassen, damit das Userinterface nicht vorübergehend eingefroren wird. Über die drei Events DoWork, ProgressChanged und RunWorkerCompleted kann man sich über den Fortschritt der Ausführung informieren lassen. So weit, so gut. Wird die Aufgabenstellung komplizierter, beispielsweise weil man während der Hintergrundausführung die Oberfläche mit den bisher berechnenden Zwischenergebnissen aktualisieren oder mehrere Aufgaben hintereinander ausführen möchte und dabei ggf. Fehlermeldungen anzeigen möchte, wird der benötigte Code schnell recht unübersichtlich. Das liegt daran, dass man bei den meisten nicht trivialen Problemen immer eine einfache State Machine (einen Endlichen Automaten) bauen muss, welche(r) die Aktionen des UI-Threads und des Worker-Threads synchronisiert. Die benötigten Delegates, Lambda-Expressions und Synchronization-Contexte führen schnell zu schwer lesbarem und somit auch schwer wartbarem Code.
Wäre es nicht schön, wenn man ganz einfach sequenziellen Code und einfache Schleifen in einer übersichtlichen Funktion „einfach so“ hinschreiben könnte und dabei für einzelne Codeabschnitte jeweils angeben könnte, ob sie im UI-Thread oder im Background-Thread ausgeführt werden sollen? Wäre nett, geht aber nicht, hätte ich gesagt, bevor ich letztes Jahr den Kurzvortrag von Ralf Hoffmann bei einem Treffen der Bonner .NET Usergroup gesehen habe. Ralf verwendet den yield-Befehl für eine erstaunliche Lösung des beschriebenen Problems. Da Ralf keinen Blog betreibt, der Ansatz aber sehr nützlich ist, möchte ich seine Idee hier vorstellen.
Beispielanwendung sdddd
Zur Demonstration des Verfahrens nehmen wi xxxr an, wir wollen irgendwelche Items berechnen und eine Listbox damit füllen. Das Berechnen eines jeden Items dauert so lange, dass es in einem Background-Thread ausgeführt werden muss. Jedes Item soll jedoch sofort angezeigt werden, wenn es berechnet wurde und nicht erst am Ende des Vorgangs, wenn alle Ergebnisse vorliegen. Außerdem soll die Berechnung über das UI abgebrochen werden können, wenn es dem Anwender zu lange dauert.
Hier das Beispiel als Silverlight-Anwendung. Die Berechnung jedes Items dauert eine knappe Sekunde und lastet dabei den Worker-Thread voll aus, wie ein Blick in den Task-Manager zeigen kann. Der Browser und die Silverlight-Anwendung werden davon aber nicht blockiert. Der Vorgang kann jederzeit über einen Button abgebrochen und wieder neu gestartet werden.
Die zugehörige Worker-Funktion aus der Codebehind-Datei sieht wie folgt aus:
/// <summary>
/// A simple function that gets alternating executed by
/// the UI-thread and a background-thread.
/// </summary>
IEnumerator<SwitchTo> MyWorkerFunktion(string someParameter, int someOtherParameter)
{
// At start we run in the background thread, so switch to UI-thread
// to update the StartStopButton and clear the ListBox
yield return SwitchTo.UIThread;
StartStopButton.Content = "Stop";
Listbox.Items.Clear();
// Now switch to background thread and start the loop
yield return SwitchTo.BackgroundThread;
for (int idx = 1; idx <= 25; idx++)
{
// Get the next item
string item = DoSomeHardWorkToGetAnItem(idx);
// Switch to UI-thread to update the list-box
yield return SwitchTo.UIThread;
Listbox.Items.Add(item);
// Check if user pressed the Stop button
if (_stop)
{
Listbox.Items.Add("<Operation was canceled by user>");
_stop = false;
break;
}
// Switch back to background thread for next loop iteration
yield return SwitchTo.BackgroundThread;
}
// We have done and reset Start/Stop button in UI-thread
yield return SwitchTo.UIThread;
StartStopButton.Content = "Start";
}
Unglaublich, oder? Über den Enum-Typ SwitchTo wird im yield return angegeben, ob der nächste Codeabschnitt im UI- oder im Background-Thread ausgeführt werden soll. Der Code ist praktisch selbstdokumentierend und sehr gut lesbar.
Aufgerufen wird diese Funktion beim Drücken des Start-Buttons wie folgt:
_smartBackgroundWorker.RunWorkerAsync(MyWorkerFunktion("Hello", 42));
Die Parameter der Woker-Funktion werden im Beispiel nicht gebraucht und sollen nur andeuten, dass man ohne weiteres beliebige Parameter übergeben kann.
Warum funktioniert das?
Um den SmartBackgroundWorker zu verwenden, muss man nicht in allen Einzelheiten verstehen, warum bzw. wie er funktioniert. Für Interessierte hier eine knappe Zusammenfassung der internen Struktur.
Der in C# 2 eingeführte yield-Befehl kann in Funktionen verwendet werden, die IEnumerator als Rückgabewert haben. Zu einer solchen Funktion generiert der Compiler eine passende private Klasse, die u.a. von IEnumerator abgeleitet ist und daher auch die Funktion MoveNext implementieren muss. Der ursprüngliche Code mit den yield return-Anweisungen wird nun vom Compiler in eine State Machine konvertiert, die beim Aufruf von MoveNext jeweils in den nächsten Zustand übergeht. Als Effekt davon führt ein Durchlaufen des Iterators mit MoveNext dazu, dass jeweils genau die Statements bis zum nächsten (ursprünglichen) yield return ausgeführt werden (bzw. der Durchlauf beendet wird). Der generierte Code von MoveNext kann dabei sehr umfangreich und verworren werden, wie ein Blick darauf mit dem .NET Reflector zeigt. Diese Komplexität stört aber nicht weiter, da der Code ja vom Compiler unsichtbar und zuverlässig generiert wird.
Durch den Einsatz von yield return haben wir also erreicht, dass der Code unserer Worker-Funktion abschnittsweise ausgeführt wird. Wir müssen nun nur noch dafür sorgen, dass zwischen den einzelnen Aufrufen ggf. der ausführende Thread gewechselt wird. Dies wird innerhalb von SmartBackgroundWorker in der Funktion OnRun durchgeführt.
/// <summary>
/// Steps through the worker function.
/// </summary>
void OnRun(object argument)
{
try
{
SwitchTo context = SwitchTo.BackgroundThread;
var enumerator = (IEnumerator<SwitchTo>)argument;
bool moveNext = true;
SendOrPostCallback nextStep = obj =>
{
moveNext = enumerator.MoveNext();
if (moveNext)
context = enumerator.Current;
};
while (moveNext)
{
if (context == SwitchTo.UIThread)
{
// Run next step synchronously on UI thread
_uiSynchronizationContext.Send(nextStep, null);
}
else
{
// Run next step on background thread
nextStep(null);
}
}
}
finally
{
_isRunning = false;
}
}
Ein SmartBackgroundWorker merkt sich im Constructor den SynchronizationContext des UI-Threads und muss daher immer im UI-Thread angelegt werden. Beim Start der Worker-Funktion über RunWorkerAsync wird diese über ein Delegate an einen Thread aus dem ThreadPool gebunden. Bis hierher ist die Implementierung identisch mit dem .NET Typ BackgroundWorker. Der Thread aus dem Pool startet dann in obigem OnRun, die den Unterschied zu BackgroundWorker ausmacht.
In der Variablen nextStep wird der Code zum Durchlaufen des nächsten Iterator-Schritts gespeichert. Die Variable context speichert den Rückgabewert von yield return, also entweder UIThread oder BackgroundThread.
In der while-Schleife wird über den Wert von context unterschieden, in welchem Thread der nächste Codeabschnitt aufgerufen werden soll. Ist es der Background-Thread, wird nextStep direkt aufgerufen, denn OnRun läuft ja bereits im Worker-Thread. Ist es der UI-Thread, wird nextStep mit der Funktion Send von SynchronizationContext an den UI-Thread „gesendet“. Technisch passiert dabei folgendes: Durch Send wird eine Art „spezielles Event“ in die Event-Queue des UI-Threads eingereiht. Ist dieses Event an der Reihe, wird der Code in nextStep durch den UI-Thread ausgeführt und somit auch der nächste Abschnitt in unserer Worker-Funktion. Bis diese Ausführung abgeschlossen ist, blockiert der Background-Thread, d.h. der Funktionsaufruf von Send kommt erst dann zurück, wenn nextStep vom UI-Thread vollständig ausgeführt wurde.
Mit etwas Abstand betrachtet liegt also die Innovation von SmartBackgroundWorker in Folgendem: Anstatt einen normalen BackgroundWorker zu verwenden und selber eine State Machine zu bauen, die zwischen dem UI-Thread und einem Background-Thread jongliert, schreibt man relativ linearen Code zusammen mit yield return und lässt den Compiler die passende State Machine generieren.
Coole Sache. Wie schon gesagt, ich habe es nicht erfunden, sondern meine Implementierung nur aus Code von Ralf Hoffmann abgeleitet, der wiederum von einem Screencast von Jeffrey Richter inspiriert wurde (vermutlich diesem hier).
Bewertung
Der Einsatz von SmartBackgroundWorker ist nicht auf Silverlight beschränkt, sondern funktioniert mit WPF oder WinForms genauso. Ich habe nur deshalb Silverlight verwendet, damit ich die Demo direkt in diesen Artikel im Blog einbauen kann.
Im letzten Jahr haben wir den SmartBackgroundWorker in verschiedenen Silverlight-Anwendungen verwendet und er hat sich als sehr nützlich erwiesen. Hier meine persönliche pro/contra-Liste.
Vorteile
- Keine zusätzlichen Eventhandler, Delegates oder Lambdas notwendig
- Führt zu übersichtlichem und intuitiv nachvollziehbarem Code
- Worker-Funktionen lassen sich sehr leicht verketten bzw. ineinander verschachteln
Nachteile
- Exception-Handling in der Worker-Funktion kann unübersichtlich werden, da yield nicht innerhalb von try/catch verwendet werden kann
- In VB.NET nicht verwendbar, da diese Sprache nicht über ein yield-Statement verfügt (was aber eher eine Einschränkung der Sprache ist)
Insgesamt kann ich den SmartBackgroundWorker sehr empfehlen.
Fazit
Man kann sich natürlich fragen, ob das Ganze wirklich eine gute Idee oder nur ein Hack ist, da ja der yield-Befehl für etwas missbraucht wird, für das er nicht erfunden wurde. Meiner Meinung nach ist es eine sehr gute Idee, denn es ist eine saubere Lösung für ein konkretes Problem. Und dies ist das Einzige, worauf es letztlich ankommt, wenn man den Nutzen einer Idee bewerten will. Und im Vergleich zu anderen Innovationen finde ich die Schreibweise der Worker-Funktion sogar sehr elegant. Die Parallel-Extensions von .NET 4.0 sind beispielsweise auch sehr nützlich und innovativ, aber der Code, den man teilweise schreiben muss, ist doch arg gewöhnungsbedürftig. Aber dies ist ein anderes Thema.
Hier der Quellcode zum Downloaden:
SmartBackgroundWorker.zip (7,77 kB)