.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.
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.
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
Thread (light-weight)
- Parallele Ausführung innerhalb eines Prozesses
- Gemeinsamer Adressraum
- Separate Stacks, Registers und Programm Counters
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).
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.
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.
Ein Thread wird instanziiert und mit der Start()
Methode gestartet:
Threading Beispielvar 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.
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
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:
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 deslock
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.
Anstelle von lock(sync)
kann auch Monitor.Enter
und Monitor.Exit
benutzt werden.
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.
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.
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
Weiters 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).
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
Task Start & WaitTask task = Task.Run(() => { //code });
task.Wait(); // hier werden innere unhandled Exeptions geworfen
Task With ResultTask<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).
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));
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)
Mit Task.WhenAny()
wird nur auf den ersten Task gewartet.
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.
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.
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)
);
Unabhängige Loops →Parallel.For()
und Parallel.ForEach()
Parallel.For(0, array.Length,
i => DoComputation(array[i])
);
Parallel.Foreach(list,
file => Convert(file)
);
Stop Parallel Loops
Das Ergebnis wurde gefunden und keine weiteren Tasks müssen prozessiert werden.
Break Parallel Loops
Überspringe alle weiteren Iterationsschritte und führe alle Tasks der vorangegangenen Iterationsschritte Schritte aus.
Parallele Aggregation
Wenn die Loops aggregiert werden, muss man wieder sicher stellen, dass keine Race Conditions entstehen.
Falsche 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.
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.
Ein Partitioner kann auch manuell gesetzt werden mit Partitioner.Create()
.
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
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;
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.
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 dievoid
zurückgeben.Task<T>
→ gibt Resultat vom Typ T nachawait
ValueTask
undValueTask<T>
→ günstiger Variante vonTask<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.
Caller ohne Synchronisations-Kontext
Async Streams
IAsyncEnumberable
ermöglicht asynchrone Iteration (jedes Item kann “awaited” werden).
Async Dispose
Pendant zum synchronen Disposable interface. Gibt einen ValueTask
zurück (z.B. für database.CloseAsync();
)
Asynchrones Programmieren: Pitfalls
async
ohneawait
→ 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.
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.
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 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.
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
Kommentare