Laden...

FAQ

[FAQ] Controls von Thread aktualisieren lassen (Control.Invoke/Dispatcher.Invoke)

Letzter Beitrag vor 10 Jahren 2 Posts 194.399 Views
[FAQ] Controls von Thread aktualisieren lassen (Control.Invoke/Dispatcher.Invoke)

Hallo Community,

_der folgende Text schildert Problem und Lösung zunächst anhand von Windows-Forms, doch die Prinzipien gelten für WPF ganz genauso, nur die Texte der Fehlermeldungen und der konkrete Code sind bei WPF etwas anders. Deshalb sollten WPF-Programmierer, den kompletten Text lesen, inkl. des Abschnitts am Ende, der die kleinen Unterschiede zwischen Windows-Forms und WPF beschreibt. Windows-Forms-Programmierer sollten ebenfalls den kompletten Text lesen, können aber den Abschnitt über WPF überspringen.

Es gibt verschiedene Möglichkeiten, wie man die Ausführung von GUI-Zugriffen an den GUI-Thread delegieren kann. Welche man davon am besten verwendet hängt hauptsächlich davon ab, welches nebenläufige Konstrukt (z.B. Thread, ThreadPool, BackgroundWorker, Tasks, async) man verwendet hat. Das zugrundeliegende Prinzip ist immer das gleiche, nämlich das am Beispiel Control.Invoke beschriebene. Die verschiedenen anderen Möglichkeiten (Dispatcher.Invoke, SynchronizationContext.Send, BackgroundWorker.ReportProgress, Task.ContinueWith, await) werden anschließend entsprechend kurz beschrieben. Deshalb sollte man den kompletten Text von oben nach unten lesen._

Das Problem

Alle Zugriffe auf GUI-Elemente (Controls, Form u.ä.) müssen aus dem Thread heraus erfolgen, der sie erzeugt hat. Wenn man sich nicht daran hält und aus einem extra Thread (oder einem beliebigen anderen echten nebenläufigem Konstrukt, z.B. BackgroundWorker oder Task) direkt auf GUI-Elemente zugreift, bekommt man ab .NET 2.0 bei eingeschalteter Prüfung meist folgende Meldung:

Fehlermeldung:
"Unzulässiger threadübergreifender Vorgang" bzw. "Ungültiger threadübergreifender Vorgang: Der Zugriff auf das Steuerelement 'Name des Steuerelements' erfolgte nicht von dem Thread aus, in dem das Steuerelement erstellt wurde." (*)

Und ggf. noch: > Fehlermeldung:

InvalidOperationException wurde nicht vom Benutzercode behandelt.

Aber auch in .NET 1.x und auch wenn diese Meldung nicht erscheint, sind direkte Zugriffe aus Threads unzulässig und können zu merkwürdigen Effekten und echten Fehlern führen. Deshalb würde es auch nichts nutzen, die Prüfung abzuschalten, sondern man muss auf jeden Fall die Ursache beheben.

In .NET 2.0 ist die Prüfung automatisch eingeschaltet. In höheren .NET Versionen ist das leider nicht mehr der Fall. Um das Debugging zu erleichtern, sollte Control.CheckForIllegalCrossThreadCalls stets auf true gesetzt und dann die Ursache aller auftretenden Exceptions behoben werden, selbst wenn das Programm bisher scheinbar korrekt lief.

Die automatische Prüfung ist eine unschätzbare Hilfe bei der Lokalisierung des Problems. Leider gibt es aufgrund von Race Conditions, Umgebungseinstellungen und weiteren Gründen keine vollständige Garantie, dass alle denkbaren Programmierfehler in diesem Bereich per "IllegalCrossThreadCall"-Exception gemeldet werden. Deshalb sollte man zusätzlich den von Threads ausgeführten Code gründlich durchsehen, um verbliebene direkte GUI-Zugriffe zu finden.

Die Lösung

Die Lösung liegt darin, die gewünschten GUI-Zugriffe in eine neue Methode zu packen und diese Methode mit Control.Invoke oder Control.BeginInvoke aufzurufen. Das Control.Invoke/BeginInvoke bewirkt, dass die Methode nicht vom aufrufenden Thread, sondern vom GUI-Thread ausgeführt wird.

