zurück
Autor: Engelbert Hedjes
Erstellt am: 24 Jul 2002 14:02

Threading mit C#

Threading Thread C# Synchronisation Multitasking Erzeuger Verbraucher Nebenläufigkeit Prozesse .Net
Themenübersicht
0. Multitasking und Prozesse
1. Was sind Threads?
2. Warum Threads?
3. Asynchrones Threading
4. Synchrones Threading
5. Synchrones Threading, Teil2
6. Fazit
0. Multitasking und Prozesse
Um Threads erklären zu können, ist es im Moment erforderlich etwas weiter auszuholen.
Moderne Betriebssysteme bieten allesamt die Möglichkeit, Anwendungen parallel auszuführen. Wobei "parallel" in diesem Sinne eher der falsche Begriff ist. Dazu aber später etwas mehr. Dieses "parallele" Ausführen wird auch als Multitasking bezeichnet.
Ältere BS setzen auf ein sogenanntes Nicht-Verdrängendes-Multitasking. Dabei wird jede Anwendung als ein eigener Prozess ausgeführt und das BS erwartet, dass alle Prozesse zu geeigneten Zeitpunkten die CPU selbst wieder freigeben und damit einem anderem Prozess ermöglichen, die CPU in Anspruch zu nehmen.
Kurz gesagt: Jeder Prozess ist selbst dafür verantwortlich, dass auch mal die anderen Prozesse an die CPU "ran" dürfen.
Das Problem an diesem System wird schnell klar, wenn man mal ein kleines Fallbeispiel betrachtet.
Was geschieht, wenn ein aktiver Prozess sich in einer Endlosschleife verfängt? Die Antwort ist simpel! Es passiert überhaupt nichts!
Auf die anderen Prozesse oder Anwendungen kann nicht mehr zugegriffen werden und selbst das BS wartet auf das Ende des Prozesses. Es dürfte jedem schnell klar werden, dass ein solches Konzept für die Praxis denkbar ungeeignet ist.
Ein wesentlich besserer Ansatz ist der des Verdrängenden-Multitaskings.
Dieses überlässt dem BS die Kontrolle darüber welcher Prozess (auch hier werden Anwendungen in Prozessen ausgeführt), zu welchem Zeitpunkt, wieviel CPU-Leistung erhält. Das BS teilt hierzu die CPU-Leistung in sogenannte Zeitscheiben (neudeutsch: time slices) auf und ordnet diese den einzelnen Prozessen zu. Das BS entreisst einem Prozess die Kontrolle sobald die Zeit abgelaufen ist. Anschliessend erhält der nächste Prozess seine Zeitscheibe. Vorteilhaft ist hierbei, dass sogar ein "toter" Prozess keinen Einfluss auf parallele Prozesse oder das BS selbst hat.
Leider hat auch dieses Konzept einen gewaltigen Haken, der aber erst unter dem Punkt "Asynchrones Threading" erläutert wird.
Jetzt dürfte auch erkennbar sein, warum hier von "parallel" keine Rede sein kann. Genau genommen werden die einzelnen Zeitscheiben der unterschiedlichen Prozesse hintereinander ausgeführt. Nur ist die Dauer einer einzelnen Zeitscheibe und damit auch der Wechsel zwischen den Prozessen dermassen kurz, dass der Eindruck einer Parallelität entsteht. Ausnahme hierbei sind Multiprozessorsysteme, bei denen wirklich von Parallelität die Rede ist.
Anmerkung: Im weiteren Text wird nur noch das Verdrängende-Multitasking betrachtet.
1. Was sind Threads?
Multitasking ermöglicht das gleichzeitige Ausführen verschiedener Anwendungen (Prozesse) innerhalb eines BS. Als zweite Ebene der Parallelität ist es möglich, selbst einen Prozess in weitere parallele Abläufe zu splitten. Diese weiteren Aufteilungen innerhalb eines Prozesses werden als Threads bezeichnet. Threads teilen sich die Zeitscheibe, die dem Prozess zugeordnet wurde. Damit wird es einer Anwendung möglich, unterschiedliche Aufgaben innerhalb der Anwendung parallel auszuführen.
Beispiel:
Eine Anwendung mit einer komplexen und langwierigen Berechnung, könnte ohne die Existenz von Threads z.B. während der Berechnung nicht mehr auf Benutzereingaben reagieren. So liesse sich die Berechnung nicht abbrechen oder die Anwendung beenden. Abhilfe in so einem Fall wäre, die Berechnung und die Benutzerdialoge in jeweils separaten Threads durchzuführen. Somit müsste der Benutzer nicht erst auf das Ende der Berechnung warten und kann in der Zwischenzeit mit der Anwendung weiter arbeiten.
2. Warum Threads?
Wie im oberen Abschnitt schon angedeutet, sind Threads immer dann sinnvoll, wenn einzelne Aspekte einer Anwendung parallel ausführbar sein sollen. Dafür ist es unvermeidlich, sich vor dem Programmieren Gedanken darüber zu machen, wie sich die einzelnen Komponenten logisch trennen lassen
z.B. komplexe Berechnung - Benutzerdialog
Diese Überlegung führt eigentlich schon zum nächsten wichtigen Punkt. Ein Programmierer sollte sich auch immer im Klaren darüber sein, in welchem Fall keine Threads verwendet werden! Threads beeinträchtigen die Performance und sind eine häufige Fehlerquelle. Schon allein aus diesen zwei Gründen sollten Threads nur eingesetzt werden, falls dies unumgänglich ist.
Um zu dem oberen Beispiel zurückzukehren, es würde keinen Sinn ergeben, den Benutzerdialog und die Berechnung logisch zu trennen, falls der Benutzerdialog das Ergebnis der Berechnung erwartet. Dadurch wäre kein paralleler Ablauf mehr gewährleistet und somit besteht in so einem Fall auch keine Notwendigkeit für Threads.

