Veröffentlicht: 22. Nov 2002 | Aktualisiert: 21. Jun 2004
Von Chris Sells
Auf dieser Seite
Windows Forms und Hintergrundverarbeitung
Abbrechen
Kommunikation mit gemeinsam genutzten Daten
Kommunizieren mit Methodenparametern
Schlussfolgerung
Referenzmaterial
Wie Sie vielleicht noch aus einem meiner früheren Artikel, Safe, Simple Multithreading in Windows Forms (in Englisch), wissen, können Sie bei der gemeinsamen Verwendung von Windows Forms und Threading gute Ergebnisse erzielen, wenn Sie vorsichtig sind. Threading eignet sich optimal zum Durchführen langwieriger Operationen, z.B. zum Berechnen der Zahl Pi mit einer großen Anzahl von Stellen, wie unten in Abbildung 1 gezeigt.
Abbildung 1. Anwendung zur Berechnung der Stellen von Pi
Windows Forms und Hintergrundverarbeitung
Im letzten Artikel haben wir untersucht, wie Threads direkt für die Hintergrundverarbeitung gestartet werden können. Allerdings haben wir uns darauf beschränkt, asynchrone Delegaten zu verwenden, um den Arbeitsthread zu starten. Asynchrone Delegaten bieten den Vorteil, eine Syntax für die Übergabe von Argumenten bereitzustellen und lassen sich besser skalieren, indem Threads aus einem prozessweiten und von der Common Language Runtime (CLR) verwalteten Pool abgerufen werden. Das einzige echte Problem trat erst auf, als der Arbeitsthread den Benutzer über den Fortschritt benachrichtigen wollte. In diesem Fall war es nicht erlaubt, direkt mit den Benutzeroberflächen-Steuerelementen zu arbeiten (ein langjähriges Tabu der Win32®-Benutzeroberfläche). Stattdessen muss der Arbeitsthread eine Nachricht an den Benutzeroberflächenthread senden, oder übergeben und hierfür Control.Invoke oder Control.BeginInvoke verwenden. Damit kann der Code in dem Thread ausgeführt werden, der die Steuerelemente besitzt. Diese Überlegungen führten zu folgendem Code:
// Delegieren, um mit der asynchronen Berechnung von Pi zu beginnen
delegate void CalcPiDelegate(int digits);
void _calcButton_Click(object sender, EventArgs e) {
// Asynchrone Berechnung von Pi beginnen
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
calcPi.BeginInvoke((int)_digits.Value, null, null);
}
void CalcPi(int digits) {
StringBuilder pi = new StringBuilder("3", digits + 2);
// Fortschritt anzeigen
ShowProgress(pi.ToString(), digits, 0);
if( digits > 0 ) {
pi.Append(".");
for( int i = 0; i < digits; i += 9 ) {
...
// Fortschritt anzeigen
ShowProgress(pi.ToString(), digits, i + digitCount);
}
}
}
// Delegieren, um Benutzeroberflächenthread über den Fortschritt des Arbeitsthreads zu
benachrichtigen
delegate
void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);
void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
// Sicherstellen, dass der Thread korrekt ist
if( _pi.InvokeRequired == false ) {
_pi.Text = pi;
_piProgress.Maximum = totalDigits;
_piProgress.Value = digitsSoFar;
}
else {
// Fortschritt synchron anzeigen
ShowProgressDelegate showProgress =
new ShowProgressDelegate(ShowProgress);
this.BeginInvoke(showProgress,
new object[] { pi, totalDigits, digitsSoFar });
}
}
Beachten Sie, dass wir über zwei Delegaten verfügen. Der erste, CalcPiDelegate, wird zum Bündeln der Argumente verwendet, die an CalcPi im Arbeitsthread übergeben werden sollen, dieser wird vom Threadpool zugewiesen. Im Ereignishandler wird eine Instanz dieses Delegaten erstellt, wenn der Benutzer sich entscheidet, die Zahl Pi zu berechnen. Durch Aufruf von BeginInvoke wird die Arbeit über eine Warteschlange an den Threadpool übermittelt. Der erste Delegat sorgt im Wesentlichen dafür, dass der Benutzeroberflächenthread eine Nachricht an den Arbeitsthread übermittelt.
Der zweite Delegat, ShowProgressDelegate, wird vom Arbeitsthread verwendet, wenn dieser eine Nachricht zurück an den Benutzeroberflächenthread senden möchte. Hierbei kann es sich insbesondere um eine Nachricht handeln, die über den Fortschritt der langwierigen Operation informiert. Um den Aufrufern die Einzelheiten einer threadsicheren Kommunikation mit dem Benutzeroberflächenthread zu ersparen, verwendet die ShowProgress-Methode den Delegaten ShowProgressDelegate, um über die Control.BeginInvoke-Methode im Benutzeroberflächenthread eine Nachricht an sich selbst zu senden. Die Control.BeginInvoke-Methode übergibt die Arbeit asynchron über eine Warteschlange an den Benutzeroberflächenthread und fährt dann fort, ohne auf das Ergebnis zu warten.
Abbrechen
In diesem Beispiel können wir unbesorgt Nachrichten zwischen den Arbeits- und Benutzeroberflächenthreads hin und her schicken. Der Benutzeroberflächenthread muss nicht warten, bis der Arbeitsthread fertig ist oder sogar bis er eine Benachrichtigung darüber erhält, dass der Arbeitsthread fertig ist. Der Arbeitsthread ist während seiner Ausführung selbst über seinen Fortschritt informiert. Entsprechend muss der Arbeitsthread nicht darauf warten, dass der Benutzeroberflächenthread seinen Fortschritt anzeigt, sofern regelmäßig Fortschrittsnachrichten gesendet werden, damit die Benutzer zufrieden sind. Es gibt jedoch eine Sache, mit der die Benutzer nicht zufrieden sind - keine vollständige Kontrolle über die Verarbeitungsvorgänge in ihren Anwendungen zu besitzen. Obwohl die Benutzeroberfläche bei der Berechnung von Pi reagiert, möchten die Benutzer ggf. dennoch die Möglichkeit haben, die Berechnung abzubrechen, wenn sie bei der Eingabe einen Fehler gemacht haben und nicht 1.000.000, sondern 1.000.001 Stellen berechnen möchten. Abbildung 2 zeigt eine aktualisierte Benutzeroberfläche für CalcPi, die einen Abbruch der Berechnung ermöglicht.
Abbildung 2. Der Benutzer kann eine langwierige Operation abbrechen
Das Implementieren der Option zum Abbrechen von langwierigen Operationen ist ein mehrere Schritte umfassender Prozess. Zunächst muss für den Benutzer eine Benutzeroberfläche bereitgestellt werden. In diesem Beispiel wurde die Schaltfläche Berechnen nach dem Start der Berechnung in die Schaltfläche Abbrechen geändert. Häufig wird auch eine Fortschrittsanzeige verwendet. Diese enthält i.d.R. aktuelle Fortschrittsdetails, einschließlich einer Fortschrittsleiste, die den Prozentsatz der abgeschlossenen Arbeit angibt, sowie einer Schaltfläche Abbrechen.
Wenn der Benutzer den Vorgang abbrechen möchte, sollte dies in einer Membervariable festgehalten werden. Die Benutzeroberfläche sollte für die kurze Zeitspanne ab dem Zeitpunkt deaktiviert werden, zu dem der Benutzeroberflächenthread weiß, dass der Arbeitsthread beendet werden soll. Dies soll geschehen noch bevor der Arbeitsthread selbst es weiß und die Möglichkeit hat, das Senden der Fortschrittsinformationen zu beenden. Wenn diese Zeitspanne ignoriert wird, kann der Benutzer eine weitere Operation starten. Bevor jedoch der erste Arbeitsprozess das Senden der Fortschrittsinformationen beendet, so dass der Benutzeroberflächenthread selbst herausfinden muss, ob die Fortschrittsinformationen vom neuen oder vom alten Arbeitsthread stammen, der eigentlich beendet werden soll.
Es ist natürlich möglich, jedem Arbeitsthread eine eindeutige ID zuzuweisen, damit der Benutzeroberflächenthread den Ablauf organisieren kann (und in Anbetracht gleichzeitiger langwieriger Operationen ist dies ggf. unumgänglich). Oft ist es jedoch einfacher, die Benutzeroberfläche für die kurze Zeitspanne ab dem Zeitpunkt, zu dem die Benutzeroberfläche weiß, dass der Arbeitsthread beendet wird, und bevor der Arbeitsthread dies weiß, zu deaktivieren. Zur Implementierung dieses Ansatzes für unseren einfachen Rechner muss lediglich eine dreiwertige Enum-Variable verwendet werden, wie im Folgenden veranschaulicht:
enum CalcState {
Pending, // Berechnung wird nicht ausgeführt oder abgebrochen
Calculating, // Berechung wird ausgeführt
Canceled, // Berechnung wird auf der Benutzeroberfläche, aber nicht im Arbeitsthread
abgebrochen
}
CalcState _state = CalcState.Pending;
Jetzt wird die Schaltfläche Berechnen je nach aktuellem Status unterschiedlich behandelt. Dies wird im Folgenden veranschaulicht:
void _calcButton_Click( ) {
// Berechnen-Schaltfläche hat keine Doppelfunktion als Abbrechen-Schaltfläche
switch( _state ) {
// Neue Berechnung starten
case CalcState.Pending:
// Abbrechen zulassen
_state = CalcState.Calculating;
_calcButton.Text = "Cancel";
// Asynchrone Delegatmethode
CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
calcPi.BeginInvoke((int)_digits.Value, null, null);
break;
// Laufende Berechnung abbrechen
case CalcState.Calculating:
_state = CalcState.Canceled;
_calcButton.Enabled = false;
break;
// Die Berechnen-Schaltfläche sollte nicht aktiviert werden können,
wenn die Berechnung abgebrochen wurde
case CalcState.Canceled:
Debug.Assert(false);
break;
}
}
Beachten Sie, dass beim Klicken auf die Schaltfläche Berechnen/Abbrechen im Status Pending der Status an Calculating gesendet (und die Beschriftung der Schaltfläche geändert) und die Berechnung wie zuvor asynchron gestartet wird. Ist der Status Calculating, dann klicken wir die Schaltfläche Berechnen/Abbrechen, somit können wir den Status in Canceled ändern und die Benutzeroberfläche deaktivieren. Es ist nun möglich eine neue Berechnung zu starten, und zwar so lange, bis der Status Canceled an den Arbeitsthread übermittelt wurde. Nachdem dem Arbeitsthread mitgeteilt wurde, dass die Operation abgebrochen werden soll, können wir die Benutzeroberfläche wieder aktivieren und den Status auf Pending zurücksetzen. Danach kann der Benutzer eine andere Operation starten. Um dem Arbeitsthread mitzuteilen, dass er beendet werden soll, können wir die ShowProgress-Methode erweitern und ihr einen neuen out-Parameter hinzufügen:
void ShowProgress(..., out bool cancel)
void CalcPi(int digits) {
bool cancel = false;
...
for( int i = 0; i < digits; i += 9 ) {
...
// Fortschritt anzeigen (auf Abbrechen prüfen)
ShowProgress(..., out cancel);
if( cancel ) break;
}
}
Es ist vielleicht verlockend, den booleschen Rückgabewert von ShowProgress als Abbruchindikator zu verwenden, aber ich kann mir nie merken, ob TRUE bedeutet, dass ein Abbruch erfolgen soll oder dass alles in Ordnung ist und der Vorgang fortgesetzt werden kann. Daher verwende ich das Verfahren, bei dem der out-Parameter herangezogen wird, denn da bringe selbst ich nichts durcheinander.
Jetzt muss nur noch die ShowProgress-Methode aktualisiert werden. Hierbei handelt es sich um den Code, der tatsächlich den Übergang zwischen Arbeitsthread und Benutzeroberflächenthread durchführt, um festzustellen, ob der Benutzer die Operation abbrechen möchte, und CalcPi entsprechend zu informieren. Die genaue Art und Weise, in der wir die Informationen zwischen dem Benutzeroberflächenthread und dem Arbeitsthread übermitteln, hängt von dem Verfahren ab, das wir verwenden möchten.
Kommunikation mit gemeinsam genutzten Daten
Die nahe liegendste Möglichkeit, den aktuellen Status der Benutzeroberfläche zu übermitteln, besteht darin, den Arbeitsthread direkt auf die _state-Membervariable zugreifen zu lassen. Hierzu können wir folgenden Code verwenden:
void ShowProgress(..., out bool cancel) {
// Machen Sie das nicht!
if( _state == CalcState.Cancel ) {
_state = CalcState.Pending;
cancel = true;
}
...
}
Ich hoffe, dass Sie beim Anblick des Codes zurückgeschreckt sind (und nicht nur wegen der Warnung). Wenn Sie eine Multithreadprogrammierung durchführen, müssen Sie stets beachten, dass zwei Threads gleichzeitig auf dieselben Daten zugreifen können (in diesem Fall auf die _state-Membervariable). Durch den gemeinsamen Zugriff auf Daten geraten die Threads in eine Wettkampfsituation, in der ein Thread schnellstmöglich Daten liest, die nur teilweise aktualisiert sind, bevor ein anderer Thread die Daten vollständig aktualisiert. Damit der gleichzeitige Zugriff auf gemeinsam genutzte Daten funktioniert, müssen Sie die Verwendung von gemeinsam genutzten Daten überwachen, um sicherzustellen, dass jeder Thread geduldig wartet, wenn der andere Thread auf die Daten zugreift. Um den Zugriff auf gemeinsam genutzte Daten zu überwachen, stellt .NET die Monitor-Klasse bereit, die bei einem gemeinsam verwendeten Objekt als Sperre für die Daten verwendet werden kann und C# in einen nützlichen Sperrblock einbindet:
object _stateLock = new object();
void ShowProgress(..., out bool cancel) {
// Machen Sie auch dies nicht!
lock( _stateLock ) { // Sperre überwachen
if( _state == CalcState.Cancel ) {
_state = CalcState.Pending;
cancel = true;
}
...
}
}
Jetzt haben wir den Zugriff auf gemeinsam genutzte Daten ordnungsgemäß gesperrt. Ich bin dabei so vorgegangen, dass vermutlich ein anderes und bei der Multithreadprogramming häufig vorkommendes Problem auftreten wird - ein Deadlock. Wenn zwei Threads gesperrt werden, warten beide Threads, bis der jeweils andere Thread seine Aufgabe abgeschlossen hat. Das heißt, dass keiner der beiden Threads mit der Verarbeitung fortfahren kann.
Hat dieses Gerede über Wettkampfsituationen und Deadlocks Sie beunruhigt? Das war meine Absicht. Multithreadprogrammierung mit gemeinsam genutzten Daten ist ein hartes Stück Arbeit. Bisher konnten wir diese Probleme vermeiden, da wir Kopien von Daten übermittelt haben, für die jeder Thread vollständige Besitzrechte erhält. Ohne gemeinsam genutzte Daten ist keine Synchronisierung erforderlich. Wenn der Zugriff auf gemeinsam genutzte Daten erforderlich ist (das heißt, das Kopieren der Daten sehr zeitaufwändig ist oder zu hohen Speicherplatz erfordert), müssen Sie sich mit der gemeinsamen Nutzung von Daten zwischen Threads befassen (im Abschnitt "Referenzmaterial" sind nützliche Dokumentationen zu diesem Thema aufgeführt).
Die meisten Multithreadingszenarios, insbesondere in Bezug auf Benutzeroberflächen-Multithreading, funktionieren anscheinend am besten mit dem bisher von uns verwendeten einfachen Schema zur Nachrichtenübermittlung. In den meisten Fällen ist es nicht erwünscht, dass die Benutzeroberfläche auf die Daten zugreifen kann, die im Hintergrund verarbeitet werden (z.B. das Dokument, das gedruckt wird, oder die Objekte, die aufgelistet werden). In diesen Fällen sind gemeinsam genutzte Daten die beste Wahl.
Kommunizieren mit Methodenparametern
Wir haben die ShowProgress-Methode bereits um einen out-Parameter erweitert. Warum lassen wir die ShowProgress-Methode nicht den Status der _state-Variable prüfen, wenn sie im Benutzeroberflächenthread ausgeführt wird? Dies wird im Folgenden veranschaulicht:
void ShowProgress(..., out bool cancel) {
// Sicherstellen, dass dies der Benutzeroberflächenthread ist
if( _pi.InvokeRequired == false ) {
...
// Auf Abbrechen prüfen
cancel = (_state == CalcState.Canceled);
// Auf Beendigung prüfen
if( cancel || (digitsSoFar == totalDigits) ) {
_state = CalcState.Pending;
_calcButton.Text = "Calc";
_calcButton.Enabled = true;
}
}
// Kontrolle an Benutzeroberflächenthread übergeben
else { ... }
}
Da nur der Benutzeroberflächenthread auf die _state-Membervariable zugreift, ist keine Synchronisierung erforderlich. Jetzt muss nur noch die Kontrolle so an den Benutzeroberflächenthread übergeben werden, dass der cancel out-Parameter der ShowProgressDelegate-Methode abgerufen wird. Leider ist dies wegen unserer Verwendung von Control.BeginInvoke aber kompliziert. Das Problem ist, dass BeginInvoke nicht auf das Ergebnis eines Aufrufs von ShowProgress für den Benutzeroberflächenthread wartet. Wir haben daher zwei Möglichkeiten.
Eine Möglichkeit besteht darin, einen anderen Delegaten an BeginInvoke zu übergeben, der aufgerufen werden soll, wenn ShowProgress vom Benutzeroberflächenthread zurückkehrt. Dies geschieht aber in einem anderen Thread im Threadpool, so dass wir zur Synchronisierung zurückkehren müssen, dieses Mal zwischen dem Arbeitsthread und einem anderen Thread aus dem Pool. Eine einfachere Möglichkeit besteht darin, zur synchronen Control.Invoke-Methode zu wechseln und auf den cancel out-Parameter zu warten. Aber auch dies ist nicht so einfach, wie der folgende Code zeigt:
void ShowProgress(..., out bool cancel) {
if( _pi.InvokeRequired == false ) { ... }
// Kontrolle an Benutzeroberflächenthread übergeben
else {
ShowProgressDelegate showProgress =
new ShowProgressDelegate(ShowProgress);
// Verpacken und Verlieren unseres Rückgabewerts vermeiden
object inoutCancel = false;
// Fortschritt synchron anzeigen (damit wir auf Abbrechen prüfen können)
Invoke(showProgress, new object[] { ..., inoutCancel});
cancel = (bool)inoutCancel;
}
}
Obwohl es am besten gewesen wäre, einfach eine boolesche Variable direkt an Control.Invoke zu übergeben, um den Abbruchparameter abzurufen, stehen wir vor einem Problem. Das Problem ist, dass bool ein Wertdatentyp ist, während Invoke ein Array von Objekten akzeptiert und Objekte sind Referenzdatentypen. Im Abschnitt "Referenzmaterial" sind Bücher aufgeführt, in denen dieser Unterschied behandelt wird. Aber Fazit ist, dass eine als Objekt übergebene bool-Variable kopiert wird, wobei die tatsächliche bool-Variable unverändert bleibt. Das heißt, wir wissen nie, wann die Operation abgebrochen wurde. Um diese Situation zu vermeiden, erstellen wir unsere eigene Objektvariable (inoutCancel) und übergeben sie stattdessen, so dass kein Kopieren erfolgt. Nach dem synchronen Aufruf von Invoke wandeln wir die object-Variable in eine bool-Variable um, um zu prüfen, ob die Operation abgebrochen werden soll oder nicht.
Die Unterscheidung zwischen Wert- und Referenztyp müssen Sie stets beachten, wenn Sie Control.Invoke (oder Control.BeginInvoke) mit out- oder ref-Parametern aufrufen, die Werttypen sind, z.B. einfache Typen wie int oder bool sowie Enumerationen und Strukturen. Wenn jedoch komplexere Daten als ein benutzerdefinierter Referenztyp, auch als Klasse bekannt, übergeben werden, müssen Sie nichts Besonderes durchführen, damit das Ganze funktioniert. Sogar die unerfreuliche Verarbeitung von Werttypen mit Invoke/BeginInvoke verblasst gegenüber der Bereitstellung von Multithreadcode für den Zugriff auf gemeinsam genutzte Daten unter Wettkampfbedingungen und ohne Deadlocks. Dies ist in meinen Augen nur ein kleiner Nachteil, den man in Kauf nehmen muss.
Schlussfolgerung
Wieder einmal haben wir ein scheinbar triviales Beispiel verwendet, um einige recht komplexe Probleme zu untersuchen. Wir haben nicht nur mehrere Threads genutzt, um die Benutzeroberfläche von einer langwierigen Operation zu trennen, sondern haben auch weitere Benutzereingaben zurück an den Arbeitsthread übermittelt, um sein Verhalten anzupassen. Obwohl wir auch die Möglichkeit gehabt hätten, gemeinsam genutzte Daten zu verwenden, um die Probleme der Synchronisierung zu vermeiden (die natürlich nur dann auftreten, wenn Ihr Chef den Code testet), haben wir uns weiterhin an das nachrichtenübermittelnde Schema für stabile, korrekte Multithreadverarbeitung gehalten.
Referenzmaterial