Multithreading in C# |
![]() |
![]() |
Samstag, den 12. Dezember 2009 um 02:00 Uhr | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Inhaltsverzeichnis
EinführungAls 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. 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. MultithreadingDer 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ätsklassenProzesse 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. ![]() ProzesseIn 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 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. ![]() 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. ![]() Eine Anwendung kann sich aus vielen Prozessen zusammensetzen. So startet der Browser Google Chrome für jeden Tab einen separaten Prozess. ![]() ThreadsEin 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. ![]() 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ätUm 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. ![]() 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. ![]() 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ätsanpassungThread-Prioritäten werden vom Betriebssystem in bestimmten Situationen angehoben.
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. ![]() 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 StatusProzesse 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. ![]() Man unterteilt den Zustand in:
Vor- und Nachteile von ThreadsTypischerweise 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:
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:
SchedulingWindows 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. ![]() 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. QuantumEin 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 BoostingWindows 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.
Dispatcher DatenbankUm 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. ![]() Thread-Local StorageWenn 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 .NETDas .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 Ausführungszustände eines ThreadDas .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 Die ![]() Die Tabelle zeigt alle ThreadState-Enumerationen:
Threads erzeugenAm einfachsten erzeugen Sie einen Thread, indem Sie eine neue Instanz der Klasse public delegate void ThreadStart(); Wie Sie sehen, darf die Methode, die Sie diesem Delegate zuordnen, keine Parameter haben und muss 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 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 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 blockierenDie Klasse SleepManchmal 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 Möchte man den laufenden Thread für zwei Sekunden unterbrechen, so ist folgende Zeile zu verwenden. Thread.Sleep(2000); Die Methode 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 Thread.Sleep(0); // Erzwinge einen Kontextwechsel Man kann einen Thread auch für eine nicht endliche Zeit "einschläfern", indem man 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 const int VALUE = 2000; Thread.SpinWait(VALUE)
Suspend und ResumeDie Methoden Die Methode Der Hauptpunkt ist, die Unterbrechung eines Thread ist keine unmittelbare Operation. JoinMan 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 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 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 Threads abbrechenIn 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 if(KeepAlive == false) { return; } InterruptEine Alternative zu der booleschen Variable besteht darin, die Methode AbortMöchte man den Thread mit Gewalt abbrechen, kann man schließlich noch die Methode 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 Threads terminierenWie 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 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 ThreadPool und asynchrone MethodenMan 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 ThreadPoolViele 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 Mit ![]() Um das Behandeln einer Arbeitsaufgabe von einem Thread im Threadpool anzufordern, rufen Sie die Eine beliebte Variante einen 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 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 // 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. BackgroundWorkerNeben der Klasse Um einen kompletten Hintergrundvorgang einzurichten fügen Sie einfach einen Ereignishandler für das Wir möchten nun ein konkretes Codebeispiel analysieren, um uns mit der 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: ![]() Die obige Reihe ist wegen ![]() 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 ![]() lässt sich zusammen mit der taylorschen Reihenentwicklung der Arcustangens-Funktion für schnelle Berechnungen verwenden. ![]() 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 π: ![]() 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 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 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 Die Klasse // Fortschrittsanzeige für den BackgroundWorker _worker.ReportProgress(i * 100 / digits); Die Methode 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-MethodenAsynchrone 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: [Serializable] public delegate void AsyncCallback( IAsyncResult ar );
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 Beim Rückruf dieser Methode wird das DelegateThatReturnsInt d = (DelegateThatReturnsInt)iar.AsyncState; Nun können Sie dieses Delegate dazu verwenden, die Methode d.EndInvoke(iar)
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; } }
![]() Callback-Methoden mit Thread-LevelEin 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:
Thread-Local StorageVerwaltete 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.
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 FelderWenn 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 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); } } } DatenslotsDas .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änenDas .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. ThreadsynchronisierungIn 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 DeadlocksEin 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 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 a = 0; // Code läuft... a++; Das Inkrementieren der Variable load a
add 1
store a
Zunächst wird die Variable 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.
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,
![]() 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. InterlockmethodenWenn 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 Mit der Methode 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öckeDie Klasse Threads wechseln in das Semaphore, indem sie die 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:
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ß. ![]() Erzeuger-Verbraucher-ProblemDas 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:
Dieses Beispiel zeigt, dass eigene Konstrukte oft zu Fehlern führen. An dieser Stelle bietet sich an, die Klasse 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 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 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 ExtensionsMit 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. ![]() In diesem Kapitel werden wir speziell auf die neue Multithreading-API im Framework 4.0 für Mehrkernprozessoren eingehen:
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:
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. ![]() PLINQPLINQ 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 // 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Ü | ||||||||
|