Unterschiede von Control.Invoke und Control.BeginInvoke

In vielen Fällen kann man wählen, ob man Control.BeginInvoke oder Control.Invoke benutzt. Der Hauptunterschied ist, dass Control.Invoke wartet, bis der GUI-Thread die Aktion ausgeführt hat, wogegen Control.BeginInvoke den Arbeitsauftrag nur in die Nachrichtenschlange des GUI-Threads stellt und sofort zurückkehrt. Control.Invoke arbeitet also (in vielen Fällen unnötig) synchron. Allerdings muss man bei Control.BeginInvoke jegliche erforderliche Synchronisation beim gleichzeitigen Zugriff auf dieselben Daten selbst realisieren. Ein weiter Unterschied ist, dass man bei Control.Invoke leichter an einen evtl. Rückgabewert der Aktion kommt als bei Control.BeginInvoke.

Wenn man Control.BeginInvoke benutzt und später doch noch an den Rückgabewert der Aktion kommen möchte oder später doch noch auf die Beendigung der Aktion warten möchte, kann man Control.EndInvoke benutzen. Control.EndInvoke arbeitet synchron und kehrt erst zurück, nachdem die Aktion ausgeführt wurde. Der Aufruf von Control.EndInvoke ist optional, also nicht erforderlich.

Control.InvokeRequired und grundlegende Codebeispiele

Mit Control.InvokeRequired kann man abfragen, ob ein Aufruf von Control.Invoke/BeginInvoke nötig ist. Dadurch kann man Methoden nach folgendem Muster schreiben, ...


bool DoCheapGuiAccess (int percent)
{
   if (this.InvokeRequired) { // Wenn Invoke nötig ist, ...
      // dann rufen wir die Methode selbst per Invoke auf
      return (bool)this.Invoke ((Func<int,bool>)DoCheapGuiAccess, percent);
      // hier ist immer ein return (oder alternativ ein else) erforderlich.
      // Es verhindert, dass der folgende Code im Worker-Thread ausgeführt wird.
   }
   // eigentliche Zugriffe; laufen jetzt auf jeden Fall im GUI-Thread
   progressBar.Value = percent; // schreibender Zugriff

   return cancelCheckBox.Checked; // lesender Zugriff
}

... die man dann nach Belieben aus dem GUI-Thread oder aus beliebigen anderen Threads heraus aufrufen kann. Es ist aber zu beachten, dass nur "billige" GUI-Zugriffe erfolgen dürfen, keine langlaufenden Aktionen. Siehe auch [FAQ] Warum blockiert mein GUI? Abschnitt "Achtung: Die Falle".

In vielen Fällen wird man die Funktion DoCheapGuiAccess sowieso nur aus dem Worker-Thread heraus aufrufen wollen. Dann kann man sich das Control.InvokeRequired sparen, weil man weiß, dass es immer true liefert.


// Implementierung der Methode
bool DoCheapGuiAccess (int percent)
{
   progressBar.Value = percent;

   return cancelCheckBox.Checked;
}

// Aufruf der Methode aus dem Worker-Thread
cancel = (bool)this.Invoke ((Func<int,bool>)DoCheapGuiAccess, percent);

Zu beachten ist, dass man die Parameter für DoCheapGuiAccess als weitere Parameter an Control.Invoke übergibt, nicht direkt an DoCheapGuiAccess! Control.Invoke reicht diese intern an DoCheapGuiAccess weiter. Andersherum wird der Rückgabewert von DoCheapGuiAccess auch von Control.Invoke zurückgegeben. Allerdings ist er beim Control.Invoke immer vom Typ Object, so dass man in den echten Rückgabetyp casten muss.

Der Rückgabetyp wird immer als letzter Typ-Parameter von Func<> angegeben. Falls nicht benötigt, kann man die Parameter und/oder den Rückgabewert auch weglassen. Lässt man den Rückgabewert weg, kann man statt Func<> die generischen Action<>-Delegaten (ebenfalls aus dem System-Namespace) verwenden. Lässt man Parameter und Rückgabewert weg, kann man den nicht generischen Action-Delegaten oder den MethodInvoker-Delegaten verwenden.

