mk-prg-net \net \asynchron-multithreading

Asynchrone Programmierung und Multithreading

Synchrone Ausführung
Das Programm wird nach Aufruf einer Methode genau dann fortgesetzt, wenn die Ausführung der Methode endet.
Asynchrone Ausführung
Das Programm wird sofort nach dem Aufruf einer Methode fortgesetzt. Das Ende eines Methodenaufrufes und seine Ergebnisse sind gesondert zu ermitteln.
Thread
Ist ein Befehlsdatenstrom, der durch die sequentiellen Ausführung von Schritten eines Algorithmusses entsteht. Threads teilen sich Prozesse. Jeder Thread besitzt seinen eigenen Stack.
Multithreading
Ist die zeitgleiche / parallele Verarbeitung von mindestens zwei Threads in einem Computer.

Asynchrone Programmierung

Ziel der asynchronen Programmierung ist es, Wartezeiten sinnvoll zu nutzen. Wurde z.B. ein Webdienst aufgerufen, dann stehen die Ergebnisse nicht selten wegen hoher Latenzzeiten erst in Millisekunden bereit. Für eine moderne CPU ist das eine Ewigkeit, die mit sinnvoller Arbeit ausgefüllt werden sollte.

Sinnvolle Arbeiten wären z.B. die Verarbeitung weiterer Benutzereingaben, Aufbau einer komplexen Computergrafik oder Aufräumen im Arbeitsspeicher.

Asynchrone Methoden

In der asynchronen Programmierung werden asynchrone Methoden implementiert. Eine asynchrone Methode besteht aus zwei Teilen:

  1. Startvorbereiutungen für einen lang laufenden Hintergrundprozess, welcher die von der Methode zu bewältigenden Aufgabe löst.
  2. Auswertung der Ergebnisse, die der lang laufende Hintergrundprozess lieferte
Der Zeitraum zwischen der Ausführung des ersten und zweiten Teils kann vom Laufzeitsystem zur Bearbeitung weiterer Arbeitsaufträge gnutzt werden.

Async- Await- Pattern

Ab NET 4.0 gibt es in C# eine vereinfachte Implementierung asyncroner Methodenaufrufe auf Basis der task parallel library (TPL).

async Task MyAsyncMethod(...)
{
    // Teil der synchronen Ausführung
    ...
    
    // Asynchroner Methodenaufruf. Die Methode 
    // kehrt hier an den Aufrufer zurück.
    var result = await ReadAsync(...);
    // Die hier folgenden Befehle werden erst ausgeführt,
    // wenn der asynchrone Methodenaufruf mit einem Ergebnis endete
    ...
}

Laufzeitumgebung zur Ausführung asynchroner Methoden

Typischer Aufbau einer Laufzeitumgebung für asynchrone Methodenaufrufe

Laufzeitumgebungen für asynchrone Aufrufe besitzen typischerweise eine Warteschlange, in welcher die als nächstes auszuführenden Aufgaben eingestellt werden. In einer Endlosschleife werden die Aufgaben in jedem Zyklus aus der Warteschlange entnommen, und anschließend ausgeführt.

Synchrone Aufrufe werden vollständig ausgeführt. Asynchrone hingegen bereiten das System zunächst vor (Parametrierung), und starten dann den langlaufenden Hintergrundprozess (z.B. senden der Anforderung an den Webdienst, warten auf den Response). Danach endet der erste Teil des asynchronen Aufrufes, ein Zyklus der Endlosschleife endet. Im nächsten Zyklus wird, falls vorhanden, eine weitere Aufgabe eingelesen und ausgeführt.

Endet der langlaufende Hintergrundprozess (z.B. Webdienstaufruf), dann wird der zweite Teil der asynchronen Methode, die das Ergebniss auswertet, wiederum als Aufgabe in die Warteschlange zur Verarbeitung gestellt.

SynchronizationContext- Schnittstelle zur asynchronen Laufzeitumgebung

Parallel Computing- Its all about SynchronizationContext

