Zum Inhalt springen

Arbeiten mit .NET: Das Framework/ Tipps/ Multithreading/ Grundlagen

Aus Wikibooks
Wikipedia hat einen Artikel zum Thema:

Wer jetzt direkt aus dem Abschnitt Ereignisse hierher gesprungen ist, sei gewarnt: In diesem Abschnitt werden wir zwar das Beispiel der "hinkenden Ereignisse" noch einmal rekapitulieren, gleichzeitig werden aber neue Punkte hinzukommen. Für das erste Verständnis ist es sicher besser, sich eins nach dem anderen anzuschauen.

Problem

[Bearbeiten]

Schauen wir uns noch einmal das Beispiel aus dem Abschnitt Ereignisse genauer an. Wir hatten eine Klasse Rechner, die eine komplizierte Berechnung durchführen soll und das Ergebnis als Ereignis OnBerechnungBeendet bekannt gibt. Und wir hatten eine Klasse Client, die dieses Ereignis abonniert und das Ergebnis nach dem Ende der Berechnung anzeigt.

C#-Quelltext
class Rechner 
 {
   public delegate void BerechnungBeendet(decimal ergebnis);
   public event BerechnungBeendet OnBerechnungBeendet;
 
   public void Berechne()
   {
     decimal zahl1 = 164m;
     decimal zahl2 =  26m;
     //
     // Hier findet eine sehr komplexe Berechnung statt...
     //
 
     // Am Ende der Berechnung lösen wir das Ereignis aus
     // Symbolisch addieren wir nur.
     if ( OnBerechnungBeendet != null )
     {
       OnBerechnungBeendet( zahl1 + zahl2 );
     }
   }
 }
 
 class Client
 {
   public void Berechne()
   {
     // Wir holen uns ein Objekt des Rechners
     Rechner rechner = new Rechner();
 
     // Wir abonnieren das Ereignis
     // Dazu erstellen wir ein neues Objekt 
     // des Delegaten "BerechnungBeendet" und 
     // fügen dieses dem Ereignis hinzu.
     rechner.OnBerechnungBeendet += new Rechner.BerechnungBeendet(rechner_OnBerechnungBeendet);
 
     // Wir beginnen die komplizierte Berechnung
     // der Addition zweier Zahlen.
     rechner.Berechne();
  
   }
 
   private void rechner_OnBerechnungBeendet(decimal ergebnis)
   {
     Console.WriteLine( ergebnis ); 
   }
 }

Unser Beispiel im Abschnitt Ereignisse war allerdings aus Gründen der einfacheren Erklärung etwas praxisfern. Dieser Praxisferne wollen wir jetzt abhelfen.

Multithreading

[Bearbeiten]

Bisher haben wir unsere Programme praktisch "linear" ausgeführt: Eine Zeile wurde stur nach der anderen abgearbeitet, und erst, wenn eine fertig bearbeitet war, durfte die nächste begonnen werden. Für unser Beispiel bedeutete das im Grunde: "Danke für das tolle Ereignis. Gebracht hat's uns aber nicht wirklich viel, weil wir bei unserer linearen Abarbeitung sowieso auf das Ende der Berechnung warten mussten."

Doch das können wir ganz leicht beheben, indem wir einfach die Berechnung in einem anderen Thread (dt. Faden, Ausführungsstrang) des Betriebssystems ausführen lassen. So haben wir unseren eigenen Thread wieder frei für andere Aufgaben, während wir auf das Ergebnis der Berechnungen warten. ... Klingt kompliziert, oder? Aber wer würde jetzt noch glauben, dass es wirklich kompliziert ist?! Schauen wir es uns in Ruhe an.

Wie zu erwarten war, müssen wir zunächst unsere Klasse Rechner ein wenig umbauen. Dazu benennen wir die Methode Berechne in BerechneAsynchron um und ändern den Zugriffsmodifizierer in private. Außerdem fügen wir einen Übergabeparameter vom Typ object hinzu. Und weil wir nun keine öffentliche Methode mehr haben, könnte unser Client nicht mehr auf die Berechnung zugreifen. Also fügen wir auch noch eine neue public-Methode ein, die wir Berechne nennen.

