communityWir suchen ständig neue Tutorials und Artikel! Habt ihr selbst schonmal einen Artikel verfasst und seid bereit dieses Wissen mit der Community zu teilen? Oder würdet ihr gerne einmal über ein Thema schreiben das euch besonders auf dem Herzen liegt? Dann habt ihr nun die Gelegenheit eure Arbeit zu veröffentlichen und den Ruhm dafür zu ernten. Schreibt uns einfach eine Nachricht mit dem Betreff „Community Articles“ und helft mit das Angebot an guten Artikeln zu vergrößern. Als Autor werdet ihr für den internen Bereich freigeschaltet und könnt dort eurer literarischen Ader freien Lauf lassen.

Multithreading in C# Drucken E-Mail
Benutzerbewertung: / 221
SchwachPerfekt 
Samstag, den 12. Dezember 2009 um 02:00 Uhr

Inhaltsverzeichnis

  1. Einführung
  2. Multitasking vs. Multithreading
  3. Prozesse, Threads und Prioritätsklassen
    1. Prozesse
    2. Threads
      1. Thread Priorität
        1. Dynamische Prioritätsanpassung
      2. Thread Status
      3. Vor- und Nachteile von Threads
  4. Scheduling
    1. Quantum
      1. Quantum Boosting
    2. Dispatcher Datenbank
  5. Thread-Local Storage
    1. Clock Interrupt
  6. Multithreading in .NET
    1. Die Klasse Thread
      1. Ausführungszustände eines Thread
      2. Threads erzeugen
      3. Threads blockieren
        1. Sleep
        2. SpinWait
        3. Suspend und Resume
        4. Join
      4. Threads abbrechen
        1. Interrupt
        2. Abort
      5. Threads terminieren
    2. ThreadPool und asynchrone Methoden
      1. ThreadPool
      2. BackgroundWorker
      3. Callback-Methoden
    3. Thread-Level
    4. Thread-Local Storage
      1. Threadbezogene statische Felder
      2. Datenslots
    5. Anwendungsdomänen
    6. Threadsynchronisierung
      1. Race Conditions und Deadlocks
      2. Interlockmethoden
      3. Die Klasse Semaphore und Synchronisationsblöcke
      4. Erzeuger-Verbraucher-Problem
    7. Threadsichere Collections
    8. Parallel Extensions
      1. Warum PFX?
      2. PLINQ

Einführung

Als die ersten modernen Computer erfunden wurden, waren diese lediglich in der Lage ein Programm zur selben Zeit auszuführen. Erst nachdem ein Programm seine Arbeit beendet hatte, konnte das nächste Programm geladen und ausgeführt werden. Dieses Konzept basierte auf einer streng sequentiellen Programmausführung. Im Laufe der Zeit wurde ein neues Konzept entwickelt, das sogenannte Timesharing. Jedes Programm erhielt eine bestimmte Menge an Rechenzeit und wenn diese Zeit vorbei war, konnte das nächste Programm in der Warteschlange auf der CPU ausgeführt werden. Damit wurde der erste Grundstein für Multithreading, auch Mehrfädigkeit genannt, gelegt. Ein laufendes Programm, häufig Prozess genannt, hatte seinen eigenen Speicherplatz, seinen eigenen Stack und einen Satz von Variablen. Ein Prozess konnte einen anderen Prozess erzeugen, beide interagierten aber unabhängig voneinander.

Einige Jahre später gab es den nächsten großen Durchbruch in der Computerindustrie. Programme wurden anspruchsvoller, grafische Oberflächen eroberten den PC und man wollte mehrere Dinge gleichzeitig erledigen — die heute bekannte Form des Multithreading entstand. Ein Browser sollte zum Beispiel eine Datei herunterladen und gleichzeitig sollte der Nutzer im Internet surfen können. Diese Fähigkeit von Programmen, mehrere Dinge gleichzeitig abarbeiten zu können, wird heute in modernen Betriebssystemen, wie Microsoft Windows oder Linux, mit Threads realisiert.

Highslide JS
Amiga Workbench (1985): Die Workbench 1.0 im Jahre 1985 mit einer grafischen Benutzeroberfläche auf dem Amiga.

In diesem Artikel werden Sie in die Konzepte des Multithreading eingeführt. Die ersten Kapitel befassen sich mit den Interna von Microsoft Windows. Sie erfahren wie das Betriebssystem mit Threads umgeht, was der Unterschied zwischen einem Prozess und einem Thread ist und wann ein Thread auf dem Hauptprozessor ausgeführt wird. Nach den theoretischen Grundlagen auf Ebene des Betriebssystems, befasst sich der Artikel mit dem Thema Multithreading in .NET in der Programmiersprache C#. Der Artikel ist so strukturiert, dass die Kapitel aufeinander aufbauen. Sie können die theoretischen Grundlagen auch überspringen und direkt in C# einsteigen. Dennoch ist es vorteilhaft sich zunächst mit den Grundlagen zu beschäftigen, um ein Gefühl für die internen Zusammenhänge und Abläufe zu entwickeln.

Multitasking vs. Multithreading

Der Begriff Multitasking bzw. Mehrprozessbetrieb bezeichnet die Fähigkeit eines Betriebssystems, mehrere Aufgaben (Tasks) nebenläufig auszuführen. In Wirklichkeit können natürlich keine zwei Tasks gleichzeitig auf einem einzelnen Prozessorkern ausgeführt werden. Die CPU schaltet allerdings zwischen den Tasks hin und her. Dabei werden die verschiedenen Prozesse in so kurzen Abständen immer abwechselnd aktiviert, dass der Eindruck der Gleichzeitigkeit entsteht.

Multithreading bezeichnet das gleichzeitige Abarbeiten mehrerer Threads, respektive Ausführungsstränge, innerhalb eines einzelnen Prozesses oder eines Tasks. Das Konzept ermöglicht es skalierbare Anwendungen zu entwickeln, da bei Bedarf neue Threads hinzugefügt werden können.

Prozesse, Threads und Prioritätsklassen

Prozesse und Threads stellen in Windows fundamentale Konzepte dar und tangieren viele Komponenten des Betriebssystems. Moderne Betriebssysteme verfügen dazu über einen Prozessmanager, der laufende Programme und Systemprozesse verwaltet. Die Grundlage bilden die Datenstrukturen, die Windows intern verwendet, um einen Prozess oder Thread zu verwalten.

Jeder Prozess in Windows wird durch einen „Executive Process Block“ (EPROCESS) repräsentiert. Dieser enthält viele Attribute (Startzeit, Name, etc.) des Prozesses und verweist zusätzlich auf weitere elementare Datenstrukturen. So besitzt jeder Prozess einen oder mehrere Threads, repräsentiert durch den „Executive Thread Block“, kurz ETHREAD. Der EPROCESS Block und seine zugehörigen Datenstrukturen existieren im Betriebssystemkern, mit Ausnahme des „Process Environment Block“ (PEB), der im Adressraum des Prozesses existiert, da er von Code der im User-Mode läuft modifiziert wird. Das folgende Bild zeigt die wesentlichen Datenstrukturen und ihre Abhängigkeiten untereinander.

thread_and_process_structures

Prozesse

In Windows NT ist ein Prozess die laufende Instanz einer Anwendung. Er setzt sich aus Codeabschnitten im Speicher zusammen, die aus Programmen und Bibliotheken geladen wurden. Jeder Prozess verfügt über seinen eigenen Adressraum und seine eigenen Ressourcen, wie z.B. Threads, Dateien und dynamisch reservierten Speicher. Prozesse selbst führen keinen Code aus; sie stellen den Adressraum dar, in denen sich der Code befindet. Der Code im Adressraum eines Prozesses wird durch einen Thread abgearbeitet, wobei jeder Prozess unter Windows mindestens einen ausführenden Thread besitzt. Ein Prozess wird automatisch beendet, wenn sein letzter Thread beendet ist.

In Windows NT wird seit Version 4.0 ein Prozess gestartet, sobald eine Anwendung aufgerufen wird. Dieser Prozess eignet sich Speicher, Systemressourcen und Threads an, die er zur Ausführung der Anwendung benötigt. Oft verfügt ein Prozess über mehrere Threads zu unterschiedlichen Zeitpunkten. Ein Thread kann bei Bedarf weitere Threads mit CreateThread generieren. Mit der Windows API CreateProcess können sogar neue Prozesse erzeugt werden, diese sind dann vollständig autark und nicht an den Erzeugerprozess gebunden, wie das bei einem Thread der Fall ist.

Wenn ein Nutzer eine Anwendung startet, werden automatisch Speicher und Ressourcen allokiert. Die physikalische Trennung zwischen Speicher und Ressourcen wird Prozess genannt. Eine Anwendung und ein Prozess sich nicht dasselbe. Der Windows Task-Manager zeigt uns alle laufenden Anwendungen an.

task_manager_anwendungen

Auch die Prozesse lassen sich im Task-Manager einsehen. Viele Prozesse lassen sich einfach einer Anwendung zuordnen, andere Prozesse sind nicht so leicht einzuordnen und tragen oftmals Abkürzungen. Die Beschreibung liefert dann meist weitere hilfreiche Informationen.

task_manager_prozesse

Eine Anwendung kann sich aus vielen Prozessen zusammensetzen. So startet der Browser Google Chrome für jeden Tab einen separaten Prozess.

google_chrome_processes

Threads

Ein Prozess besitzt in der Regel mindestens einen Thread, der den Code ausführt. Ein Thread bezeichnet einen Ausführungsstrang oder eine sequentielle Ausführungsreihenfolge in der Abarbeitung eines Programms. Ein Thread ist Teil eines Prozesses und teilt sich mit den anderen vorhandenen Threads (multithreading) des zugehörigen Prozesses eine Reihe von Betriebsmitteln, nämlich das Codesegment, das Datensegment und die verwendeten Dateideskriptoren. Die Verwendung mehrerer Threads ist die effektivste Methode, um die Ansprechempfindlichkeit in einer Anwendung zu steigern und nahezu zeitgleich die notwendigen Daten zu verarbeiten. Ein Thread ist in Windows NT die kleinste ausführbare Einheit.

Man unterscheidet zwei Arten von Threads. Threads im engeren Sinne (Kernelthread) laufen als Teil des Betriebssystems ab, im Gegensatz zu User Threads. Windows unterscheidet intern nicht zwischen Kernel und User Threads.

Ein Thread ist immer einem bestimmten Prozess zugeordnet und existiert nur innerhalb dieses Prozesses. Threads innerhalb des gleichen Prozesses verwenden voneinander unabhängige Stapel (Stacks), die unterschiedlichen Abschnitten des Adressraums zugeordnet sind. Andere Betriebsmittel, wie der Heap, werden von allen Threads gemeinsam verwendet. Da Threads, die demselben Prozess zugeordnet sind, den gleichen Adressraum verwenden, ist eine Kommunikation zwischen diesen Threads von vornherein möglich. Durch die gemeinsame Nutzung von Betriebsmitteln kann es zu Konflikten kommen. Diese müssen durch den Einsatz von Synchronisationsmechanismen aufgelöst werden.

process-with-threads

Threads werden oft auch als leichtgewichtige Prozesse bezeichnet, die parallel zum Hauptprogramm laufen. Sie werden leichtgewichtig genannt, da sie im Kontext einer großen Anwendung operieren und dabei trotzdem den Vorteil genießen, alle Ressourcen nutzen zu können.

Auf einem Einkern-Prozessor-System können Threads entweder im präemptiven Modus oder im kooperativen Modus laufen. Die heutzutage standardmäßig angewendete Methode ist das präemptive Multitasking, bei dem der Betriebssystemkern die Abarbeitung der einzelnen Prozesse steuert und jeden Prozess nach einer bestimmten Abarbeitungszeit zu Gunsten anderer Prozesse anhält. Diese „schlafen“ (sind inaktiv) und setzen während ‚ihrer‘ Zuteilung im Prozessor ihre Arbeit fort. Eine beliebte Umsetzung des präemptiven Multitaskings ist die Verwendung einer Vorrangwarteschlange in Verbindung mit der Round-Robin-Scheduling-Strategie. Dabei spricht man auch von so genannten Zeitschlitzen (bzw. Zeitscheiben, engl. time slicing). Damit wird jedem Prozess absolut oder pro definierter Zeiteinheit abhängig von dessen Rechenaufwand ein bestimmter Prozentteil dieser Zeit zugewiesen, die er höchstens nutzen kann. Die Länge eines Zeitabschnitts ist von Betriebssystem und Prozessor abhängig. In der Realität sind das bei modernen Prozessoren meist einige Millisekunden. Da die einzelnen Zeitanteile klein sind, entsteht der Eindruck, dass mehrere Threads gleichzeitig ausgeführt werden, auch wenn nur ein Prozessor vorhanden ist. Dies ist bei Systemen mit mehreren Prozessoren auch tatsächlich der Fall, bei denen die ausführbaren Threads unter den verfügbaren Prozessoren aufgeteilt werden. Dabei erhöht sich ebenfalls die Effizienz, da die Verteilung von Threads auf mehrere Prozessoren schneller ist, als die Vergabe von Zeitschlitzen auf einem einzigen Prozessorkern. Mehrkern-Prozessor-Systeme sind insbesondere bei der 3D-Modellierung und Bildverarbeitung hilfreich.

Ein weiterer Modus ist das kooperative Multitasking. Dabei ist es jedem Thread selbst überlassen, wie lange er die CPU in Anspruch nimmt und wann er die Kontrolle an den Kern zurückgibt. Eine Prioritätszuweisung nach Wichtigkeit ist damit systembedingt ausgeschlossen. Vorteil dieser Methode ist, dass Systemfunktionen (z. B. Ein-/Ausgabe) nicht reentrant sein müssen und daher nicht synchronisiert sein müssen, was eine erhebliche Vereinfachung für den Hersteller bedeutet.

Thread Priorität

Um die verschiedenen Algorithmen, die von Schedulern genutzt werden, zu verstehen, muss man sich mit den Prioritäten (engl. priority levels) beschäftigen, die Windows verwendet. Die Priorität ist in Windows NT die ausschlaggebende Komponente, die bestimmt, wieviel Zeit ein Thread auf dem Prozessor zugewiesen bekommt. In dem folgenden Bild werden die 32 verschiedenen Level dargestellt, sie laufen von 0 bis 31.

thread-priority-levels

Es gibt insgesamt 16 Real-time Level, die von 16 bis 31 reichen. Dazwischen liegen die variablen Level, sie beginnen bei 1 und reichen bis 15. Abschließend gibt es noch die Priorität 0, diese ist reserviert für den sogenannten „Zero Page Thread“. Es handelt sich dabei um den Leerlaufprozess.

Im Gegensatz zu einem Prozess besitzt ein Thread zwei Prioritäten, die „Base Priority“ und die „Current Priority“. Der Scheduler trifft Entscheidungen auf Basis der „Current Priority“. Ein Thread erhält seine Basispriorität von dem Prozess, der diesen Thread erzeugt hat. Der Prozess widerrum erhält seine eigene Basispriorität von dem Prozess, der den Prozess erzeugt hat. Windows passt die dynamische Priorität an, dieser Vorgang wird als „Priority Boosting“ bezeichnet. Eine Anpassung findet nur in dem variablen Bereich, zwischen 1 und 15 statt. Windows passt die Priorität von Threads im Real-time Bereich niemals an. Die Basispriorität kann von Windows aber in manchen Fällen bei der Erzeugung des Prozesses leicht angehoben werden.

Die Priorität von Prozessen kann auch manuell verändert werden. Öffnet man den Task-Manager, fügt in der Ansicht weitere Spalten, wie die Basispriorität hinzu, lässt sich die Priorität abändern. Alle Threads und Prozesse, die dann neu erzeugt werden, haben automatisch eine höhere Priorität und bekommen mehr CPU-Zeit zugewiesen. Beachten Sie das die Basispriorität nicht in Zahlen von 0 bis 31 angegeben wird. Der Task-Manager zeigt die von dem Kernel auf die Windows API abgebildeten Prioritäten an. Die Windows API organisiert die Prozesse anhand ihrer Prioritätsklasse (Real-time, High, Above Normal, Normal, Below Normal und Idle). Innerhalb dieser Klasse findet dann eine weitere relative Einteilung (Echtzeit, Hoch, Höher als normal, Normal, Niedriger als normal, Niedrig) statt. So entspricht die Priorität Normal intern dem Level 6 bis 10.

task_manager_priority

Das Betriebssystem muss die eingestellte Priorität nicht zwangsläufig beachten. Darüberhinaus ist bei der manuellen Anpassung der Priorität Vorsicht geboten. Bei falschen Einstellungen erhalten bestimmte Threads unter Umständen nicht mehr ausreichend CPU-Zeit und das System wird träge und ineffizient.

Dynamische Prioritätsanpassung

Thread-Prioritäten werden vom Betriebssystem in bestimmten Situationen angehoben.

  • Vervollständigung einer I/O-Operation
  • Nach dem Warten auf auszuführende Ereignisse oder Semaphores
  • Nachdem Threads im Vordergrund-Prozess eine Warteoperation abgeschlossen haben
  • Wenn GUI-Threads aufgewacht sind, weil das Fenster aktiv wurde
  • Wenn ein Thread im "Ready"-Zustand lange Zeit nicht ausgeführt wurde