3. Asynchrones Threading
Nach all der Theorie, ist es Zeit für ein konkretes Beispiel:
using System;
using System.Threading;

namespace MyConsoleThread
{
 class MyThread
 {
  public static int i=0;
  
  static void Main(string[] args)
  {
   Thread[] ta = new Thread[100]; // Thread-Array
   for (int j = 0; j<100; j++)
   {
    ta[j] = new Thread(new ThreadStart(tuwas)); // Threads werden erzeugt
    ta[j].Start(); // Threads werden gestartet
   }
   System.Console.ReadLine();
  }
  
  public static void tuwas()
  {
   String mystring = "Thread gestartet ";
   i++;
   mystring += i;
   System.Console.WriteLine(mystring);
  }
 }
}
Das Beispiel startet 100 Threads die jeweils alle die Methode tuwas() ausführen.

Von grösserem Interesse sind folgende zwei Zeilen:
...
    ta[j] = new Thread(new ThreadStart(tuwas)); // Threads werden erzeugt
    ta[j].Start(); // Threads werden gestartet
...
ta[j] = new Thread(new ThreadStart(tuwas)); erzeugt einen Thread, der die Methode tuwas() ausführt.
Mit ta[j].Start(); wird der Thread letztendlich gestartet.
 
Ein Aufruf des Programms könnte folgendes Ergebnis liefern:

Nun kommt auch schon der Haken, der unter dem Abschnitt "Multitasking und Prozesse" erwähnt wurde. Wie bereits weiter oben erläutert, unterbricht das Verdrängende-Multitasking die einzelnen Prozesse (und auch die einzelnen Threads) zu einem nicht näher bestimmbaren Zeitpunkt. Dies erklärt auch die Threadfolge ...,41,42,3,20,44,...
Thread 3 und Thread 20 wurden zu Beginn scheinbar unterbrochen und bekommen erst nach der Ausführung von Thread 42 wieder eine Zeitscheibe zugeteilt. Thread 43 wird wiederum scheinbar von Thread 3 und Thread 20 unterbrochen und wird wahrscheinlich auch erst zu einem späteren Zeitpunkt oder wurde evtl. schon vorher ausgeführt.
Zur Veranschaulichung:
  public static void tuwas()
  {
   // kritischer Abschnitt - Anfang
   String mystring = "Thread gestartet ";
   i++;
   mystring += i;
   // Hier wird z.B. Thread 3 unterbrochen
   // i wurde inkrementiert,
   // Ausgabe erfolgt aber zu einem späteren Zeitpunkt
   System.Console.WriteLine(mystring);
   // kritischer Abschnitt - Ende
  }