C#-Quelltext
class Rechner 
 {
   public delegate void BerechnungBeendet(decimal ergebnis);
   public event BerechnungBeendet OnBerechnungBeendet;
 
   private void BerechneAsynchron(object state)
   {
     decimal zahl1 = 164m;
     decimal zahl2 =  26m;
     //
     // Hier findet eine sehr komplexe Berechnung statt...
     //
 
     // Und weil das sonst so rasend schnell geht,
     // veranstalten wir hier einen sinnlosen Loop.
     // Weil aber der Compiler merkt, dass wir hier Unfug schreiben,
     // würde er es einfach wegoptimieren.
     // Um das zu verhindern, täuschen wir eine Berechnung vor.
     // Dabei machen wir in jedem Durchlauf nichts anderes, 
     // als x auf 0 zu setzen und anschließend das Ergebnis von "0 * zahl1 / 4"
     // in x zu speichern, nur um es beim nächsten Durchlauf wieder zu löschen.
     // Die 100.000.000 Durchläufe hängen damit zusammen,
     // dass die Computer heute einfach so schnell sind.
     // Wem das Warten zu lange dauert, der sollte eine kleinere Zahl nehmen.
     decimal x = 10;
     for (long i = 1; i <= 100000000; i++) x = 0; x *= zahl1 / 4;
 
     // Am Ende der Berechnung lösen wir das Ereignis aus
     // Symbolisch addieren wir nur.
     if ( OnBerechnungBeendet != null )
     {
       OnBerechnungBeendet( zahl1 + zahl2 );
     }
   }
 
   // ************
   // Neue Methode
   // ************
   public void Berechne()
   {
     // Wir sagen dem Betriebssytem,
     // dass wir gern noch einen zusätzlichen Thread hätten.
     // Dazu stellen wir unsere Methode "BerechneAsynchron" 
     // in die Warteschlange des Thread-Verteilers.
     System.Threading.ThreadPool.QueueUserWorkItem(new System.Threading.WaitCallback(BerechneAsynchron));
   }
 }

Und schließlich erweitern wir unseren Client noch um die eigentlich wichtigen Zeilen:

C#-Quelltext
class Client
 {
   public void Berechne()
   {
     // Wir holen uns ein Objekt des Rechners
     Rechner rechner = new Rechner();
 
     // Wir abonnieren das Ereignis
     // Dazu erstellen wir ein neues Objekt 
     // des Delegaten "BerechnungBeendet" und 
     // fügen dieses dem Ereignis hinzu.
     rechner.OnBerechnungBeendet += new Rechner.BerechnungBeendet(rechner_OnBerechnungBeendet);
 
     // Wir beginnen die komplizierte Berechnung
     // der Addition zweier Zahlen.
     rechner.Berechne();
   
     // **********************
     // Neue Zeilen hinzufügen
     // Während wir auf das Ergebnis warten,
     // können wir in unserem eigenen Thread 
     // schon fleißig weiterarbeiten.
     for (int i = 1; i <= 100; i++)
     {
       Console.WriteLine("Wir erledigen andere Aufgaben nebenbei ...");
     }
     // **********************
   }
 
   private void rechner_OnBerechnungBeendet(decimal ergebnis)
   {
     Console.WriteLine( ergebnis ); 
   }
 }

Jetzt haben wir ganze Arbeit geleistet. Wenn wir das Programm nun starten, werden wir etwa folgende Ausgabe sehen:

Ausgabe
'"`UNIQ--syntaxhighlight-00000006-QINU`"'

Perfekt. Wir haben zunächst die Berechnung ausgelöst und konnten uns dann um andere Dinge kümmern. Irgendwann hat uns der Rechner über das Ereignis OnBerechnungBeendet informiert, dass er fertig ist. Trotzdem mussten wir unsere "anderen Aufgaben" nicht unterbrechen, sondern haben fleißig daran weitergearbeitet.

Das Beste an der ganzen Sache ist wohl, dass wir den größten Teil dieses Beispiels damit verbracht haben, uns Möglichkeiten auszudenken, wie wir den Computer ausbremsen können, damit wir überhaupt ein Ergebnis sehen.