Bei der Umsetzung der Beispiele in euren Code müsst ihr insbesondere darauf achten, dass ihr Control.Invoke/BeginInvoke benutzt (und nicht etwa z.B. Delegate.Invoke/BeginInvoke). Das this in den Codebeispielen ist vom Typ Form. Außerdem müsst ihr die vielleicht ungewohnte Aufrufsyntax genau einhalten. Wenn es nicht gleich klappt, also zuerst diese beiden Punkte prüfen.

Zugriffe zusammenfassen / Performance von Control.Invoke und Control.BeginInvoke

Solange alle Forms und Controls - wie empfohlen - vom selben Thread, also dem GUI-Thread erzeugt werden, ist es egal, welches Form oder Control man für das Control.Invoke verwendet, denn Control.Invoke übergibt die Steuerung ja nicht an das konkrete Form oder Control, sondern an den Thread, in dem das Form oder Control erzeugt wurde. Und das ist ja bei allen Forms und Controls dann ein und derselbe (GUI-)Thread, egal welches Form oder Control man wählt.

In einer Methode, die man mit Control.Invoke/BeginInvoke aufruft, können mehrere Zugriffe auf mehrere Controls erfolgen. Man muss also nicht für jeden einzelnen Zugriff oder jedes einzelne Control eine einzelne Methode schreiben, sondern kann und sollte Zugriffe zusammenfassen (und dazu nötigenfalls zunächst zu sammeln und die eigentliche Aktualisierung des GUIs per Timer durchzuführen). Das ist auch deshalb zu empfehlen, weil Control.Invoke eine vergleichsweise teure (=langsame) Operation ist.

Allerdings muss man darauf achten, dass die Laufzeit der zusammengefassten Zugriffe nicht mehr als 1/10s beträgt, da sonst das GUI-blockiert; siehe [FAQ] Warum blockiert mein GUI?

Control.BeginInvoke ist deutlich weniger teuer als Control.Invoke. Trotzdem sollte man mit Aufrufen von Control.BeginInvoke genauso sparsam sein, denn jeder Aufruf von Control.BeginInvoke stellt eine Nachricht in die Nachrichtenschlange des GUI-Threads. Wenn sich nun hintereinander soviele solcher Nachrichten in der Nachrichtenschlange angesammelt haben, dass deren Abarbeitung in der Summe länger 1/10s dauert, blockiert das GUI genauso, als wäre nur eine Nachricht eingestellt worden, deren Verarbeitung länger als 1/10s dauert.

Databinding: Zugriffe auf gebundene Daten

Nicht nur Zugriffe auf Controls selbst müssen aus dem GUI-Thread erfolgen. Auch alle Zugriffe auf andere Objekte/Daten müssen aus dem GUI-Thread erfolgen, nachdem diese mittels Databinding an Controls gebunden wurden. Das heißt, man kann durchaus eine aufwändige Liste in einem Worker-Thread füllen, aber sobald diese Liste an ein Control gebunden wurde, was natürlich im GUI-Thread erfolgen muss, müssen auch alle weiteren Zugriffe auf die Liste und/oder darin enthalte Objekte aus dem GUI-Thread erfolgen.

Parent-Child-/Owner-Owned-Beziehungen

Wenn man zwei Forms/Controls in eine z.B. Parent-Child-Beziehung zueinander setzt (z.B. durch form1.Controls.Add (textBox1)), müssen beide Forms/Controls im selben Thread erzeugt worden sein. Controls, die in Beziehung stehen, führen untereinander Zugriffe aus und die wären dann fälschlich threadübergreifend, wenn die beiden Forms/Controls in unterschiedlichen Threads laufen würden. Man darf also insbesondere nicht das Form in einem und seine Controls in einem anderen Thread erzeugen. Sinnvollerweise sollte es immer nur einen GUI-Thread geben.

Im Thread Controls in anderem Thread erzeugen als das Form [==> auf keinen Fall] wird erklärt, warum eben dies gar nicht nötig ist.

Fehlender Fenster-Handle

Fehlermeldung:
Invoke oder BeginInvoke kann für ein Steuerelement erst aufgerufen werden, wenn das Fensterhandle erstellt wurde.

