Hallo Community,
Das Problem
exemplarisch eine Problembeschreibung:
| Zitat: |
Wenn ich eine Windows-Forms-Anwendung laufen lasse und aus irgendwelchen Gründen in eine andere Anwendung wechsle, bekomme ich keine aktuelle Darstellung mehr.
Die Anwendung läuft und macht ihren Job (432 Dateien bearbeiten), aber ich bekomme keine Info wie weit die Anwendung ist - die Progressbar bewegt sich nicht, selbst wenn ich nicht auf eine andere Anwendung umgeschaltet habe.
Das GUI ist blockiert, das Fenster lässt sich auch nicht bedienen, also nicht verschieben, nicht maximieren oder minimieren. Beim Versuch das Fenster zu schließen kommt ein Dialog "Keine Rückmeldung"/"Das Programm reagiert nicht" mit der Möglichkeit das Programm "Sofort beenden" zu können. |
Diese Effekte treten immer dann auf, wenn langlaufende Aktionen (Aktionen, die länger als 1/10s laufen oder laufen können) im GUI-Thread ausgeführt werden.
Die Lösung
Diese langlaufenden Aktionen, die länger als 1/10s also 100ms brauchen, müssen in einen extra (Arbeits-)Thread ausgelagert werden. Nach neueren Empfehlungen von Microsoft sollten sogar alle Aktionen, die länger als 1/20s also 50ms laufen könnten, asynchron ausgeführt werden. Dazu kann man einen extra Thread starten, auf den ThreadPool zurückgreifen, einen BackgroundWorker verwenden oder jedes andere Konstrukt, das echte Nebenläufigkeit ermöglicht.
Achtung: GUI-Zugriffe nur aus dem GUI-Thread
In allen Fällen ist zu beachten, dass von dem Thread nicht direkt auf das GUI zugegriffen werden darf. Bei Thread und ThreadPool muss man Control.Invoke bzw. Control.BeginInvoke verwenden (siehe
[FAQ] Controls von Thread aktualisieren lassen (Control.Invoke)). Bei BackgroundWorker müssen die GUI-Zugriffe aus den ProgressChanged- oder RunWorkerCompleted-EventHandlern durchgeführt werden.
Achtung: Die Falle
Wenn man seine langlaufende Aktion nun glücklich in einen extra Thread verlagert hat, muss man aber aufpassen, dass man nicht zuviel oder gar alles auf einmal wieder in den GUI-Thread zurück verlagert.
Der folgende Code macht keinen Sinn:
C#-Code (FALSCH): |
private void DoSomethingExpensiveClick (Object objSender, EventArgs e) {
new Thread (DoSomethingExpensive).Start ();
}
private void DoSomethingExpensive () {
if (this.InvokeRequired) {
this.Invoke (new MethodInvoker (DoSomethingExpensive));
return;
}
}
|
Wie man sieht, wird mit this.Invoke das
komplette DoSomethingExpensive zurück an den GUI-Thread zur Ausführung übergeben. Es dürfen aber gerade nur "billige" Methoden per Control.Invoke aufgerufen werden, also welche, die weniger als 1/10s lang laufen. Korrekt wäre z.B. sowas:
C#-Code (RICHTIG): |
private void DoSomethingExpensiveClick (Object objSender, EventArgs e) {
new Thread (DoSomethingExpensive).Start ();
}
private void DoSomethingExpensive () {
while (...) {
this.Invoke (new MethodInvoker (DoCheapGuiAccess));
}
}
private void DoCheapGuiAccess () {
}
|
InvokeRequired ist dabei nicht erforderlich, da man sowieso weiß, dass die while-Schleife im Thread läuft und daher InvokeRequired immer true liefern würde.
Achtung: Noch eine Falle: Thread.Join
Wenn man seine langlaufende Aktion nun glücklich in einen extra Thread (Worker) verlagert hat, darf man nicht den Fehler begehen, im GUI-Thread auf die Beendigung des Workers zu warten.
Der folgende Code macht keinen Sinn:
C#-Code (FALSCH): |
private void DoSomethingExpensiveClick (Object objSender, EventArgs e) {
Thread worker = new Thread (DoSomethingExpensive);
worker.Start ();
worker.Join ();
}
private void DoSomethingExpensive () {
}
|
Wie man leicht einsieht, kann der GUI-Thread durch das Thread.Join genauso wenig weiterarbeiten, als wenn man DoSomethingExpensive direkt aufgerufen hätte, denn Thread.Join wartet ja genauso lange, wie DoSomethingExpensive dauert. Man darf im GUI-Thread grundsätzliche keine Synchronisationsoperationen verwenden, die möglicherweise länger als 1/10s warten würden.
An der Situation ändert sich auch nichts zu Guten, wenn man man versucht
worker.Join (); durch
while (worker.IsAlive) { Thread.Sleep (x); } oder gar
while (worker.IsAlive) { } zu ersetzen, außer dass zusätzlich die Prozessorlast des ausführenden Prozessorkerns wegen des BusyWaiting auf 100% steigt. In allen Fällen wird im GUI-Thread solange gewartet, bis DoSomethingExpensive fertig ist, wodurch das GUI solange blockiert ist.
Wenn man nach der Beendigung der eigentlichen Aktion des Workers noch weitere Aktionen ausführen will - typischerweise um das GUI mit den Ergebnissen des Workers zu aktualisieren -, kann man das wie folgt tun:
C#-Code (RICHTIG): |
private void DoSomethingExpensiveClick (Object objSender, EventArgs e) {
new Thread (DoSomethingExpensive).Start ();
}
private void DoSomethingExpensive () {
this.Invoke (new MethodInvoker (DoSomethingAfterCompletion));
}
private void DoSomethingAfterCompletion () {
}
|
Wenn man einen BackgroundWorker verwendet, kann man den Code "Do something after completion" stattdessen im RunWorkerCompleted-EventHandler ausführen, um den gleichen Effekt zu erreichen.
DoSomethingAfterCompletion läuft (im Gegensatz zu RunWorkerCompleted) nicht automatisch im GUI-Threads. Wenn man von DoSomethingAfterCompletion auf das GUI zugreifen will, muss man wieder Control.Invoke verwenden.
Weitere Fallen
In
[FAQ] Controls von Thread aktualisieren lassen (Control.Invoke/Dispatcher.Invoke) sind weitere Situation beschrieben, in denen das GUI blockiert, obwohl man versucht hat, langlaufende Aktionen in einen extra Thread auszulagern, z.B. durch zu häufiges Aufrufen von Control.BeginInvoke.
DoEvents ist Mist
Das im Zusammenhang mit blockierten GUIs immer wieder genannte Application.DoEvents ist ungereifter (oder sagen wir simpifizierender :-) Programmierstil. Mit DoEvents kann man sich schnell eine ganze Reihe von Problemen einhandeln, insbesondere weil EventHandler erneut aufgerufen werden können, obwohl die aktuelle Ereignisbehandlung noch nicht abgeschlossen ist, bis hin zum Programmabsturz wegen einer StackOverflowException, weshalb man davon die Finger lassen sollte. Außerdem kann man damit auch
nicht in allen Fällen ein Blockieren verhindern, was mit Threads zuverlässig und in jedem Fall funktioniert. Für weitere Informationen zu und Probleme mit DoEvents siehe
Warum DoEvents Mist ist!
Timer statt Threads
Es gibt einen Fall, in dem keine extra Threads benötigt werden, nämlich wenn die Blockierung nur deshalb zu Stande kommt, weil im GUI-Thread Thread.Sleep verwendet wird. In diesem Fall sollte der Code von Thread.Sleep auf System.Windows.Forms.Timer umgestellt werden. Der Tick-Eventhandler läuft dann übrigens automatisch im GUI-Thread, weshalb Control.Invoke nicht benötigt wird bzw. sogar schädlich wäre.
Nur ein GUI-Thread
Alle Fenster sollten/müssen immer aus dem GUI-Thread erzeugt werden. Das Erzeugen von Fenster und Controls sollte nicht/nie in extra Thread verlagert werden. Wenn man das tut, kann das - so paradox das klingt - ebenfalls zum Blockieren des neue erzeugten Fensters und zu anderen Problemen führen, insbesondere wenn Fenster aus verschiedenen Threads aufeinander zugreifen. Ein Prozess sollte also insgesamt nur einen GUI-Thread haben, um diese Probleme zu vermeiden.
Siehe dazu auch
Controls in anderem Thread erzeugen als das Form [==> auf keinen Fall]. Dort ist auch beschrieben, was man tun kann, wenn schon alleine das reine Füllen der Controls länger als 1/10 Sekunde dauert.
Wie geht es in WPF?
Vom Grundsatz genauso. Langlaufende Aktionen müssen in einen extra Thread ausgelagert werden und alle GUI-Zugriffe müssen aus dem GUI-Thread erfolgen. Der eigentliche Unterschied liegt in der Art, wie man zurück in den GUI-Thread wechselt. Siehe dazu den Abschnitt "Wie geht es in WPF?" in
[FAQ] Controls von Thread aktualisieren lassen (Control.Invoke/Dispatcher.Invoke).
herbivore