Windows gewährt Threads einen Prioritätsschub, nachdem eine I/O-Operation vervollständigt wurde. Auf diese Weise kann ein Thread, der auf eine I/O gewartet hat, seine Arbeit beenden. Eine gängige I/O-Operation ist das Schreiben zu einer Festplatte. Windows NT erhöht die Priorität dann um 1. Threads, die eine Mausnachricht oder Tastaturnachricht empfangen, werden mit einer 6 belohnt. Die Anpassung findet dabei nur im variablen Bereich zwischen 1 und 15 statt, so dass die Priorität niemals über den Wert 15 ansteigen kann.

priority-boosting-and-decay

Der Schub erfolgt auf der Basispriorität, nicht der dynamischen Priorität. Nach der Anhebung der Thread-Priorität, läuft der Thread für ein zusätzliches Quantum mit der gesteigerten Priorität. Nachdem der Thread ein Quantum lang gelaufen ist, wird seine Priorität um 1 verringert und der Thread läuft für ein weiteres Quantum. Dieser Zyklus setzt sich fort, bis der Thread seine Basispriorität erreicht hat. Ein Thread mit einer höheren Priorität kann den angehobenen Thread auch weiterhin verdrängen, aber der unterbrochene Thread beendet seinen Zeitschlitz auf dem angehobenen Level, bevor er auf die nächstniedrigere Stufe versetzt wird.

Thread Status

Prozesse und Threads besitzen feste Momente in ihrem Leben wie Einrichten, Ausführen von Aktionen und Sterben. Nachdem ein Thread eingerichtet wurde, durchläuft er verschiedene Stadien. In Windows 2000 und Windows XP kann ein Thread die folgenden Zustände einnehmen.

thread-states

Man unterteilt den Zustand in:

  • Ready:
    Diese Threads sind einsatz- bzw. ausführungsbereit und werden einzig vom Scheduler beachtet.
  • Standby:
    Das ist der Thread, der als nächstes auf einem bestimmten Prozessor "am Zug" ist. Der Dispatcher führt einen Kontextwechsel zu diesem Thread durch. Auf dem System kann sich nur ein Thread pro CPU-Kern im Status Standby befinden. Der Thread kann umgangen werden, wenn ein Thread mit höherer Priorität einsatzbereit wird oder ein Interrupt auftritt.
  • Running:
    Dieser Thread wird solange ausgeführt, bis sein Quantum abgelaufen ist, er freiwillig in den Wartezustand geht, er unterbrochen oder terminiert wird.
  • Waiting:
    Normalerweise geht ein Thread in den Wartezustand, wenn er auf I/O-Informationen wartet, die er zum Weiterarbeiten benötigt. Sobald diese Ressource verfügbar ist, geht er in den "Ready"-Zustand über.
  • Transition:
    Ein ablaufbereiter Thread, dessen Kernel Stack nicht im Speicher verfügbar ist. Sobald der Kernel Stack zurück in den Speicher geladen wurde, tritt der Thread in den "Ready"-Zustand ein.
  • Terminated:
    Sobald ein Thread seine Arbeit beendet hat, tritt dieser in den "Terminated"-Zustand ein. Wird ein Thread terminiert, so wird das Objekt des Threads manchmal nicht sofort gelöscht; es kann noch länger verfügbar bleiben, um es zu reinitalisieren und wieder zu verwenden.
  • Initialized:
    Dieser Zustand wird intern verwendet, während ein Thread erzeugt wird.
Vor- und Nachteile von Threads

Typischerweise setzt man Threads ein, wenn ein Programm mehrere Dinge gleichzeitig tun soll. Nehmen Sie an, Sie wollen die Zahl PI (3,141592653...) auf die milliardste Stelle genau berechnen. Der Prozessor beginnt mit seiner Berechnung, aber währenddessen erscheint keinerlei Ausgabe auf der Benutzeroberfläche. Die CPU ist vollauf damit beschäftigt die Zahl PI zu berechnen und hat einfach keine Zeit auch noch Ausgaben zu tätigen. Gerne würden Sie vielleicht die Berechnung mit einem Stop-Button unterbrechen, aber das Programm wird auf Ihre Benutzereingabe auch nicht reagieren können. Damit das Programm reagieren kann, benötigen Sie einen zweiten Ausführungs-Thread und damit ist der Vorteil von Threads bereits ersichtlich. Threads ermöglichen Multitasking innerhalb der Anwendung.

Vorteile:

  • Durchführen zeitaufwendiger Operationen in einem parallelen Ausführungsstrang.
  • Gute Skalierung auf Mehrkern-Prozessor-Systemen. Jeder Thread läuft auf einem Prozessorkern und nutzt die vorhandenen Ressourcen effizient aus.
  • Nicht blockierende Kommunikation über ein Netzwerk, mit einem Webserver und mit einer Datenbank.
  • Unterscheiden von Aufgaben nach unterschiedlicher Priorität. Ein Thread mit hoher Priorität übernimmt beispielsweise Aufgaben mit hoher Dringlichkeitsstufe, während ein Thread mit niedriger Priorität andere Aufgaben ausführt.
  • Aufrechterhalten der Benutzeroberflächen-Reaktivität, während Hintergrundaufgaben bearbeitet werden.

Threads haben auf den ersten Blick nur Vorteile. Doch leider gibt es dort wo Licht ist, auch oft Schatten und bei Threads ist das nicht anders. Threads können in Anwendungen in denen viele asynchrone Prozesse ablaufen ziemlich kostenintensiv werden. Bedenken Sie das für einen neuen Thread zunächst einmal ein Kernel Objekt allokiert werden muss, der zugehörige Stack initialisiert wird und anschließend sendet Windows® allen DLL's im Prozess eine entsprechende Benachrichtigung. Wird der Thread wieder zerstört so geht das Spiel in entgegengesetzter Richtung wieder von vorne los.

Es empfiehlt sich deshalb, so wenig Threads wie möglich zu verwenden, um die Beanspruchung von Betriebssystemressourcen zu minimieren und somit die Leistung zu steigern. Beim Entwurf einer Anwendung müssen bezüglich des Threadings sowohl Ressourcenanforderungen als auch potenzielle Konflikte in Betracht gezogen werden, die durch den Zugriff auf gemeinsam genutzte Ressourcen resultieren. Durch Synchronisierung des Zugriffs auf gemeinsame Ressourcen können solche Konflikte vermieden werden. Fehler bei der Synchronisierung des Zugriffs (innerhalb derselben oder in verschiedenen Anwendungsdomänen) können Probleme wie z. B. Deadlocks (dabei wird die Ausführung zweier Threads beendet, weil jeder auf den anderen Thread warten muss, um die eigene Ausführung zu beenden) und Wettlaufsituationen (bei Auftreten eines abweichenden Ergebnisses infolge einer unerwarteten kritischen Abhängigkeit vom zeitlichen Ablauf zweier Ereignisse) auftreten.

Nachteile:

  • Das Steuern der Codeausführung mit vielen Threads ist sehr komplex und kann viele Fehler verursachen. Mehrere Threads müssen synchronisiert werden und die Fehlersuche (Debug) gestaltet sich schwieriger.
  • Eine große Anzahl von Threads nimmt einen beträchtlichen Teil der CPU-Zeit in Anspruch. Bei zu vielen Threads kann die Ausführung der meisten nur ungenügend vorankommen. Das Betriebssystem muss sehr oft zwischen den Threads hin- und herschalten, was zu mehr Overhead führt. Wenn sich die meisten der aktuellen Threads in einem Prozess befinden, werden Threads in anderen Prozessen weniger häufig in den Ablaufplan aufgenommen.
  • Das Betriebssystem nimmt Arbeitsspeicher für die Kontextinformationen in Anspruch, die für Prozesse, AppDomain-Objekte und Threads erforderlich sind. Deshalb ist die Anzahl der Prozesse, AppDomain-Objekte und Threads, die erstellt werden können, durch den verfügbaren Arbeitsspeicher begrenzt.
  • Das Löschen von Threads erfordert Kenntnisse über mögliche Auswirkungen auf das Programm und deren Behandlung.

Scheduling

Windows NT verwendet ein prioritätengesteuertes Scheduling. Wieviel Rechenzeit einem Thread gewährt wird, hängt nur von seiner Priorität ab. Das Umschalten von einem Thread zu einem anderen, ist ein elementarer Vorgang in modernen Betriebssystemen, die auf präemptiven Multitasking basieren. Er wird von dem Scheduler, einem wichtigen Steuerprogramm durchgeführt. Ein Scheduler ist eine Arbitrationslogik, die die zeitliche Ausführung mehrerer Threads im Betriebssystemen regelt. Scheduler kann man grob in unterbrechende (preemptive) und nicht unterbrechende (non preemptive, auch kooperativ genannt) aufteilen. Nicht unterbrechende Scheduler lassen einen Thread, nachdem ihm die CPU einmal zugeteilt wurde, solange laufen, bis dieser diese von sich aus wieder freigibt oder bis er blockiert. Unterbrechende Scheduler teilen die CPU von vornherein nur für eine bestimmte Zeitspanne zu und entziehen dem Thread diese daraufhin wieder.

Wird ein Thread unterbrochen, um für einen anderen Platz zu machen, muss sein Kontext gespeichert werden. Der Threadkontext beinhaltet alle Informationen, die der Thread zur nahtlosen Wiederaufnahme der Ausführung im Adressbereich des Hostprozesses des Threads benötigt, einschließlich des zugehörigen Satzes von CPU-Registern und Stapeln. Der Thread tritt daraufhin in die Warteschleife ein und ein anderer Thread wird ausgeführt. Dieser Vorgang nennt sich Kontextwechsel (engl. Context-Switch) und ist in dem nachfolgenden Bild illustriert.

voluntary-thread-switching

Der laufende Thread tritt in den Wartezustand ein und ein Thread mit der Priorität 17 wird auf dem Prozessorkern ausgeführt. Ein Kontextwechsel ist aufwendig und beansprucht Zeit, weshalb die Zahl der Kontextwechsel pro Zeiteinheit möglichst niedrig ausfallen sollte. Umso mehr Threads auf einem System aktiv sind, desto mehr zeitintensive Kontextwechsel müssen stattfinden, um jeden Thread ausreichend Rechenzeit zur Verfügung zu stellen. Ab einer bestimmten Threadanzahl führt der Overhead zu signifikanten Geschwindigkeitseinbußen. Rechenintensive Threads sollten daher möglichst auf individuelle Prozessorkerne verteilt werden, um eine optimal skalierbare Anwendung realisieren zu können. Es gilt unnötige Threads zu vermeiden.

Quantum

Ein Quantum, auch Zeitscheibe genannt, ist die Zeiteinheit die ein Thread läuft, bis Windows aktiv wird und nachsieht, ob ein anderer Thread mit derselben bzw. einer höheren Priorität darauf wartet ausgeführt zu werden. Wenn ein Thread sein Quantum beendet und sich kein anderer Thread mit derselben bzw. einer höheren Priorität in der Wartschlange befindet, gestattet Windows dem Thread ein weiteres Quantum zu laufen.

Auf Windows 2000 Professional und Windows XP beträgt die Standardzeit, die ein Thread läuft, exakt 2 CPU-Takte. Auf einem Windows Server System sind es 12 CPU-Takte. Der Grund für die längere Intervalldauer auf einem Server ist die Minimierung der Kontextwechsel. Die Länge eines Taktintervalls hängt von der Hardware ab. Die Frequenz eines Taktinterrupt wird von der HAL festgelegt, nicht vom Kernel. So beträgt der Taktinterrupt auf den meisten x86 Einkern-Prozessor-Systemen ungefähr 10 Millisekunden und auf den meisten Mehrkern-Prozessor-Systemen ungefähr 15 Millisekunden. Die exakte Zeitspanne können Sie mit einem Programm, wie ClockRes v2.0 herausfinden.

Jeder Prozess hat seinen eigenen Quantum-Wert im Kernel Process Block. Dieser Wert wird verwendet, wenn einem Thread ein neues Quantum zugewiesen wird. Wenn ein Thread läuft, wird sein Quantum mit jedem Taktintervall reduziert. Wenn der Thread über kein verbleibendes Quantum mehr verfügt, wird ein spezieller Vorgang getriggert. Falls ein weiterer Thread mit derselben Priorität darauf wartet ausgeführt zu werden, findet ein Kontextwechsel statt und der nächste Thread wird ausgeführt. Sofern bei der Ausführung eines Threads ein Clock-Interrupt für einen DPC oder einen anderen Interrupt stattfindet und der laufende Thread in den Wartezustand verschoben wird, wird das Quantum des Threads um 1 reduziert, auch wenn dieser nicht ein vollständiges Taktintervall lang gelaufen ist.

Ein Quantum wird intern in Vielfachen von 3 CPU-Takten gespeichert. Das bedeutet das ein Quantum auf Windows XP einen Wert von 6 (2 * 3) besitzt und auf einem Windows Server System den Wert 36 (12 * 3). Jedesmal wenn die Uhr die CPU unterbricht, reduziert die Clock-Interrupt-Routine das Quantum des Threads um einen festen Wert (3).

Sie können das Quantum für alle Prozesse in Windows verändern, aber Sie können nur zwischen zwei Werten wählen. Zur Auswahl stehen 2 CPU-Takte für Programme und 12 CPU-Takte für Hintergrunddienste.

quantum-konfiguration
Quantum Boosting

Windows NT erteilt bestimmten Threads einen Schub in der Priorität, auch „Quantum Boosting“ genannt. Fenster die in den Vordergrund gebracht werden, erhalten auf diese Weise mehr CPU-Zeit zugewiesen. Dieses Verhalten sorgt unter anderem dafür, dass grafische Benutzeroberflächen schneller auf Benutzereingaben reagieren. Vor Windows NT 4.0 erhielten alle Threads von einem Fenster, welches in den Vordergrund (Fokus) gebracht wurde, einen Prioritätsschub von 2. Dieses Verhalten wurde geändert, so das Windows nun das Quantum dieser Threads verdreifacht. Daher laufen alle Threads im Vordergrund mit einem Quantum von 6 Takten, während Threads in anderen Prozessen mit dem Standardwert von 2 Takten arbeiten.

In der nachfolgenden Tabelle können Sie die Quantum-Werte in verschiedenen Szenarios sehen. Werden lange Quantumwerte verwendet, zusammen mit einer variablen Anpassung für Prozesse im Vordergrund (VG), weist ein Thread in einem Hintergrund-Prozess den Wert 12 auf.

Kurze Quantumwerte Lange Quantumwerte
Variabel Fest Variabel Fest
Thread im HG-Prozess 6 18 12 36
Thread im VG-Prozess 12 18 24 36
Aktiver Thread im VG-Prozess 18 18 36 36

Dispatcher Datenbank

Um Entscheidungen über das Scheduling zu treffen, verwaltet der Kernel einen Satz von Datenstrukturen, allgemein bekannt als Dispatcher Database. Diese Datenbank speichert Informationen darüber, welche Threads darauf warten ausgeführt zu werden und welche Prozessoren welche Threads ausführen. Die Ready queues zeigen an, welche Threads sich im "Ready"-Zustand befinden. Für jede Priorität gibt es eine Warteschlange. Um die Auswahl zu beschleunigen, wird eine 32-Bit Maske verwendet, Ready summary genannt. Jedes Bit zeigt an, ob es in der betreffenden Warteschlange einen oder mehrere Threads gibt, die darauf warten ausgeführt zu werden.

dispatcher-database

Thread-Local Storage

Wenn ein Thread seinen Zeitschlitz beendet hat, wird der Thread vom Scheduler nicht einfach in die Warteschleife versetzt. Wie wir bereits erfahren haben, muss zunächst sein Threadkontext gespeichert werden, damit der Thread zu einem späteren Zeitpunkt wieder ordnungsgemäß fortgesetzt werden kann. Beim sogenannten Kontextwechsel werden alle wesentlichen Daten, z.B. der Befehlszeiger, abgespeichert. Ein Thread besitzt dafür seinen eigenen privaten Speicherbereich, den Thread-Local Storage (TLS) oder auch verwalteter lokaler Threadspeicher.

Der Thread Environment Block (TEB) verweist auf den Thread-Local Storage Puffer. Alle Threads in einem Prozess teilen sich einen virtuellen Adressraum. Die lokalen Variablen einer Funktion sind für jeden Thread einzigartig, der diese Funktion ausführt. Die statischen und globalen Variablen in einem Prozess werden allerdings von allen Threads geteilt. Mit dem Thread-Local Storage (TLS) kann man jedem Thread individuelle Daten zur Verfügung stellen, die von dem Prozess, dem der Thread gehört, über einen globalen Index angesteuert werden können. Ein Thread allokiert den Index, der dann auch von anderen Threads verwendet werden kann, um die Daten zu empfangen, die mit diesem Index assoziiert sind. Auf diese Weise ermöglicht es TLS das multiple Threads aus demselben Prozess einen Index verwenden, um Daten abzuspeichern und zu empfangen, die lokal an den Thread gebunden sind. TLS wird in Windows von Subsystemen, Laufzeitbibliotheken und DLLs verwendet.

Multithreading in .NET

