Artikel

April 2005 | Artikel

Metronom

(Link zum Artikel: http://www.it-republik.de/dotnet/artikel/0685)

Multithreading bei Windows Forms mit Timer-Klassen - ein Blick hinter die Kulissen

Text: von Andreas Kosch
  • Teilen
  • kommentieren
  • empfehlen
  • Bookmark and Share
Wenn Multithreading mit Windows Forms und ihren Steuerelementen kombiniert werden soll, betreten viele Entwickler neues Terrain. Am einfachsten wird Gleichzeitigkeit über Timer-Klassen realisiert, doch auch hier lauern historisch bedingt einige Klippen, die Sie mithilfe dieses Beitrags ein für alle Mal elegant umschiffen.

Der Wohlklang beim Zusammenspiel aller Musiker eines Orchesters wird nur dann erreicht, wenn der Dirigent den Takt angibt und sich alle anderen daran halten. Die Väter des .NET Framework haben dieses Prinzip übernommen, aufgrund der sehr unterschiedlichen Spielorte reicht ein Dirigent aber nicht mehr aus. Stattdessen finden sich gleich drei Timer-Klassen, die .NET-Anwendungen in Schritt und Tritt bringen wollen. Während das .NET Framework 1.x noch intime Kenntnisse des Win32-API voraussetzt, wird das kommende .NET Framework 2.0 den Entwickler spürbar besser von diesen Details abschirmen.

Das Prinzip, in der eigenen Anwendung in regelmäßigen Abständen bestimmte Funktionen auszuführen, ist sehr alt. Im Laufe der Zeit hat sich nur die technische Umsetzung geändert, sodass es sich auf den ersten Blick bei einem Timer nach einem sehr langweiligen Gepicturee anhört. Mit der Ruhe ist es jedoch schlagartig vorbei, wenn der Entwickler erst einmal in eine der zahlreich aufgestellten Fallen getappt ist. Dahinter verbergen sich keine Boshaftigkeiten der Väter des .NET Framework, sondern die für die unterschiedlichen Umgebungsbedingungen optimierten Wirkungsprinzipien der alternativ verfügbaren Timer-Klassen. Ihnen ist sicherlich bereits aufgefallen, dass Visual Studio .NET in der Toolbox auf den Tab-Seiten Windows Forms und Components gleich zwei verschiedene Timer-Komponenten zur Auswahl anbietet. Auf den ersten Blick sehen beide gleich aus - sowohl die von der Toolbox verwendete Grafik als auch die Bezeichnung unterscheiden sich nicht. Trotzdem verhalten sich die aus verschiedenen Namespaces stammenden Komponenten völlig unterschiedlich. Um die Verwirrung komplett zu machen, versteckt sich im Namespace System.Timers noch eine dritte Timer-Klasse. Außerdem fällt auf, dass das .NET Framework 1.x an einigen Stellen davon ausgeht, dass sich der Entwickler gut mit dem Win32-Regelwerk auskennt. Für alte Hasen kein Problem, aber für Entwickler, die sofort mit dem .NET Framework angefangen haben, wird es stellenweise gefährlich, wenn man im jugendlichen Leichtsinn die dazugehörenden Hilfeseiten zu flüchtig durchliest. Der prüfende Blick muss immer dann gründlicher ausfallen, wenn Formulare (Windows Forms) und zusätzliche Threads im Spiel sind. An dieser Stelle kommen die prähistorischen Wurzeln des .NET Frameworks ins Spiel - zumindest was die Formulare (Forms) und Steuerelemente (Controls) in einer Windows-Forms-Anwendung angeht.

Es war einmal
Auch wenn das .NET Framework selbst noch sehr jung ist, setzen die Steuerelemente einer Windows-Forms-Anwendung noch auf die hoch betagten Win32-Controls des Betriebssystems auf. Dies bedeutet zwangsläufig, dass sich auch .NET an die alten Spielregeln halten muss. An dieser Stelle schließt sich der Kreis - ein Entwickler kann nur dann die einzelnen Beschränkungen im .NET Framework verstehen, wenn ihm auch die Hintergründe der Win32-Controls vertraut sind.

Windows wird als ereignisgesteuertes Betriebssystem bezeichnet, bei dem die zeitliche Reihenfolge der Programmausführung im Fall der Benutzeroberfläche nicht absolut feststeht. Das Programm reagiert auf Benutzeraktionen beziehungsweise auf Systemereignisse von Windows, indem es auf die vom Betriebssystem verschickten Botschaften in der vorgeschriebenen Art und Weise antwortet. Botschaften können von Win32 prinzipiell auf zwei verschiedenen Wegen zugestellt werden: Zum einen über die Botschaftswarteschlange der Anwendung und zum anderen durch das direkte Senden der Botschaft zur zuständigen Fensterprozedur der Anwendung.
Die nicht so eiligen Botschaften werden über die Win32-API-Funktion PostMessage in die Botschaftswarteschlange des Threads abgelegt, aus dem heraus das Fenster (Steuerelement) erzeugt wurde. Prinzipiell gilt, dass alle Botschaften aus der Warteschlange nach dem FIFO-Prinzip (first in first out) ausgelesen werden. Allerdings macht Windows dabei viele Ausnahmen - typische Vertreter dieser gepufferten Botschaften sind WM_MOUSEMOVE, WM_CHAR, WM_TIMER und WM_PAINT. Im Gegensatz zur Botschaftswarteschlange werden die nicht zwischengespeicherten Botschaften über die Win32-API-Funktion SendMessage direkt zur Fensterprozedur gesendet, indem Windows diese direkt aufruft. Jede Botschaft steht als logisches Symbol für einen bestimmten numerischen Wert. Die Fensterprozedur wertet in einer Case-Abfrage diesen Wert aus und verzweigt damit auf die der Botschaft zugewiesene Programmfunktion. Damit lösen diese Botschaften sofort eine Reaktion aus, wobei ein Rückgabewert eine Rückmeldungsoption für den Versender der Botschaft anbietet. Zusätzlich zu den Windows-Botschaften gibt es noch zwei weitere Unterarten, die jeweils die Windows-Botschaft WM_COMMAND als Transportmedium nutzen. Die Steuerelementbenachrichtigungen werden von Dialogelementen oder anderen untergeordneten Fenstern an das Elternfenster gesendet. Die Befehlsbotschaften zirkulieren immer dann, wenn der Anwender einen Menüeintrag, einen Toolbar Button oder eine Accelator-Tastenkombination ausgewählt hat.
Im Hintergrund lauert P/Invoke
Diese Aufzählung der verschiedenen Botschaftsarten hat verdeutlicht, wie wichtig die korrekte Botschaftsbehandlung für Windows ist. Normalerweise ist das kein Thema, da sich das .NET Framework darum kümmert, diese Hintergründe so gut es geht zu verstecken. Sobald ein Entwickler jedoch etwas an der Oberfläche kratzt oder gar die Win32-Regeln missachtet, tauchen die Hintergründe schlagartig auch in trivialen Windows-Forms-Anwendungen auf. Da Sie sich jetzt lange genug mit der trockenen Theorie beschäftigten mussten, wird es Zeit, das Ganze mit einer Demo zu visualisieren. Das folgende Beispiel macht absichtlich am Anfang einiges falsch - denn nur so treten die Auswirkungen hervor. Das Demoprogramm ruft über P/Invoke (Platform Invocation Services) die Win32-API-Funktion PostMessage auf, um in einer Schleife jeden Buchstaben einzeln über die Windows-Botschaft WM_CHAR in das TextBox-Steuerelement einzufügen. Mithilfe der Checkbox (Abb. 1) kann das Programm zur Laufzeit prüfen, welche Auswirkungen das regelmäßige Auslesen der Botschaftswarteschlange hat.

Um im Visual Basic.NET- und C#-Programm die Windows-Botschaft WM_CHAR an ein Steuerelement verschicken zu können, muss zuerst in der Datei WinUser.h (diese Header-Datei ist im Verzeichnis C:\Programme\Microsoft Visual Studio .NET 2003\Vc7\PlatformSDK\Include zu finden) der numerische Wert dieser Botschaftskonstante herausgesucht werden. In der C-Syntax wird die Konstante über das Konstrukt #define WM_CHAR 0x0102 deklariert. Während in C# die Deklaration als const int WM_CHAR = 0x0102; annähernd dem C-Original entspricht, unterscheidet sich die VISUAL BASIC.NET-Fassung Const WM_CHAR As Integer = &H102L bei der Kennzeichnung des hexadezimalen Botschaftswertes. Nachdem dann auch die Win32-API-Funktion PostMessage über das Attribut DllImport aus dem Namespace System.Runtime.InteropServices deklariert wurde, steht dem Aufruf in der Schleife nichts mehr im Wege. Über die Eigenschaft Handle stellt das TextBox-Steuerelement sein darunter liegendes Win32-Fensterhandle (Win32-Control EDIT) zur Verfügung, sodass der Empfänger der Botschaft eindeutig feststeht. Da auch das zu transportierende Zeichen nur in der numerischen Repräsentation transportiert werden kann, darf die Schleifenvariable i direkt als Botschaftsparameter mit auf den Weg gegeben werden. Damit der Effekt besser sichtbar wird, legt das Programm nach jedem Schleifendurchlauf für 100 Millisekunden eine Pause ein, indem der primäre Thread der Windows-Forms-Anwendung für diese Zeitspanne freiwillig auf Rechenzeit verzichtet. Nur dann, wenn die Checkbox im Formular angekreuzt ist, ruft das Programm innerhalb der Schleife die Methode DoEvents der Application-Klasse auf.
  1. For i As Integer = 48 To 98
  2. PostMessage(TextBox1.Handle.ToInt32, _
  3. WM_CHAR, i, 0)
  4. System.Threading.Thread.Sleep(100)
  5. ProgressBar1.PerformStep()
  6. If CheckBoxDoEvents.Checked Then
  7. Application.DoEvents()
  8. End If
  9. Next

Wie verhält sich nun dieses Beispielprogramm zur Laufzeit? Wenn die Checkbox nicht ankreuzt wird, macht sich nach dem Anklicken des Buttons Primärer Thread Folgendes bemerkbar:
  • Die Balkenanzeige wird ständig aktualisiert, aber in der oberen Textbox tauchen die eingefügten Zeichen erst am Ende nach dem Verlassen der Schleife als vollständiger Text auf.
  • Während die Schleife läuft, aktualisiert die zweite Textbox bei einer Eingabe nicht die Anzeige und auch das Anklicken des Button MessageBox wird erst nach dem Ende der Schleife erkannt.
Das Bild ändert sich schlagartig, wenn die Checkbox im Formular angekreuzt wird, bevor die Schleife startet. Denn dann ist in der oberen Textbox sofort jedes Zeichen zu sehen und auch während der Schleifenlaufzeit kann der Inhalt der zweiten Textbox geändert werden. Der Grund für dieses geänderte Verhalten liegt auf der Hand - nur dann, wenn das eigene Programm innerhalb der Schleife über die Methode DoEvents die eigene Botschaftswarteschlange ausliest und somit das pünktliche Zustellen der dort vorgefundenen Botschaften ermöglicht, hält sich das Programm an die von Win32 aufgestellten Regeln. Das erste Beispiel hat somit bewiesen, dass die alten Win32-Regeln auch noch für eine aktuelle Windows-Forms-Anwendung gültig sind. Allerdings bleibt die Schleife sofort stehen, wenn der mittlere MessageBox Button angeklickt wird. Um dieses Problem zu lösen, wird der Schleifendurchlauf in einen separaten Thread ausgelagert. Sobald jedoch ein zusätzlicher Thread ins Spiel kommt, verschärfen sich die Regeln.
Thread-Affinität
Sowohl im Win32-API als auch im .NET Framework gilt der Grundsatz, dass nur der Thread direkt auf ein Formular beziehungsweise ein Steuerelement zugreifen darf, der dieses auch selbst erzeugt hat. Der primäre Grund für diese Regel besteht darin, dass Windows die Botschaftswarte-schlange direkt einem bestimmten Thread zuordnet, sodass die Botschaftsbehandlung nur innerhalb dieses Thread störungsfrei möglich ist. Zur Demonstration macht auch das zweite Beispiel alles falsch - Sie sollten die in Listing 1 (Visual Basic .NET) beziehungsweise Listing 2 (C#) dargestellte Technik daher nicht in Ihren Anwendungen nutzen. Auf den ersten Blick sieht es zur Laufzeit so aus, als ob alle Anforderungen erfüllt werden. Auch wenn die Checkbox nicht angekreuzt wird, bleibt das Formular während des Schleifendurchlaufs vollständig bedienbar. Sogar der mittlere MessageBox Button blockiert die Schleife nicht mehr. Getreu dem Motto wo kein Kläger, ist auch kein Richter arbeitet die CLR die Aufrufe ab, die Büchse der Pandora wird in den meisten Fällen geschlossen bleiben, da in dem Beispiel eine sofortige Kollision unwahrscheinlich ist. Erst dann, wenn das Beispielprojekt mit Visual Studio 2005 (Beta 1) kompiliert und unter dem .NET Framework 2.0 ausgeführt wird, warnt die CLR über die ausgelöste InvalidOperationException (Abb. 2) sofort vor dem drohenden Unheil. Da im .NET Framework 2.0 die neue Komponente BackgroundWorker zur Verfügung steht, ist dort das Problem schnell lösbar, sodass die Zügel straffer angezogen werden.

Listing 1
  1. Private Sub DoThreadWork()
  2. For i As Integer = 48 To 98
  3. PostMessage( _
  4. TextBox1.Handle.ToInt32, _
  5. WM_CHAR, i, 0)
  6. System.Threading.Thread.Sleep(100)
  7. ProgressBar1.PerformStep()
  8. If CheckBoxDoEvents.Checked Then
  9. Application.DoEvents()
  10. End If
  11. Next
  12. StatusBar1.Text = String.Empty
  13. End Sub
  14. Private Sub Button3_Click( _
  15. ByVal sender As System.Object, _
  16. ByVal e As System.EventArgs) _
  17. Handles Button3.Click
  18. StatusBar1.Text = "Der Thread läuft..."
  19. Dim aThrd As New Thread( _ <br></br> AddressOf DoThreadWork)
  20. aThrd.Start()
  21. End Sub
Listing 2
  1. private void DoThreadWork()
  2. {
  3. for (int i = 48; i <= 98; i++)
  4. {
  5. PostMessage(TextBox1.Handle.ToInt32(),
  6. WM_CHAR, i, 0);
  7. System.Threading.Thread.Sleep(100);
  8. ProgressBar1.PerformStep();
  9. if (CheckBoxDoEvents.Checked)
  10. Application.DoEvents();
  11. }
  12. StatusBar1.Text = String.Empty;
  13. }
  14. private void Button3_Click(
  15. object sender, System.EventArgs e)
  16. {
  17. StatusBar1.Text = "Der Thread läuft...";
  18. ThreadStart aTS = new ThreadStart(
  19. this.DoThreadWork);
  20. Thread aThrd = new Thread(aTS);
  21. aThrd.Start();
  22. }
Da nur der Thread auf ein Steuerelement zugreifen darf, der dieses auch erzeugt hat, ist der direkte Zugriff aus meinem separaten Thread heraus nicht Regelkonform. Die Entwickler könnten nun auf die Fähigkeiten des Interface ISynchronizeInvoke zurückgreifen, um über die Invoke-Methode den Aufruf des Steuerelementzugriffs in den primären Thread der Anwendung zu verschieben. Aber warum soll man etwas selbst implementieren, wenn im .NET Framework 1.x bereits etwas Einsatzfertiges herumliegt? An dieser Stelle schließt sich der Argumentationskreis und landet wieder bei den verschiedenen Timer-Klassen. Wie Tabelle 1 zeigt, passt die Timer-Komponente auf der Windows-Forms-Tabseite der Toolbox am Besten zum Beispielprogramm, denn diese Klasse aus dem Namespace System.Windows.Forms berücksichtigt automatisch alle bereits genannten Besonderheiten. Das dritte Beispiel (wieder in einer Visual Basic.NET- und einer C#-Fassung) zeigt, dass die gleiche Aufgabe mit dieser Timer-Komponente viel einfacher und sicherer umgesetzt werden kann.
  1. private void timer1_Tick(
  2. object sender, System.EventArgs e)
  3. {
  4. TextBox1.Text += Convert.ToChar(iChar);
  5. ProgressBar1.PerformStep();
  6. if (iChar == 98)
  7. {
  8. timer1.Enabled = false;
  9. StatusBar1.Text = String.Empty;
  10. }
  11. iChar++;
  12. }
SynchronizingObject
Während beim Timer aus dem Namespace System.Windows.Forms das Timer-Ereignis immer im primären Thread des Formulars ausgeführt wird, ist das bei den beiden anderen Alternativen nicht der Fall. Das nächste Beispiel verbaut die Timer-Komponente von der Tab-Seite Components der Toolbox, sodass die Timer-Klasse aus dem Namespace System.Timers ins Spiel kommt. Und dort legt die Eigenschaft SynchronizingObject fest, in welchem Thread das Timer-Ereignis verarbeitet wird. Jedes Formular beziehungsweise Steuerelement, das das Interface ISynchronizeInvoke implementiert, ist über diese Eigenschaft auswählbar. Da im Beispielprogramm der Timer einen neuen Eintrag in die Listbox einfügen soll, wird dieses Control zugewiesen, wenn die Checkbox im Formular angekreuzt wird. Abpictureung 3 zeigt das Ergebnis dieser Manipulation, zur besseren visuellen Darstellung der Unterschiede greift das Programm auf die Methode GetCurrentThreadId der AppDomain-Klasse zu, um die jeweils beteiligten Thread-ID mit anzuzeigen.
  1. if (checkBox1.Checked)
  2. timer1.SynchronizingObject = listBox1;
  3. else
  4. timer1.SynchronizingObject = null;
  5. timer1.Start();

Immer dann, wenn die Eigenschaft SynchronizingObject nicht auf eine Formular- beziehungsweise Steuerelementinstanz gesetzt wurde, liefert die betroffene Control-Eigenschaft InvokeRequired den Wert True zurück. Der Zugriff auf die Listbox erfolgt direkt durch den Arbeits-Thread des Timer und ist somit nach den Win32-Regeln illegal, da nur der Formular-Thread direkt auf das Steuerelement zugreifen darf. Erst dann, wenn die Eigenschaft SynchronizingObject korrekt initialisiert wurde, greift der Timer nach dem Wechsel zum Formular-Thread auf das Steuerelement zu, sodass alle Regeln eingehalten werden. Der Formular-Designer von Visual Studio .NET berücksichtigt dies automatisch - dort wird die Eigenschaft SynchronizingObject sofort gesetzt, wenn die Timer-Komponente von der Toolbox (Tab-Seite Components) auf das Formular gezogen wird. Aber immer dann, wenn der Entwickler den Timer direkt im Quelltext verbaut, muss er selbst für die Synchronisation sorgen.
System.Threading.Timer
Zum besseren Vergleich wird die Programmfunktion nun auch mit der dritten Timer-Klasse nachgebaut. Wie zu erwarten war, ist der Aufwand bei diesem Low-Level-Timer am größten, wenn das Win32-Regelwerk beim Zugriff auf Steuerelemente beachtet werden soll. Die Timer-Klasse aus dem Namespace System.Threading stellt vier überladene Methoden des Konstruktors bereit, die jedoch alle als ersten Parameter den Delegate-Typ TimerCallback erwarten. Um den Timer in jeder Sekunde auszulösen, reicht somit der folgende Aufruf aus:
  1. aTimer = new System.Threading.Timer(
  2. new TimerCallback(OSTimer), null, 0, 1000);

Die Implementierung der vom TimerCallback aufgerufenen Methode OSTimer finden Sie in Listing 3. Da auch diese Timer-Klasse den Thread-Pool der CLR nutzt, muss vor dem Zugriff auf die Listbox über das Interface ISynchronizeInvoke nachgesehen werden, ob ein Wechsel zum Formular-Thread notwendig ist. Nur dann, wenn die Eigenschaft InvokeRequired den Wert false zurückliefert, darf die private Methode SetListBox direkt aufgerufen werden.
  1. private void SetListBox(string sMsg)
  2. {
  3. listBox1.Items.Add(sMsg);
  4. }

Ist das jedoch nicht der Fall, muss die Methode SetListBox als Delegate verpackt werden, damit die ISynchronizeInvoke-Methode Invoke sie indirekt aus dem für die Listbox zuständigen Thread heraus ausführt.

Listing 3
  1. private void OSTimer(object aObj)
  2. {
  3. string sMsg = String.Format(cFmt, iMainThreadId,
  4. AppDomain.GetCurrentThreadId(),
  5. Thread.CurrentThread.IsThreadPoolThread);
  6. // Threadsicherer Zugriff auf das Control
  7. ISynchronizeInvoke aSync = listBox1;
  8. if (aSync.InvokeRequired == false)
  9. {
  10. SetListBox(sMsg + " (direkter Aufruf)");
  11. return;
  12. }
  13. // Threadwechsel ist notwendig
  14. SetListBoxDelegate aDel = new SetListBoxDelegate(SetListBox);
  15. try
  16. {
  17. aSync.Invoke(aDel,new object[]{sMsg});
  18. }
  19. catch
  20. {
  21. }
  22. }
Resümee
Die vorgestellten Beispiele haben gezeigt, dass die Väter des .NET Framework 1.x an einigen Stellen Fallgruben aufgestellt haben, die bei allzu flüchtigem Umgang mit der Dokumentation in nicht-trivialen Anwendungen zum Problem werden können. Mit dem .NET Framework 2.0 wird die Anzahl derartiger Gefahrenstellen geringer, zumal mit der neuen BackgroundWorker-Komponente eine bequeme Alternative verfügbar ist. In jedem Fall bleibt als Anforderung bestehen, dass sich der Entwickler die für die umzusetzende Aufgabe am besten geeignete Klasse heraussuchen muss, um den eigenen Implementierungsaufwand so niedrig wie möglich zu halten.

Timer-Klasse Beschreibung
System.Windows.Forms.Timer Diese Timer-Komponente (Toolbox-Seite Windows Forms) ist für den sicheren Einsatz auf Windows Forms geeignet, da die Timer-Ereignisse synchron mit den restlichen Formularereignissen ausgelöst werden. Solange nicht die Methode DoEvents der Application-Klasse aufgerufen wird, unterbricht das Timer-Ereignis niemals den ausgeführten Code, da der Timer im primären Thread der Anwendung ausgeführt wird.
System.Timers.Timer Diese Timer-Komponente (Toolbox-Seite Components) ist für den Einsatz in beliebigen Klassen geeignet, die von System.ComponentModel.Component abstammen. Das asynchrone Timer-Ereignis wird in einem von der CLR abgeforderten Arbeits-Thread aus dem Thread Pool ausgeführt. Dies bedeutet aber auch, dass im ausgeführten Timer-Ereignis keine direkten Zugriffe auf Controls der Benutzeroberfläche zulässig sind, solange nicht die Eigenschaft SynchronizingObject genutzt wird.
System.Threading.Timer Dient zur Umsetzung von Low-Level-Timerfunktionen, die über vier überladene Konstruktoren erreichbar sind. Das asynchrone Timer-Ereignis wird in einem von der CLR aus dem Pool abgeforderten Arbeits-Thread ausgeführt.
 
 
Andreas Kosch (Baujahr 1962) beschäftigt sich in seinem Alltag mit der Konzeption und Entwicklung von dreischichtigen Datenbankanwendungen für den MS SQL Server (Schwerpunkt: .NET Enterprise Services). Als alter Hase hat er es mit mehreren Sprachen (SQLWindows, Delphi, C# und VISUAL BASIC.NET) zu tun. Sie erreichen ihn unter: OssiSoft@aol.com.
Links und Literatur


Anzeige

Kommentare

zurück zum Seitenanfang