Ab .NET 2.0 ist die asynchrone Laufzeitumgebung austauschbar. Dabei ist von der Basisklasse System.Threading.SynchronizationContext abzuleiten.

Die asynchronen Komponenten der Windows Forms Bibliothek nutzen eine Implementierung, die als UI-Thread bezeichnet wird. Hierbei werden die asynchronen Aufträge in die allgemeine Warteschlange für Ereignisse der Windowsanwendung gestellt. Der Hauptthread der Anwendung, genannt UI- Thread, liest sie aus und verarbeitet sie. So wird sichergestellt, daß alle Änderungen an GUI- Komponenten, die nicht threadsave sind, innerhalb eine und desselben Threads erfolgen, der auch die GUI- Komponenten erstellt hat.

Innerhalb des Threads einer Konsole- Anwendung oder eines mittels der Thread- Klasse erzeugten Threads wird eine Implementierung des SynchronizationContexts angeboten, welche jeden asynchron erteilten Auftrag einem Thread aus dem Threadpool zur Verarbeitung zuteilt.

Achtung: Die fehlerfreie Ausführung asynchrone Komponenten der Windows Forms Bibliothek, wie BackgroundWorker ist im Kontext einer Konsole Anwendung z.B. nicht mehr garantiert, solange die Standardimplementierung des SynchronizationContexts verwendet wird.

Asp.Net Webanwendungen setzen eine eigene, spezielle und komplexe Implementierung von SynchronizationContext (AspNetSynchronizationContext) für die Implementierung asynchroner Seitenabrufe und MVC- Actions ein.

Ähnlich der Standardimplementierung, die den Threadpool nutzt, hat die Implementierung zusätzlich folgende Einschränkungen:

  1. Der Aufrufer wird blokiert, um einen leeren Response zu vermeiden
  2. In der Phase der Ergebnisauswertung wird die gleiche Benutzeridentität und der gleiche Kulturkontext eingesetzt wie beim Start des asynchronen Aufrufes
aber noch sicherstellt,

Multithreading

In einem Prozess können mehrere Befehlsdatenströme parallel verarbeitet werden. Jeder Befehlsdatenstrom wird als Thread bezeichnet.

Die Befehlsdatenströme werden auf der CPU von einem Kern (Core) verarbeitet. Moderne CPU's besitzen mehrere Kerne (Duo = 2, Quad = 4), und können somit Threads tatsächlich parallel ausführen. Klassisch ist die quasi- parallele Verarbeitung von Threads mit einem einzigen Kern. Dabei wird nur ein Teil des Threads innerhalb eines kurzen Zeitintervalls (=Zeitscheibe) vom Kern verarbeitet. Der Zustand des Kerns am Ende der Zeitscheibe wird in einem Datensatz zum Thread gespeichert und dann an das Ende der Warteschlange der "Running Threads" gestellt. Aus der Warteschlange der "Running Threads" wird dann der nächste ausführungsbereite Thread in den Kern geladen und ausgeführt.

Hat eine CPU mehrere Kerne, dann wird jeder Kern im Zeitscheiben- Betrieb gefahren.

Befinden sich in einem Befehlsdatenstrom z.B. IO- Befehle für die Festplatte, deren Ausführungszeit extrem lang sein kann, dann kann der Thread blockieren. Dabei wird der Thread in den Pool der blockierten Threads gestellt ( Waiting Threads ). Anschließend wird der nächste ausführungsbereite Thread aus der Warteschlange in den Kern geladen. Aus dem Pool der Waiting Threads wird der Thread erst wieder zurück in die Warteschlange der Ausführungsbereiten gestellt, wenn der blockierende IO- Befehl beendet wurde.

Threads können auch dauerhaft blockiert werden. Sie befinden sich dann im Pool der suspendierten Threads , aus dem sie erst mit einem Resume – Befehl "befreit" werden können.

Wettstreit um die Ressourcen: Race Conditions