Das .NET Framework stellt eine Vielzahl von Werkzeugen für das verwaltete Threading zur Verfügung. In den kommenden Kapiteln sollen diese Werkzeuge im Detail erörtert werden. Das .NET Framework unterteilt einen Betriebssystemprozess in einfache verwaltete Unterprozesse, so genannte Anwendungsdomänen, die durch System.AppDomain dargestellt werden. Ein oder mehrere verwaltete Threads (dargestellt durch System.Threading.Thread) können in beliebiger Anzahl von Anwendungsdomänen innerhalb desselben verwalteten Prozesses ausgeführt werden. Obwohl jede Anwendungsdomäne mit einem einzigen Thread gestartet wird, kann der Code in dieser Anwendungsdomäne zusätzliche Anwendungsdomänen und weitere Threads erstellen. Dadurch kann sich ein verwalteter Thread frei zwischen Anwendungsdomänen innerhalb desselben verwalteten Prozesses bewegen. Gegebenenfalls kann es einen einzelnen Thread geben, der sich zwischen verschiedenen Anwendungsdomänen bewegt.

Grundlage für das Threading in .NET ist der Namensraum System.Threading. Der Namensraum bietet eine Fülle von Klassen und Interfaces zur Unterstützung von Multithread-Programmen. Die meisten Programmierer werden niemals explizit mit Threads umgehen müssen, denn die CLR fasst einen Großteil der Thread-Unterstützung in Klassen zusammen, mit denen Multithreading-Aufgaben vereinfacht werden. Wenn Sie beim Ausführen einer Anwendung nur einen einzelnen Thread verwenden, können Sie asynchrone Programmierung mit .NET Framework Remoting oder XML-Webdiensten kombinieren, die mit ASP.NET erstellt wurden.

Beginnend mit .NET Framework, Version 4 wird die Multithread-Programmierung durch die System.Threading.Tasks.Parallel-Klasse und die System.Threading.Tasks.Task-Klasse, Paralleles LINQ (PLINQ), neue parallele Auflistungsklassen im System.Collections.Concurrent-Namespace und ein neues Programmierungsmodell erheblich vereinfacht, das auf dem Konzept von Aufgaben und nicht auf Threads basiert. Weitere Informationen finden Sie unter Parallele Programmierung in .NET Framework.

Die Klasse Thread

.NET-Threads sind die Managed-Code Variante der unterliegenden Threads des Betriebssystems. Diese haben wir bereits in den ersten Kapiteln kennengelernt. In .NET auf Windows, werden die Threads eins zu eins auf die nativen Win32- bzw. Win64-Threads abgebildet (diese Abbildung könnte in Zukunft zum Beispiel zu Gunsten von Fibers geändert werden).

Alle .NET-Threads werden durch die Klasse Thread im Namensraum System.Threading repräsentiert. Diese Klasse bietet zahlreiche Methoden und Eigenschaften um einen verwalteten Thread zu kontrollieren.

Ausführungszustände eines Thread

Das .NET Framework abstrahiert die nativen Threads des Betriebssystems und führt eigene Bezeichnungen für Methoden und Eigenschaften ein, die sich teilweise deutlich von den Bezeichnern aus der Windows API unterscheiden. Bevor wir uns mit der .NET Klasse Thread beschäftigen können, müssen wir zunächst die Bezeichnung und Bedeutung der Ausführungszustände von .NET-Threads kennenlernen. Alle Zustände entsprechen einem Thread Status, welche bereits in einem vorangegangenen Kapitel detailliert erläutert wurden.

Die ThreadState-Enumeration definiert eine Gruppe aller möglichen Ausführungszustände für Threads im .NET Framework. Ein Thread befindet sich nach seiner Erstellung bis zu seiner Beendigung in mindestens einem der Zustände. Innerhalb der Common Language Runtime (CLR) erstellte Threads befinden sich anfangs im "Unstarted"-Zustand, während in die Runtime eintretende externe Threads sich bereits im "Running"-Zustand befinden. Ein "Unstarted"-Thread geht durch den Aufruf von Start in den "Running"-Zustand über. Es sind nicht alle Kombinationen von ThreadState-Werten gültig. Ein Thread kann sich z. B nicht gleichzeitig im "Aborted"-Zustand und im "Unstarted"-Zustand befinden. In dem folgenden Diagramm sehen Sie die Ausführungszustände und die Wege, die zu den Zuständen führen.

thread-states-in-dotnet

Die Tabelle zeigt alle ThreadState-Enumerationen:

Zustand Beschreibung
Running Der Thread wurde gestartet, er wird nicht blockiert, und es ist keine ausstehende ThreadAbortException vorhanden.
StopRequested Es besteht eine Anforderung für die Beendigung des Threads. Dies ist ausschließlich für die interne Verwendung vorgesehen.
SuspendRequested Es besteht eine Anforderung für die Unterbrechung des Threads.
Background Der Thread wird nicht als Vordergrundthread, sondern als Hintergrundthread ausgeführt. Dieser Zustand wird durch Festlegen der Thread.IsBackground-Eigenschaft gesteuert.
Unstarted Die Thread.Start-Methode wurde für den Thread nicht aufgerufen.
Stopped Der Thread wurde beendet.
WaitSleepJoin Der Thread ist blockiert. Die Ursache hierfür könnte sein, dass Thread.Sleep oder Thread.Join aufgerufen wurde, dass eine Sperre angefordert wurde, z. B. durch Aufrufen von Monitor.Enter oder Monitor.Wait, oder dass auf ein Threadsynchronisierungsobjekt wie ManualResetEvent gewartet wird.
Suspended Der Thread wurde unterbrochen.
AbortRequested Die Thread.Abort-Methode wurde für den Thread aufgerufen, doch der Thread hat noch nicht die ausstehende System.Threading.ThreadAbortException empfangen, die ihn zu beenden versucht.
Aborted Der Threadzustand schließt AbortRequested ein, und der Thread ist jetzt deaktiviert. Der Zustand hat sich jedoch noch nicht in Stopped geändert.
Threads erzeugen

Am einfachsten erzeugen Sie einen Thread, indem Sie eine neue Instanz der Klasse Thread anlegen. Der Konstruktor von Thread hat ein einziges Argument, und zwar eine delegate-Instanz. Die CLR bietet speziell für diesen Zweck die Delegate-Klasse ThreadStart, die auf eine von Ihnen benannte Methode weist. Damit können Sie einen Thread erstellen und ihm sagen: »Wenn du startest, rufe diese Methode auf.« Die Deklaration des ThreadStart-Delegate ist:

public delegate void ThreadStart();

Wie Sie sehen, darf die Methode, die Sie diesem Delegate zuordnen, keine Parameter haben und muss void liefern. Die Erstellung eines Thread kann somit folgendermaßen erfolgen:

Thread myThread = new Thread(new ThreadStart(myMethode));

Ab der Version 2.0 des .NET Framework, ist es nicht mehr erforderlich explizit einen Delegaten zu erzeugen. Es reicht aus den Namen der Methode im Konstruktor zu spezifizieren und der Compiler sucht sich den richtigen Delegaten selbst heraus.

Thread myThread = new Thread(myMethode);

Die Methode eines Thread führt gewöhnlich irgend eine Arbeit aus. So können Sie zwei Worker-Threads erzeugen, von denen ein Thread eine Variable von 0 bis 100 inkementiert:

public void Incrementer()
{
    for(int i = 0; i < 100; i++) {
        Console.WriteLine("Incrementer: {0}", i);
    }
}

Der andere Thread dekrementiert:

public void Decrementer()
{
    for(int i = 0; i >= 0; i--) {
        Console.WriteLine("Decrementer: {0}", i);
    }
}

Nach der Instantiierung des Threads befindet dieser sich im Zustand "Unstarted". Um die Threads laufen zu lassen, müssen Sie die Methode Start des Thread-Objekts selbst aufrufen.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    class StandardThread
    {
        public static void Main(string[] args)
        {
            Thread t1 = new Thread(new ThreadStart(Incrementer));
            Thread t2 = new Thread(new ThreadStart(Decrementer));
 
            // Threads starten
            t1.Start();
            t2.Start();
        }
 
        public static void Incrementer()
        {
            for (int i = 0; i < 100; i++) {
                Console.WriteLine("Incrementer: {0}", i);
            }
        }
 
        public static void Decrementer()
        {
            for (int i = 100; i >= 0; i--) {
                Console.WriteLine("Decrementer: {0}", i);
            }
        }
    }
}

Sie können nun beobachten wie beide Threads wechselseitig Ausgaben auf die Konsole ausgeben. Das geschieht keineswegs im Gleichschritt, wie man dies vermuten könnte. Das Betriebssystem gewährt jedem Thread unterschiedlich viel Rechenzeit. Der Scheduler entscheidet in Abhängigeit der Systemressourcen darüber, wann ein Thread an der Reihe ist und wie lange er die CPU in Anspruch nehmen darf.

Die Signatur der Methode Thread gestattet es nicht, Parameter zu übergeben. Mit ParameterizedThread-Start lässt sich allerdings ein Parameter vom Typ Object an den Thread übergeben.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    class ParameterizedThread
    {
        public static void Main(string[] args)
        {
            Thread t1 = new Thread(Incrementer);
            Thread t2 = new Thread(Decrementer);
 
            // Threads starten
            t1.Start(0);
            t2.Start(100);
        }
 
        public static void Incrementer(object o)
        {
            for (int i = Convert.ToInt32(o); i < 100; i++) {
                Console.WriteLine("Incrementer: {0}", i);
            }
        }
 
        public static void Decrementer(object o)
        {
            for (int i = Convert.ToInt32(o); i >= 0; i--) {
                Console.WriteLine("Decrementer: {0}", i);
            }
        }
    }
}

Diese Vorgehensweise funktioniert naturgemäß nur bei einem Parameter. Möchte man mehrere Parameter übergeben, ist man gezwungen einen anderen Weg einzuschlagen. Die korrekte Variante, mehrere Parameter an einen Thread zu übergeben, ist es die Thread-Methode zu einem Bestandteil einer Klasse zu machen, deren Eigenschaften als Argumente von dem Thread ausgelesen werden können.

public class MyThreadClass
{
    public MyThreadClass()
    {
    }
 
    // Thread Funktion
    public void ShowSentence()
    {
        // Empfange die Argumente
        string localName = Name;
        int localNumber = Number;
 
        Console.WriteLine("{0} und die {1} Zwerge.", localName, localNumber);
    }
 
    // Automatic Properties in C# 3.0
    public string Name { get; set; }
 
    public int Number { get; set; }
}

Die Übergabe erfolgt dann einfach in gewohnter Weise über die Setter/Getter der Klasse.

MyThreadClass m = new MyThreadClass();
m.Name = "Schneewittchen";
m.Num = 7;
 
Thread t3 = new Thread(m.ShowSentence);
t3.Start();
Threads blockieren

Die Klasse Thread bietet diverse Methoden an, um einen laufenden Thread zu blockieren. Zu diesen Methoden gehört die Unterbrechung eines Thread mit Suspend, das Schlafen legen mit Sleep und das Warten auf die Beendigung eines anderen Thread mit Join. In diesem Kapitel erfahren Sie mehr über diese blockierenden Operationen.

Sleep

Manchmal müssen Sie den Ablauf eines Threads für eine kurze Zeit blockieren. Zum Beispiel können Sie einen Thread, der die Uhrzeit digital ausgibt, zwischen zwei Abfragen der Systemzeit etwa eine Sekunde lang aussetzen lassen. Das gibt Ihnen die Möglichkeit, etwa einmal pro Sekunde die aktuelle Zeit anzeigen zu lassen, ohne dafür ständig den Prozessor auszulasten.

Die statische Methode Sleep der Klasse Thread dient genau diesem Zweck. Die Methode Sleep ist zweifach überladen und übernimmt entweder einen Int32-Wert oder ein TimeSpan-Objekt. In beiden Fällen wird die Anzahl der Millisekunden angegeben, für den der Thread angehalten werden soll. In dieser Zeit wird der Thread vom Scheduler nicht beachtet. Obwohl ein TimeSpan-Objekt sogar CPU-Ticks messen kann, beschränkt sich die Granularität der Sleep()-Methode auf Millisekunden.

Möchte man den laufenden Thread für zwei Sekunden unterbrechen, so ist folgende Zeile zu verwenden.

Thread.Sleep(2000);

Die Methode Sleep blockiert den Thread und versetzt diesen in den Zustand "WaitSleepJoin".

Mit den gewonnen Erkenntnissen können wir einen faulen Thread programmieren, der bis 100 zählt und sich dabei nach jeder Dekade ein wenig ausruht, indem er eine Sekunde lang schläft.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    class SleepingThread
    {
        public static void Main(string[] args)
        {
            Thread t1 = new Thread(new ThreadStart(LazyIncrementer));
            t1.Start();
        }
 
        public static void LazyIncrementer()
        {
            for (int i = 0; i < 100; i++) {
                Console.WriteLine("Lazy Incrementer: {0}", i);
 
                if (i % 10 == 0) {
                    Console.WriteLine("ZZzzzzZzzzzz...");
                    Thread.Sleep(1000);
                }
            }
        }
    }
}

Die Methode Sleep ist ein blockierender Aufruf. Das bedeutet das der Thread umgehend in den Zustand "WaitSleepJoin" fällt und die Kontrolle erst wieder an den Thread zurückgibt, wenn die angegebene Zeitspanne verstrichen ist. Jeder Thread, der Sleep aufruft, verzichtet auf sein verbleibendes Quantum auf dem Prozessor, auch wenn der in der Sleep()-Methode angegebene Wert den auf der CPU noch verbliebenen Quantum-Wert unterschreitet. Das bedeutet das bei der Übergabe von 0 ein Kontextwechsel stattfindet.

Thread.Sleep(0);    // Erzwinge einen Kontextwechsel

Man kann einen Thread auch für eine nicht endliche Zeit "einschläfern", indem man Timeout.Infinite verwendet.

Thread.Sleep(Timeout.Infinite);    // Schlafe unbegrenzt

Einen Thread unbegrenzt in den Zustand "WaitSleepJoin" zu versetzen macht naturgemäß nicht viel Sinn. Falls man den Thread nicht mehr benötigt, sollte man diesen terminieren. Wartet man auf ein asynchrones Ereignis, ist man bei den Synchronisationsmechanismen des .NET Framework besser aufgehoben.

SpinWait

.NET stellt neben Sleep eine weitere Methode zur Verfügung mit der man einen Thread unterbrechen kann, die Methode Thread.SpinWait. SpinWait bewirkt das der aufgerufene Thread für eine bestimmte Anzahl an Iterationen warten muss und in dieser Zeit nicht in die Schlange der auf die Ausführung wartenden Threads eingereiht wird. Der Thread tritt in den Zustand "WaitSleepJoin" ein ohne dabei sein noch bestehendes Quantum zu verlieren. Der Prozessor wird mit SpinWait in eine sehr enge Ausführungsschleife versetzt. Die Dauer hängt dabei von der Geschwindigkeit der CPU ab.

const int VALUE = 2000;
Thread.SpinWait(VALUE)

Thread.SpinWait soll nicht Thread.Sleep ersetzen, die Methode ist für fortschrittliche Optimierungszwecke vorgesehen. Die SpinWait-Methode ist beispielsweise nützlich zum Implementieren von Sperren. Klassen im .NET Framework, wie Monitor und ReaderWriterLock, verwenden diese Methode intern. Bestimmte Operationen können damit effizienter gestaltet werden, da kein Kontextwechsel zwangsläufig stattfindet, ein Vorgang im OS, der sehr kostenintensiv ist.

Suspend und Resume

Die Methoden Thread.Suspend und Thread.Resume dienen dazu, Threads zu unterbrechen und wieder fortzusetzen. Jeder Thread kann die Methode Suspend an einem Thread-Objekt aufrufen, inklusive dem Thread, der unterbrochen werden soll. Die Fortsetzung eines Thread mit Resume muss natürlich von einem laufenden Thread heraus erfolgen und kann nicht von dem unterbrochenen Thread erzwungen werden.

Die Methode Suspend selbst ist nicht blockierend, d.h. das die Kontrolle umgehend an den Aufrufer (Caller) zurück übergeben wird und der Thread später an einer geeigneten Stelle unterbrochen wird. Eine geeignete Stelle, ist eine sichere Stelle im Code für die Garbage Collection. Immer wenn die GC in Aktion tritt, unterbricht .NET alle laufenden Threads, so dass der Heap komprimiert werden kann, verschiebt Objekte und korrigiert alle Referenzen. Der JIT-Compiler identifiziert alle für die Unterbrechung von Threads geeigneten Stellen im Code, das kann z.B. der Rücksprung aus einer Methode oder die nächste Iteration in einer Schleife sein. Der Zustand "Suspend" wird demzufolge dann erreicht, wenn der nächste sichere Punkt erreicht wurde. Bis zu diesem Zeitpunkt befindet sich der Thread im Ausführungszustand "SuspendRequested".

Der Hauptpunkt ist, die Unterbrechung eines Thread ist keine unmittelbare Operation. Suspend und Resume werden oftmals benutzt um die Ausführung von Threads zu synchronisieren. Auf die Threadsynchronisation werden wir in einem separaten Kapitel detailliert eingehen. Der direkte Gebrauch von Suspend und Resume ist nicht empfehlenswert, weil der Programmierer nicht weiß wann die Operationen ausgeführt werden. Microsoft hat diese Methode deshalb in der API obsolet erklärt und rät von der Nutzung ab.