Und damit uns die eigentlich wichtige Zeile hier nicht untergeht, holen wir sie noch einmal heraus:

System.Threading.ThreadPool.QueueUserWorkItem(new System.Threading.WaitCallback(BerechneAsynchron));

So einfach geht Multithreading in .NET. Alles was wir machen müssen, ist uns im ThreadPool hinten anzustellen und zu warten, bis uns das Betriebssystem ein Zeitsegment und einen Thread zuteilt. Um all die kleinen Details kümmern sich also .NET und das Betriebssytem. Wir brauchen nichts anderes zu tun, als die Arbeit zu verteilen und auf die Ergebnisse zu warten...

Praxisbeispiele

[Bearbeiten]

Nicht immer ist es uns vergönnt, unsere Zeit mit Warten verbringen zu können. Manche - eigentlich die meisten - Anwendungsfälle von multiplen Threads sollen die ansonsten sehr langwierige und aufwändige Arbeit verteilen, um die Ergebnisse schneller zu bekommen.

Wikipedia hat einen Artikel zum Thema:

Fibonacci-Zahlen berechnen

[Bearbeiten]

Zu den Klassikern sehr aufwändiger Berechnungen gehört ohne Zweifel die Fibonacci-Folge. Was uns der gute Herr Fibonacci da eingebrockt hat, ist wirklich vom Feinsten: Er dachte sich ein Modell einer "unendlich wachsenden Kaninchenpopulation" aus, wobei jede Zahl in der Reihe die Summe der beiden vorangehenden Zahlen darstellt; also (0, 1,) 1, 2, 3, 5, 8, ...
Und weil diese Aufgabe geradezu nach Threads und Rekursion schreit, kommt sie uns wie gerufen.

C#-Quelltext
using System;
 
 namespace Org.Wikibooks.De.CSharp.MultiThreading
 { 
   class Program
   {
     static void Main(string[] args)
     {
       //
       // Test OHNE zusätzliche Threads.
       //
     
       Fibonacci fibonacci = new Fibonacci();
 
       // Wir wählen 10 verschiedene Zahlen aus,
       // die die Position innerhalb der Reihe darstellen.
       // Von diesen Zahlen lassen wir uns die Fibonacci-Zahl geben.
       // ACHTUNG: Zu große Zahlen können die Büchse
       // heftig ins Schwitzen bringen!
       //     Wer eine langsame Büchse, wenig RAM oder wenig Geduld hat,
       //     sollte hier kleinere Zahlen wählen!
       // Fangen wir also vorsichtig an und steigern uns langsam:
       int[] fibonacciFolge_PositionsListe = new int[] { 5, 9, 11, 16, 20, 25, 30, 35, 40, 45 };
       long ergebnis = 0;
       DateTime startGesamt = DateTime.Now;
 
       foreach( int position in fibonacciFolge_PositionsListe)
       {
         ergebnis = fibonacci.Berechne( position );
         Console.WriteLine( "Die Zahl an Position {0} lautet: {1}", position, ergebnis );
       }
       DateTime endeGesamt = DateTime.Now;
       TimeSpan spanneGesamt = endeGesamt - startGesamt;
       Console.WriteLine("Die Berechnung aller Zahlen hat {0} Sekunden gedauert.", spanneGesamt.Seconds);
       Console.ReadLine();
     }
   }
   
   class Fibonacci
   {
     public long Berechne(int zahl)
     {
       if (zahl <= 1)
         return zahl;
 
       return Berechne(zahl - 1) + Berechne(zahl - 2);
     }
   }
 }
Wikipedia hat einen Artikel zum Thema:

Wir haben hier ganz beiläufig einen neuen Programmier-Mechanismus eingesetzt, der sich Rekursion nennt. Dabei handelt es sich um eine kreuzgefährliche - aber sehr spannende - Technik, sich die Arbeit zu erleichtern und eleganten Programmcode zu schreiben. Dummerweise gibt es aber zwei gewaltige Haken:

  • Es belastet den Speicher bis ins Unermessliche und
  • es ist langsam.