Wenn diese Fehlermeldung erscheint, dann existiert zwar schon das (.NET)Control, für das ihr Control.Invoke/BeginInvoke aufrufen wollt, aber das zugrundeliegende Win32-Control wurde noch nicht erzeugt. Und somit gibt es noch keinen Handle, mit dem das Win32-Control angesprochen werden kann. In diesem Fall reicht es rechtzeitig vorher im GUI-Thread Control.CreateControl aufzurufen. Die CreateControl-Methode erzwingt das Erstellen eines Handles für das Steuerelement (sowie der untergeordneten Steuerelemente). Wenn man Control.CreateControl benötigt, dann ruft man es am besten gleich im Konstruktor des Forms auf.

Diese Meldung - oder alternativ eine ObjectDisposedException - kann auch erscheinen, wenn das Form/Control schon wieder geschlossen/zerstört ist. Dieser Fall kann außerdem eintreten, wenn Control.Invoke aus einem (Timer-)Event heraus erfolgt und das (Timer-)Event gefeuert wird, nachdem das Form geschlossen wurde. Vor dem Schließen/Zerstören eines Forms/Controls sollte also alle EventHandler, die ein Control.Invoke auf das entsprechende Form/Control ausführen, deregistriert werden. Darüber hinaus muss (durch korrekte Thread-Synchronisation) sichergestellt werden, dass sich keiner dieser EventHandler aktuell in Ausführung befindet.

Der Fehler mit dem fehlenden Fensterhandle tritt auch auf, wenn man sowas probiert:

new Form1 ().Invoke ((Action)DoCheapGuiAccess); // falsch

Dieser Code würde selbst dann nichts nützen, wenn man vor dem Control.Invoke den FensterHandle noch explizit erzeugen würde, denn ein Form oder Control, das man in dem Thread erzeugt hat, der Control.Invoke aufruft, macht natürlich keinen Sinn. Man braucht immer ein Control, das im und vom GUI-Thread erzeugt wurde, damit Control.Invoke die Kontrolle an den GUI-Thread übergibt.

Spezielle Probleme und Lösungen