Join

Man kann einen Thread dazu veranlassen, die Verarbeitung zu unterbrechen und darauf zu warten, dass ein anderer Thread seine Arbeit abgeschlossen hat. Dies wird als Vereinigen (engl. Join) des ersten mit dem zweiten Thread bezeichnet. Es ist, als würden Sie die Spitze des ersten Threads mit dem Ende des zweiten Threads verknüpfen, also »vereinigen«.

Um einen Thread t1 mit einem zweiten Thread t2 zu vereinigen, schreiben Sie innerhalb der Methode von Thread t1:

t2.Join();

Die Methode Join sorgt dafür das der erste Thread solange wartet, bis der zweite Thread beendet hat. Nehmen Sie an, Sie haben eine Thread-Collection namens myThreads angelegt. Sie durchlaufen die Collection und vereinigen dabei den aktuellen Thread der Reihe nach mit allen laufenden Threads in der Collection.

foreach(Thread myThread in myThreads) {
    myThread.Join();
}
 
Console.WriteLine("Der letzte Thread ist fertig.");

Die Meldung wird dabei erst ausgegeben, wenn alle Threads in der Collection ihre Arbeit abgeschlossen haben.

Als Collection wollen wir eine generische Queue verwenden. Mit dem Konstruktoraufruf legen wir die Anzahl an zu erstellenden Thread-Objekten fest. Die Threads gehen einer Tätigkeit nach und melden sich kontinuierlich mit ihrem Namen und einer Zufallszahl.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    public class JoinThread
    {
        public static void Main(string[] args)
        {
            ThreadCollection tCol = new ThreadCollection(3);
            tCol.StartThreads();
        }
    }
 
    public class ThreadCollection
    {
        public ThreadCollection(int value)
        {
            _myThreads = new Queue<Thread>();
            _initThreads = value;
        }
 
        public void StartThreads()
        {
            // Threads erzeugen und benennen
            for (int i = 0; i < _initThreads; i++) {
                Thread temp = new Thread(new ThreadStart(Task));
                _myThreads.Enqueue(temp);
                temp.Name = String.Format("Thread [{0}]", i);
                temp.Start();
            }
 
            while (_myThreads.Count > 0) {
                Console.ReadLine();
                _exitThread = _myThreads.Dequeue();
                _exitThread.Join();
            }
        }
 
        private void Task()
        {
            Random rand = new Random();
            while (true) {
                Console.WriteLine("{0}: {1}", Thread.CurrentThread.Name, rand.Next());
                Thread.Sleep(rand.Next() % 2048);
                if (_exitThread == Thread.CurrentThread) {
                    Console.WriteLine("{0} wird beendet.", Thread.CurrentThread.Name);
                    Thread.Sleep(5000);
                    return;
                }
            }
        }
 
        // Felder
        private Queue<Thread> _myThreads;
 
        private Thread _exitThread = null;
 
        private int _initThreads;
    }
}

Der wesentliche Codeabschnitt befindet sich in der Methode StartThreads(). In der while-Schleife wird eine Vereinigung des Hauptthreads mit dem erzeugten Thread vorgenommen. Der Hauptthread liest zunächst eine Zeile vom Benutzer ein, entfernt den ersten Thread aus der Queue (FIFO) und blockiert dann mit dem Join solange, bis der entsprechende Thread sich beendet hat. Dazu fragen alle Threads kontinuierlich das Feld _exitThread ab, ob sie ausgewählt wurden. Ist das der Fall, schläft der betreffende Thread zunächst 5 Sekunden und beendet dann. In dieser Zeit blockiert der Hauptthread, so dass keine neuen Benutzereingaben ausgegeben werden können.

Threads abbrechen

In der Regel beendet sich ein Thread, nachdem er seine Aufgabe erledigt hat. Ein Thread kann aber auch vorzeitig abgebrochen werden. Eine saubere Möglichkeit einen Thread zu beenden, ist eine boolesche Variable zu verwenden, die periodisch von dem Thread geprüft wird. Ändert das Flag seinen Zustand (z.B. von true in false) kann sich der Thread selbst beenden.

if(KeepAlive == false) {
    return;
}
Interrupt

Eine Alternative zu der booleschen Variable besteht darin, die Methode Thread.Interrupt aufzurufen, die den Thread auffordert, sich selbst abzubrechen. Interrupt beendet den Thread nicht sofort, stattdessen wird dieser dazu angehalten sich zu beenden.

Abort

Möchte man den Thread mit Gewalt abbrechen, kann man schließlich noch die Methode Thread.Abort aufrufen. Dies hat zur Folge, dass die Exception ThreadAbortException ausgelöst wird, die der abgebrochene Thread abfangen kann.

Jeder Thread sollte die Exception als Signal verstehen, dass es Zeit zum sofortigen Beenden ist. Auf diese Weise werfen Sie den Thread nicht einfach hinaus, sondern bitten ihn höflich zu gehen.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    class ThreadAbort
    {
        static void Main(string[] args)
        {
            ThreadAbort abort = new ThreadAbort();
            abort.StartThreads();
        }
 
        public void StartThreads()
        {
            Thread[] myThreads = 
            {   
                new Thread(new ThreadStart(Decrementer)),
                new Thread(new ThreadStart(Incrementer)),
                new Thread(new ThreadStart(Decrementer)),
                new Thread(new ThreadStart(Incrementer))
            };
 
            int counter = 0;
 
            foreach(Thread thread in myThreads) {
                thread.IsBackground = true;
                thread.Start();
                thread.Name = String.Format("Thread [{0}]", counter);
                counter++;
                Console.WriteLine("Thread {0} gestartet", thread.Name);
                Thread.Sleep(50);
            }
 
            // Thread auffordern, sich zu beenden 
            myThreads[0].Interrupt();
 
            // Thread sofort abbrechen
            myThreads[1].Abort();
 
            foreach(Thread thread in myThreads) {
                thread.Join();
            }
 
            Console.WriteLine("Der letzte Thread ist fertig.");
        }
 
        public void Incrementer()
        {
            try {
                for (int i = 0; i < 100; i++) {
                    Console.WriteLine("{0}. Incrementer: {1}",
                        Thread.CurrentThread.Name, i);
                    Thread.Sleep(1);
                }
            } catch (ThreadAbortException ex) {
                Console.WriteLine("{0} abgebrochen! Räume auf...",
                    Thread.CurrentThread.Name);
            } catch (Exception ex) {
                Console.WriteLine("Thread wurde unterbrochen.");
            } finally {
                Console.WriteLine("{0} wird beendet. ", Thread.CurrentThread.Name);
            }
        }
 
        public void Decrementer()
        {
            try {
                for (int i = 100; i >= 0; i--) {
                    Console.WriteLine("{0}. Decrementer: {1}",
                        Thread.CurrentThread.Name, i);
                    Thread.Sleep(1);
                }
            } catch (ThreadAbortException ex) {
                Console.WriteLine("{0} abgebrochen! Räume auf...",
                    Thread.CurrentThread.Name);
            } catch (Exception ex) {
                Console.WriteLine("Thread wurde unterbrochen.");
            } finally {
                Console.WriteLine("{0} wird beendet. ", Thread.CurrentThread.Name);
            }
        }
    }
}

In dem Beispiel werden vier Threads erzeugt. Der erste Thread wird mit Thread.Interrupt sanft beendet, während der zweite Thread mit Thread.Abort hart abgebrochen wird. Diese Operation löst eine ThreadAbortException aus. Alle vier Threads sind Hintergrundthreads, die sich im Gegensatz zu Vordergrundthreads darin unterscheiden, dass sie den Prozess nicht daran hindern sich vorzeitig zu beenden.

Threads terminieren

Wie bereits erfahren, ist die sauberste Methode einen laufenden Thread abzubrechen, diesen selbst dazu zu veranlassen. Dies erreicht man dadurch das man den Thread periodisch ein Flag prüfen lässt. Im .NET Framework wird ein entsprechendes Muster (Pattern) verwendet, mit dem man das auf einfache Art und Weise bewerkstelligen kann. Das nachfolgende Beispiel zeigt eine Klasse, die IDisposable implementiert und aufzeigt, wie Ressourcen sauber freigegeben werden, nachdem ein Thread sanft mit KillThread beendet wurde.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    /// <summary>
    /// Eine einfache Thread-Klasse, die eine saubere
    /// Variante aufzeigt, einen laufenden Thread abzubrechen.
    /// Hierfür wird ein Flag verwendet, welches vom Thread
    /// periodisch geprüft wird.
    /// </summary>
    public class GoodThread : IDisposable
    {
        // Konstruktor
        public GoodThread(string name)
        {
            Console.WriteLine("Betrete Konstruktor " + name);
            _name = name;
            Console.WriteLine("Verlasse Konstruktor " + name);
        }
 
        // Die Anwesenheit eines Destruktors generiert einen Aufruf von Finalize
        // was das Objekt als Finalizable deklariert und in die Finalize-Warteschlange
        // einreiht. Diese Warteschlange wird von der CLR überprüft, bevor der
        // Speicher für Objekte freigegeben wird. Das wird nur aufgerufen,
        // wenn der Nutzer nicht explizit Dispose() aufruft.
        ~GoodThread()
        {
            // Befreie nur die unverwalteten Ressourcen.
            Console.WriteLine("Betrete Destruktor " + _name);
            Dispose(false);
            Console.WriteLine("Verlasse Destruktor " + _name);
        }
 
        /* IDisposable implementieren */
 
        // Nutzer müssen diese Funktion explizit aufrufen, um die Ressourcen
        // freizugeben. Danach muss der Destruktor nicht mehr einschreiten.
        public void Dispose()
        {
            Console.WriteLine("Betrete IDisposable.Dispose " + _name);
            // Befreit verwaltete und unverwaltete Ressourcen.
            Dispose(true);
 
            // Unterdrücke den Finalizer, weil Dispose(true) 
            // bereits verwaltete und unverwaltete Ressourcen freigibt.
            GC.SuppressFinalize(this);
            Console.WriteLine("Verlasse IDisposable.Dispose " + _name);
        }
 
        // Eigenschaften
        public bool KeepAlive
        {
            get
            {
                // Synchronisiere den Zugriff auf die Variable
                // die von allen Klienten geteilt wird.
                bool result = true;
                _mutexKeepAlive.WaitOne();
                result = _keepAlive;
                _mutexKeepAlive.ReleaseMutex();
                return result;
            }
            set
            {
                // Synchronisiere den Zugriff auf die Variable
                // die von allen Klienten geteilt wird.
                _mutexKeepAlive.WaitOne();
                _keepAlive = (bool)value;
                _mutexKeepAlive.ReleaseMutex();
            }
        }
 
        // Thread Funktion
        public void Run()
        {
            Console.WriteLine("Betrete Thread Funktion " + _name);
 
            // Speichere den aktuelle Thread. Der aktuelle Thread sollte
            // stets in der Thread-Funktion gespeichert werden und nicht
            // im Konstruktor, der im Klient-Thread läuft.
            _curThread = Thread.CurrentThread;
            _curThread.Name = _name;
            _autoEvent.Set();
 
            // Abfragen of der Thread weiterlaufen soll.
            while (KeepAlive == true) {
                // Hier erledigt der Thread seine Arbeit
                Thread.Sleep(1000);
                Console.Write(".");
            }
            Console.WriteLine("Verlasse Thread Funktion " + _name);
        }
 
        // Thread terminieren
        public void KillThread()
        {
            Console.WriteLine("Betrete KillThread " + _name);
 
            // Falls der Thread bereits tot ist
            if (_curThread.IsAlive == false)
                return;
 
            // Thread ist am Leben. Setze einen Flag um anzuzeigen das
            // der Thread terminiert werden soll.
            KeepAlive = false;
 
            // Diese Funktion soll erst dann beendet werden, wenn der
            // betreffende Thread gestorben ist.
            _curThread.Join();
 
            Console.WriteLine("Verlasse KillThread " + _name);
        }
 
        public void WaitForThreadToStart()
        {
            Console.WriteLine("Betrete WaitForThreadToStart " + _name);
            _autoEvent.WaitOne();
            Console.WriteLine("Verlasse WaitForThreadToStart " + _name);
        }
 
        // Hilfsfunktion, die die Aufräumarbeiten übernimmt.
        protected virtual void Dispose(bool bFreeAll)
        {
            Console.WriteLine("Betrete Helper Dispose " + _name);
 
            // Terminiere zuerst den Thread
            KillThread();
 
            // Gebe alle Ressourcen frei
            if (bFreeAll) {
                // Aufgerufen von Dispose(). Befreit verwaltete und unverwaltete
                // Ressourcen.
                _mutexKeepAlive.Close();
            } else {
                // Aufgerufen von Destructor(). Befreit nur unverwaltete
                // Ressourcen.
                _mutexKeepAlive.Close();
            }
            Console.WriteLine("Verlasse Helper Dispose " + _name);
        }
 
        // Member 
        protected bool _keepAlive = false;
        protected string _name = "";
        protected Mutex _mutexKeepAlive = new Mutex();
        protected AutoResetEvent _autoEvent = new AutoResetEvent(false);
        protected Thread _curThread = null;
    }
}

In dem Beispiel sehen Sie bereits eine Klasse mit dem Namen Mutex, die zur Synchronisation mehrerer Threads dient. Auf diese spezielle Klasse werden wir später detailliert eingehen.

ThreadPool und asynchrone Methoden

Man unterscheidet in den Softwareentwicklung zwischen synchronen und asynchronen Aufrufen. Beim synchronen Aufruf einer Methode wird der aufrufende Thread blockiert, solange die aufgerufene Methode noch aktiv ist. Nach dem Ende des Arbeitsschritts gibt die Methode ihre Ergebnisse an den Aufrufer (engl. Caller) zurück. Da der Aufruf synchron erfolgt, weiß der aufrufende Code, dass die Methode garantiert bis zu einem der vorgesehenen Rückkehrpunkte durchgelaufen ist. Damit ist sichergestellt, dass die zurückgegebenen Werte gültig sind, sofern der Code fehlerfrei ist. Das offensichtliche Problem bei synchronen Aufrufen ist, der Thread blockiert solange, bis die aufgerufene Methode abgearbeitet wurde. Das heißt: Ein neuer Schritt kann erst dann beginnen, wenn der vorherige Schritt vollständig abgeschlossen wurde. Dauert dies länger als erwartet, zeigt die Anwendung möglicherweise keine Reaktion mehr und/oder die Benutzeroberfläche friert ein.

Abhilfe schaffen asynchrone Methoden, mit denen Anwendungen mehrere Arbeitsschritte parallel bearbeiten können. Hier kommen immer mehrere Threads zum Einsatz. Asynchrone Methoden sind eng mit Multithreading verbunden, sprich der Programmierung parallel laufender Threads. Führt der Thread einen asynchronen Methodenaufruf durch, kehrt die aufgerufene Methode sofort zum Aufrufer zurück. Der aufrufende Thread wird nicht blockiert und kann sich anderen Dingen widmen.

Oftmals muss die aufgerufene Methode aber Daten an den Aufrufer übergeben, so dass der aufrufende Thread natürlich auf die Daten zugreifen können muss. Dafür stellt die .NET-Infrastruktur für asynchrone Funktionsaufrufe zwei Mechanismen bereit. Der Aufrufer kann entweder nach den Ergebnissen fragen (engl. pollen) oder die Infrastruktur überstellt die Ergebnisse an den aufrufenden Thread, sobald Ergebnisse vorliegen. Das Problem der ersten Lösung: Um die Ergebnisse zu beschaffen, muss ein weiterer Aufruf ins Objekt erfolgen. Das Problem der zweiten Lösung mit dem Rückruf (Callback) an den aufrufenden Thread liegt darin, dass das Serverobjekt eine gültige Referenz auf das Empfängerobjekt braucht, bis der Aufruf abgeschlossen ist.

.NET stellt diverse Mechanismen bereit asynchrone Aufrufe zu tätigen, darunter die Klassen ThreadPool und BackgroundWorker. Außerdem sorgt die Infrastruktur für die Übergabe der Argumente. Anschließend kann der asynchrone Thread die Methode parallel zum aufrufenden Thread ausführen. Asynchrone Aufrufe werden in .NET meist über Delegates abgebildet.

ThreadPool

Viele Anwendungen erstellen Threads, die lange Zeit im Ruhezustand verbleiben und auf das Eintreten eines Ereignisses warten. Andere Threads gehen in einen Ruhezustand über und verlassen diesen nur in periodischen Abständen, um Änderungen oder aktualisierte Statusinformationen abzufragen. In der Regel sind die Aufgaben vieler Threads sehr kurz, so dass die Erstellung des Thread, gemessen an der Arbeit die der Thread verrichtet, in einem ungünstigen Verhältnis steht. Das unnötige Erhöhen der Anzahl von Threads im Leerlauf kann ebenfalls Leistungsprobleme verursachen. Jedem Thread muss Stapelspeicher (Stack) zugeordnet werden. Abhilfe schafft die Klasse ThreadPool in .NET.

