Skip to main content
swissICT Booster  |  M&F Academy  |  M&F Events   |  +41 44 747 44 44  | 
17 Minuten Lesezeit (3423 Worte)

.Net Parallel Programming and Concurrency

Im Rahmen eines zweitägigen Workshops hat uns Luc Bläser, ehem. Informatik-Dozent an der OST, die wichtigsten Grundlagen rund um Parallel Programming and Concurrency mit .NET vermittelt. In folgendem Blogbeitrag haben wir unsere Learnings aus dem Workshop zusammengefasst. Der Umfang zeigt, wie komplex das Thema ist.

Inhalt

Einführung
     Level des Parallelismus
     Parallelismus vs. Concurrency
     Prozesse vs. Threads
     Prozess- und Thread-Scheduling
Thread Grundlagen
     Lifecycle
Critical Sections & Mutual Exclusion
Monitor
Beispiel: Bounded Buffer
Einige Synchronization Primitives
Concurrency Fehler
     Deadlock
     Livelock
     Starvation
     Alternativen zu Synchronisation
Thread-Pools and Task Parallel Library
     .NET Task Parallel Library
     Task-Parallelismus: Pitfalls
Data-Parallelismus mit TPL
     Data-Parallelismus
     Parallele Aggregation
     Parallel Partitioning
     Parallel LINQ (PLINQ)
Asynchrones Programmieren
     Async/Await
     Async Streams
     Async Dispose
     Asynchrones Programmieren: Pitfalls
Threading und GUI
     UI-Programmierung
.NET Memory Model
     Interlocked Klasse
     Optimierungen
Weitere Tools und Ressourcen

Einführung

Wieso benutzen wir Parallel Programming?

  • Bessere Performance, wenn Berechnungen nicht voneinander abhängen (Multi-core).
  • Natürliches Modelling, wenn nach dem Auslösen eines Ereignisses, erst auf eine Rückmeldung gewartet werden muss (Asynchronität).

Vor allem zum ersten Punkt: Seit ca. 2003 gilt Moore's Law nicht mehr. Prozessoren werden nicht mehr immer schneller. Heutzutage bekommen wir eher mehr Prozessorkerne. Also brauchen wir mehr Threads, um dies auszunutzen.

Intel CPU Clock Rates
Level des Parallelismus

Hyper-Threading: Mehrere Registersets auf selbem CPU, der dann zwei Instruktionen gleichzeitig ausführen kann, wenn sich diese sich nicht gegenseitig in die Quere kommen. Wie zum Beispiel eine Ganzzahl- und eine Fliesskomma-Operation.

Multi-core: Mehrere Prozessorkerne auf demselben Chip, die sich dann je nach Architektur auch Cache teilen.

Multi-Prozessor: Mehrere Prozessorpackages in derselben Maschine. 

Computer-Netzwerk: Wenn mehrere Computer über ein Netzwerkprotokoll am selben Problem arbeiten, wie zum Beispiel bei Server-Farms.

Level des Parallelismus

Andere

  • GPUs: limitiertes Instruktionsset, sehr gut um gleiche Operation auf grossen Datensets durchzuführen. Hat aber Overhead, da Daten erst an Graphikspeicher gesendet werden müssen.
  • SIMD-Instruktionen: "Single Instruction, Multiple Data"-Instruktionen wie in AVX/SSE enthalten, funktionieren ähnlich wie GPUs wenn die selbe Operation für mehr als ein Datenset benutzt werden soll. Auf CPU sind das typischerweise "nur" 4-8 parallele Operationen. 

Parallelismus vs. Concurrency

Parallelismus: Zerlegung eines Programms in Unterprogramme, die parallel auf mehreren Prozessoren ausgeführt werden können. => Schnellere Ausführung

Concurrency: Gleichzeitig oder verschachtelt ausgeführte Routinen, die auf gemeinsame Ressourcen zugreifen. => Effizientere Ressourcennutzung

Bei beiden haben wir typischerweise mehrere interagierende Prozesse oder Threads.


Prozesse vs. Threads