Wenn man in dem Thread gar kein Control zur Verfügung hat, für das man Control.Invoke oder Control.BeginInvoke aufrufen kann, muss man nicht verzweifeln, denn gibt es ab .NET 2.0 SynchronizationContext.Send/Post. Allerdings wird in Eleganteste Art aus Worker-Thread auf Controls zugreifen [generell Kontrollfluss zwischen Threads] beschrieben, warum das eher eine Notlösung ist. (Für .NET 1.1 hat Programmierhans in Komponenten mit mehreren Threads quasi einen SynchronizationContext nachgebaut, den man aber nur verwendet werden sollte, wenn man tatsächlich noch .NET 1.1 benutzt.

Wenn ihr Control.InvokeRequired benutzt, kann es in sehr seltenen Fällen zu einem heimtückischen Problem kommen. Details in Threadübergreifender Vorgang trotz InvokeRequired.

Wenn ihr den Fehler threadübergreifender Vorgang im EventHandler eines Timers bekommt, dann habt wahrscheinlich den falschen Timer. Nehmt System.Windows.Forms.Timer.

Bei der Verwendung von Control.Invoke kann man sich Deadlocks einhandeln, weil der extra Thread darauf wartet, bis das GUI die Aktion durchgeführt hat. Wenn das GUI nun so programmiert ist, dass es gerade auf den extra Thread wartet, warten die beiden ewig aufeinander. Das GUI sollte nie auf die Threads warten (es sollte grundsätzlich nie auf irgendwas warten). Das ist auch nie nötigt. Stattdessen sollen Thread nötigenfalls eigene Events feuern, die das GUI abonnieren kann. Hinweis: Die EventHandler laufen ohne weiteres Zutun im Worker-Thread, weshalb man beim Zugriff auf Controls und gebundene Daten das Control.Invoke nicht vergessen darf.

Ob so eine Deadlock-Situation vorliegt, kann man leicht testen, in dem man Control.Invoke durch Control.BeginInvoke ersetzt. Funktioniert es dann, hatte man vorher wohl einen solchen Deadlock. Aber einfach Control.BeginInvoke zu lassen, ist keine Lösung! Man sollte dann die Ursache suchen und beheben. Meist geht das so, wie im vorigen Absatz beschrieben.

Wenn man im FormClosing das Beenden des Worker-Threads anstoßen will und gleichzeitig möchte, dass sich das Form erst schließt, nachdem der Thread beendet ist, sollte man auf keinen Fall Thread.Join oder while(IsAlive) verwenden (das GUI sollte grundsätzlich nie auf irgendwas warten), sondern stattdessen sollte man das FormClosing abbrechen (e.Cancel = true), solange der Thread noch lebt. Als letzte Aktion kann der Thread einen eigenes Event feuern, der vom GUI abonniert wird und das dann per Control.Invoke Form.Close aufrufen kann.

Eine Konstruktion mit einem Worker-Thread per anonymen Delegaten mit Parameterübergabe, bei der auf Controls zugegriffen wird, also z.B.

new Thread(() => { DoSomethingExpensive (myControl1.Text, myControl2.Text) }).Start ();

kann und wird zu einem unzulässigem threadübergreifendem Zugriff führen, weil der Zugriff auf myControl1.Text und myControl2.Text erst aus dem neu gestarteten Thread heraus erfolgt. Wenn man so eine Konstruktion verwenden will, muss man die Werte aus den Controls noch im GUI-Thread abrufen, diese z.B. in lokalen Variablen zwischenspeichern und mit diesen DoSomethingExpensive aufrufen, also z.B.


String text1 = myControl1.Text;
String text2 = myControl2.Text;
new Thread(() => { DoSomethingExpensive (text1, text2) }).Start ();

**
Weitere Codebeispiele**

Weitere Codebeispiele finden sich in Controls von Thread aktualisieren lassen (Invoke-/TreeView-Beispiel) und invoke und eventproblem sowie über die Forumsuche. Diese Codebeispiele solltet ihr euch unbedingt ansehen.

Weiterführende Infos

Hier noch ein Verweis auf den guten Artikel Sicheres und einfaches Multithreading in Windows Forms von den Microsoft-Seiten.

BackgroundWorker

Statt mit extra Threads und Control.Invoke, kann man auch mit der BackgroundWorker-Klasse ungültige threadübergreifende Vorgänge vermeiden. Dazu müssen bei BackgroundWorker alle Zugriffe auf das GUI einfach aus den ProgressChanged- oder RunWorkerCompleted-EventHandlern durchgeführt werden. Ersteres wird durch BackgroundWorker.ReportProgress ausgelöst, letzteres nach dem Ende der Verarbeitung im BackgroundWorker. Wenn man sich daran hält, ist explizites Control.Invoke bei BackgroundWorker nicht erforderlich, denn die genannten EventHandler werden hinter den Kulissen automatisch per Control.BeginInvoke aufgerufen.

Alles vorausgesetzt, der BackgroundWorker wurde korrekt initialisiert, so dass er insbesondere einen passenden SynchronizationContext verwendet. Wenn der BGW unter Verwendung von VS im GUI-/Main-Thread erzeugt wurde, sollte das automatisch der Fall sein.

Tasks/TPL/async/await

Wenn man die Nebenläufigkeit direkt oder indirekt durch Tasks erzeugt hat, kann man statt Control.Invoke für den GUI-Zugriff auch Continuations verwenden, die im GUI-Thread laufen, oder await im GUI-Thread verwenden. Wie das geht, hat gfoidl weiter unten beschrieben.

Wie geht es in WPF?

Da ich (noch) kein WPF-Kenner bin, hat mir bei dem folgenden Text und Code michlG geholfen. Vielen Dank!

Im Vergleich zu Windows-Forms hat sich bei WPF nicht viel geändert. In Windows.Forms hat man direkt Control.Invoke verwendet, in WPF ist jedes Control von DispatcherObject abgeleitet und besitzt daher einen Dispatcher. Statt Control.Invoke verwendet man jetzt Control.Dispatcher.Invoke. Statt der InvokeRequired-Property von Windows-Forms gibt es in WPF nun die CheckAccess-Methode, welches true zurückliefert, wenn man im GUI-Thread ist und false, wenn man in einem anderen Thread ist. Der Rückgabewert ist also gegenüber Control.InvokeRequired genau negiert. Die Fehlermeldung lautet bei WPF:

Fehlermeldung:
Der aufrufende Thread kann nicht auf dieses Objekt zugreifen, da sich das Objekt im Besitz eines anderen Threads befindet.

Hier das Codebeispiel von oben abgeändert für WPF:



bool DoCheapGuiAccess (int percent)
{
   if (!this.Dispatcher.CheckAccess ()) { // Wenn Invoke nötig ist, ...
      // dann rufen wir die Methode selbst per Invoke auf
      return (bool)this.Dispatcher.Invoke ((Func<int,bool>)DoCheapGuiAccess, percent);
      // hier ist immer ein return (oder alternativ ein else) erforderlich.
      // Es verhindert, dass der folgende Code im Worker-Thread ausgeführt wird.
   }
   // eigentliche Zugriffe; laufen jetzt auf jeden Fall im GUI-Thread
   progressBar.Value = percent; // schreibender Zugriff

   return cancelCheckBox.Checked; // lesender Zugriff
}

Weitere Infos für WPF: Threading-Modell

Siehe auch

Gewusst wie: Threadsicheres Aufrufen von Windows Forms-Steuerelementen

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

(*) Alternative Fehlermeldungen in anderen Sprachen bzw. aus anderen .NET-Versionen sowie andere Fehlermeldungen, die ebenfalls daraufhinweisen, dass der verursachende Code fälschlich nicht im GUI-Thread läuft.

Fehlermeldung:

Control.Invoke must be used to interact with controls created on a separate thread.

SendKeys kann nicht innerhalb der Anwendung ausgeführt werden, da diese Anwendung keine Windows-Meldungen verarbeitet. Ändern Sie die Anwendung so, dass sie Meldungen behandelt, oder verwenden Sie die SendKeys.SendWait-Methode.

Hallo Community,

mit der Einführung der TPL in .NET 4.0 und durch async/await in .NET 4.5/C# 5 gibt es weitere Möglichkeiten, mit denen das GUI aktualisiert werden kann.

TPL in .NET 4.0

Tasks werden in der Task Parallel Library (TPL) mit Hilfe von Task Schedulern ausgeführt. Der Standard-TaskScheduler verwendet dabei den ThreadPool. Um von dort auf ein GUI-Element zugreifen zu können, wäre es nicht falsch, eine der oben erwähnten Möglichkeiten zu verwenden, aber die TPL bietet aber mit der TaskScheduler.FromCurrentSynchronizationContext Methode eine eigene Möglichkeit, die Task in einem bestimmten Thread (hier: GUI-Thread) laufen zu lassen. Somit ist es möglich, eine Continuation (Fortsetzung) - also eine Aktion, die angestoßen wird, wenn die ursprüngliche Task fertig ist - im GUI-Thread laufen zu lassen.

Im folgenden Beispiel wird gezeigt, wie z.B. eine Datenbank-Abfrage, ein Dateidownload, eine lange Berechnung, etc., in einen (Hintergrund-) Thread verlagert und sobald der Thread die Aufgabe erledigt hat, das GUI aktualisiert wird.


void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
    Task<string> expensiveWork = DoSomethingExpensiveAsync();

    expensiveWork.ContinueWith(
        t => DoCheapGuiAccess(t.Result),
        TaskScheduler.FromCurrentSynchronizationContext()
    );
}