Was dieses Beispiel verdeutlichen soll, ist dass Threads ohne weiteres zutun immer asynchron ausgeführt werden. D.h. In welcher Reihenfolge die Threads letztendlich ausgeführt werden, ist nicht bekannt. Für Aufgaben, in denen die einzelnen Threads unabhängig voneinander agieren, stellt dies kein Problem dar. Anders gestaltet sich die Situation, wenn die Reihenfolge der Threads eine Voraussetzung für die Lösung eines Problems ist. Hierfür ist eine Synchronisierung von nöten.
4. Synchrones Threading
Falls das obere Beispiel um die Anforderung "Alle Threads sollen der Reihe nach ausgeführt werden. D.h. 1,2,3,4,..." erweitert wird, ist für die Lösung eine Synchronisation notwendig.
Das synchronisierte Beispiel sieht wie folgt aus:
using System;
using System.Threading;

namespace MyConsoleThread
{
 class MyThread
 {
  public static int i=0;
  public static Object lockvar = ""; // Lock-Variable
  
  static void Main(string[] args)
  {
   Thread[] ta = new Thread[100]; // Thread-Array
   for (int j = 0; j<100; j++)
   {
    ta[j] = new Thread(new ThreadStart(tuwas)); // Threads werden erzeugt
    ta[j].Start(); // Threads werden gestartet
   }
   System.Console.ReadLine();
  }
  
  public static void tuwas()
  {
   lock (lockvar)
   {
    String mystring = "Thread gestartet ";
    i++;
    mystring += i;
    System.Console.WriteLine(mystring);
   }
  }
 }
}
Das Ergebnis der Modifikationen:

Mit public static Object lockvar = ""; wird eine zur Synchronisation benötigte Lock-Variable erzeugt.
Der Ausdruck lock(lockvar){...} markiert einen atomaren (ununterbrechbaren) Block.
Während sich ein Thread innerhalb eines atomaren Blocks befindet, kann dieser nicht unterbrochen werden.
Damit wird gewährleistet, dass ein Thread seinen so genannten kritischen Abschnitt beenden und erst danach ein anderer Thread eine Unterbrechung hervorrufen kann. Aktiv (und damit ununterbrechbar) ist immer nur gerade der Thread der mit Hilfe von lock(lockvar)... den Lock auf die Lock-Variable besitzt (in diesem Fall lockvar).
Bild zur Verdeutlichung:

Solange Thread 1 den Zugriff auf die Lock-Variable besitzt, muss Thread 2 solange warten bis Thread 1 den Lock wieder freigegeben hat.
Mit diesen kleinen Modifikationen wurde eine simple, funktionierende Synchronisation erreicht.
5. Synchrones Threading, Teil 2
Das Beispiel im vorhergehenden Abschnitt, eignet sich für einfache Demonstrationen, wird aber in dieser Form wohl eher selten, in der alltäglichen Praxis eines Entwicklers vorkommen. Für komplexere Fälle gibt es einige Konzepte, welche die meisten Threadsynchronisations-Probleme behandeln. Eines davon ist als Erzeuger-Verbraucher-Problem bekannt.
Um dieses näher zu erläutern ist vorher noch eine Begriffserklärung notwendig.
Es gibt verschiedene Arten der Synchronisation, das vorherige Beispiel (lock(lockvar)...) stellt eine so genannte Monitor-Synchronisation dar. Dabei dient eine statische Klasse Monitor als eine Art Wächter über einen kritischen Abschnitt. Sie kontrolliert den Zugriff auf atomare Blöcke und ermöglicht u.a. auch komplexere Synchronisationen.
Identisch zu dem oberen Beispiel, wäre folgender Code:
using System;
using System.Threading;

namespace MyConsoleThread
{
 class MyThread
 {
  public static int i=0;
  public static Object lockvar = ""; // Lock-Variable
  
  static void Main(string[] args)
  {
   Thread[] ta = new Thread[100]; // Thread-Array
   for (int j = 0; j<100; j++)
   {
    ta[j] = new Thread(new ThreadStart(tuwas)); // Threads werden erzeugt
    ta[j].Start(); // Threads werden gestartet
   }
   System.Console.ReadLine();
  }
  