Mit der Klasse ThreadPool können Sie Threads effizienter einsetzen, indem Sie für die Anwendung einen Pool von systemverwalteten Arbeitsthreads bereitstellen. Es gibt in .NET genau einen Threadpool pro Prozess. Der ThreadPool weist gegenwärtig eine Standardgröße von 25 Arbeitsthreads pro verfügbarem Prozessor und 1000 I/O-Abschlussthreads auf. Die Anzahl der Threads im ThreadPool kann mithilfe der SetMaxThreads-Methode geändert werden. Jeder Thread verwendet die Standardstapelgröße und wird mit Standardpriorität ausgeführt.

Mit ThreadPool verfügt der Programmierer über eine Threadauflistung, mit der verschiedene Aufgaben im Hintergrund ausgeführt werden können. Auf diese Weise kann der primäre Thread asynchron andere Aufgaben ausführen, während die Threads im Pool sich um die in der Warteschlange befindlichen Arbeiten kümmern. Threads in einem ThreadPool beenden sich nach ihrer Arbeit nicht, sie reihen sich einfach wieder in die Schlange der wartenden Threads ein. Ein einzelner Thread überwacht den Status mehrerer Wartevorgänge, die sich in der Warteschlange für den Threadpool befinden. Nach Beendigung eines Wartevorgangs führt ein Arbeitsthread aus dem Threadpool die entsprechende Rückruffunktion (Callback) aus. Die Threads im verwalteten Threadpool sind alle Hintergrundthreads.

threadpool

Um das Behandeln einer Arbeitsaufgabe von einem Thread im Threadpool anzufordern, rufen Sie die QueueUserWorkItem-Methode auf. Diese Methode verwendet als Parameter einen Verweis auf die Methode oder auf den Delegaten WaitCallback, die bzw. der von dem aus dem Threadpool ausgewählten Thread aufgerufen wird und alternativ ein Objekt. Sobald eine Arbeitsaufgabe in die Warteschlange aufgenommen wurde, kann sie nicht mehr abgebrochen werden. Ist zur Zeit der Anforderung kein Thread im Pool frei, so wird mit der Ausführung so lange gewartet, bis ein Thread frei wird.

Eine beliebte Variante einen ThreadPool zu demonstrieren sind Fibonacci-Zahlen. Die Fibonacci-Folge ist eine unendliche Folge von Zahlen, bei der sich die jeweils folgende Zahl durch Addition der beiden vorherigen Zahlen ergibt: 0, 1, 1, 2, 3, 5, 8, 13, …. Benannt ist die Folge nach Leonardo Fibonacci, der damit 1202 das Wachstum einer Kaninchenpopulation beschrieb.

Fibonacci-Zahlen sind besonders gut zur Veranschaulichung rekursiver Funktionen geeignet, da man für die Berechnung der Zahl n alle Vorgänger n - 1 berechnen muss. Angenommen, Sie wollen mehrere Fibonacci-Folgen rekursiv berechnen. Dies können Sie an einen ThreadPool deligieren, der für verschiedene n die Fibonacci-Zahl berechnet.

public void Start()
{
    // Für jedes Fibonacci Objekt wird ein Ereignis verwendet
    ManualResetEvent[] doneEvents = new ManualResetEvent[N];
    Fibonacci[] fibArray = new Fibonacci[N];
    Random r = new Random();
 
    // Konfiguriere und starte den ThreadPool
    Console.WriteLine("Starte {0} Aufgaben...", N);
 
    for (int i = 0; i < N; i++) {
        doneEvents[i] = new ManualResetEvent(false);
        Fibonacci f = new Fibonacci(r.Next(Lower, Upper), doneEvents[i]);
        fibArray[i] = f;
 
        // Übergebe die Aufgabe an den Pool
        ThreadPool.QueueUserWorkItem(f.ThreadPoolCallback, i);
    }
 
    // Warte darauf das alle Threads aus dem Pool ihre
    // Arbeit abgeschlossen haben
    WaitHandle.WaitAll(doneEvents);
    Console.WriteLine("Alle Berechnungen abgeschlossen.");
 
    // Zeige die Ergebnisse an
    for (int i = 0; i < N; i++) {
        Fibonacci f = fibArray[i];
        Console.WriteLine("Fibonacci({0}) = {1}", f.N, f.FibOfN);
    }
 
    Console.Read();
}

Zu dem Beispiel gehört die Klasse Fibonacci, deren wesentlicher Code nachfolgend aufgeführt ist.

public class Fibonacci
{
    public Fibonacci(int n, ManualResetEvent doneEvent)
    {
        _n = n;
        _doneEvent = doneEvent;
    }
 
    // Wrapper Methode für den Threadpool.
    public void ThreadPoolCallback(Object threadContext)
    {
        int threadIndex = (int)threadContext;
        Console.WriteLine("Thread {0} beginnt mit Berechnung...", threadIndex);
        _fibOfN = Calculate(_n);
        Console.WriteLine("Thread {0} fertig...", threadIndex);
 
        // Benachrichtigung das die Berechnung abgeschlossen ist.
        _doneEvent.Set();
    }
 
    // Rekursive Methode berechnet die n-te Stelle
    public int Calculate(int n)
    {
        if (n <= 1) {
            return n;
        }
 
        return Calculate(n - 1) + Calculate(n - 2);
    }
 
    // ...

Beim Aufruf der Methode Start() werden n Fibonacci-Zahlen berechnet. Die Zahlen liegen zwischen zwei zuvor angegebenen Schranken. Innerhalb des angegebenen Intervalls werden dann Zufallszahlen ermittelt, aus denen anschließend die Fibonacci-Zahl berechnet wird. Da jedem Fibonacci-Objekt ein halb zufälliger Wert zur Berechnung zugewiesen wird und da alle Threads um Prozessorzeit konkurrieren, lässt sich nicht vorhersagen, wie lange die Berechnung aller Ergebnisse dauert. Aus diesem Grund wird an jedes Fibonacci-Objekt während der Konstruktion eine Instanz der ManualResetEvent-Klasse übergeben. Jedes Objekt signalisiert dem bereitgestellten Ereignisobjekt die Fertigstellung seiner Berechnung. Auf diese Weise kann der primäre Thread die Ausführung mit WaitAll blockieren, bis alle zehn Fibonacci-Objekte ein Ergebnis berechnet haben. Die Main-Methode wartet dann auf die Hintergrundthreads mit:

// Warte darauf das alle Threads aus dem Pool ihre
// Arbeit abgeschlossen haben
WaitHandle.WaitAll(doneEvents);

Alle Fibonacci-Ergebnisse werden am Schluß ausgegeben.

Starte 8 Aufgaben...
Thread 0 beginnt mit Berechnung...
Thread 0 fertig...
Thread 1 beginnt mit Berechnung...
Thread 2 beginnt mit Berechnung...
Thread 3 beginnt mit Berechnung...
Thread 4 beginnt mit Berechnung...
Thread 1 fertig...
Thread 5 beginnt mit Berechnung...
Thread 5 fertig...
Thread 6 beginnt mit Berechnung...
Thread 6 fertig...
Thread 7 beginnt mit Berechnung...
Thread 3 fertig...
Thread 2 fertig...
Thread 4 fertig...
Thread 7 fertig...
Alle Berechnungen abgeschlossen.
Fibonacci(27) = 196418
Fibonacci(26) = 121393
Fibonacci(37) = 24157817
Fibonacci(34) = 5702887
Fibonacci(38) = 39088169
Fibonacci(28) = 317811
Fibonacci(26) = 121393
Fibonacci(38) = 39088169

Die Berechnung einer bestimmten Fibonacci-Zahl kann auch iterativ erfolgen. Auf einem Multiprozessor-System geht dies schneller, wenn jede Rechnung in ihrem eigenen Thread läuft. Wenn Sie aber eine Einkern-Maschine haben, läuft die Berechnung mit mehreren Threads langsamer, als wenn Sie erst die eine und dann die andere Aufgabe in einem einzigen Thread berechnen. Schließlich müssen Ressourcen allokiert werden und zwischen den Threads finden Kontextwechsel statt, die Zeit kosten.

BackgroundWorker

Neben der Klasse Threadpool stellt das .NET Framework noch eine Reihe weiterer Klassen zur Verfügung mit denen sich ein Thread erstellen lässt ohne sich dabei detailliert mit der Klasse Thread befassen zu müssen. Die BackgroundWorker-Klasse ermöglicht Ihnen einen Vorgang auf einem separaten, dedizierten Thread auszuführen. BackgroundWorker wird immer dann verwendet, wenn genau ein Thread für eine spezielle Aufgabe benötigt wird. Die Klasse spielt insbesondere bei der Arbeit mit grafischen Benutzeroberflächen unter Windows Forms eine Rolle. Bei zeitaufwändigen Vorgängen, wie Downloads und Datenbanktransaktionen, ist es so möglich, dass die Benutzeroberfläche während der Ausführung dieser Vorgänge weiter reagieren kann. Zum Ausführen eines zeitaufwändigen Vorgangs im Hintergrund legen Sie einfach einen BackgroundWorker an und überwachen die Ereignisse, die den Fortschritt des Vorgangs melden und seinen Abschluss signalisieren.

Um einen kompletten Hintergrundvorgang einzurichten fügen Sie einfach einen Ereignishandler für das DoWork-Ereignis hinzu. Rufen Sie den zeitaufwändigen Vorgang in diesem Ereignishandler auf. Danach rufen Sie RunWorkerAsync auf, um den Vorgang zu starten. Behandeln Sie das ProgressChanged-Ereignis, um Benachrichtigungen über Fortschrittsaktualisierungen zu erhalten. Um eine Benachrichtigung über den Abschluss des Vorgangs zu erhalten, behandeln Sie das RunWorkerCompleted-Ereignis.

Wir möchten nun ein konkretes Codebeispiel analysieren, um uns mit der BackgroundWorker-Klasse vertraut zu machen. An dieser Stelle bietet sich zur Demonstration die Berechnung der Zahl PI (3,141592653...) an. Wie schon am Anfang dieses Artikels erwähnt, muss bei der Berechnung der irrationalen und transzendenten Zahl PI ein zweiter Thread die Arbeit übernehmen, damit der Hauptthread noch auf Benutzereingaben reagieren kann und das Programm nicht einfriert.

Bevor wir die Zahl π berechnen können, müssen wir natürlich wissen, wie die Kreiszahl π zu berechnen ist. Kaum eine andere Zahl hat die Menschen in ihrer Geschichte mehr beschäftigt und fasziniert als die Kreiszahl π. Schon vor den Griechen suchten die Völker nach dieser geheimnisvollen Zahl, und obschon die Schätzungen immer genauer wurden, gelang es erstmals dem griechischen Mathematiker Archimedes um 250 v. Chr., diese Zahl mathematisch zu bändigen. In der weiteren Geschichte wurden die Versuche zur größtmöglichen Annäherung an π phasenweise zu einer regelrechten Rekordjagd, die zuweilen skurrile und auch aufopfernde Züge annahm. Um 480 n. Chr. berechnete der chinesische Mathematiker und Astronom Zu Chong-Zhi (430–501) für die Kreiszahl 3,1415926 < π < 3,1415927, also im Grunde die ersten 7 Dezimalstellen exakt. Er verwendete dafür einen Näherungsbruch, der noch heute manchmal verwendet wird.

355/113

Selbstverständlich eignete sich dieser Näherungsbruch nur bedingt für eine exakte Berechnung der Zahl π und so ließ die magische Zahl den Mathematikern auch weiterhin keine Ruhe. Im Jahre 1682 steuerte der große Gottfried Wilhelm Leibniz der Suche nach einer bestmöglichen Annäherung an die Kreiszahl Pi folgende Formel bei, die auch als Leibniz-Reihe bekannt ist:

leibniz_reihe

Die obige Reihe ist wegen arctan 1 = π / 4 auch ein Spezialfall (θ = 1) der Reihenentwicklung des Arcustangens, die der schottische Mathematiker James Gregory in den 1670ern fand:

james_gregory_formula

Sie war Grundlage vieler Approximationen von π in der folgenden Zeit. John Machin berechnete mit seiner Formel von 1706 die ersten 100 Stellen von π. Seine Gleichung

john_machin_formula

lässt sich zusammen mit der taylorschen Reihenentwicklung der Arcustangens-Funktion für schnelle Berechnungen verwenden.

taylorreihe_des_arkustangens

Die Reihe konvergiert umso schneller, je näher x bei 0 liegt, so dass in der Formel von John Machin für π, der arctan(1/239) Term deutlich schneller konvergiert, als der arctan(1/5) Term.

Schließlich entdeckte 1996 David Harold Bailey, zusammen mit Peter Borwein und Simon Plouffe, eine neuartige Reihendarstellung (BBP-Reihe) für π:

bbp_reihe

Diese Reihe (auch Bailey-Borwein-Plouffe-Formel genannt) erlaubt es auf einfache Weise, die n-te Stelle einer binären, hexadezimalen oder beliebigen Darstellung zu einer 2er-Potenzbasis von π zu berechnen, ohne dass zuvor die n − 1 vorherigen Ziffernstellen berechnet werden müssen. Dazu muss diese spezielle Formel zunächst ein wenig umgestellt werden, um einen Algorithmus daraus ableiten zu können, der eine beliebige Ziffer der Darstellung von π im Hexadezimalsystem bestimmen kann. Mittlerweile wurden viele Varianten der ursprünglichen BBP-Reihe entdeckt, darunter die schnelle Reihe von Fabrice Bellard.

Da die Kreiszahl π eine irrationale Zahl ist, lässt sich ihre Darstellung in keinem Stellenwertsystem vollständig angeben: Die Darstellung ist stets unendlich lang und nicht periodisch. Ihr Computer wäre theoretisch also bis in alle Ewigkeit damit beschäftigt die Kreiszahl π zu berechnen. Unser Programm soll den Benutzer deshalb zu Beginn fragen, wieviel Stellen nach dem Komma berechnet werden sollen. Da der Datentyp double und andere primitive Datentypen für die Darstellung sehr vieler Nachkommastellen nicht geeignet sind, benötigt man in der Regel eine spezielle Klasse, die die Darstellung und Berechnung großer Zahlen ermöglicht.

Das .NET Framework bietet leider auch in der Version 3.5 keine Klasse an, die für die Berechnung großer Gleitkommazahlen geeignet ist. Allerdings stellt Microsoft mit dem .NET Framework 4.0 im Namensraum System.Numerics die Klasse BigInteger zur Verfügung. BigInteger eignet sich für Berechnungen mit Ganzzahlen in nahezu unbegrenzter Größe, Gleitkommazahlen werden von dieser Klasse allerdings nicht unterstützt. Es existieren im Netz diverse Klassen und Bibliotheken, darunter C# BigInt oder IntX mit denen große Gleitkommazahlen berechnet werden können. In unserem Beispiel werden wir auf eine Variante der genannten BBP-Reihe von Simon Plouffe zurückgreifen, und ersparen uns auf diese Art und Weise die Nutzung größerer und komplexer Datentypen. Da die Bailey-Borwein-Plouffe-Formel die n-te Stelle von π berechnen kann, ohne dafür die n − 1 vorherigen Ziffernstellen berechnen zu müssen, können wir einfach Stelle für Stelle mit den verfügbaren elementaren Datentypen berechnen und anschließend in einem StringBuilder speichern.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.ComponentModel;
 
namespace CodePlanet.Articles.Multithreading
{
    public class PiTest
    {
        public static void Main(string[] args)
        {
            try {
                Console.WriteLine("Wieviel Stellen berechnen?");
                int number = Convert.ToInt32(Console.ReadLine());
                new PiTest(number);
            } catch (FormatException e) {
                Console.WriteLine(e.Message);
            }
        }
 
        public PiTest(int digits)
        {
            BackgroundWorker bgWorker = new BackgroundWorker();
            bgWorker.DoWork += new DoWorkEventHandler(bgWorker_DoWork);
            bgWorker.RunWorkerCompleted += new RunWorkerCompletedEventHandler(bgWorker_WorkCompleted);
            bgWorker.ProgressChanged += new ProgressChangedEventHandler(bgWorker_ProgressChanged);
 
            bgWorker.RunWorkerAsync(digits);
            bgWorker.WorkerReportsProgress = true;
 
            while (!_workCompleted) {
                Thread.Sleep(50);
            }
 
            Console.WriteLine("Resultat = " + _result);
 
            Console.Read();
        }
 
        private void bgWorker_DoWork(object sender, DoWorkEventArgs e)
        {
            PI pi = new PI((BackgroundWorker)sender);
            Console.WriteLine("Berechne {0} Nachkommastellen von PI!\n", e.Argument);
            e.Result = pi.Calculate((int)e.Argument, true);
        }
 
        private void bgWorker_WorkCompleted(object sender, RunWorkerCompletedEventArgs e)
        {
            Console.WriteLine("Fertig!\n");
            _result = (string)e.Result;
            _workCompleted = true;
        }
 
        // Der Ereignishandler aktualisiert den Fortschritt
        private void bgWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
        {
            Console.WriteLine(String.Format("Fortschritt {0}% ", e.ProgressPercentage));
        }
 