void DoCheapGuiAccess(string result) 
{
    ctrl.Text = result;
}

Anstatt in Task.ContinueWith überall TaskScheduler.FromCurrentSynchronizationContext() zu schreiben, kann der (GUI-) SynchronizationContext auch in einem (privaten) Feld gehalten und dieses verwendet werden.

Sobald jedoch Schleifen ins Spiel kommen, wird es mit der TPL etwas holprig, da die Schleife in verschiedene Continuations aufgebrochen werden müsste. In diesem Fall wäre die Verwendung von Control.Invoke wiederum einfacher (siehe oben). Oder man verwendet noch besser die folgende Möglichkeit.

async/await ab C# 5.0

In C# 5 wurde durch die Einführung der Schlüsselwörter async/await die asynchrone Programmierung erleichtert, indem der Compiler die Continuations für Tasks generiert. Der Code ist dadurch leichter zu lesen und auch wartbarer. Wegen der dahinter steckenden Compiler-Magie, ist es allerdings auf den ersten Blick nicht ganz leicht zu verstehen, was genau passiert. Aus folgender Methode


async void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
    string result = await DoSomethingExpensiveAsync();
    DoCheapGuiAccess(result);
}

generiert der Compiler sinngemäß(*) den Code wie im TPL-Beispiel.

