Suchen

Anmeldung



cfThreadingTools

E-Mail Drucken

Wer den landläufigen Empfehlungen in allerlei Publikationen folgt und seine Anwendung multi-threaded programmiert, bekommt schnell Probleme beim Einbinden von Windows-Forms-Benutzersteuerlementen. Der "ungültige thread-übergreifende Vorgang" bereitete schon so manchem User die eine oder andere suboptimale Programmier-Minute. Ziel der cfThreadingTools ist es, den Programmierern hier einen bequemen Weg zu bieten.

Folgendes Szenario ist ernsthaften .NET-Entwicklern garantiert schon mal untergekommen: Man schreibt eine GUI-basierte Anwendung. Man stellt fest, dass sie zu zäh reagiert und folgert sofort, dass sie Multithreading nutzen muss. Man sorgt über WaitCallback und ThreadPool oder Thread.Start für die Leistung. Die Anwendung knallt einem um die Ohren. Selbst diejenigen, die sofort an das leidige InvokeRequired gedacht haben, sind jetzt nicht viel besser dran, denn sie müssen jede noch so kleine ein Control verändernde Aktion mit einer Wrapper-Methode versehen.

codingfreaks ging es nicht anders und über die Jahre entstand in dem ein oder anderen Projekt ein Wrapper dafür. Das Problem war, dass auch wir keinen einheitlichen Umgang mit dem Problem hatten. Das hat sich nun geändert und wir stellen hiermit unsere Lösung jedem Interessierten frei zur Verfügung.

Das Problem

Zunächst einmal möchten wir in alter codingfreaks-Manier das eigentliche Problem beschreiben. Ich gehe hier jetzt nicht zum 1.000sten Mal auf die Vorzüge von Multithreading in Multicore-Umgebungen ein. Das Verständnis dafür ist quasi eine Vorbedingung dafür, dass jemand überhaupt versteht, worüber ich hier rede. Was aber manche nicht genau wissen, ist, was für ein Problem man eigentlich umgehen muss und woher es stammt.

Ein .NET-Prozess wird, wie andere Prozesse auch, auf einem Haupt-Thread ausgeführt. Bei Bedarf kann der Hauptprozess jederzeit Arbeiten an Unterthreads delegieren. Diese laufen dann eine Weile und werden zerstört, sobald die beim Erzeugen des Threads definierte Aufgabe erledigt ist. Man kann also sagen, dass ein neu gestarteter Thread immer eine bestimmte Aufgabe erfüllt. In .NET wird nun ein ggf. anzuzeigendes User-Inerfaces auf einem eigenen Thread ausgeführt, dem UI-Thread. Dadurch trennt .NET Anzeige von Arbeit bzw. schafft die Vorbedingungen dafür.

Wenn nun ein Arbeitsthread gestartet wird, ist es oft notwendig, etwas im UI-Thread zu ändern. Das klingt jetzt etwas holprig, weil diese Änderung so gut wie alles umfassen kann. Man möchte z.B. beim Starten einer Aufgabe einen Button deaktivieren, oder man möchte eine ProgressBar initialisieren oder, oder ....

Zur besseren Veranschaulichung hier ein Beispiel:

Listing #1: Thread starten und Button deaktivieren
1
2
3
4
5
6
7
8
9
10
11
12
private void btnTest_Click(object sender, EventArgs e)
{
WaitCallback wcbThis = new WaitCallback(DoSomethingInOwnThread);
ThreadPool.QueueUserWorkItem(wcbThis);
}
 
private void DoSomethingInOwnThread(object objNull)
{
btnTest.Enabled = false;
...
btnTest.Enabled = true;
}

Die Zeilen 3 und 4 stellen die wohl sauberste Variante des Starten eines eigenen Threads dar, weil sie den jeder .NET-App mitgegebenen ThreadPool berücksichtigen und nicht einfach blindlings einen neuen Thread starten, obwohl die App vielleicht schon am Limit ist. Ansonsten wird DoSomethingInOwnThread wie der Name bereits sagt, in einem eigenen Thread gestartet, der neben dem UI-Thread her läuft.

Zeile 9 oder Zeile 11 werden nun früher oder später (eher früher) zu einer Exception mit dem Titel "Ungültiger thread-übergreifender Vorgang..." führen. Aber warum?

cfthreading_01

Ganz einfach. Wie die Abbildung oben zeigt, sieht es für .NET so aus, als ob ein Thread direkt versucht (rote Linie), Objekte eines anderen Threads zu ändern. Und das ist aufgrund von CAS (code access security) einfach nicht erlaubt. Mit einer etwas gröberen Brille betrachtet verhindert .NET richtigerweise, dass Objekte von außen verändert werden, weil dies eins der häufigsten Angriffs-Szenarien darstellt.

Wichtig ist hierbei auch zu erwähnen, dass man dieses Problem nicht durch den Einsatz von Events umgehen kann! Viele glauben nämlich, dass sie einfach ein Ereignis auf dem Worker-Thread auslösen und dies auf dem UI-Thread behandeln und dann im Ereignishandler die UI-Zugriffe machen. Dahinter steckt der Irrglaube, dass ein Ereignis automatisch einen Kontextwechsel mit sich bringen würde. Das Gegenteil ist der Fall. Ein Ereignis findet immer im Thread-Kontext des Aufrufers statt, weil es letztlich nur ein umgelenkter Methodenaufruf ist.

