Laden...

FAQ

[FAQ] Warum blockiert mein GUI?

Letzter Beitrag vor 17 Jahren 1 Posts 91.913 Views
[FAQ] Warum blockiert mein GUI?

Hallo Community,

Das Problem

exemplarisch eine Problembeschreibung:

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 oder Tasks 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:


void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
   new Thread (DoSomethingExpensive).Start ();
}

void DoSomethingExpensive () 
{
   if (this.InvokeRequired) {
      this.Invoke ((Action)DoSomethingExpensive); // *unsinning*
      return;
   }
   // Do something expensive
   // ...
}

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:


void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
   new Thread (DoSomethingExpensive).Start ();
}

void DoSomethingExpensive ()
{
   bool cancel;
   int percent = 0;

   do {
      Thread.Sleep (200); // simuliert aufwändige Aktion
      percent += 10;
      cancel = (bool)this.Invoke ((Func<int,bool>)DoCheapGuiAccess, percent); // sinnvoll
   } while (percent < 100 && !cancel);
}

bool DoCheapGuiAccess (int percent)
{
   // Do cheap GUI access
   // ...
}

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:


void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
   Thread worker = new Thread (DoSomethingExpensive);
   worker.Start ();
   worker.Join (); // *unsinnig*
   // Do something after completion
   // ...
}

void DoSomethingExpensive () 
{
   // Do something expensive
   // ...
}

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. Das Gesagte gilt nicht nur für Thread.Join; 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:


void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
   new Thread (DoSomethingExpensive).Start ();
}

void DoSomethingExpensive () 
{
   // Do something expensive
   // ...

   this.Invoke ((Action)DoSomethingAfterCompletion); // sinnvoll
}

void DoSomethingAfterCompletion () 
{
   // Do something after completion
   // ...
}

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-Thread, sondern nur, wenn man wie im Beispiel explizit Control.Invoke oder vergleichbares verwendet.

Statt Action kann man vor .NET 3.5 MethodInvoker verwenden. Wenn man Parameter an DoSomethingAfterCompletion übergeben möchte, kann man einen der generischen Action<>-Delegaten verwenden, die es ab .NET 2.0 gibt. Ein Beispiel für Control.Invoke mit Parameterüber- und -rückgabe findet sich in [FAQ] Controls von Thread aktualisieren lassen (Control.Invoke/Dispatcher.Invoke).

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 Worker-Threads und Thread.Sleep

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 (wo es tabu ist). 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. Voraussetzung ist wieder, dass der Code, der im Tick-EventHandler ausgeführt wird, kürzer als 1/10s läuft. Wie lange das Timer-Intervall ist, spielt dagegen keine Rolle.

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.

Spezielle Probleme und Lösungen

Wenn der GUI-Thread an einem Deadlock beteiligt ist, führt das ebenfalls zum Blockieren des GUIs. Wie Deadlocks entstehen und wie man sie sucht, findet und auflöst, würde allerdings den Rahmen diese FAQ sprengen; im Forum und im Netz lassen sich jedoch leicht Hinweise dazu finden. Auf eine bestimmte Deadlock-Situation im Zusammenhang mit Control.Invoke wird in [FAQ] Controls von Thread aktualisieren lassen (Control.Invoke/Dispatcher.Invoke) eingegangen.

Einige Controls, z.B. ListView und DataGridView, bieten einen VirtualMode an, der sich für große Datenmengen anbietet, weil man in diesem Modus die Daten nicht vorab in das Control füllen muss, sondern das Control nur die jeweils zur Anzeige benötigen Informationen erfragt, siehe z.B. Exemplarische Vorgehensweise: Implementieren des virtuellen Modus im DataGridView-Steuerelement in Windows Forms.

Bei TreeViews braucht man vor der Anzeige nur die Daten der obersten Ebene zu laden und kann weitere Daten erst dann laden, wenn der Benutzer beginnt, Knoten zu erweitern, siehe Verzögertes Laden von Daten im TreeView.

Weitere Möglichkeiten, wie man das Füllen von Controls beschleunigen und das Blockieren des GUIs verhindern kann, werden im Thread Controls in anderem Thread erzeugen als das Form [==> auf keinen Fall / Liste der Alternativen] beschrieben.

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).

Was soll ich tun, wenn mir alle diese (und die verlinkten) Infos noch nicht geholfen haben?

Bitte mach keinen neuen Thread auf, denn:

Die FAQ ist mittlerweile so gereift, dass alle denkbaren Normal-, Spezial- und Fehlerfälle vollständig abgedeckt sind. Wir könnten in einem neuen Thread also auch nichts anderes oder neues schreiben! Lies dir also bitte alles nochmal (mehrfach) gründlich durch.

Wenn es mit deiner bisherigen Herangehensweise nicht funktioniert, kann das auch daran liegen, dass deine Herangehensweise ungünstig oder ungeeignet ist. In diesem Fall solltest du dich besser auf einen der beschriebenen Ansätze einlassen. Und die allgemeine Beschreibung auf dein konkretes Problem zu übertragen, ist in unseren Augen eben genau deine Aufgabe.

Wenn der Code trotzdem nicht tut, was er deiner Meinung nach tun sollte, stelle alle deine Annahmen, die du getroffen hast, infrage. Oft genug kommt man gerade deshalb nicht weiter, weil man annimmt, dass der Fehler an einer bestimmten Stelle oder an einer bestimmten Ursache auf keinen Fall liegen kann, aber gerade genau dort liegt. Überlege, welche Codestellen und Fehlerquellen du gedanklich ausgeschlossen hast, und prüfe, wenn du nicht weiterkommst, genau diese (erneut und) unvoreingenommen.

Wenn alle Stricke reißen, probiere, ob du mit [Tutorial] Vertrackte Fehler durch Vergleich von echtem Projekt mit minimalem Testprojekt finden weiterkommst.

Und wenn das alles noch nicht hilft, liegt es vermutlich daran, dass dir noch ein paar Grundlagen der objektorientierten oder der Windows-Programmierung fehlen. Diese Grundlagen eignest du dir am besten an, indem du ein gutes Buch durcharbeitest (siehe auch [FAQ] Wie finde ich den Einstieg in C#? und Buchempfehlungen). Ein Forum kann keine Einführung in die (Windows-)Programmierung ersetzen und auch nicht leisten.

In diesem Sinne viel Erfolg!

herbivore