Wie wir sehr leicht feststellen können, "verschachteln" die Selbstaufrufe der Methode Berechne in der Klasse Fibonacci so lange ineinander, bis die Zahl auf 1 runtergerechnet wurde. Da aber bis dahin kein einziger der Methoden-Aufrufe zurückkehrt, also beendet wird, bleiben sie alle im Speicher. Erst wenn wir die magische 1 erreicht haben, lösen sich diese verschachtelten Aufrufe von innen auf und kommen mit ihren Ergebnissen zurück. Logisch also, dass die Speicherverwaltung mit Rekursionen einen enormen Aufwand verursacht, nicht wahr?

Kommen wir zu unserem Problem zurück: Wie wir gesehen haben, dauert die Berechnung der einfachen Zahlen schon einige Zeit. Um wieviel mehr Zeit würde es brauchen, wollten wir höhere Positionen der Fibonacci-Folge berechnen?! ... Und hier kommen nun unsere Threads zum Einsatz:

C#-Quelltext
using System;
using System.Collections;
using System.ComponentModel;
 
namespace Org.Wikibooks.De.CSharp.MultiThreading
 { 
   class Program
   {
     static void Main(string[] args)
     {
       //
       // Test MIT zusätzlichen Threads.
       // Und zwar nehmen wir für jede Zahl einen eigenen,
       // so dass jeder dann anfangen kann, sobald das Betriebssystem
       // es ihm erlaubt.
       //
     
       // Wir wählen 10 verschiedene Zahlen aus,
       // die die Position innerhalb der Reihe darstellen.
       // Von diesen Zahlen lassen wir uns die Fibonacci-Zahl geben.
       int[] fibonacciFolge_PositionsListe = new int[] { 5, 9, 11, 16, 20, 25, 30, 35, 40, 45 };
       ArrayList workerList = new ArrayList( fibonacciFolge_PositionsListe.Length );
       DateTime startGesamt = DateTime.Now;
 
       foreach( int position in fibonacciFolge_PositionsListe )
       {
         // Wir schicken für jede Aufgabe einen "Backgroundworker" ins Rennen.
         BackgroundWorker worker = new BackgroundWorker();
         // Um sie kontrollieren zu können,
         // schreiben wir sie auf unsere "Lohnliste".
         workerList.Add( worker );
 
         // Jetzt geben wir dem neuen Thread noch Arbeitsanweisungen ...
         //
         // Lieber "BackgroundWorker", bitte erledige diesen Job.
         worker.DoWork += new DoWorkEventHandler( worker_DoWork );
         // Und wenn du fertig bist, sage bitte hier Bescheid.
         worker.RunWorkerCompleted += new RunWorkerCompletedEventHandler( worker_RunWorkerCompleted );
         
         // ... alles erledigt, Belehrung erfolgt: Lasst die Spiele beginnen!
         // Fange jetzt bitte an zu arbeiten!
         worker.RunWorkerAsync( position );
       }
 
       // Wir haben alle an die Arbeit geschickt.
       // Jetzt warten wir auf das Ende.
       Console.WriteLine( "... Warte auf Ende der Berechnungen..." );
       bool istBeschaeftigt = true;
       // ... Mindestens einmal müssen wir alle worker fragen...
       do
       {
         foreach ( BackgroundWorker worker in workerList)
         {
           istBeschaeftigt = worker.IsBusy;
           // Wenn wenigstens einer noch beschäftigt ist,
           // brauchen wir gar nicht weiter prüfen.
           if (istBeschaeftigt)
             break;
         }
       }
       while ( istBeschaeftigt );
       DateTime endeGesamt = DateTime.Now;
       TimeSpan spanneGesamt = endeGesamt - startGesamt;
       Console.WriteLine( "Die Berechnung aller Zahlen hat {0} Sekunden gedauert."
                          ,spanneGesamt.Seconds );
       Console.ReadLine();
     }
 
     static void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
     {
       // Würdest du bitte das Ergebnis deiner Berechnungen 
       // auf der Konsole ausgeben?!
       Console.WriteLine( "Die Zahl an Position {0} lautet: {1}"
                          ,(int) ((object[])e.Result)[0] 
                          ,(long)((object[])e.Result)[1] );
     }
 
     static void worker_DoWork(object sender, DoWorkEventArgs e)
     {
       long fibonacciZahl = 0;
       int position = (int)e.Argument;
 
       // Wir holen uns ein neues Objekt
       Fibonacci fibonacci = new Fibonacci();
       fibonacciZahl = fibonacci.Berechne( position );
       
       // Wir speichern das Ergebnis in einem object-Array...
       object[] ergebnis = new object[] { position, fibonacciZahl };
       // ... und legen dieses object-Array in das "Result".
       e.Result = ergebnis; 
     }
   }
   
   class Fibonacci
   {
     public long Berechne(int zahl)
     {
       if (zahl <= 1)
         return zahl;
 
       return Berechne(zahl - 1) + Berechne(zahl - 2);
     }
   }
 }