Durch die Verarbeitung von Befehlen werden die vom Anwender gewünschten Berechnungen durchgeführt. Dabei werden Werte aus den Registern, Arbeitsspeicher und Peripherieports gelesen und geschrieben. Register, Arbeitspeicher und Peheripherie werden als Ressourcen bezeichnet. Wenn mehrere Befehlsdatenströme gleichzeitig im System aktiv sind, kann es passieren, das ein und dieselbe Ressource von diesen gleichzeitig bearbeite wird. Diese wird als Race Condition bezeichnet.

Formalisierung:

Ta

Thread a

Ta.Op(t)

Operation (Befehl), der zum Zeitpunkt t im Thread ausgeführt wird

Ta.Op(t).IO

Ressource, die von einem Befehl im Thread zum Zeitpunkt t gelesen/geschrieben wird

RaceCondition

a != b && Ta.Op(t).IO != null && Tb.Op(t).IO != null && Ta.Op(t).IO == Tb.Op(t).IO

Erzeugen und Verwalten eines Threads

Jeder Befehlsdatenstrom hat einen Anfang. Dieser Einssprungpunkt muss eine Signatur besitzten, wie sie der vordefinierte Delegate ThreadStart definiert:

public delegate void ThreadStart();

Threads werden in .NET als Instanzen der Klasse Thread verwaltet. Der Einsprungpunkt in den Befehlsdatenstrom kann verpackt in einem ThreadStart- Objekt und dem Konstruktor der thread- Klasse übergeben werden.

Thread myFirstThread = new Thread(new ThreadStart(MyProc));

In die Warteschlange der ausführungsbereiten Threads wird ein Thread mit der Methode start() der Thread- Instanz gestellt:

myFirstThread.start();

In einem Befehlsdatenstrom kann jederzeit auf die ihn verwaltende Thread- Instanz zugegriffen werden mittels:

public static Thread CurrentThread {get;}

Thread im Kurzschlaf

Für eine frei definierbare Zeitspanne kann ein Thread blockiert werden mittels:

class Thread {
  ...
  public static void Sleep(Int32);
  public static void Sleep(TimeSpan);
  …
}

  1. Bei Übergabe einer 0 an Sleep wird die restliche Zeitscheibe abgegeben.

  2. Durch Timeout.Infinite blockiert der Thread bis zum Programmende oder bis zum Ausführung der Methode <ThreadInstanz>.Interrupt()

Threads im "Dornröschen" Schlaf

Durch die Methode <ThreadInstanz>.Suspend() wird ein Thread auf unbestimmte Zeit blockiert. Mittels <ThreadInstanz>.Resume() kann die Blockade wieder aufgehoben werden.

Thread beenden

Threads können vorzeitig mit der Methode Abort() beendet werden. Wurde für ein Threadinstanz Abort aufgerufen, dann wird seinem Befehlsdatenstrom die Ausnahme ThreadAbortException geworfen. Mittels eines try...catch- Blockes muß diese dann abgefangen werden.

Soll der vorzeitige Abbruch Abort() verhindert werden, dann muß im catch- Block ResetAbort() aufgerufen werden.

Achtung: Einem ResetAbort() in einem catch- Block darf kein goto- Befehl folgen- das ist verboten und führt zum Auslösen weiterer Ausnahmen !

Prioritäten

Wie wichtig die schnelle Ausführung des Befehlsdatenstroms hinter einem Thread ist, wird durch eine Priorität ausgedrückt. Die möglichen Werte werden durch den Enum ThreadPriority definiert:

Bezüglich der Prioritäten wird die Warteschlange der ausführungsbereiten Threads regelmäßig sortiert. Um auch Threads mit geringer Priorität eine Chance zur Ausführung zu ermöglichen, wird gelegentlich auch nach Absteigender Reihenfolge bezüglich der Prioritäten sortiert.

Threadpool

Q: Details zum CLR- Threadpool

Häufig können Anwendungen die Ressourcen besser nutzen, wenn wiederkehrende Teilaufgaben, die rechenintensiv sind oder langsame Peripherie ansteuern, asynchron ausgeführt werden. Um den Aufwand für das Erzeugen neuer, und löschen nicht mehr benötigter Threads zu minimieren, wurde der Threadpool erfunden.