Die Lösung in .NET

.NET wäre nicht das, was es ist, wenn es für dieses Problem keine eingebaute Lösung gäbe. Die Lösung versteckt sich hinter der Eigenschaft InvokeRequired und der Methode Invoke, die alle von Control abgeleiteten Klassen mitbringen. InvokeRequired gibt für ein Control true zurück, wenn sich der aktuelle Thread-Kontext nicht auf dem UI-Thread befindet. Durch einen Aufruf von Invoke() kann nun der aktuelle Kontext um eine Ebene in Richtung UI-Kontext "hochgehoben" werden. Das ganze muss so lange rekursiv erfolgen, bis InvokeRequired true zurück gibt. Es kann natürlich sein, dass wir uns derzeit 10 Ebenen unterhalb des UI-Threads befinden, weil ein Thread einen anderen starten kann.

Das hört sich einfach an, ist es aber leider nur in der Theorie. Für unser oben dargestelltes Problem mit dem Button wäre etwas in folgender Art nötig:

Listing #2: .NET-typisches Setzen des Enabled-State
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
delegate void SetButtonStateDelegate(bool blnState);
 
private void SetButtonState(bool blnState)
{
if (btnTest.InvokeRequired)
{
SetButtonStateDelegate d = new SetButtonStateDelegate(SetButtonState);
btnTest.Invoke(d, blnState);
}
else
{
btnTest.Enabled = blnState;
}
}
 
private void DoSomethingInOwnThread(object objNull)
{
SetButtonState(false);
...
SetButtonState(true);
}

Wir brauchen für diese einfache Aufgabe einen Delegaten und eine dedizierte Methode. Der Delegate aus Zeile 1 ist nötig, weil wir in Zeile 7 einen Methoden-Zeiger definieren müssen, der auf die Methode SetButtonState passt. Da diese einen Boolean erfordert, muss der Delegat entsprechend definiert werden. Zeile 7 definiert nun den Methodenzeiger und in Zeile 8 rufen wir den Delegaten auf. Wichtig ist, dass wir nicht das Invoke des Delegates, sondern das des Controls verwenden und ihm die Parameter immer wieder mit geben!

Das ist, wenn man es einmal verstanden hat, nicht wirklich schwer, aber es ist andererseits extrem aufwendig. Mit diesem Codeblock können wir bisher nur das Enabled eines Buttons setzen. Überlegen Sie selbst, wieviele Eigenschaften sie threadsicher setzen können müssen. Und dann rechnen sie noch Methoden, wie Refresh oder Clear dazu, die ebenfalls thread-sicher implementiert werden müssten! Da vergeht der Lustfaktor extrem schnell.

Die codingfreaks-Lösung

Der bisher beschriebene aufwendige Weg sorgt oft dafür, dass Multithreading als zu aufwendig empfunden und einfach nicht angewendet wird. Dies schreit geradezu nach einer Unterstützung, die wir mit den cfThreadingTools hiermit anbieten. Die Tools gliedern sich in zwei Bereiche:

  1. Die Klasse BaseTools
  2. Diverse Klassen mit Extension-Methoden für die einzelnen Controls aus System.Windows.Forms

BaseTools

cfthreading_03Wie die Abbildung rechts zeigt, bietet die Klasse insgesamt 4 Methoden an. Diese reichen prinzipiell aus, um jeden uns bekannten Vorgang auf Controls thread-sicher zu gestalten. Es gibt immer eine Methode, um eine Eigenschaft bzw. Methode eines Controls direkt zu nutzen und noch einmal eine, um in Abhängigkeit des InvokeRequired eines Controls die Eigenschaft eines anderen Objektes zu beeinflussen.

Die beiden Direktmethoden sind InvokeMethod und SetPropertyValue. Die Pendents für untergeordnete Objekte haben jeweils den Zusatz "Sub" im Namen und bieten eine geänderte Aufruf-Parameter-Liste.

Beginnen wir mit dem Beispiel oben. Im folgenden sehen Sie den Code unter Zuhilfenahme der Klasse BaseTools:

Listing #3: Threadsicheres Setzen einer Property mit cfThreadingTools
1
2
3
4
5
6
private void DoSomethingInOwnThread(object objNull)
{
BaseTools.SetPropertyValue(btnTest, "Enabled", false);
...
BaseTools.SetPropertyValue(btnTest, "Enabled", true);
}

Nachdem ein Verweis auf cfThreadingTools im eigenen Projekt eingebunden wurde, wird einfach SetPropertyValue von BaseTools genutzt. Man übergibt der Methode das Steuerelement selbst, den Namen des zu beeinflussenden Eigenschaft als String und dann den Wert, den die Eigenschaft annehmen soll. Diese Methode kann man nun für alle direkten Beeinflussungen von Eigenschaften benutzen.