Dieser Versuch lehrt uns eine ganze Menge interessante Dinge:

Wer jetzt aufmerksam die Prozessorlast beobachtet hat, wird festgestellt haben, dass sich zum oberen Beispiel, also dem ohne zusätzliche Threads, nichts geändert hat: Wir haben nach wie vor 100% Prozessorlast.

Listigerweise ist die Ursache im ersten Beispiel aber eine andere, als die im zweiten Beispiel. Während wir im ersten Fall die Last allein durch die Berechnungen verursacht haben, wurde uns im zweiten Fall die "Warteschleife" zum Verhängnis. Pausenlos, also wirklich alle paar Millisekunden, wurden die Threads worker abgefragt (Fachleute nennen diesen Vorgang "pollen"), ob sie bereits fertig sind.

Aber auch die zweite Frage, die hier auftaucht: "Und warum hat jeder Durchlauf nun insgesamt länger als der Versuch ohne zusätzliche Threads und zugleich auch noch bei jedem Testdurchlauf selbst unterschiedlich lange gedauert?" ist einen oder zwei Gedanken wert.

Wie wir bereits gelernt haben, sind wir beim Multithreading nicht allmächtig. Es gibt neben unserer Anwendung, so fantastisch sie auch sein mag, noch andere, die ebenfalls Ansprüche besitzen - oder sich anmaßen zu glauben, solche zu besitzen - Prozessorzeit zu bekommen. Deshalb führt der Einsatz vieler Threads gleichzeitig nicht zwingend (tatsächlich sogar fast nie) zu einer linearen Beschleunigung der Bearbeitung. Meist ist sogar eher das Gegenteil zu beobachten: Es wird eine Spur langsamer.

Und dennoch gewinnen wir jede Menge Zeit...paradox, oder?!

Nein, für uns nicht mehr, denn wir haben beide Beispiele aufmerksam studiert und dabei festgestellt, dass im zweiten Beispiel eine lange Warteschleife existiert, in der wir den größten Teil der Zeit verbracht haben. Und anstatt faul rumzustehen und auf das Ende der Arbeit der worker zu warten, hätten wir auch schon andere Dinge erledigen können. ... Und das werden wir in Zukunft auch machen.

Bleibt als letzte große Lehre noch eines: Die ominösen EventArgs. Sehen wir einmal von den Framework-Bauanleitungs-Vorschriften ab, die uns im Abschnitt Bibliothekenbau noch einmal auf die Füße fallen werden, erfüllen sie einen weiteren interessanten Zweck: Sie sind vollständige Klassen, die eine Menge Eigenschaften besitzen, zu denen auch eine namens Result vom "Universal-Datentyp" object gehört. Da wir aber aus den Abschnitten Datentypen und Objekte und Klassen, der erste Kontakt noch wissen, dass man einem object auch ganze Listen von Objekten übergeben kann, wenn diese innerhalb eines einzigen Objektes existieren, nutzen wir das schamlos aus, um unsere "Ergebnisanzeige-Werte" durchzureichen, so dass wir sie in der Methode worker_RunWorkerCompleted nur noch wieder auseinander klauben müssen. Und wie das geht, haben wir im Abschnitt Typumwandlungen gelernt.