Prozess (heavy-weight)

  • Parallele Ausführung von Anwendungen
  • Eigener, abgekapselter Adressraum
  • Speicherintensiv und deshalb relativ teuer
  • Inter-Prozess-Kommunikation schwierig und teils rechenintensiv

Prozess (heavy-weight)

Thread (light-weight)

  • Parallele Ausführung innerhalb eines Prozesses
  • Gemeinsamer Adressraum
  • Separate Stacks, Registers und Programm Counters

Thread (light-weight)

Prozess- und Thread-Scheduling

Kreierte Threads müssen auf einem Prozessorkern ausgeführt werden. Die Verantwortung zu verwalten, welcher Thread wann ausgeführt wird, fällt dem Thread-Scheduler zu. Dieser nimmt Threads, die in der Ready-Queue auf die Ausführung warten, weist sie einem Prozessor zu, reiht gerade ausführende oder wartende Threads wieder in diese Queue ein. So können auch auf Single-Core CPUs mehrere Threads anscheinend gleichzeitig ausgeführt werden (Prozessor Multiplexing). 

Prozess- und Thread-Scheduling

Ein Kontextswitch passiert, wenn ein Thread seine Ausführung unterbricht, dem Scheduler mitteilt, dass er jetzt gerade auch anderen Threads den Vortritt lassen möchte (Thread.Yield() in C#)  oder wenn der Scheduler nach einer ungewissen Zeit andere Threads "zum Zug kommen lässt".  Wenn der Scheduler so eingreift, nennt man das auch Preemption. 

Preemption


Thread Grundlagen

Jeder Prozess startet in einem Main Thread, der dann andere Threads erstellen kann, die unabhängig ausgeführt werden und selbst auch wieder neue Threads erstellen können. Auch ohne Threads bewusst zu nutzen, kann das Framework neue Threads erstellen. Zum Beispiel ist der .NET-Garbage-Collector ein eigener Thread.

Thread Grundlagen

Ein Thread wird instanziiert und mit der Start() Methode gestartet:

Threading Beispiel
var myThread = new Thread(() =>  {     for (int i = 0; i < 100; i++)  {         Console.WriteLine("MyThread step {0} ",i);     } }); myThread.Start(); // Andere arbeiten auf dem "Parent-Thread" myThread.Join(); // Wartet bis myThread beendet ist

Werden gerade mehrere Threads ausgeführt, kann man auf mit der Join() Methode warten bis ein bestimmter Thread fertig ist. So kann es zum Beispiel sein, das Thread 1 einen Thread 2 startet, der eine unabhängige Berechnung durchführt. Jedoch braucht Thread 1 das Resultat zu einem gewissen Zeitpunkt um fortzufahren. Mit der Join() Methode wird sichergestellt, das Thread 1 erst fortfährt, wenn Thread 2 das Resultat berechnet hat.

Thread 1 and 2


Ein Thread wird beendet wenn:

  • Er seine Sequenz beendet hat
  • Ein explizites Return aufgerufen wird
  • Eine geworfene Exception nicht gefangen wird

Nachdem ein Thread beendet wurde, kann er nicht mehr neu gestartet werden. Es muss stattdessen ein neuer Thread erstellt werden. 


Program Termination
Ein Programm terminiert sobald jeder Thread terminiert hat.

Ausnahme: wenn thread.IsBackground == true , dann wird nicht auf diesen gewartet (z.B. Garbage-Collector).


Passivierung
Als Programmierer können wir auch den Code durch Instruktionen verlangsamen.

Thread.Sleep(int milliSeconds) →  Suspendiert den Thread für die bestimmte Zeit.

Thread.Yield() →  Lässt andere Threads vor, ist aber meist unnötig, da aufgrund von Preemption auch so andere Threads zum Zug kommen.


Lifecycle

Lifecycle Thread

Critical Sections & Mutual Exclusion

Threads agieren mit demselben Heap, können also auf gemeinsame Objekte zugreifen. Dabei können sie sich aber auch gegenseitig in die Quere kommen. Wie in diesem Beispiel:

class BankAccount
{
 private int balance;
 
 public void Deposit(int amount)
 {
     // Begin Critical Section
 balance += amount;
 // End Critical Section
 }
}

Wenn nun zwei Threads die DepositMethode fast gleichzeitig aufgerufen werden, kann folgendes passieren:

DepositMethode


Am Ende ist der Kontostand bei 50, anstelle von 150.

Um dies zu vermeiden, gibt es in C# das lock Keyword. Dies synchronisiert die Zugriffe.

class BankAccount
{
 private readonly object sync = new();
 private int balance;


 public void Deposit(int amount)
 {
 lock (sync)
 {
 balance += amount;
 }
 }
}

Nun kann immer nur höchstens ein Thread innerhalb des lock Blocks sein. Somit kann das Data Race von oben nicht mehr auftreten.

Wir könnten auch this anstelle vom sync Objekt benutzen. Es wird aber laut Microsoft-Design-Konvention davon abgeraten. 


Monitor

Ein Monitor ist ein Objekt, dessen public Methoden alle synchronisiert sind. Dadurch kann sich jeweils nur ein Thread gleichzeitig im Monitor befinden. 

Für Monitors können wir auch komplexere Logik implementieren, bei der wir auf die Erfüllung von bestimmten Konditionen warten: Auch wenn wir eine Withdraw Methode zum BankAccount hinzufügen, muss erst genügend Geld auf einem Konto sein, bevor wir diese ausführen können. Für diesen Fall gibt es eine innere Warteschleife. Wenn eine solche Kondition noch nicht erfüllt ist, kann ein Thread mit Monitor.Wait(sync) das Lock freigeben und in die innere Warteschleife gehen. Dort wartet der Thread, bis ein anderer Thread Monitor.PulseAll(sync) oder Monitor.Pulse(sync) (nimmt nur einen Thread von der inneren Warteschleife) aufruft, und ihn wieder in die äussere Warteschleife einreiht. Monitor.Wait, Monitor.Pulse  und Monitor.PulseAll müssen alle zwingend innerhalb des lock Blocks aufgerufen werden.

class BankAccount
{
    private readonly object sync = new();
 private int balance;
 
 public void Withdraw(int amount)
 {
 lock (sync)
 {
 while(amount > balance)
 {
 Monitor.Wait(sync) // Wait inside Monitor
 }
 balance += amount; 
 }
 }
 
 public void Deposit(int amount)
 {
 lock (sync)
 {
 balance += amount;
 Monitor.PulseAll(sync) // Wake up all Waiters inside Monitor
 }
 }
}

Nach PulseAll müssen die wartenden Threads das Lock neu bekommen. In C#: Von der inneren Warteschleife gehen die Threads dann zuhinterst in die äussere Warteschleife (wahrscheinlich weil am einfachsten zu implementieren). Der Thread der "gepulsed" hat, behält aber das Lock und arbeitet weiter. Der nächste Thread kommt erst nach Exit des lock Blocks zum Zug.

Lock Blocks


Anstelle von lock(sync) kann auch Monitor.Enter und Monitor.Exit benutzt werden.

lock(sync)

Beispiel: Bounded Buffer

Hier gilt es zu beachten, dass die roten whiles nicht durch ifs ersetzt werden können, wie auch die Notwendigkeit PulseAll und nicht nur Pulse zu verwenden, zumindest wenn mehrere Consumers und Producers bestehen. Beide Male ist es so, dass wir nicht unterscheiden können, ob der Thread von der inneren Queue auch vom selben Typ ist. Wir wissen auch nicht, ob bereits andere Threads in der äusseren Queue warten, die die Konditionen wieder ändern. 


Einige Synchronization Primitives

Barrier: Wird benutzt wenn eine fixe Anzahl Threads sich einreihen muss, bis alle wieder freigegen werden. Nachdem die Barriere geöffnet wurde, schliesst sie sich sofort wieder selbst. Wie zum Beispiel bei einem Gokart-Rennen, wo alle wieder an den Start müssen, bis die nächste Runde gestartet wird.

Barrier

Count Down Events: Ungleich der Barriere, ist die Anzahl Threads nicht fix, aber CDE kann nicht wieder verwendet werden. Hier sind Signal und Wait separat. Also können Threads auch mehrere Signale senden. Es gibt auch AddCount(), aber das geht nur falls das CDE noch nicht offen ist. Ist wie ein Raketenstart: sobald released wird, können wir nicht mehr zurück und brauchen eine neue Rakete.

Semaphores: Eine Semaphore definiert eine bestimmte Anzahl Threads, die aktiv sein dürfen. Man könnte dies mit einem Parkplatz mit beschränkter Anzahl Plätzen beschreiben. Ist der Parkplatz voll, müssen weitere Autos (Threads) warten, bis ein Auto den Parkplatz verlässt und somit wieder einen Platz freigibt.

 Semaphores

In .NET sollte fast immer SemaphoreSlim, nicht Semaphore benutzt werden. Dieser ist innerhalb des Prozesses verwaltet und darum schneller als Semaphore, welches das OS benutzt. Semaphore kann nützlich sein, wenn mehrere Prozesse den gleichen Semaphore brauchen sollen (mit gleichen Namen).

Mutex: Ist ein Binärer Semaphore, der nur durch den Eigentümer wieder freigegeben werden kann. Dieser ist auch teuer wegen Kernelzugriff. 

Reader-Writer Lock: Enter/Exit Read/Write/UpgradableRead Lock

  Read

UpgradableRead

Write
Read
UpgradableRead
Write


Da C# keine const-Methoden hat, wird dies nicht oft verwendet.

ManualResetEvent: Manuell "Türchen" öffnen und schliessen. Selten gebraucht, da sehr manuell und auch nicht granular. Keine Garantien wie viele Threads durch gehen. Wie beim Sempaphore, gibt es auch hier ein ManualResetEventSlim, welches bevorzugt für Synchronisation innerhalb des selben Prozesses verwendet werden sollte.

AutoResetEvent: Manuell Türe für höchsten einen Waiter öffnen, aber es gibt keine Garantie, dass auch einer durchgeht.


Concurrency Fehler

Concurrency Fehler sind Halteproblem-schwer, also im generellen programmatisch nicht erkennbar und reproduzierbar (non-deterministic).  Es gibt aber statische Analysen zur Erkennung der meisten Fehler.

Race Condition: Bei einer Race Condition versuchen mehrere Threads dieselben Ressourcen zuzugreifen ohne ausreichend Synchronisation. Das führt zu möglichen fehlerhaften Verhalten oder Ergebnissen, meist aufgrund eines Data Race.

Data Race: Zwei gleichzeitige Zugriffe auf denselben Speicher. Dabei ist mindestens eine Write Operation vorhanden. 


Deadlock

Mehrere Threads locken sich gegenseitig, sodass keiner fortfahren kann.


Einfaches Deadlock Beispiel

Einfaches Deadlock Beispiel


Weiters Deadlock Beispiel

Weitere Deadlock Beispiel

Deadlock verhindern:

  • Lineare Ordnung zwischen Locks aufstellen und diese Reihenfolge befolgen.
  • Grobkörnige Locks, welche mehrere Ressourcen schützen, indem eine einziges Lock für einen größeren Bereich von Code oder Daten verwendet wird.


Livelock

Spezialfall des Deadlocks, aber CPU wird verbraucht (Spinlock).

Livelock


Starvation

Ein Thread bekommt keine Ressourcen. Nie hoffnungslos, kann aber möglicherweise für immer in innerer und äusserer Queue warten.


Alternativen zu Synchronisation

Immutability: Das Objekt hat ausschliesslich schreibgeschützte Felder.

Confinement: Das Objekt gehört höchstens einem Thread zur gleichen Zeit. Das innere Objekt wird in einem thread-sicheren äußeren Objekt verkapselt → keine Synchronisierung für innere Objekte erforderlich.

.NET-Datenstrukturen: Datenstrukturen in .Net sind grundsätzlich nicht thread-sicher. Es gibt aber Concurrent Collections: System.Collections.Concurrent.

Weitere Quellen von Concurrency: GC, Finalizers, Timers


Thread-Pools and Task Parallel Library

Task: Ein Objekt, dass potentiell parallel ausgeführten Code beschreibt. Tasks können parallel ausgeführt werden, müssen aber nicht.

Thread-Pool: Eine Queue für Tasks, die noch ausgeführt werden müssen. Ein Thread-Pool hat Limitierte Anzahl von Worker-Threads, die neue Tasks aus der Queue nehmen und komplett ausführen. Der Potentieller Parallelisierungsgrad wird durch Anzahl Worker-Threads bestimmt. Thread-Pools haben folgende Vorteile:

  • Limitierte Anzahl von Threads → Zu viele Threads verlangsamen das System oder überschreiten den Speicherplatz.
  • Rezyklieren von Threads → Einsparungen bei Erstellen und Entsorgen von Threads.
  • Höherer Abstraktionsgrad → Separieren von Problembeschreibung und Ausführung. 
  • Anzahl Threads ist abhängig von Anzahl logischer Kerne, auf der die Runtime ausgeführt werden.


.NET Task Parallel Library

Effizienter Thread-Pool

Effizienter Thread-Pool

Task Start & Wait
Task task = Task.Run(() => { //code });
task.Wait(); // hier werden innere unhandled Exeptions geworfen

Task With Result
Task<int> task = Task.Run(() => {int total = //code; return total;});
int outerTotal = task.Result; // Blockiert bis ausgeführt

Run-to-Completion Principle und Thread-Injection
Task haben eine Limitation: Worker-Threads müssen einen Task bis zum Ende ausführen, ansonsten können sie keine anderen Tasks ausführen (Run to Completion Principle). Die Ausnahmen bilden hier geschachtelte Tasks und Fortsetzungen. Aus diesem Grund sollten Tasks nicht auf andere Tasks warten, ausser auf Untertasks und Fortsetzungen! Wenn dieses Prinzip verletzt wird, resultieren Performance Probleme oder Deadlocks (da limitierte Anzahl Threads). Wenn die Threads nicht weiter kommen (0.5s), fügt .NET weitere Threads dem Pool hinzu (Thread-Injection). Dies löst zwar Deadlocks, sollte aber nicht so verwendet werden (schlechtes Design).

Run-to-Completion Principle und Thread-Injection


Nested Tasks
Tasks können ineinander verschachtelt werden:

Task.Run(() => {
  var left = Task.Run(() => //left );
  var right = Task.Run(() => //right);
  int result = left.Result + right.Result;
});

Task Continuation
Tasks können auch verkettet werden → Tasks definieren nachfolgende Tasks, die nach ihrer Erledigung ausgeführt werden:

Task<User[]> usersTask = server.GetUsersAsync();
usersTask.ContinueWith(t => Display(t.Result));

Task Continuation
Multi-Continuation
Mit Task.WhenAll() kann mit der Ausführung des Programms gewartet werden bis gewisse Tasks abschlossen sind.

Task.WhenAll(task1, task2).ContinueWith(continuation)

Multi-Continuation
Mit Task.WhenAny() wird nur auf den ersten Task gewartet.

Task.WhenAny(task1, task2).ContinueWith(continuation)

Task.WhenAny(task1, task2).ContinueWith(continuation)
Exceptions
Calls zu .Wait() oder .Result werfen AggregateExceptions, wenn innerhalb des Tasks ein geworfene Exception nicht gefangen wurde. Man kann die Exceptiones auch etwas schöner haben mit task.GetAwaiter().GetResult();, dann bekommt man sofort die innere Exception und der Stack ist auch ansehbarer.

Wenn niemand .Wait() oder .Resultaufruft, "verpuffen" diese Exceptions (Fire-and-Forget). Der GC fängt die Exception dann stillschweigend als “unobserved Exception” auf. Es gibt einen Eventhandler: TaskScheduler.UnobseredTaskException wird erst nach Garbage-Collection und Finalisierung ausgeführt.


Cooperative Task Cancellation
Um einen Task zu canceln muss dem Task ein Cancellation-Token übergeben werden.

Cooperative Task Cancellation


Work-Stealing Thread-Pool
Jeder Thread im Pool hat auch nochmals eigene Queue um globale Queue zu entlasten. Wenn ein Thread im Pool keine Arbeit mehr hat und auch auf der globalen Task-Queue keine Tasks sind, kann ein Thread auch Tasks von anderen Threads nehmen.

Work-Stealing Thread-Pool


TPL Tuning Heuristics
Die TPL misst den Throughput. Darauffolgend werden dynamisch mehr oder weniger Threads für den Thread-Pool erstellt. Es wird empfohlen Tasks möglichst kurz zu halten oder falls nötig, Tasks als long-running zu bezeichnen (dann wird ein eigener Thread erstellt).

Task.Factory.StartNew(action, TaskCreationOptions.LongRunning);

Task-Parallelismus: Pitfalls

  • Warteabhängigkeiten → Es werden viele Threads erzeugt (oder Deadlocks bei begrenzter Poolgrösse).
  • Race conditions → Tasks greifen auf gemeinsame Ressource zu, ohne ordnungsgemässe Synchronisierung.
  • TPL verwendet background Threads → D.h. es ist möglich das gewisse Tasks beim Programmende noch nicht abgeschlossen sind.
  • Überparalellisierung →  Der Overhead für die Queue, Threads und Tasks sind nicht sehr hoch, aber mit einer extrem hohen Anzahl Tasks kann es doch in das Gewicht fallen.
  • Fehlende Delegation von Exceptions → Exceptions werden nur durch .Wait() oder .Result() and den Initiator weitergegeben.

Data-Parallelismus mit TPL

Data-Parallelismus

Unabhängige Statements →Parallel.Invoke()

Parallel.Invoke(
    () => MergeSort(start, middle),
    () => MergeSort(middle, end)
);

Data-Parallelismus


Unabhängige LoopsParallel.For() und Parallel.ForEach()

Parallel.For(0, array.Length, 
    i => DoComputation(array[i])
);
Parallel.Foreach(list,
    file => Convert(file)
);

Data-Parallelismus

Stop Parallel Loops
Das Ergebnis wurde gefunden und keine weiteren Tasks müssen prozessiert werden.

Stop Parallel Loops


Break Parallel Loops
Überspringe alle weiteren Iterationsschritte und führe alle Tasks der vorangegangenen Iterationsschritte Schritte aus.

Break Parallel Loops


Parallele Aggregation

Wenn die Loops aggregiert werden, muss man wieder sicher stellen, dass keine Race Conditions entstehen.

Falsche Parallele Aggregation:

Falsche Parallele Aggregation


Korrekte Parallele Aggregation:

Korrekte Parallele Aggregation


Für eine möglichst effiziente Aggregation, kann jeder Thread auch sein eigenes Subtotal behalten und erst ganz am Ende synchronisiert zum Gesamttotal hinzufügen.

Threads


Parallel Partitioning

Da es nicht effizient wäre, aus jedem Loop einen Task zu generieren, gruppiert die TPL die Loops automatisch in Tasks auf Grundlage der momentanen Systemauslastung.

Parallel Partitioning


Ein Partitioner kann auch manuell gesetzt werden mit Partitioner.Create().

Partitioner


Parallel LINQ (PLINQ)

Sehr Deskriptiv und funktioniert sehr gut bei bereits bestehendem LINQ code.

.AsParallel() → Ausführung in zufälliger Reihenfolge

.AsOrdered() → Auf diese Weise kann die Eingabereihenfolge beibehalten werden. Statement folgt auf .AsParallel().AsParallel() .AsOrdered()


PLINQ: Resultate Pipen

PLINQ: Resultate Pipen

Oder mit den LINQ Extension-Methoden

var query  = bookCollection.AsParallel().Where(book => book.Title.Contains("Concurrency"));


Asynchrones Programmieren

Synchrone Calls: Normale Methoden, welche blockieren bis sie fertig ausgeführt sind. Nachdem ein Thread in die Methode gegangen ist, muss diese komplett durchgeführt werden. Dies ist ineffizient für zum Beispiel IO Operationen oder bei Netzwerkaufrufen. 

Asynchrone Calls: Lange Calls werden in separate Threads ausgelagert mit dem Ziel, dass der Caller-Thread nicht blockiert bleibt.

var task = Task.Run(
    // LongOperation
);


// perform other work
int result = task.Result;

Asynchrone Calls

Async/Await

Keyword async für Methoden →  Der Caller muss nicht für die gesamte Dauer der asynchronen Methode blockiert sein.

Keyword await für Tasks → Warte asynchron auf den Return des TPL-Tasks und nimm, falls zutreffend, das .Result()des Tasks. 

Async/Await


Returntypen

  • void → "fire-and-forget". ⚠️ async void sollte nur für GUI-EventHandler verwendet werden, sonst ist das gefährlich.
  • Task → Analog zu synchronen Methode die void zurückgeben.
  • Task<T>→ gibt Resultat vom Typ T nach await
  • ValueTask und ValueTask<T>→  günstiger Variante von Task<T>. Achtung: Kann nur einmal awaited werden, da ValueTaskim Hintergrund rezykliert wird.

ref und out Parameter sind nicht möglich bei async Methoden.


Regeln

  • async Methoden müssen ein await enthalten.
  • await muss in einer async Methode sein. 

Grund: spezielles, verstecktes Ausführungsmodell für async/await


Async/await Ausführungsmodell
Bis zum ersten await (dass noch nicht bereit ist) wird die Methode synchron ausgeführt. Der Rest der Methode wird dann asynchron ausgeführt. Im Hintergrund zerlegt der Compiler die Methode in Sektionen, die ähnlich wie task.ContinueWith(); funktionieren. Die Sektionen nach dem await werden vom Thread-Pool aus ausgeführt.

Async/await Ausführungsmodell

Caller ohne Synchronisations-Kontext


Async Streams

IAsyncEnumberable ermöglicht asynchrone Iteration (jedes Item kann “awaited” werden).

Async Streams


Async Dispose

Pendant zum synchronen Disposable interface. Gibt einen ValueTask zurück (z.B. für database.CloseAsync();

Async Dispose


Asynchrones Programmieren: Pitfalls

  • async ohne await→ Lösung: return await Task.Run(() => { // code });. Das erlaubt uns Operationen in der TPL auszuführen.
  • Thread switch in async-Methoden → kein Synchronisations-Kontext, falls der Aufrufer nicht der UI-Thread ist.
  • Race conditions → Sektion nach await kann parallel zu Caller laufen.
  • Fire-and-forget Tasks in async Methoden werden möglicherweise vor Programmende nicht abgeschlossen, da TPL Worker-Threads background Threads sind. → zusätzliche Synchronisation nötig. 


Threading und GUI

Nur ein dedizierter UI-Thread darf auf die GUI Komponente zugreifen. (Grund: keine Synchronisation nötig → responsiver und einfacher)

  • Event loop wird von UI-Thread ausgeführt (Messagepump)
  • Der UI-Thread hat die GUI-Komponente erstellt (d.h. er hat Application.Run() aufgerufen). Andere Threads müssen dem UI-Thread mitteilen, was sie gemacht haben möchten und als Event in die Queue legen. Wenn Events zu lange brauchen, friert die GUI ein.

Threading und GUI

UI-Programmierung

Mit async/await und Synchronisations-Kontext können wir die ganze Logik in einer Methode beschreiben und müssen den Code nicht fragmentieren wie in der klassischen UI-Programmierung vor C#5.0.

UI-Programmierung

Caller mit Synchronisations-Kontext


Klassischer Fehler: Nie Task.Result() oder Task.Wait() in UI-Thread benutzen → Deadlock


UI-Timers
Es gibt UI-Timers (System.Windows.Threading.DispatchTimer / System.Windows.Forms.Timer) für Handlers die GUI-Elemente beeinflussen. Diese geben die Delegates in die UI-Thread Event Queue, anstatt sie selbst auszuführen. 


.NET Memory Model

Interlocked Klasse

Das Lesen oder Schreiben von 1, 2 oder 4-byte Variablen ist atomar (kann in einem Schritt ausgeführt werden). Die meisten Instruktionen in C# sind aber nicht atomar und daher potentiell nicht thread-sicher. Für gewisse nicht-atomare Instruktionen gibt es in der Interlocked Klasse ein atomares Gegenstück. Beispielsweise:

  • T Iterlocked.Increment(ref T x)
  • T Interlocked.Add(ref T x, T d)
  • T Interlocked.Exchange(ref T x, T y)
  • T Interlocked.CompareExchange(ref T x, T y, T z)


Optimierungen

Instruktionen können neu angeordnet werden (vom Compiler, vom Runtime-System (JIT Compiler) oder vom CPU). Um die Integrität der Programmsemantik aufrechtzuerhalten und die erwarteten Ergebnisse zu gewährleisten, bietet C# für Concurrency das Konzept der "as if serial"-Semantik. Das bedeutet, dass Anweisungen zwar zu Optimierungszwecken umgeordnet werden können, das Endergebnis jedoch so aussehen sollte, als ob die Anweisungen in einer seriellen, einfädigen Weise ausgeführt würden. Mit anderen Worten, die seriellen Effekte jedes isolierten Threads sollten beibehalten werden, und das Gesamtverhalten sollte mit einer hypothetischen sequentiellen Ausführung übereinstimmen. 

Die Neuanordnung von Instruktionen kann auch eingeschränkt werden.

Neuanordnung verhindern mit Full-Fence
Alle lock acquire and releases (Monitor, Semaphore, etc.)

  • Interlocked Methoden
  • Thread/Task .Start() und .Join()
  • Thread.MemoryBarrier();

Neuanordnung verhindern mit Full-Fence


Neuanordnung verhindern mit Partial-Fence
Wenn ein Feld als volatil deklariert wurde, wird sichergestellt, dass Lese- und Schreibzugriff auf das Feld immer in der Reihenfolge erfolgen, wie sie im Code angegeben sind.

  • Ein volatile Lesevorgang mit "acquire" Semantik stellt sicher, dass die Leseoperation in der Reihenfolge vor allen nachfolgenden Speicherzugriffen oder Operationen bleibt. 
  • Eine volatile Schreiboperation mit "release" Semantik stellt sicher, dass die Schreiboperation in der Reihenfolge nach allen vorherigen Speicherzugriffen oder Operationen bleibt. 

Neuanordnung verhindern mit Partial-Fence


Spinlock
Ein Synchronisationsprinzip, dass es Threads ermöglicht den Zugriff auf eine gemeinsame Ressource, durch Spinning (Busy-Waiting), zu synchronisieren. Dabei wird CPU-Zeit verbrennt wenn auf das lock gewartet wird. In C# existieren die SpinLock und die SpinWait Klasse. Die Verwendung von Thread.Yield() ist aber in der Praxis in ausgelasteten Loops meist schneller.


Weitere Tools und Ressourcen

Parallel Checker
Luc Bläser, der Referent,  hat ein VS-plugin geschrieben, welches viele Fälle erkennt und die Benutzer warnt. Das Tool kann auf Parallel Checker , auf Nuget oder in den VS-Extensions gefunden werden.


Parallel Helper
Concurrency bug pattern Erkennung, ergänzend zu Parallel Checker. Das Tool kann auf Parallel Helper, auf Nuget oder in den VS-Extensions gefunden werden.


Antipatterns
10 Antipattern: Parallel Code Smells

2
Von der Hardware zur Software
OAuth und OpenID - Effiziente Autorisierung und Au...

Ähnliche Beiträge

 

Kommentare

Derzeit gibt es keine Kommentare. Schreibe den ersten Kommentar!
Sonntag, 28. April 2024

Sicherheitscode (Captcha)