Wozu nun aber die Methode SetSubPropertyValue? Denken sie einfach mal an ein ListView und stellen Sie sich vor, Sie wollen dessen Items komplett löschen. Der normale Code lautet ListViewControl.Items.Clear(). D.h., Sie rufen die Clear-Methode auf dem Objekt Items und nicht auf dem ListView selbst auf. Genau dafür gibt es die "Sub"-Methoden:

Listing #4: Threadsicher ListViewItems entfernen
1
BaseTools.InvokeSubMethod(lvwControl, lvwControl.Items, "Clear");

Man übergibt das Objekt, das die Methode anbietet einfach als zweiten Parameter. Die Threading-Tools sind übrigens so implementiert, dass sie genau zwischen evtl. vorhanden Methoden-Überladungen unterscheiden können, denn Sie können hinter dem Methodennamen auch noch Parameter angeben:

Listing #5: Aufruf einer Methode mit Parametern
1
2
3
4
ListViewItem litTmp = (ListViewItem)BaseTools.InvokeSubMethod(litControl, 
litControl.Items,
"Add",
"Hello");

Wie Listing 5 zeigt, geben die Aufrufe ggf. auch die bekannten Objekte zurück.

Extensions

Seit .NET 2.0 gibt es nun eine Möglichkeit, eigene Erweiterungen zu bereits vorhanden Klassen hinzuzufügen, ohne diese vererben zu müssen: Extension-Methods. Vereinfach gesagt, kann man dem Kompiler mitteilen, dass man für einen bestimmten Typ eine neue Funktion anbieten möchte, die diesem Typ quasi als Plugin hinzugefügt wird. codingfreaks hat dieses Mittel genutzt, um für bestimmte Controls vereinfachte Aufrufe zu ermöglichen. Dazu müssen Sie lediglich ein using in Ihrem Code anbieten, dass auf den Namespace "de.codingfreaks.libs.cfThreadingTools.Extensions" zeigt. Danach können wir unser Button-Beispiel noch weiter vereinfachen:

Listing #6: Extension von cfThreadingTools
1
2
3
btnTest.DisableThreadSafe();
...
btnTest.EnableThreadSafe();

Wir haben also in einer Klasse die beiden Methoden DisableThreadSafe und EnableThreadSafe erstellt. Diese Klasse heißt bei uns ControlExtensions. Die Methode EnableThreadSafe sieht beispielsweise wie folgt aus:

Listing #7: EnableThreadSafe
1
2
3
4
5
6
7
8
/// <summary>
/// Sets the Enabled-property of a control to true.
/// </summary>
/// <param name="ctlTarget">The control for extensions-purposes.</param>
public static void EnableThreadSafe(this Control ctlTarget)
{
BaseTools.SetPropertyValue(ctlTarget, "Enabled", true);
}

Sie ruft intern also SetPropertyValue auf und bietet diese Funktionalität jedem Steuerelement an, das von Control vererbt wurde. Das schöne daran ist, dass Sie die Methode bereits in IntelliSense sehen, wenn sie "Control." eingeben. Anhand der von uns gewählten Namenskonvention mit dem angehängten "ThreadSafe" erscheint jede Extension-Methode direkt unterhalb der vom Control selbst definierten.

Performance

Man könnte sich nun nach all dem fragen, warum Microsoft nicht einfach alle Controls von Haus sicher im Umgang mit Threads gemacht hat. Ganz einfach, es hat seinen Preis und der liegt, wie immer bei solchen Dingen, bei der Performance. Die nutzung der cfThreadingTools kostet Leistung. Wieviel, das hängt letztlich von der Umgebung und der Aufgabe ab. Im Source-Download der Projektmappe unten ist ein Testprojekt enthalten, dass u.a. einen kleinen Benchmark auf einer Textbox durchführt und dafür die Millisekunden ausgibt. Auf unserer Maschine dauerte die Operation mit cfThreadingTools ca 700 ms und im Direktmodus ohne eigenen Thread ca. 550-600 ms. Entscheiden müssen Sie letztlich selbst.

Ausblick

Wir werden die Tools weiter pflegen und sie wahrscheinlich demnächst auf codeplex als Projekt einstellen. Bis dahin stehen Ihnen die jeweils aktuellen Versionen auch hier zur Verfügung. Bisher sind noch nicht alle Standard-Controls abgedeckt, es sollte aber kein Problem darstellen, dies notfalls selbst zu ergänzen. Tun Sie uns bei Nutzung einfach nur den Gefallen, die Namespaces nicht zu verändern, sodass auch bei Weitergabe Ihres Codes der Ursprung erkennbar bleibt.

Controls, wie z.B. Sammlungen von Drittherstellern oder Eigenentwicklungen benötigen ggf. eigene Extension-Klassen. Sobald die cfThreadingTools bei codeplex liegen, kann sich jeder gern als Entwickler bei uns bewerben und bei Bedarf, seinen Code der Gemeinde zur Verfügung stellen. Wir freuen uns schon auf Feedback!

Downloads

Das Projekt steht unter http://cftt.codeplex.com/ zum Download bereit.
Zuletzt aktualisiert am Sonntag, den 11. April 2010 um 14:24 Uhr