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.
For i As Integer = 48 To 98PostMessage(TextBox1.Handle.ToInt32, _WM_CHAR, i, 0)System.Threading.Thread.Sleep(100)ProgressBar1.PerformStep()If CheckBoxDoEvents.Checked ThenApplication.DoEvents()End IfNext
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.
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
Private Sub DoThreadWork()For i As Integer = 48 To 98PostMessage( _TextBox1.Handle.ToInt32, _WM_CHAR, i, 0)System.Threading.Thread.Sleep(100)ProgressBar1.PerformStep()If CheckBoxDoEvents.Checked ThenApplication.DoEvents()End IfNextStatusBar1.Text = String.EmptyEnd SubPrivate Sub Button3_Click( _ByVal sender As System.Object, _ByVal e As System.EventArgs) _Handles Button3.ClickStatusBar1.Text = "Der Thread läuft..."Dim aThrd As New Thread( _ <br></br> AddressOf DoThreadWork)aThrd.Start()End Sub
private void DoThreadWork(){for (int i = 48; i <= 98; i++){PostMessage(TextBox1.Handle.ToInt32(),WM_CHAR, i, 0);System.Threading.Thread.Sleep(100);ProgressBar1.PerformStep();if (CheckBoxDoEvents.Checked)Application.DoEvents();}StatusBar1.Text = String.Empty;}private void Button3_Click(object sender, System.EventArgs e){StatusBar1.Text = "Der Thread läuft...";ThreadStart aTS = new ThreadStart(this.DoThreadWork);Thread aThrd = new Thread(aTS);aThrd.Start();}
private void timer1_Tick(object sender, System.EventArgs e){TextBox1.Text += Convert.ToChar(iChar);ProgressBar1.PerformStep();if (iChar == 98){timer1.Enabled = false;StatusBar1.Text = String.Empty;}iChar++;}
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.if (checkBox1.Checked)timer1.SynchronizingObject = listBox1;elsetimer1.SynchronizingObject = null;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:aTimer = new System.Threading.Timer(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.
private void SetListBox(string sMsg){listBox1.Items.Add(sMsg);}
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
private void OSTimer(object aObj){string sMsg = String.Format(cFmt, iMainThreadId,AppDomain.GetCurrentThreadId(),Thread.CurrentThread.IsThreadPoolThread);// Threadsicherer Zugriff auf das ControlISynchronizeInvoke aSync = listBox1;if (aSync.InvokeRequired == false){SetListBox(sMsg + " (direkter Aufruf)");return;}// Threadwechsel ist notwendigSetListBoxDelegate aDel = new SetListBoxDelegate(SetListBox);try{aSync.Invoke(aDel,new object[]{sMsg});}catch{}}
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.