        private bool _workCompleted = false;
        private string _result;
    }
}

In dem Programmbeispiel sehen Sie den Einsatz der BackgroundWorker-Klasse. Nachdem der BackgroundWorker erstellt wurde, abboniert man die entsprechenden Events und ruft dann RunWorkerAsync auf. Der BackgroundWorker erstellt implizit einen Thread, führt die Berechnung aus und gibt das Resultat zum Schluß an die aufrufende Funktion zurück. Zwischendurch wird der Nutzer über den Fortschritt der Berechnung informiert. Dies erreicht man durch einen Ereignishandler für das Ereignis ProgressChanged.

Die Klasse PI, die die eigentliche Berechnung durchführt, ist hier nicht näher dargestellt. Sie können sich die Klasse im Anhang des Artikels detailliert ansehen. Eine entscheidende Operation in der Klasse PI ist die Aktualisierung der Fortschrittsanzeige mit

// Fortschrittsanzeige für den BackgroundWorker
_worker.ReportProgress(i * 100 / digits);

Die Methode ReportProgress erwartet eine Zahl zwischen 1 und 100. Während der Berechnung von π triggert die Methode die abbonierten Methoden und ruft den entsprechenden Ereignishandler bgWorker_ProgressChanged auf, der dem Nutzer den Status der Berechnung anzeigt. In einer Konsole erfolgt die Ausgabe schriftlich, bei einer grafischen Benutzeroberfläche wird dementsprechend ein Fortschrittsbalken angezeigt.

Berechne 300 Nachkommastellen von PI!

Fortschritt 0%
Fortschritt 3%
Fortschritt 6%
Fortschritt 9%
Fortschritt 12%
Fortschritt 15%
Fortschritt 18%
Fortschritt 21%
Fortschritt 24%
Fortschritt 27%
Fortschritt 30%
Fortschritt 33%
Fortschritt 36%
Fortschritt 39%
Fortschritt 42%
Fortschritt 45%
Fortschritt 48%
Fortschritt 51%
Fortschritt 54%
Fortschritt 57%
Fortschritt 60%
Fortschritt 63%
Fortschritt 66%
Fortschritt 69%
Fortschritt 72%
Fortschritt 75%
Fortschritt 78%
Fortschritt 81%
Fortschritt 84%
Fortschritt 87%
Fortschritt 90%
Fortschritt 93%
Fortschritt 96%
Fortschritt 99%
Fertig!

Resultat = 3.1415926535 8979323846 2643383279 5028841971 6939937510 5820974944 5
923078164 0628620899 8628034825 3421170679 8214808651 3282306647 0938446095 5058
223172 5359408128 4811174502 8410270193 8521105559 6446229489 5493038196 4428810
975 6659334461 2847564823 3786783165 2712019091 4564856692 3460348610 4543266482
 1339360726 0249141273
Callback-Methoden

Asynchrone Methodenaufrufe lassen sich in .NET direkt mit Delegates implementieren. Dazu stellt .NET einen Callback-Mechanismus zur Verfügung. Immer wenn Sie ein Delegate erzeugen, generiert der Compiler drei Methoden für Sie: Invoke, BeginInvoke und EndInvoke. Während Invoke synchron ausführt, dienen die beiden anderen Methoden zur asynchronen Ausführung. Sie werden stets zusammen aufgerufen, jeder Aufruf von BeginInvoke muss in einem Aufruf von EndInvoke münden. Die Methode BeginInvoke übernimmt dieselben Argumente, wie der Delegate und zusätzlich noch zwei weitere Parameter - einen Delegate vom Typ ASyncCallback, und der zweite ist Ihr eigenes Delegate.

[Serializable]
public delegate void AsyncCallback(
    IAsyncResult ar
);

AsyncCallback ist eine Delegate für eine Methode, die void liefert und ein einziges Argument hat, nämlich ein Objekt des Typs IAsyncResult. Dieses Interface wird durch das Framework definiert, und die CLR ruft Ihre Methode mit einem Objekt auf, dass das Interface implementiert. Sie müssen die Einzelheiten des Interface also nicht kennen, sondern können einfach das Ihnen übergebene Objekt benutzen.

Das nachfolgende Codebeispiel zeigt den prinzipiellen Aufbau eines asynchronen Aufrufs:

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    public class Test
    {
        public static void Main()
        {
            DelegateThatReturnsInt d = new DelegateThatReturnsInt(Print);
 
            d.BeginInvoke("Hallo!", new AsyncCallback(Callback), d);
 
            // Lässt dem Callback Zeit ausgeführt zu werden
            // bevor das Programm beendet.
            Thread.Sleep(1000);
        }
 
        public static int Print(string param)
        {
            Console.WriteLine(param);
            return 28;
        }
 
        public static void Callback(IAsyncResult iar)
        {
            DelegateThatReturnsInt d = (DelegateThatReturnsInt)iar.AsyncState;
            Console.WriteLine("Delegate gibt {0} zurück.", d.EndInvoke(iar));
        }
 
        private delegate int DelegateThatReturnsInt(string p);
    }
}

Die Methode, die zurückgerufen werden soll, in dem Codebeispiel ist das die Methode Callback, muss bezüglich des Rückgabewerts und der Signatur mit dem Delegate AsyncCallback übereinstimmen.

Beim Rückruf dieser Methode wird das IAsyncResult-Objekt vom .NET Framework übergeben. Der zweite Parameter für BeginInvoke, Ihr Delegate, wird für Sie in der Eigenschaft AsyncState von IAsyncResult als Objekt beiseite gelegt. Innerhalb der Callback-Methode Callback können Sie das Objekt auslesen und auf seinen Ursprungstyp abbilden.

DelegateThatReturnsInt d = (DelegateThatReturnsInt)iar.AsyncState;

Nun können Sie dieses Delegate dazu verwenden, die Methode EndInvoke aufzurufen, wobei Sie das als Parameter erhaltene IAsyncResult-Objekt übergeben:

d.EndInvoke(iar)

EndInvoke liefert den Wert der aufgerufenen Methode, in diesem Fall einen Integer, der umgehend ausgegeben wird. Ein weiteres Beispiel demonstriert den kontinuierlichen Aufruf von der Callback-Methoden, indem zunächst zwei Abonnenten sich eintragen und anschließend in der Methode Run() immer wieder asynchron aufgerufen werden.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    class CallbackTest
    {
        public static void Main(string[] args)
        {
            ClassWithDelegate theClassWithDelegate = new ClassWithDelegate();
 
            FirstSubscriber fs = new FirstSubscriber();
            fs.Subscribe(theClassWithDelegate);
 
            SecondSubscriber ss = new SecondSubscriber();
            ss.Subscribe(theClassWithDelegate);
 
            theClassWithDelegate.Run();
        }
    }
 
    public class ClassWithDelegate
    {
        public void Run()
        {
            for (; ; ) {
 
                // Eine halbe Sekunde schlafen
                Thread.Sleep(500);
 
                if (_theDelegate != null) {
                    // Alle delegierten Methoden explizit aufrufen
                    foreach (DelegateThatReturnsInt del in 
                        _theDelegate.GetInvocationList()) {
                        // Asynchron aufrufen
                        // Das Delegate als Status-Objekt übergeben
                        del.BeginInvoke(new AsyncCallback(ResultsReturned), del);
                    }
                }
            }
        }
 
        // Callback-Methode zum Abfangen der Ergebnisse
        public void ResultsReturned(IAsyncResult iar)
        {
            // Das Status-Objekt zurück in den Delegate-Typ wandeln
            DelegateThatReturnsInt del = (DelegateThatReturnsInt)iar.AsyncState;
 
            // Beim Delegate EndInvoke aufrufen, um das Ergebnis zu bekommen
            int result = del.EndInvoke(iar);
 
            // Ergebnis anzeigen
            Console.WriteLine("Delegate liefert Ergebnis: {0}", result);
        }
 
        // Ein Multicast-Delegate, dessen gekapselte Methode
        // ein int liefert
        public delegate int DelegateThatReturnsInt();
        public event DelegateThatReturnsInt _theDelegate;
    }
 
    public class FirstSubscriber
    {
        public void Subscribe(ClassWithDelegate theClassWithDelegate)
        {
            theClassWithDelegate._theDelegate +=
                new ClassWithDelegate.DelegateThatReturnsInt(DisplayCounter);
        }
 
        public int DisplayCounter()
        {
            Console.WriteLine("Beschäftigt mit DisplayCounter...");
            Thread.Sleep(10000);
            Console.WriteLine("Fertig mit DisplayCounter...");
            return ++_myCounter;
        }
 
        private int _myCounter = 0;
    }
 
    public class SecondSubscriber
    {
        public void Subscribe(ClassWithDelegate theClassWithDelegate)
        {
            theClassWithDelegate._theDelegate +=
                new ClassWithDelegate.DelegateThatReturnsInt(Doubler);
        }
 
        public int Doubler()
        {
            return _myCounter += 2;
        }
 
        private int _myCounter = 0;
    }
}

Delegate.BeginInvoke darf nicht mit Control.BeginInvoke verwechselt werden! Delegate.BeginInvoke nutzt einen eigenen Thread aus dem Threadpool des Hauptprozesses, während Control.BeginInvoke keinen neuen Thread ausführt und die Ausführung in die Windows Nachrichtenschleife (engl. message queue) übergibt.

delegate_begininvoke

Callback-Methoden mit Delegate.BeginInvoke sind nicht so effizient, wie ThreadPool.QueueUserWorkItem. Wenn Sie die höchstmögliche Performance erzielen wollen, sollten Sie einen Threadpool verwenden.

Thread-Level

Ein verwalteter .NET-Thread ist entweder ein Hintergrundthread oder ein Vordergrundthread. Hintergrundthreads sind im Prinzip identisch zu Vordergrundthreads, mit einer Ausnahme. Ein Hintergrundthread wird die Ausführung von Code nicht weiterführen, wenn alle Vordergrundthreads beendet haben. Es gilt:

  • Ob ein Thread ein Vorder- oder Hintergrundthread ist, hängt von der Eigenschaft Thread.IsBackground ab.
  • Alle Threads, die die verwaltete Ausführungsumgebung aus einer unverwalteten Umgebung heraus betreten, werden automatisch als Hintergrundthreads markiert.
  • Alle Threads, die mit der Klasse Thread direkt instanziert werden, sind Vordergrundthreads.
  • Alle Threads in einem ThreadPool sind Hintergrundthreads.
  • Sobald einmal alle Vordergrundthreads in der verwalteten Umgebung ausgeführt worden sind, werden alle laufenden Hintergrundthreads beendet.

Thread-Local Storage

Verwaltete lokale Threadspeicher (TLS) können zur Speicherung von Daten verwendet werden, die für einen Thread und eine Anwendungsdomäne eindeutig sind. Das .NET Framework bietet zwei Möglichkeiten zur Verwendung verwalteter lokaler Threadspeicher: threadbezogene statische Felder und Datenslots.

  • Verwenden Sie threadbezogene statische Felder (threadbezogene Shared-Felder in Visual Basic), wenn Sie die Anforderungen zur Kompilierzeit genau einschätzen können. Threadbezogene statische Felder bieten die beste Leistung. Außerdem ermöglichen sie die Typüberprüfung zur Kompilierzeit.
  • Verwenden Sie Datenslots, wenn Sie die tatsächlichen Anforderungen unter Umständen erst zur Laufzeit kennen. Datenslots sind langsamer und umständlicher als threadbezogene statische Felder. Außerdem werden die Daten als Object-Typ gespeichert, sodass Sie sie vor der Verwendung in den richtigen Typ umwandeln müssen.

Die Daten im verwalteten TLS sind für die Kombination aus Thread und Anwendungsdomäne eindeutig, und zwar unabhängig davon, ob Sie threadbezogene statische Felder oder Datenslots verwenden.

Threadbezogene statische Felder

Wenn Sie wissen, dass bestimmte Daten für eine Kombination aus Thread und Anwendungsdomäne stets eindeutig sind, wenden Sie das ThreadStaticAttribute-Attribut auf das statische Feld an. Die Verwendung des Felds unterscheidet sich nicht von der eines anderen statischen Felds. Die Daten im Feld sind für jeden Thread, der sie verwendet, eindeutig.

Threadbezogene statische Felder bieten eine bessere Leistung als Datenslots und ermöglichen zudem die Typüberprüfung zur Kompilierzeit.

Beachten Sie, dass jeder Klassenkonstruktorcode auf dem ersten Thread im ersten Kontext ausgeführt wird, der auf das Feld zugreift. In allen anderen Threads oder Kontexten in der gleichen Anwendungsdomäne werden die Felder mit null (Nothing in Visual Basic) initialisiert, wenn sie Referenztypen sind, oder mit ihren Standardwerten, wenn es sich um Werttypen handelt. Daher sollten Sie die Initialisierung threadbezogener statischer Felder nicht von Klassenkonstruktoren abhängig machen. Stattdessen sollten Sie threadbezogene statische Felder nach Möglichkeit nicht initialisieren und davon ausgehen, dass sie mit null (Nothing) oder mit ihren Standardwerten initialisiert werden.

Ein static-Feld, das mit ThreadStaticAttribute markiert ist, kann von verschiedenen Threads nicht gemeinsam verwendet werden. Jeder Ausführungsthread besitzt eine eigene Instanz des Felds, und die Werte des Felds werden jeweils unabhängig abgerufen und festgelegt. Wenn auf das Feld von einem anderen Thread aus zugegriffen wird, enthält es einen anderen Wert.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    /// <summary>
    /// Aus der MSDN.
    /// © 2009 Microsoft Corporation. All rights reserved.
    /// </summary>
    class ThreadDataMain
    {
        static void Main()
        {
            for (int i = 0; i < 3; i++) {
                Thread newThread = new Thread(ThreadData.ThreadStaticDemo);
                newThread.Start();
            }
        }
    }
 
    class ThreadData
    {
        [ThreadStaticAttribute]
        static int threadSpecificData;
 
        public static void ThreadStaticDemo()
        {
            // Speichere die Managed Thread Id für jeden Thread in der
            // statischen Variable.
            threadSpecificData = Thread.CurrentThread.ManagedThreadId;
 
            // Gewähre anderen Threads genügend Zeit, um denselben Code
            // auszuführen, so dass sich zeigt das die statischen Daten für
            // jeden Thread einzigartig sind.
            Thread.Sleep(1000);
 
            // Zeige die statischen Daten.
            Console.WriteLine("Data for managed thread {0}: {1}",
                Thread.CurrentThread.ManagedThreadId, threadSpecificData);
        }
    }
}
Datenslots

Das .NET Framework stellt dynamische Datenslots bereit, die für eine Kombination aus Thread und Anwendungsdomäne eindeutig sind. Es gibt zwei Arten von Datenslots: benannte und nicht benannte. Beide werden mit der LocalDataStoreSlot-Struktur implementiert.

Verwenden Sie für benannte und unbenannte Datenslots die Thread.SetData-Methode und die Thread.GetData-Methode, um die Informationen im Slot festzulegen und abzurufen. Hierbei handelt es sich um statische Methoden, die stets auf die Daten für den Thread angewendet werden, der sie zurzeit ausführt.

Benannte Slots können von Vorteil sein, da Sie den Slot bei Bedarf abrufen können, indem Sie seinen Namen an die GetNamedDataSlot-Methode übergeben, anstatt einen Verweis auf einen unbenannten Slot beizubehalten. Wenn jedoch eine andere Komponente den gleichen Namen für ihren threadbezogenen Speicher verwendet und ein Thread Code sowohl aus Ihrer Komponente als auch aus der anderen Komponente ausführt, können die beiden Komponenten die Daten der jeweils anderen Komponente beschädigen. (In diesem Szenario wird davon ausgegangen, dass beide Komponenten in der gleichen Anwendungsdomäne ausgeführt werden und nicht zur Verwendung der gleichen Daten vorgesehen sind.)

using System;
using System.Collections.Generic;
using System.Threading;
using System.Text;
 
namespace CodePlanet.Articles.Multithreading
{
    /// <summary>
    /// Aus der MSDN.
    /// © 2009 Microsoft Corporation. All rights reserved.
    /// </summary>
    class SlotMain
    {
        static void Main()
        {
            Thread[] newThreads = new Thread[4];
            for (int i = 0; i < newThreads.Length; i++) {
                newThreads[i] = new Thread(new ThreadStart(Slot.SlotTest));
                newThreads[i].Start();
            }
        }
    }
 
    class Slot
    {
        static Random randomGenerator = new Random();
 
        public static void SlotTest()
        {
            // Setze verschiedene Daten in jedem Slot.
            Thread.SetData(
                Thread.GetNamedDataSlot("Random"),
                randomGenerator.Next(1, 200));
 
            // Gebe die Daten aus dem Slot jedes Threads aus.
            Console.WriteLine("Data in Thread ({0}) data slot: {1,3}",
                AppDomain.GetCurrentThreadId().ToString(),
                Thread.GetData(Thread.GetNamedDataSlot("Random")).ToString());
 
            // Gewähre anderen Threads genügend Zeit, um SetData
            // auszuführen, so dass sich zeigt das die statischen Daten für
            // jeden Thread einzigartig sind.
            Thread.Sleep(1000);
 
            Console.WriteLine("Data in Thread ({0}) data slot is still: {1,3}",
                AppDomain.GetCurrentThreadId().ToString(),
                Thread.GetData(Thread.GetNamedDataSlot("Random")).ToString());
 
            // Gebe den anderen Threads ausreichend Zeit, um ihre 
            // Daten zu zeigen und demonstriere dann das jeder
            // Code, den ein Thread ausführt, Zugriff auf den benannten
            // Datenslot hat.        
            Thread.Sleep(1000);
 
            Outside outside = new Outside();
            outside.ShowSlotData();
            Console.ReadLine();
        }
    }
 