An die Methode ThreadPool.QueueUserWorkItem(...) kann in einem WaitCallback - Delegate die Einsprungadresse des asynchron auszuführenden Unterprogramms übergeben werden. Beim ersten Aufruf wird dabei vom Threadpool ein Thread erzeugt und das Unterprogramm in diesem gestartet.

Wenn das Unterprogramm endet, dann wird der Thread mittels Suspend in den Dornröschen- Schlaf versetzt. Wird in den nächsten 40s an Threadpool erneut ein asynchron auszuführendes Unterprogramm übergeben, dann weckt dieser den Thread wieder auf und startet die Ausführung des neuen Unterprogramms in diesem.

Diese Vorgehensweise spart die Zeit für das Erstellen und Löschen eines Threads ein. Insbesondere bei häufigem Start asynchroner Prozeduren wird die zur Verfügung stehende Rechenleistung effizienter genutzt.

Asynchroner Methodenstart mittels Delegates

Synchronisierung

Kritische Abschnitte

Wenn eine Race Condition auftritt, kann es zu fehlerhaften Berechnungen kommen. Beispiel:

100: wert ++;
101: Console.WriteLine(wert);

Wird ein Thread nach Zeile 100 unterbrochen, dann können andere Threads die Variable wert erhöhen, ohne das der unterbrochene Thread etwas davon merkt. Nach beendeter Unterbrechung werden die Registerstände zurückgeladen, und der Thread gibt fälschlicherweise den alten Wert für wert aus, obwohl aktuell schon ein viel höherer Stand erreicht wurde.

Mittels der Klasse Monitor können kritische Abschnitte gesichert werden. Wird ein so gesicherter kritischer Abschnitt von einem Thread durchlaufen, blokieren alle anderen, wenn sie versuchen, diesen ebenfalls zu betreten.

 99: Monitor.Enter(this);
100:   wert ++;
101:   Console.WriteLine(wert);
102: Monitor.Exit(this);

C# als auch VB.NET bieten vereinfachte Konstruktionen an, die lock – Blöcke:

// C#
 99: lock(this) {
100:   wert ++;
101:   Console.WriteLine(wert);
102: }
' vb.net
 99: SyncLock Me 
100:   wert += 1
101:   Console.WriteLine(wert)
102: End SyncLock

Achtung: Kritische Abschnitte können mittels Monitor nur innerhalb eines Prozesses gesichert werden. Der konkurierende Zugriff auf Resourcen unabhängig aus verschiedenen Prozessen kann nur mittels benannter WaitHandle gesteuert werden.

WaitHandle

Eine WaitHandle ist eine Betriebssystemresource, auf die ein Thread zugreifen kann und dabei blockiert, wenn sich diese in dem besonderen Zustand "nicht gesetzt" befindet. Mittels der Methode Set kann der Zustand von "nicht gesetzt" auf "gesetzt" geändert werden, wodurch die vorher blockierten Threads wieder in die Warteschlange der Ausführungsbereiten übertragen werden.

WaitHandles können über Prozessgrenzen hinweg eingesetzt werden.

Signalisierung mit AutoResetEvent

Manchmal ist es notwendig, das ein Thread seine Arbeit erst dann fortsertzt, bis bestimmte Ereignisse im System eingetreten sind. Dies kann durch Signalisierung über ein AutoResetEvent erreicht werden:

AutoResetEvent fertig = new AutoResetEvent(false);
string ipath;
public void traverse(string path)
{
  ipath = path;
  fertig.Reset();
  Thread thread_traverse = new Thread(new ThreadStart(worker));
  thread_traverse.Start();
  // Warten, bis worker das Ende der Arbeit signalisiert                
  fertig.WaitOne();
                        
                
}
void worker() 
{
  traverse_exe(ipath);
  // Signalisieren, das Arbeit beendet wurde
  fertig.Set();
}