[ExtensionMethods] GuiThread und WorkerThread einfach umschalten

ErfinderDesRades
Neulich stieß ich auf eine erstaunliche Möglichkeit, innerhalb derselben Methode nach Belieben umzuschalten, zw. Gui-Thread und Worker-Thread. Damit hat man den glaub einfachsten Lösungsansatz der viel gefragten  [FAQ] Warum blockiert mein GUI? , und der auf dem Fuße folgenden  [FAQ] Controls von Thread aktualisieren lassen (Control.Invoke)
Statt also für Zwischenmeldungen ans Gui, und für die Abschlußmeldung je spezifische Methoden zu implementieren, kann man die im WorkerThread laufende Methode einfach zeitweilig auf "GuiThread" umschalten, Meldungen absetzen, und fortfahren. Insbesondere der Transfer von Variablen von einem Thread in den anderen vereinfacht sich sehr (nämlich indem er entfällt Augenzwinkern ) da ja alles in derselben Methode stattfindet.

Der Trick besteht darin, einen Iterator-Block zu schreiben.

Ein Iterator-Block ist eine Methode, die als IEnumerable deklariert ist, und mittels yield return imstande, mehrere (!) Werte nacheinander (!) an ihren Aufrufer zu liefern.
Im einzelnen läuft im Iterator-Block dabei der Code immer von einem yield return Statement zum nächsten, yieldet den Wert, und verharrt an dieser Stelle, bis die aufrufende Schleife den nächsten yield-Wert anfordert.
(An dieser Stelle hätte ich empfohlen, sich in der MSDN schlau zumachen - Stichwort "Iterator". Leider fehlt in der Express-Edition der (gute) Beitrag.)

Eine "YieldThreading"-Methode jedenfalls ist ein solcher Iterator-Block, und er kann ExecuteIn.WorkerThread und ExecuteIn.GuiThread yielden, beliebig viele, und zwar nacheinander, wie gesagt.
(ExecuteIn ist eine hierfür geschaffene Enumeration).

Die YieldThreading-Methode wird nun von aussen enumeriert, von einer Methode, die im WorkerThread läuft.
Yieldet sie nun ExecuteIn.GuiThread an den Aufrufer, so sendet dieser das Anfordern des nächsten yield-Wertes über einen SynchronisationContext an den GuiThread (statt den nächsten Wert direkt anzufordern).
Das bewirkt, daß nun auch der Code im GuiThread läuft, mit dem die YieldThreading-Methode zum nächsten yield return Statement eilt.

Hmm - ob einer diese Erklärung versteht?

Inne Praxis jedenfalls bewirkt

C#-Code:
yield return ExecuteIn.GuiThread

daß der darauf folgende Code im GuiThread ausgeführt wird

Und mit

C#-Code:
yield return ExecuteIn.WorkerThread

kann man in den WorkerThread zurück-switchen.

Ein Beispiel mit etwas gehobenen Ansprüchen

Gesetzt seien 2 zeitaufwändige-Lade-Vorgänge, nämlich die Personen-Namen, und die Personen-Geburtsdaten. Die Ladevorgänge können wahlweise einzeln gestartet werden, aber auch zusammen, in einem gemeinsamen Worker-Job.
Jeder Ladevorgang umfasst
  • [GuiThread] ---- Löschung der alten Daten, dann Status-Meldung "Beginne Laden xy", dann Rücksetzen und Anzeigen der Progressbar.
  • [WorkerThread] Laden der Daten.
  • [GuiThread] ---- Dazwischen eingestreut Meldungen über den Fortschritt des Vorgangs
  • [WorkerThread] Weiter-Laden der Daten
  • [GuiThread] ---- Zum Abschluß die Progressbar wieder verstecken, und die Status-Meldung "Ready" ausgeben.
Eine besondere Feinheit ist die als '##Optimierung gekennzeichnete Verknüpfung der Ladevorgänge: Der 2. Vorgang verwendet den Workerthread des ersten einfach weiter.

C#-Code:
using System;
using System.Data;
using System.Drawing;
using System.Windows.Forms;
using System.Threading;
using System.Collections.Generic;

namespace YieldThreadTest {
   public partial class uclYieldThread : UserControl {

      public uclYieldThread() {
         InitializeComponent();
         SetupDB();
         /* .FirstSection(ExecuteIn.GuiThread) gibt an, daß in LoadNames() der Code-Abschnitt bis zum
          * ersten yield return im GuiThread erfolgen soll.
          * Die Thread-Bestimmung der weiteren Abschnitte erfolgt den jeweiligen yield Rückgabewert. */

         btLoadNames.Click += (s, e) => LoadNames().FirstSection(ExecuteIn.GuiThread);
         btLoadDates.Click += (s, e) => LoadBirthDates().FirstSection(ExecuteIn.GuiThread);
      }

      IEnumerable<ExecuteIn> LoadNames() {
         SetGuiViewToAsync("Loading Names");    // Beginn der Aktion melden
         personDts.Person.Clear();
         yield return ExecuteIn.WorkerThread;       // in WorkerThread schalten
         foreach (var rw in dataBase.Person) {
            // zeitaufwändig Daten holen
            Thread.Sleep(300);
            var name = rw.Name;
            // den im Gui-Thread auszuführenden Code-Bereich in entsprechende yield-returns einschließen.
            yield return ExecuteIn.GuiThread;    // in GuiThread schalten
            personDts.Person.AddPersonRow(name, default(DateTime));   // aufgrund von DataBinding ist dieses
            //                                                                       dem GuiThread zuzuordnen
            toolStripProgressBar1.PerformStep();
            yield return ExecuteIn.WorkerThread; // in WorkerThread zurück-schalten
         }
         yield return ExecuteIn.GuiThread;              //für Abschluß-Meldungen wieder in GuiThread schalten
         ResetGuiView();                      // Abschluß der Aktion melden
         if (MessageBox.Show(this,
            "Wanna load BirthDates too?", "Wanna load BirthDates too?",
            MessageBoxButtons.YesNo) == DialogResult.Yes) {
            // ##Optimierung: in den WorkerThread schalten und dann die YieldThread-Ausführung aufrufen
            // So wird statt eines neuen NebenThreads der bisherige weiter verwendet.
            yield return ExecuteIn.WorkerThread;
            LoadBirthDates().FirstSection(ExecuteIn.GuiThread);
         }
      }