    public class Outside
    {
        public void ShowSlotData()
        {
            // Diese Methode hat keinen Zugriff auf die Variablen
            // der Klasse Slot, dennoch können die Threads, die
            // diesen Code ausführen die Daten aus dem 
            // benannten Datenslot lesen.
            Console.WriteLine(
                "Other code displays data in Thread ({0}) data slot: {1,3}",
                AppDomain.GetCurrentThreadId().ToString(),
                Thread.GetData(Thread.GetNamedDataSlot("Random")).ToString());
        }
    }
}

Anwendungsdomänen

Das .NET Framework teilt einen Betriebssystemprozess in einfache verwaltete Unterprozesse, so genannte Anwendungsdomänen, die durch System.AppDomain dargestellt werden. Ein oder mehrere verwaltete Threads (dargestellt durch System.Threading.Thread) können in beliebiger Anzahl von Anwendungsdomänen innerhalb desselben verwalteten Prozesses ausgeführt werden. Obwohl jede Anwendungsdomäne mit einem einzigen Thread gestartet wird, kann der Code in dieser Anwendungsdomäne zusätzliche Anwendungsdomänen und weitere Threads erstellen. Dadurch kann sich ein verwalteter Thread frei zwischen Anwendungsdomänen innerhalb desselben verwalteten Prozesses bewegen. Gegebenenfalls kann es einen einzelnen Thread geben, der sich zwischen verschiedenen Anwendungsdomänen bewegt.

Threadsynchronisierung

In dem Kapitel über asynchrone Operationen wurde bereits darauf hingewiesen das eine Anwendung die höchste Effizienz erreicht, wenn die Threads der Anwendung nicht darauf warten müssen, dass Operationen abgeschlossen werden. Daher ist es am besten, wenn Sie Methoden implementieren, die mit Ihren Daten arbeiten. Sie sollten keine Methoden schreiben, die auf irgendwelche gemeinsam genutzten Daten zugreifen. Leider kommt es aber nur selten vor, dass ein Thread völlig isoliert läuft, ohne auf irgendwelche gemeinsam genutzten Daten zuzugreifen.

Alle Threads im System müssen Zugriff auf bestimmte Systemressourcen haben, zum Beispiel auf Heaps, serielle Schnittstellen, Dateien, Fenster und zahllose andere. Falls ein Thread exklusiven Zugriff auf eine Ressource benötigt, können andere Threads, die ebenfalls diese Ressource benutzen müssen, ihre Arbeit nicht erledigen. Andererseits können Sie nicht einfach jedem beliebigen Thread erlauben, jederzeit auf jede beliebige Ressource zuzugreifen, sonst ist das Chaos vorprogrammiert. Stellen Sie sich einen Thread vor, der in einen Speicherblock schreibt, während ein anderer Thread aus demselben Speicherblock liest. Das wäre dasselbe, als würden Sie ein Buch lesen, während jemand gleichzeitig den Text der Seite verändert. Die Sätze und Buchstaben wären völlig durcheinander und Sie könnten nichts vernünftiges lesen.

Das ist die Stunde der Synchronisation, ein unabdingbarer Vorgang beim Multithreading. Um zu verhindern, dass eine gemeinsam genutzte Ressource durch den Zugriff mehrerer Threads beschädigt wird, müssen Programmierer Threadsynchronisierungskonstrukte in ihren Code einfügen. Microsoft Windows und die Common Language Runtime, kurz CLR, bieten verschiedene Threadsynchronisierungskonstrukte an, die jeweils ihre Vor- und Nachteile haben. Viele dieser Konstrukte der CLR sind in Wirklichkeit nur objektorientierte Wrapperklassen um Win32-Threadsynchronisierungskonstrukte. Schließlich sind alle .NET-Threads Windows-Threads. In den nächsten Kapiteln werden wir uns mit den wesentlichen Threadsynchronisierungskonstrukten der CLR befassen, doch zunächst wollen wir auf zwei elementare Probleme bei der Threadsynchronisierung eingehen, den Race Conditions und Deadlocks.

Race Conditions und Deadlocks

Ein kritischer Wettlauf, auch Wettlaufsituation (engl. Race Condition oder Race Hazard) ist in der Programmierung eine Konstellation, in denen das Ergebnis einer Operation vom zeitlichen Verhalten bestimmter Einzeloperationen abhängt.

Nehmen Sie beispielsweise an, Sie haben zwei Threads - einer ist dafür zuständig, eine Datei zu öffnen, und der andere, soll in diese Datei schreiben. Sie müssen nun dafür sorgen, dass der erste Thread rechtzeitig die Datei öffnet, nämlich noch bevor der zweite Thread aktiv wird und Daten in die Datei schreiben will. Die beiden Threads können nicht unabhängig voneinander laufen, Sie müssen sicherstellen, dass Thread1 fertig ist, bevor Thread2 beginnt.

Nun werden Sie sagen, das ist eine sehr einleuchtende Tatsache und gut zu behandeln, doch es gibt Race Conditions, die nicht so offensichtlich sind. Wenn Sie eine globale Variable in Ihrer Klasse vom Typ int nutzen, und diese Variable von zwei oder mehr Threads inkrementiert wird, haben Sie bereits ein Problem.

int a = 0;
// Code läuft...
a++;

Das Inkrementieren der Variable a ist nämlich keine sogenannte atomare (unteilbare) Operation. Das bedeutet, wenn der Compiler diese eine Codezeile übersetzt, werden vom Prozessor (IA32) mindestens drei Operationen durchgeführt.

load a 
add 1
store a

Zunächst wird die Variable a in ein Register geladen, dann wird 1 addiert und zum Schluß wird das Ergebnis zurück in den Speicher geschrieben. Findet während dieser drei Operation ein Kontextwechsel statt, d.h. ein anderer Thread kommt zum Zug, der in demselben Prozessraum läuft und auf diese Variable zugreift, befindet sich ihr Programm in keinem kontrollierten Zustand mehr.

So könnte man von einer Variable, die mit 1 initialisiert und anschließend von zwei Threads inkrementiert wurde, erwarten, dass der Wert dieser Variable nach diesen Operationen 3 beträgt. Tatsächlich kann der Ablauf allerdings dazu führen, dass Ihre Variable am Ende den Wert 2 hat.

Zeitpunkt Thread A Gespeicherter Wert Thread B
1 Einlesen des Wertes
Wert: 1
1 Einlesen des Wertes
Wert: 1
2 Erhöhen des internen Wertes um eins
Wert: 2
1 Erhöhen des internen Wertes um eins
Wert: 2
3 Speichern des Wertes 2 Speichern des Wertes

Natürlich ist die Wahrscheinlichkeit das dieser Fall eintritt, bei wenigen Threads und sehr kurzen, gemeinsamen Codeabschnitten gering, aber sie ist vorhanden und erhöht sich schnell, umso mehr Threads beteiligt sind. Auch wenn die Wahrscheinlichkeit gering ist, so führt ein moderner Prozessor in einer Sekunde über 1 Milliarde Instruktionen (MIPS) aus, d.h. der Fall wird früher oder später eintreten.

Dieses Beispiel hat eine weniger offensichtliche Race Condition beschrieben und demonstriert zugleich, warum die Synchronisation von mehreren Threads so wichtig ist. Wettlaufsituationen sind schwer zu debuggen und daher besonders gefährlich. Leider ergeben sich bei der Threadsynchronisierung weitere Probleme, ein sehr bekanntes Phänomen ist der Deadlock.

Der Deadlock oder auch tödliche Umklammerung bezeichnet in der Informatik einen Zustand, bei dem ein oder mehrere Prozesse auf Betriebsmittel warten, die dem Prozess selbst oder einem anderen beteiligten Prozess zugeteilt sind. Es warten zwei oder mehrere Threads aufeinander, und keiner kann sich aus dieser Situation befreien. Nehmen wir an, wir haben zwei Threads, Thread1 und Thread2. Thread1 sperrt ein Objekt vom Typ Student und versucht dann, den Zugriff auf eine Datenbankzeile zu sperren, damit er alleine darauf zugreifen kann. Dabei stellt sich heraus, dass Thread2 diese Zeile bereits gesperrt hat.

Thread2 versucht verzweifelt das Objekt Student zu sperren, damit er einen Wert auslesen kann und diesen anschließend in der Datenbank abspeichern kann. Leider wurde das Objekt bereits von Thread1 gesperrt und so warten beide Threads in einem Teufelskreis darauf, dass die noch fehlende Ressource endlich freigegeben wird, sie befinden sich in einem Deadlock.

deadlock

Umso mehr Threads beteiligt sind, und umso komplexer ihre gegenseitigen Wechselwirkungen werden, umso schwerer sind Deadlocks aufzulösen.

Wie Sie gesehen haben, bringt die parallele Programmierung auch einige Probleme mit sich. Threads müssen zwingend synchronisiert werden, um Fehler im Programmablauf zu vermeiden. Die Synchronisation ist eine wesentliche Performancebaustelle in modernen Betriebssystemen. Das Betriebssystem umgeht das Problem im Kernel gelegentlich dadurch, dass es den Scheduler kurzzeitig deaktiviert. Auch Hardware-Interrupts werden auf diese Weise blockiert, bis das OS den kritischen Bereich wieder verlassen hat. Wenn der Scheduler deaktiviert ist, findet auch kein Kontextwechsel statt, so dass kein anderer Thread den Ablauf stören kann. Im User-Mode ist diese Variante natürlich für Standardapplikationen keine Option, so dass man hier andere Methoden und Konstrukte anwendet. Diese sollen im Detail erläutert werden.

Interlockmethoden

Wenn mehrere Threads auf gemeinsam genutzte Daten zugreifen, muss der Zugriff auf die Daten threadsicher erfolgen. Am weitaus schnellsten können Sie Daten verändern, wenn Sie die Familie der Interlockmethoden verwenden. Diese Methoden sind extrem schnell (im Vergleich zu anderen Threadsynchronisierungskonstrukten) und einfach zu bedienen. Ihr Nachteil ist, dass sie keine allzu umfangreichen Fähigkeiten bieten. Die Klasse Interlocked definiert eine Reihe statischer Methoden, die eine Variable in einer atomaren Operation auf threadsichere Weise verändern können.

Sehen wir uns zunächst ein nicht synchronisiertes Programm an. Zwei Threads inkrementieren eine globale Variable. Das Ergebnis ist, sobald beide Threads laufen kommen sie sich gegenseitig in die Quere und das Ergebnis wird chaotisch.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
 
namespace CodePlanet.Articles.Multithreading
{
    class UnsynchronizedThreads
    {
        static void Main(string[] args)
        {
            UnsynchronizedThreads unsyncThreads = new UnsynchronizedThreads();
 
            Thread[] myThreads = 
            {   
                new Thread(new ThreadStart(unsyncThreads.Incrementer)),
                new Thread(new ThreadStart(unsyncThreads.Incrementer))
            };
 
            int counter = 0;
 
            foreach (Thread thread in myThreads) {
                thread.IsBackground = true;
                thread.Start();
                thread.Name = String.Format("Thread [{0}]", counter);
                counter++;
                Console.WriteLine("{0} gestartet", thread.Name);
                Thread.Sleep(50);
            }
 
            foreach (Thread thread in myThreads) {
                thread.Join();
            }
        }
 
        public void Incrementer()
        {
            try {
 
                while (_counter < 100) {
                    int temp = _counter;
                    temp++;
 
                    // Mehr Code simulieren
                    Thread.Sleep(1);
 
                    _counter = temp;
 
                    Console.WriteLine("{0}, Incrementer: {1}",
                        Thread.CurrentThread.Name, _counter);
                }
 
            } catch (ThreadInterruptedException ex) {
                Console.WriteLine(ex.Message);
            } finally {
                Console.WriteLine("Thread wird beendet.");
            }
        }
 
        private int _counter = 0;
    }
}

Sehen wir uns einen Auszug aus dem Ergebnis an.

Thread [0], Incrementer: 27
Thread [0], Incrementer: 28
Thread [0], Incrementer: 29
Thread [0], Incrementer: 30
Thread [0], Incrementer: 31
Thread [1] gestartet
Thread [0], Incrementer: 32
Thread [0], Incrementer: 33
Thread [1], Incrementer: 33
Thread [0], Incrementer: 34
Thread [1], Incrementer: 34
Thread [1], Incrementer: 35
Thread [0], Incrementer: 35
Thread [1], Incrementer: 36
Thread [1], Incrementer: 37
Thread [0], Incrementer: 36
Thread [1], Incrementer: 37
Thread [0], Incrementer: 38

In den ersten Zeilen läuft alles wie erwartet. Der erste Thread inkrementiert den Zähler bei jedem Aufruf um 1. Nachdem der zweite Thread startet, kommt es allerdings sehr bald zu einem Durcheinander. Entgegen der Erwartung das der Zähler nun von beiden Threads inkrementiert wird, bleibt die Schrittweite manchmal konstant. Der Grund liegt im Kontextwechsel, der zwischen dem Lesen der temporären Variable und dem Schreiben stattfindet. In unserem Beispiel wird der Kontextwechsel durch Sleep angeregt.

Mit der Methode Interlocked.Increment lässt sich sicherstellen, dass der Zugriff kontrolliert stattfindet.

public void Incrementer()
{
    try {
 
        while (_counter < 100) {
 
            // Mehr Code simulieren
            Thread.Sleep(1);
 
            // Atomarer Zugriff, threadsicher
            Interlocked.Increment(ref _counter);
 
            Console.WriteLine("{0}, Incrementer: {1}",
                Thread.CurrentThread.Name, _counter);
        }
 
    } catch (ThreadInterruptedException ex) {
        Console.WriteLine(ex.Message);
    } finally {
        Console.WriteLine("Thread wird beendet.");
    }
}

Das Resultat sieht nun so aus, wie wir es erwartet haben.

Thread [0], Incrementer: 42
Thread [0], Incrementer: 43
Thread [0], Incrementer: 44
Thread [0], Incrementer: 45
Thread [0], Incrementer: 46
Thread [1] gestartet
Thread [0], Incrementer: 47
Thread [1], Incrementer: 48
Thread [1], Incrementer: 49
Thread [0], Incrementer: 50
Thread [0], Incrementer: 51
Thread [1], Incrementer: 52
Thread [0], Incrementer: 53
Thread [1], Incrementer: 54
Die Klasse Semaphore und Synchronisationsblöcke

Die Klasse Interlocked ist nicht dazu geeignet bestimmte Codeblöcke zu synchronisieren. Um die bestmögliche Kontrolle über Ressourcen zu bekommen, können Sie einen Semaphore einsetzen.

Threads wechseln in das Semaphore, indem sie die WaitOne-Methode aufrufen, die von der WaitHandle-Klasse geerbt wird, und geben das Semaphor durch Aufrufen der Release-Methode frei. Der Zähler für ein Semaphore verringert sich jedes Mal, wenn dem Semaphore ein Thread hinzugefügt wird, und er erhöht sich, wenn ein Thread das Semaphore freigibt. Wenn der Zähler 0 (null) ist, werden nachfolgende Anforderungen blockiert, bis andere Threads das Semaphore freigeben. Wenn alle Threads das Semaphor freigegeben haben, hat der Zähler den maximalen Wert, der beim Erstellen des Semaphores angegeben wurde.

Semaphore als Mechanismus für die Synchronisation wurden von Edsger W. Dijkstra konzipiert und 1965 in seinem Artikel Cooperating sequential processes vorgestellt. Ein Semaphore ist eine Datenstruktur mit zwei speziellen Nutzungsoperationen. Die Datenstruktur besteht aus einem Zähler und einer Warteschlange für die Aufnahme blockierter Threads:

struct Semaphor {
   int zaehler;
   Queue queue;             /* Warteschlange */
}

Zähler sowie Warteschlange sind geschützt und können nur über die Semaphoroperationen verändert werden. Die Wirkung der Nutzungsoperation kann wie folgt zusammenfassend beschrieben werden:

  • Semaphore regeln durch Zählen Wechselwirkungssituationen von Prozessen.
  • Semaphore realisieren ein passives Warten der Prozesse, wenn eine Weiterausführung nicht gestattet werden kann.

Mit dem Konstruktor wird der Zähler auf einen nicht negativen Wert (>= 0) und die Warteschlange i. d. R. auf leer gesetzt.

Die Nutzungsoperationen wurden von Dijkstra mit P und V bezeichnet. Dies sind Initialen niederländischer Wörter bzw. Kofferwörter für prolaag und verhoog. Weitere, verbreitete Erklärungen sind passeer, probeer und vrijgeven. Programmierschnittstellen verwenden mnemonisch deutlichere Bezeichnungen wie wait, acquire oder down für die P-Operation und signal, release oder up für die V-Operation.