Das liegt daran, dass das Standardverhalten beim "Erwarten" ("awaiting") von Tasks ist, dass der aktuelle SynchronizationContext gefangen wird und die Continuation in diesem SynchronizationContext ausgeführt wird. D.h. wenn await im GUI-Thread verwendet wird, so wird die Continuation (der Rest der Methode nach await) mit Hilfe von SynchronizationContext.Post zurück in den GUI-Thread delegiert und dort ausgeführt.

Die extra Methode DoCheapGuiAccess ist im Prinzip nicht nötig und die Zuweisung an eine Eigenschaft eines GUI-Elements könnte direkt im Click-Handler erfolgen, da eben der Code hinter der Zeile mit dem await als Continuation im GUI-Thread ausgeführt wird.

(*) Dass der resultierende Code dem aus dem TPL-Beispiel entspricht, ist etwas vereinfacht. In Wirklichkeit erstellt der Compiler eine Statemachine welche die Continuation besitzt. Die genaue Funktionsweise von async und await zu erklären, würde hier den Rahmen sprengen. Nachzulesen in das Asynchronous Programming - Pause and Play with Await - bitte aber nicht durch das "Pause" aus dem Titel irritieren lassen, es blockiert nichts in Sinne von Pause = Stop, Warten.

Wenn Schleifen ins Spiel kommen, entfällt das oben genannte Problem mit der TPL. Der Code bleibt bei Verwendung von async/await strukturell unverändert(**). Das technisch trotzdem erforderliche Aufbrechen in verschiedene Continuations übernimmt der Compiler hinter den Kulissen.


void DoSomethingExpensiveClick (Object objSender, EventArgs e) 
{
	DoSomethingExpensive();
}

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

	do {
		await Task.Delay(200); // simuliert aufwändige Aktion
		percent += 10;
		cancel = DoCheapGuiAccess(percent);

	} while (percent < 100 && !cancel);
}

bool DoCheapGuiAccess(int percent) 
{
	progressBar.Value = percent;

	return cancelCheckBox.Checked;
}

(**) In [FAQ] Warum blockiert mein GUI? findet sich das gleiche Codebeispiel unter Verwendung von Thread.Start und Control.Invoke.

**
Verhindern, dass die Continuation im GUI-Thread ausgeführt wird**

Das (relativ teure) Zurückdelegieren zum gefangen SynchronizationContext ist nur dort nötig, wo GUI-Zugriffe erfolgen sollen. Wird stattdessen eine weitere langlaufende Aktion ausgeführt, würde das Zurückdelegieren sogar das GUI blockieren.


async void DoSomethingExpensiveAsync() 
{
    await Task.Delay(2000); // simuliert aufwändige asynchrone Aktion
    Thread.Sleep(1000);     // simuliert aufwändige synchrone Aktion
}

In DoSomethingExpensiveAsync wird zuerst ein asynchroner Vorgang (simuliert durch Task.Delay) gestartet und sobald diese Task fertig ist, wird die Continuation zurück in den GUI-Thread delegiert. In der Continuation wird aber eine Operation ausgeführt (simuliert durch Thread.Sleep), die länger als 1/10s dauert, und daher blockiert das GUI. Siehe [FAQ] Warum blockiert mein GUI?

Damit es nun nicht zu einem Blockieren kommt, kann das Zurückdelegieren mittels ConfigureAwait abgestellt werden:


async void DoSomethingExpensiveAsync() 
{
    await Task.Delay(2000).ConfigureAwait(false); // simuliert aufwändige asynchrone Aktion
    Thread.Sleep(1000);                           // simuliert aufwändige synchrone Aktion
}

gfoidl, herbivore

Stellt fachliche Fragen bitte im Forum, damit von den Antworten alle profitieren. Daher beantworte ich solche Fragen nicht per PM.

"Alle sagten, das geht nicht! Dann kam einer, der wusste das nicht - und hat's gemacht!"