  public static void tuwas()
  {
   Monitor.Enter(lockvar);
    String mystring = "Thread gestartet ";
    i++;
    mystring += i;
    System.Console.WriteLine(mystring);
   Monitor.Exit(lockvar);
  }
 }
}

Zurück zum Erzeuger-Verbraucher-Problem.
Aufgabenstellung:
"Es existieren 2 Arten von Threads. Erzeuger und Verbraucher. Erzeuger erzeugen Elemente z.B. Ergebnisse einer Berechnung. Verbraucher erwarten diese Elemente und verbrauchen diese z.B. werden die Ergebnisse ausgewertet. Die Elemente werden über einen Puffer fester Grösse ausgetauscht d.h. ein Erzeuger legt Elemente in den Puffer und ein Verbraucher entnimmt diese wieder. Das Erzeugen und Verbrauchen soll parallel stattfinden."
Diese Probleme können z.B. auftreten:
"Ein Erzeuger versucht ein erzeugtes Element in einen vollen Puffer abzulegen."
"Ein Verbraucher versucht ein Element aus einem leeren Puffer zu entnehmen."
Beide können mit einer Synchronisation abgefangen werden.
Folgender Quellcode stellt eine mögliche Lösung dar:
using System;
using System.Threading;

namespace MyConsoleThread
{
 class ThreadMain
 {
  public static void Main(string[] args)
  {
   Erzeuger erz = new Erzeuger();
   Verbraucher ver = new Verbraucher();
   Thread te = new Thread(new ThreadStart(erz.start));
   Thread tv = new Thread(new ThreadStart(ver.start));
   te.Start();
   tv.Start();
   System.Console.ReadLine();
  }
 }
  
 public class MyMonitor
 {
  // Anzahl der Elemente
  public static int ANZ = 1000;
  
  // Thread - Lock - Variablen
  private static Object platz_frei = "";
  private static Object elemente_vorhanden = "";
  
  // Puffer - Variablen
  private const int N = 50; // maximale Puffergrösse
  private static int zaehler=0; // aktuelle Anzahl der Elemente im Puffer
  public static Object[] oa = new Object[N]; // Puffer
  
  // Synchronisierte erzeugen - Methode
  public static void erzeugen(Object o)
  {
   lock(elemente_vorhanden)
   {
    if(MyMonitor.zaehler==N) // Falls Puffer voll
    {
     // wartet bis wieder Platz im Puffer vorhanden ist
     Monitor.Wait(platz_frei);
    }
    // Lege Daten ab
    oa[MyMonitor.zaehler] = o;
    System.Console.WriteLine("Element " + o + " wurde erzeugt");
    // -------------
    MyMonitor.zaehler++;
    
    // signalisiert, dass wieder ein Element vorhanden ist
    Monitor.PulseAll(elemente_vorhanden);
   }
  }
  
  // Synchronisierte verbrauchen - Methode
  public static Object verbrauchen()
  {
   Object o = new Object();
   
   lock(platz_frei)
   {
    if(MyMonitor.zaehler==0) // Falls keine Elemente zum Verbrauchen
    {
     // wartet bis wieder ein Element vorhanden ist
     Monitor.Wait(elemente_vorhanden);
    }
    // Hole Daten ab
    o = oa[MyMonitor.zaehler-1];
    oa[MyMonitor.zaehler-1] = null;
    System.Console.WriteLine("Element " + o + " wurde verbraucht");
    // -------------
    MyMonitor.zaehler--;
    
    // signalisiert, dass wieder Platz im Puffer ist
    Monitor.PulseAll(platz_frei);
   }
   
   return o;
  }
 }
 
 public class Erzeuger
 {
  public void start()
  {
   int i = 0;
   while(i < MyMonitor.ANZ)
   {
    //erzeuge Daten
    MyMonitor.erzeugen(i);
    i++;
   }
  }
 }
 
 public class Verbraucher
 {
  public void start()
  {
   int i = 0;
   while(i < MyMonitor.ANZ)
   {
    // verbrauche Daten
    Object o = MyMonitor.verbrauchen();
    i++;
   }
  }
 }
}

Mögliche Ausgabe:

Für die Synchronisation sind in erster Linie nur folgende zwei Methoden von Bedeutung:
...
  // Synchronisierte erzeugen - Methode
  public static void erzeugen(Object o)
  {
   lock(elemente_vorhanden)
   {
    if(MyMonitor.zaehler==N) // Falls Puffer voll
    {
     // wartet bis wieder Platz im Puffer vorhanden ist
     Monitor.Wait(platz_frei);
    }
    // Lege Daten ab
    oa[MyMonitor.zaehler] = o;
    System.Console.WriteLine("Element " + o + " wurde erzeugt");
    // -------------
    MyMonitor.zaehler++;
    
    // signalisiert, dass wieder ein Element vorhanden ist
    Monitor.PulseAll(elemente_vorhanden);
   }
  }
  
  // Synchronisierte verbrauchen - Methode
  public static Object verbrauchen()
  {
   Object o = new Object();
   
   lock(platz_frei)
   {
    if(MyMonitor.zaehler==0) // Falls keine Elemente zum Verbrauchen
    {
     // wartet bis wieder ein Element vorhanden ist
     Monitor.Wait(elemente_vorhanden);
    }
    // Hole Daten ab
    o = oa[MyMonitor.zaehler-1];
    oa[MyMonitor.zaehler-1] = null;
    System.Console.WriteLine("Element " + o + " wurde verbraucht");
    // -------------
    MyMonitor.zaehler--;
    
    // signalisiert, dass wieder Platz im Puffer ist
    Monitor.PulseAll(platz_frei);
   }
   
   return o;
  }
...
Zur Erläuterung:
Die erzeugen-Methode erhält ein Objekt und überprüft ob noch Platz im Puffer vorhanden ist. Falls dies nicht der Fall ist, wird der Erzeuger-Thread mit Monitor.Wait(platz_frei); in einen Wartezustand versetzt. Sollte im Puffer noch min. ein Platz frei sein, wird das Objekt im Puffer abgelegt und anschliessend an alle sich im Wartezustand befindlichen Verbraucher-Threads signalisiert (Monitor.PulseAll(elemente_vorhanden);), dass wieder ein Objekt zum Verbrauchen vorhanden ist. Daraufhin wird ein wartender Verbraucher-Thread wieder gestartet.
Die verbrauchen-Methode überprüft, ob min. ein Element zum Verbrauchen vorhanden ist. Sollte dies nicht der Fall sein, wird der Verbraucher-Thread mit Monitor.Wait(elemente_vorhanden); in einen Wartezustand versetzt. Ansonsten wird ein Element verbraucht. Anschliessend wird an alle wartenden Erzeuger-Threads signalisiert (Monitor.PulseAll(platz_frei);), dass wieder ein Platz im Puffer frei geworden ist. Daraufhin wird ein wartender Erzeuger-Thread wieder gestartet.
 
Es empfiehlt sich mit diesem Quellcode zu experimentieren um das Threading-Verhalten des BS zu beobachten. Z.B. kann die Puffergrösse verändert werden oder eine Verzögerung in der verbrauchen- oder der erzeugen-Methode implementiert werden etc.

6. Fazit
Dieser Artikel stellt nur eine kleine Einführung in das Thema "Threading mit C#" und "Threading allgemein" dar. Daher wurden einige Elemente z.B. "Welche anderen Arten der Synchronisation gibt es?" oder "Welche anderen bekannten Synchronisationskonzepte gibt es ausser dem Erzeuger-Verbraucher-Problem?" entweder nur kurz angedeutet oder überhaupt nicht erwähnt. Darüber hinaus wurde auf genauere technische Erläuterungen z.B. "Wie nutzen Threads oder Prozesse gemeinsame Ressourcen?" verzichtet. Wer nach diesem Artikel, Interesse am Thema Threading gefunden hat, dem empfehle ich diverse Fachliteratur oder eine Artikelsuche im Internet. Eine gute Möglichkeit um mehr über sämtliche Methoden des .Net Monitors zu erfahren, stellt wie so oft die MSDN dar. Kurz gesagt: Eine vollständige Beschreibung des Themas würde bei weitem den Umfang dieses Artikels sprengen.

© Copyright 2008 ppedv AG