Bei einem Aufruf der P-Operation wird der Zähler dekrementiert. Ist der Zähler danach größer gleich 0, so setzt der Thread seine Aktionen fort. Ist der Zähler jedoch kleiner als 0, kehrt der Kontrollfluss nicht aus der Operation zurück. Der aufrufende Thread wird blockiert und in die Warteschlange des Semaphores eingereiht. Bei einem Aufruf der V-Operation wird der Zähler inkrementiert. Es wird ein Thread aus der Warteschlange entnommen und entblockiert, falls die Warteschlange nicht leer ist. Der entblockierte Thread setzt dann seine Aktionen mit denen fort, die dem P-Aufruf folgen, der den Thread blockierte.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Text;
 
namespace CodePlanet.Articles.Multithreading
{
    public class SemaphoreDemo
    {
        public static void Main()
        {
            // Erzeugt einen Semaphore, der bis zu 3
            // konkurrierende Threads abfertigen kann.
            // Der Zähler wurde auf 0 gesetzt, so dass im
            // Moment kein weiterer Thread den Semaphore
            // betreten kann. Der Semaphore wird vollständig
            // von dem Hauptthread beschlagnahmt.
            _pool = new Semaphore(0, 3);
 
            // Erzeuge 5 Threads.
            for (int i = 0; i < 5; i++) {
                Thread t = new Thread(new ParameterizedThreadStart(Worker));
                t.Start(i);
            }
 
            // Warte kurz und erlaube allen Threads
            // den Sempahore zu betreten und blockiert zu werden.
            Thread.Sleep(500);
 
            // Der Hauptthread gibt den Semaphore nun frei,
            // indem er den Zähler um 3 erhöht.
            Console.WriteLine("Hauptthread ruft Release(3).");
            _pool.Release(3);
 
            Console.WriteLine("Hauptthread endet.");
        }
 
        private static void Worker(object num)
        {
            // Jeder Thread wartet auf den Semaphore
            Console.WriteLine("Thread {0} beginnt " +
                "und wartet auf den Semaphore.", num);
            _pool.WaitOne();
 
            // Hilft Ausgabe zu sortieren.
            int padding = Interlocked.Add(ref _padding, 100);
 
            Console.WriteLine("Thread {0} betritt den Semaphore.", num);
 
            // Simuliert Arbeit. Jeder Thread arbeitet etwas länger.
            Thread.Sleep(1000 + padding);
 
            Console.WriteLine("Thread {0} gibt den Semaphore frei.", num);
            Console.WriteLine("Thread {0}. Vorheriger Zählerstand des Semaphore: {1}",
                num, _pool.Release());
        }
 
        // Ein Semaphore simuliert den limitierten Zugriff auf eine Ressource.
        private static Semaphore _pool;
 
        // Hilft die Ausgabe zu sortieren.
        private static int _padding;
    }
}

Das nachfolgende Diagramm zeigt den Programmfluß.

semaphore
Erzeuger-Verbraucher-Problem

Das Erzeuger-Verbraucher-Problem ist eine klassische, abstrakt formulierte Problemstellung der Prozesssynchronisation, welche eine Regelung der Zugriffsreihenfolge auf eine Datenstruktur durch elementerzeugende (schreibende) und elementverbrauchende (lesende) Prozesse bzw. Threads thematisiert. Die Zugriffsregelung soll verhindern, dass ein verbrauchender Prozess auf die Datenstruktur zugreift, wenn die Datenstruktur keine Elemente enthält und eine Entnahme eines Elements aus der Datenstruktur somit nicht möglich ist.

Ein Erzeuger (engl. producer) produziert Artikel, die in einem Container gespeichert werden. Der Verbraucher (engl. consumer) liest diese Artikel aus entfernt sie aus dem Container, d.h. er verbraucht den Artikel. Der Container hat nur eine bestimmte Größe. Sobald er voll ist, muss der Erzeuger mit der Produktion warten, bis wieder Artikel aus dem Container von einem Verbraucher entnommen wurden. Ist der Container leer, muss der Verbraucher warten, bis der Erzeuger einen Artikel im Container abgelegt hat. Erzeuger und Verbraucher laufen jeweils in einem eigenen Thread.

Der nachfolgende Quellcode zeigt eine naive Lösung für das Problem.

private const int N = 100;
private int count = 0;
 
public void Producer() 
{
    while (true) {
        ProduceItem(item);    // Artikel produzieren
        if (count == N) Sleep();
        EnterItem(item);
        count = count + 1;
        if (count == 1) Wake(consumer);
    }
}
 
public void Consumer() 
{
    while(true) {
        if (count == 0) Sleep();
        RemoveItem(item);
        count = count - 1;
        if (count == N - 1) Wake(producer);
        ConsumeItem(item);    // Artikel verbrauchen
    }
}

Auf den ersten Blick erscheint diese Lösung plausibel, tatsächlich führt sie aber zu einer Race Condition, die in einem Deadlock enden kann. Betrachten wir folgenden Vorgang:

  • Der Verbraucher liest die Variable count, die gerade den Wert 0 hat.
  • Bevor der Verbraucher Sleep aufrufen kann, findet ein Kontextwechsel zum Erzeuger statt.
  • Der Erzeuger stellt einen Artikel her, inkrementiert count und weckt den Verbraucher.
  • Der Verbraucher legt sich schlafen, da er für count gerade eben den Wert 0 gelesen hatte.
  • Der Erzeuger schreibt den Puffer voll und legt sich dann auch schlafen, der Deadlock ist etabliert.

Dieses Beispiel zeigt, dass eigene Konstrukte oft zu Fehlern führen. An dieser Stelle bietet sich an, die Klasse Semaphore einzusetzen. Wir kapseln die öffentlichen Methoden der Klasse in Methoden und demonstrieren die P- und V-Operation von Dijkstra.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Text;
 
namespace CodePlanet.Articles.Multithreading
{
    /// <summary>
    /// Demonstration des Erzeuger-Verbraucher-Problems mithilfe von
    /// Semaphores in C#. Zwei Threads greifen auf eine nicht threadsichere
    /// Datenstruktur zurück, in diesem Beispiel ein Stack. Eine
    /// Prozesssynchronisation ist notwendig, um einen geregelten Zugriff 
    /// zu ermöglichen und Deadlocks zu verhindern. Semaphores werden verwendet,
    /// da diese außer dem wechselseitigen Ausschluss bei der Ausführung 
    /// kritischer Abschnitte auch die verlangte Kooperation zwischen Prozessen
    /// respektive Threads unterstützen.
    /// </summary>
    class ProducerConsumerProblem
    {
        public ProducerConsumerProblem()
        {
            _stack = new Stack<Item>(N);
        }
 
        public void Run()
        {
            // Generiere einen parametrisierten Thread
            Thread prodThread = new Thread(new ParameterizedThreadStart(Producer));
 
            // Starte den Producer Thread
            prodThread.Start("Producer");
 
            // Generiere einen parametrisierten Thread
            Thread consThread = new Thread(new ParameterizedThreadStart(Consumer));
 
            // Starte den Consumer Thread
            consThread.Start("Consumer");
        }
 
        public void Producer(object obj)
        {
            Console.WriteLine("Thread {0} wurde gestartet.", obj);
 
            while (true) {  /* Endlosschleife */
                ProduceItem(ref _item); /* Erzeuge etwas für den Puffer */
                P(_empty);   /* Leere Plätze dekrementieren bzw. blockieren */
                P(_mutex);   /* Eintritt in den kritischen Bereich */
                EnterItem(ref _item);   /* In den Puffer einstellen */
                V(_mutex);   /* Kritischen Bereich verlassen */
                V(_full);    /* Belegte Plätze inkrementieren, evtl. consumer wecken */
            }
        }
 
        public void Consumer(object obj)
        {
            Console.WriteLine("Thread {0} wurde gestartet.", obj);
 
            while (true) {  /* Endlosschleife */
                P(_full);   /* Belegte Plätze dekrementieren bzw. blockieren */
                P(_mutex);   /* Eintritt in den kritischen Bereich */
                RemoveItem(ref _item);   /* Aus dem Puffer entnehmen */
                V(_mutex);   /* Kritischen Bereich verlassen */
                V(_empty);    /* Freie Plätze inkrementieren, evtl. producer wecken */
                ConsumeItem(ref _item);   /* Verbrauchen */
            }
        }
 
        private void ProduceItem(ref Item i)
        {
            i = new Item(new Random().Next());
            Console.WriteLine("Produziere Item {0}.", i.Number);
            Thread.Sleep(_producerSleepTime.Next(1, 2001));
        }
 
        private void RemoveItem(ref Item i)
        {
            i = _stack.Pop();
        }
 
        private void EnterItem(ref Item i)
        {
            _stack.Push(i);    // Push item on the stack
            Console.WriteLine("{0} Item(s) auf dem Stack.", _stack.Count);
        }
 
        private void ConsumeItem(ref Item i)
        {
            Console.WriteLine("Verbrauche Item {0}.", i.Number);
            Thread.Sleep(_consumerSleepTime.Next(1, 7500));
        }
 
        private void P(Semaphore semaphore)
        {
            // P-Operation, dekrementiert Semaphore bzw. Warte-Operation
            semaphore.WaitOne();
        }
 
        private void V(Semaphore semaphore)
        {
            // V-Operation, inkrementiert Semaphore bzw. Signal-Operation
            semaphore.Release();
        }
 
        // Anzahl der Plätze im Puffer
        private const int N = 100;
 
        // Kontrolliert den Zugriff auf den Puffer
        private static Semaphore _mutex = new Semaphore(1, 1);
 
        // Zählt die freien Plätze im Puffer
        private static Semaphore _empty = new Semaphore(N, N);
 
        // Zählt die belegten Plätze im Puffer
        private static Semaphore _full = new Semaphore(0, N);
 
        // Repräsentiert ein item
        private Item _item = null;
 
        // Der Puffer mit den items
        private Stack<Item> _stack;
 
        // Liefert eine zufällige Zeit zurück.
        // Dieser Zufall sorgt dafür dass das producer und consumer ping-pong Spiel
        // ein wenig interessanter wird, da die Threads unterschiedlich lang schlafen.          
        private Random _producerSleepTime = new Random();
 
        private Random _consumerSleepTime = new Random();
    }
}

Als Container benutzen wir die Collection Stack. Der Stack kann insgesamt 100 Artikel (Item) aufnehmen. Es zeigt sich, dass der Zugriff auf den Stack durch das Semaphore-Objekt geregelt wird, wie der Verkehrsfluss von einer Ampel. Die Funktion P dekrementiert, die Funktion V inkrementiert. P kapselt die Methode WaitOne und blockiert alle Threads, solange die Ampel rot ist, d.h. solange der Zähler von dem Semaphore bei 0 steht.

Beim Verbraucher sieht man bei den ersten beiden P-Operationen bereits die Funktionsweise. Zunächst wird überprüft, ob der Container Artikel enthält und danach wird nachgesehen, ob der Zugriff auf den Stack erlaubt ist. Der Zugriff auf den Stack, wird von der Ampel respektive dem Semaphore _mutex geregelt. Mutex ist der Name für eine besondere Variante des Semaphore, nämlich einen binären Semaphore mit genau 1 freien Platz in der Warteschlange und einem Zähler, der nur bis 1 zählt. Der Semaphore N = 1 wird also in Codeabschnitten verwendet, die nur von einem einzigen Thread zur selben Zeit betreten werden dürfen. Der Zugriff auf den Stack ist so ein Codeabschnitt. Der Stack darf zu einem Zeitpunkt nur von einem Thread, entweder gefüllt oder geleert werden.

Betriebssysteme implementieren oftmals ihre eigenen Versionen von Semaphoren und Mutexen. Hierbei sind Implementierungsdetails zu beachten. So entspricht ein binärer Semaphore funktionell einem Mutex, dennoch stellt Windows separat auch Mutexe zur Verfügung. Diese verhalten sich bei Threadabstürzen anders, als binäre Semaphore.

Mit den Semaphoren haben wir eine threadsichere Variante für das Erzeuger-Verbraucher-Problem entwickelt. Das Programm läuft endlos ohne Probleme durch, der Erzeuger produziert seine Artikel, der Verbraucher konsumiert sie. Der Verbrauch und Konsum wird durch zufällige Zeitperioden im Programm simuliert, je nachdem wie schnell der Erzeuger produziert und wie schnell der Verbraucher konsumiert, füllt oder leert sich der Container. Die Gefahr einer Race Condition oder eines Deadlock wurde durch die Synchronisation erfolgreich beseitigt.

Parallel Extensions

Mit dem .NET Framework 4.0 wurde eine neue Schicht zur parallelen Programmierung eingeführt. Diese Schicht wurde unter dem Namen Parallel Extensions (parallele Erweiterungen) veröffentlicht, auch bekannt als Parallel Framework Extensions (PFX). Es handelt sich um eine Bibliothek zur Unterstützung der parallelen Programmierung bei Verwendung des Managed Code des Microsoft .NET Frameworks. Die Parallel Extensions bestehen grundsätzlich aus zwei Teilen: Parallel LINQ (PLINQ) und der Task Parallel Library (TPL). Die Bibliothek verfügt über verschiedene Datentypen (referenzierte Objekte) und Funktionen zur Verwaltung und Steuerung von parallelen Prozessen während der Laufzeit. Die Bibliothek erschien erstmalig (als CTP) am 29. November 2007 und wurde im Dezember 2007 sowie im Juni 2008 aktualisiert. Seit der Version 4.0 des .NET Frameworks sind die Parallel Extensions fester Bestandteil des Frameworks und können in allen .NET-Sprachen verwendet werden.

DotNet-Framework-Stack

In diesem Kapitel werden wir speziell auf die neue Multithreading-API im Framework 4.0 für Mehrkernprozessoren eingehen:

  • Parallel LINQ oder PLINQ.
  • Die Klasse Parallel.
  • Die Task Parallelism Konstruktion.
  • Die Concurrent Collections.
  • SpinLock und SpinWait.

Diese APIs werden nachfolgend als Parallel Framework Extensions (PFX) bezeichnet. Die Klasse Parallel bildet zusammen Task Parallelism die Task Parallel Library oder TPL.

Das .NET Framework 4.0 fügt eine Reihe von Low-Level Threading-Konstrukten hinzu, die sich an die traditionelle Multithreading-Programmierung richten. Dazu zählen:

  • Signalkontrukte mit geringen Latenzzeiten (SemaphoreSlim, ManualResetEventSlim, CountdownEvent und Barrier).
  • CancellationToken für den kooperativen Abbruch.
  • Die Lazy Initialization Klasse.
  • ThreadLocal<T>.

Sie sollten die Grundlagen in diesem Artikel verstanden haben — insbesondere das Thema Threadsynchronisierung.

Alle Quelltexte zur parallelen Programmierung sind als interaktive Codebeispiele in LINQPad verfügbar. LINQPad ist ein Notizblock für C# Code und eignet sich ideal zum Testen von Codeschnipseln ohne das dafür weitere Klassen oder Projekte generiert werden müssen.

Warum PFX?

Der Trend zur parallelen Programmierung basiert primär auf der Entwicklung der Hardware. Zu Beginn des 21. Jahrhunderts haben große Prozessorhersteller, wie AMD oder Intel, ihren Fokus zunehmend auf die Produktion von Mehrkernprozessoren gerichtet. Viele Personalcomputer und Arbeitsstationen verfügen heute über mehrere Kerne (d. h. CPUs), die die gleichzeitige Ausführung mehrerer Threads ermöglichen. Schon in naher Zukunft werden Computer vermutlich über deutlich mehr Kerne verfügen. Um sowohl aktuelle als auch künftige Hardware nutzen zu können, kann der Code parallelisiert werden, um die Arbeit über mehrere Prozessoren zu verteilen. Früher erforderte die Parallelisierung Änderungen von Threads und Sperren auf niedriger Ebene. Visual Studio 2010 und .NET Framework 4 verbessern die Unterstützung für parallele Programmierung, indem sie eine neue Laufzeit, neue Klassenbibliothekstypen und neue Diagnosetools bereitstellen. Durch diese Funktionen wird die parallele Entwicklung vereinfacht, sodass sie effizienten, differenzierten und skalierbaren parallelen Code in einer natürlichen Sprache schreiben können, ohne direkt mit Threads oder dem Threadpool arbeiten zu müssen. Die folgende Abbildung stellt in .NET Framework 4 eine allgemeine Übersicht der parallelen Programmierarchitektur bereit.

Parallel-Extensions-Architecture

PLINQ

PLINQ parallelisiert automatisch lokale LINQ Queries. PLINQ hat den Vorteil das es einfach anwendbar ist, da es einige Aufgaben, wie die Arbeitsaufteilung und die richtige Zusammenfassung der Ergebnisse, an das Framework delegiert.

Um PLINQ nutzen zu können, wird einfach AsParallel() zu Beginn aufgerufen und anschließend wird mit der Query in gewohnter Art und Weise weitergearbeitet. Die folgende Query berechnet die Primzahlen zwischen 3 und 50000 — unter Ausnutzung aller Kerne auf einem Zielrechner:

// Calculate prime numbers using a simple (unoptimized) algorithm.
 
IEnumerable<int> numbers = Enumerable.Range(3, 50000-3);
 
var parallelQuery = 
    from n in numbers.AsParallel()
    where Enumerable.Range(2, (int) Math.Sqrt(n)).All(i => n % i > 0)
    select n;
 
int[] primes = parallelQuery.ToArray();
Zuletzt aktualisiert am Freitag, den 06. April 2012 um 23:42 Uhr
 
AUSWAHLMENÜ