      IEnumerable<ExecuteIn> LoadBirthDates() {
         SetGuiViewToAsync("Loading Birthdates");
         personDts.Person.ForEach(rw => rw.BornAt = default(DateTime));    //alte Datumse rauslöschen
         yield return ExecuteIn.WorkerThread;                           // in WorkerThread schalten
         foreach (var rw in personDts.Person) {
            // zeitaufwändig Daten holen
            Thread.Sleep(300);
            var bornAt = dataBase.Person.FindByName(rw.Name).BornAt;
            // den im Gui-Thread auszuführenden Code-Bereich in entsprechende yield-returns einschließen.
            yield return ExecuteIn.GuiThread;
            rw.BornAt = bornAt;                                       // aufgrund von DataBinding ist dieses
            //                                                                       dem GuiThread zuzuordnen
            toolStripProgressBar1.PerformStep();
            yield return ExecuteIn.WorkerThread;
         }
         yield return ExecuteIn.GuiThread;
         ResetGuiView();
      }

      private void SetupDB() {
         dataBase.Person.AddPersonRow("Homberg", DateTime.Parse("11.9.1962"));
         dataBase.Person.AddPersonRow("Hempel", DateTime.Parse("12.9.1962"));
         dataBase.Person.AddPersonRow("Meier", DateTime.Parse("13.9.1962"));
         dataBase.Person.AddPersonRow("Lüdenscheidt", DateTime.Parse("14.9.1962"));
         dataBase.Person.AddPersonRow("Gauß", DateTime.Parse("11.4.1962"));
         dataBase.Person.AddPersonRow("Euler", DateTime.Parse("15.9.1962"));
         dataBase.Person.AddPersonRow("Gates", DateTime.Parse("18.9.1962"));
         dataBase.Person.AddPersonRow("Merkel", DateTime.Parse("11.9.1962"));
         dataBase.Person.AddPersonRow("Hinz", DateTime.Parse("10.9.1962"));
         dataBase.Person.AddPersonRow("Kunz", DateTime.Parse("6.9.1962"));
      }

      private void SetGuiViewToAsync(string status) {
         toolStripStatusLabel1.Text = status;
         toolStripProgressBar1.Value = 0;
         toolStripProgressBar1.Visible = true;
      }

      private void ResetGuiView() {
         toolStripStatusLabel1.Text = "Ready";
         toolStripProgressBar1.Visible = false;
      }

   }
}

Ein Minuspunkt:

Das Yield-Threading verwendet SynchronisationContext.Send() statt .Post(). ( entspricht Control.Invoke() statt .BeginInvoke()).
Der Nachteil besteht darin, daß .Send() / .Invoke() auf die Fertig-Ausführung im Gui-Thread warten. Der WorkerThread bremst sich also selbst ein bischen aus.
Und das ist leider nicht zu umgehen:
Würde der WorkerThread nicht warten, so würde er dem Gui-Thread den für diesen bestimmten yield return quasi "wegschnappen" - die bekannte CrossthreadCall-Exception wäre direkte Folge von das. unglücklich .

Aber man betrachte die Relationen: Eine Meldung ans Gui sollte eh schnell vonstatten gehen, und man sollte die Threads auch nicht häufiger als ca. 3 mal pro Sekunde wechseln, weil
  • so schnell kann keiner gucken
  • ein Thread-Wechsel ist in jedem Fall "teuer"
Auch zu beachten

Threading wird dadurch nicht automatisch zum Kinderspiel.
Z.B. besteht die Gefahr, daß yield returns vergessen werden, zu setzen, oder den ungeeigneten Thread angeben. Oder daß beim Umherschieben von Zeilen Threading-Code in Gui-Code-Bereiche gerät.
Besonders bedenkenswert scheint mir, daß ein yield return versehentlich übersprungen wird, wenn aus einer Block-Struktur, etwa mit break; heraussgesprungen wird.
Nichtmal mit einem try{ }finally{ }-Konstrukt ist diesen Problemen zu begegnen, denn yield return darf nicht in finally-Blöcken stehen.

Auch kann man nicht innerhalb von weiter-verzweigten Methoden den Thread umschalten, jedenfalls nicht direkt.
In dem Fall muß man die Unterfunktion auch als Yield-Thread-Methode anlegen, und wie im Beispiel als Optimierung gezeigt, der Yield-Threading-Extension übergeben.

Danksagung:
Diese fabelhafte Idee ist leider nicht von mir. Ich habe sie aus dem  Material zum Kurzvortrag am 17.2.09, den Ralf Hoffmann gehalten hat, der Erfinder dieses Ansatzes.
Clepsydra
Nachdem ich den "AsyncExecutor" jetzt seit fast 2 Jahren verwende dachte ich mir es wird Zeit ihn zusammen mit einem Beispiel bei codeplex einzustellen:
 http://asyncexecutor.codeplex.com/
Grüße,
Ralf
herbivore
Hallo zusammen,

zur Info: mit C# 5.0 wurden die Schlüsselworte async und await eingeführt, wobei das await im wesentlichen dem ExecuteIn.GuiThread entspricht.

herbivore