Kapitel 1. Gleichzeitigkeit: Ein Überblick
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Gleichzeitigkeit ist ein Schlüsselaspekt für schöne Software. Jahrzehntelang war Gleichzeitigkeit zwar möglich, aber schwer zu erreichen. Gleichzeitige Software war schwer zu schreiben, schwer zu debuggen und schwer zu warten. Daher wählten viele Entwickler den einfacheren Weg und vermieden die Parallelität. Mit den Bibliotheken und Sprachfunktionen, die für moderne .NET-Programme zur Verfügung stehen, ist Gleichzeitigkeit jetzt viel einfacher. Microsoft hat die Messlatte für die Gleichzeitigkeit deutlich gesenkt. Früher war die gleichzeitige Programmierung eine Domäne von Experten; heute kann (und sollte) jeder Entwickler die Parallelität nutzen.
Einführung in die Gleichzeitigkeit
Bevor ich fortfahre, möchte ich einige Begriffe klären, die ich in diesem Buch verwenden werde. Das sind meine eigenen Definitionen, die ich immer wieder verwende, um verschiedene Programmiertechniken voneinander abzugrenzen. Beginnen wir mit Gleichzeitigkeit.
- Gleichzeitigkeit
-
Mehr als eine Sache auf einmal zu tun.
Ich hoffe, es ist klar, wie hilfreich Gleichzeitigkeit ist. Endbenutzer-Anwendungen nutzen die Gleichzeitigkeit, um auf Benutzereingaben zu reagieren und gleichzeitig in eine Datenbank zu schreiben. Serveranwendungen nutzen die Gleichzeitigkeit, um auf eine zweite Anfrage zu reagieren , während sie die erste Anfrage abschließen. Du brauchst Gleichzeitigkeit immer dann, wenn eine Anwendung etwas tun soll , während sie an etwas anderem arbeitet. Fast jede Softwareanwendung auf der Welt kann von der Gleichzeitigkeit profitieren.
Wenn die meisten Entwickler den Begriff "Gleichzeitigkeit" hören, denken sie sofort an "Multithreading". Ich möchte einen Unterschied zwischen diesen beiden Begriffen machen.
- Multithreading
-
Eine Form der Gleichzeitigkeit, bei der mehrere Threads zur Ausführung verwendet werden.
Multithreading bezieht sich buchstäblich auf die Verwendung mehrerer Threads. Wie in vielen Rezepten in diesem Buch gezeigt wird, ist Multithreading eine Form der Gleichzeitigkeit, aber sicher nicht die einzige. Tatsächlich hat die direkte Verwendung von Low-Level-Threading-Typen in einer modernen Anwendung fast keinen Sinn mehr; Abstraktionen auf höherer Ebene sind leistungsfähiger und effizienter als Multithreading der alten Schule. Aus diesem Grund werde ich mich auf veraltete Techniken beschränken. Keines der Multithreading-Rezepte in diesem Buch verwendet die Typen Thread
oder BackgroundWorker
; sie wurden durch bessere Alternativen ersetzt.
Warnung
Sobald du new Thread()
eingibst, ist es vorbei; dein Projekt hat bereits Legacy-Code.
Aber komm nicht auf die Idee, dass Multithreading tot ist! Multithreading lebt im Thread-Pool weiter, einem nützlichen Ort, um Arbeit in eine Warteschlange zu stellen, die sich automatisch an den Bedarf anpasst. Der Thread-Pool wiederum ermöglicht eine andere wichtige Form der Gleichzeitigkeit: die Parallelverarbeitung.
- Parallele Verarbeitung
-
Doing viel Arbeit, indem du sie auf mehrere Threads aufteilst, die gleichzeitig laufen.
Bei der Parallelverarbeitung (oder Parallelprogrammierung) wird Multithreading eingesetzt, um die Nutzung mehrerer Prozessorkerne zu maximieren. Moderne CPUs haben mehrere Kerne, und wenn es viel zu tun gibt, macht es keinen Sinn, einen Kern die ganze Arbeit machen zu lassen, während die anderen untätig bleiben. Bei der Parallelverarbeitung wird die Arbeit auf mehrere Threads aufgeteilt, die unabhängig voneinander auf einem anderen Kern laufen können.
Parallelverarbeitung ist eine Art von Multithreading, und Multithreading ist eine Art von Gleichzeitigkeit. Es gibt noch eine andere Art der Gleichzeitigkeit, die in modernen Anwendungen wichtig ist, aber vielen Entwicklern nicht so vertraut ist: asynchrone Programmierung.
- Asynchrone Programmierung
-
Eine Form der Gleichzeitigkeit, die Futures oder Callbacks verwendet, um unnötige Threads zu vermeiden.
Ein Future (oder Versprechen) ist ein Typ, der eine Operation darstellt, die in der Zukunft abgeschlossen wird. Einige moderne Zukunftstypen in .NET sind Task
und Task<TResult>
. Ältere asynchrone APIs verwenden Callbacks oder Ereignisse anstelle von Futures. Bei der asynchronen Programmierung geht es um die Idee einer asynchronen Operation: eine Operation, die gestartet wird und irgendwann später abgeschlossen wird. Während die Operation läuft, wird der ursprüngliche Thread nicht blockiert; der Thread, der die Operation startet, kann sich anderen Aufgaben widmen. Wenn der Vorgang abgeschlossen ist, benachrichtigt er seine Zukunft oder ruft seinen Callback oder ein Ereignis auf, um die Anwendung darüber zu informieren, dass der Vorgang beendet ist.
Asynchrone Programmierung ist eine leistungsstarke Form der Gleichzeitigkeit, aber bis vor kurzem war dafür extrem komplexer Code erforderlich. Die Unterstützung von async
und await
in modernen Sprachen macht die asynchrone Programmierung fast so einfach wie die synchrone (nicht-gleichzeitige) Programmierung.
Eine weitere Form der Gleichzeitigkeit ist die reaktive Programmierung. Asynchrone Programmierung bedeutet, dass die Anwendung eine Operation startet, die erst zu einem späteren Zeitpunkt abgeschlossen wird. Die reaktive Programmierung ist eng mit der asynchronen Programmierung verwandt, basiert aber auf asynchronen Ereignissen anstelle von asynchronen Operationen. Asynchrone Ereignisse haben keinen tatsächlichen "Start", können jederzeit eintreten und mehrmals ausgelöst werden. Ein Beispiel sind Benutzereingaben.
- Reaktive Programmierung
-
Eine deklarative Art der Programmierung, bei der die Anwendung auf Ereignisse reagiert.
Wenn du eine Anwendung als massiven Zustandsautomaten betrachtest, kann das Verhalten der Anwendung als Reaktion auf eine Reihe von Ereignissen beschrieben werden, indem der Zustand bei jedem Ereignis aktualisiert wird. Das ist nicht so abstrakt oder theoretisch, wie es klingt; moderne Frameworks machen diesen Ansatz in der Praxis sehr nützlich. Reaktive Programmierung ist nicht zwangsläufig gleichlaufend, aber sie ist eng mit der Gleichzeitigkeit verwandt, weshalb dieses Buch die Grundlagen behandelt.
Normalerweise wird beim Schreiben eines nebenläufigen Programms eine Mischung aus verschiedenen Techniken verwendet. Die meisten Anwendungen nutzen zumindest Multithreading (über den Threadpool) und asynchrone Programmierung. Es steht dir frei, die verschiedenen Formen der Gleichzeitigkeit zu kombinieren und für jeden Teil der Anwendung das passende Werkzeug zu verwenden.
Einführung in die asynchrone Programmierung
Asynchrone Programmierung hat zwei Hauptvorteile. Der erste Vorteil gilt für GUI-Programme für Endbenutzer: Asynchrone Programmierung ermöglicht Reaktionsfähigkeit. Jeder hat schon einmal ein Programm benutzt, das sich während der Arbeit vorübergehend aufhängt; ein asynchrones Programm kann auf Benutzereingaben reagieren, während es arbeitet. Der zweite Vorteil gilt für serverseitige Programme: Asynchrone Programmierung ermöglicht Skalierbarkeit. Eine Serveranwendung kann allein durch die Nutzung des Threadpools etwas skalieren, aber eine asynchrone Serveranwendung kann in der Regel noch um Größenordnungen besser skalieren.
Beide Vorteile der asynchronen Programmierung beruhen auf demselben grundlegenden Aspekt: Die asynchrone Programmierung gibt einen Thread frei. Bei GUI-Programmen gibt die asynchrone Programmierung den UI-Thread frei; dadurch kann die GUI-Anwendung weiterhin auf Benutzereingaben reagieren. Bei Serveranwendungen gibt die asynchrone Programmierung Anfrage-Threads frei; dadurch kann der Server seine Threads nutzen, um mehr Anfragen zu bedienen.
Moderne asynchrone .NET-Anwendungen verwenden zwei Schlüsselwörter: async
und await
. Das Schlüsselwort async
wird zu einer Methodendeklaration hinzugefügt und erfüllt einen doppelten Zweck: Es aktiviert das Schlüsselwort await
innerhalb der Methode und signalisiert dem Compiler, einen Zustandsautomaten für diese Methode zu erzeugen, ähnlich wie yield return
funktioniert. Eine async
Methode kann Task<TResult>
zurückgeben, wenn sie einen Wert zurückgibt, Task
, wenn sie keinen Wert zurückgibt, oder einen anderen "aufgabenähnlichen" Typ, wie ValueTask
. Außerdem kann eine async
Methode IAsyncEnumerable<T>
oder IAsyncEnumerator<T>
zurückgeben, wenn sie mehrere Werte in einer Aufzählung zurückgibt. Die aufgabenähnlichen Typen stellen Zukünfte dar; sie können den aufrufenden Code benachrichtigen, wenn die Methode async
abgeschlossen ist.
Warnung
Vermeide async void
! Es ist möglich, dass eine async
Methode void
zurückgibt, aber das solltest du nur tun, wenn du einen async
Event-Handler schreibst. Eine normale async
Methode ohne Rückgabewert sollte Task
zurückgeben, nicht void
.
Vor diesem Hintergrund wollen wir uns kurz ein Beispiel ansehen:
async
Task
DoSomethingAsync
()
{
int
value
=
13
;
// Asynchronously wait 1 second.
await
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
1
));
value
*=
2
;
// Asynchronously wait 1 second.
await
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
1
));
Trace
.
WriteLine
(
value
);
}
Eine async
Methode wird wie jede andere Methode synchron ausgeführt. Innerhalb einer async
Methode führt das Schlüsselwort await
ein asynchrones Warten auf sein Argument aus. Zuerst prüft es, ob der Vorgang bereits abgeschlossen ist; ist dies der Fall, setzt es die Ausführung (synchron) fort. Andernfalls hält es die Methode async
an und gibt eine unvollständige Aufgabe zurück. Wenn dieser Vorgang einige Zeit später abgeschlossen ist, wird die Methode async
wieder ausgeführt.
Du kannst dir eine async
Methode so vorstellen, dass sie mehrere synchrone Teile hat, die durch await
Anweisungen unterbrochen werden. Der erste synchrone Teil wird in dem Thread ausgeführt, der die Methode aufruft, aber wo werden die anderen synchronen Teile ausgeführt? Die Antwort ist ein bisschen kompliziert.
Wenn du await
eine Aufgabe (das häufigste Szenario), wird ein Kontext erfasst, wenn der await
beschließt, die Methode anzuhalten. Das ist der aktuelle SynchronizationContext
, es sei denn, es handelt sich um null
, in diesem Fall ist der Kontext der aktuelle TaskScheduler
. Die Methode wird innerhalb des erfassten Kontexts weiter ausgeführt. Normalerweise ist dieser Kontext der UI-Kontext (wenn du dich im UI-Thread befindest) oder der Threadpool-Kontext (in den meisten anderen Situationen). Wenn du eine ASP.NET Classic (pre-Core) Anwendung hast, kann der Kontext auch ein ASP.NET Anfragekontext sein. ASP.NET Core verwendet den Threadpool-Kontext und nicht einen speziellen Anfragekontext.
Im obigen Code versuchen also alle synchronen Teile, im ursprünglichen Kontext fortzufahren. Wenn du DoSomethingAsync
von einem UI-Thread aus aufrufst, wird jeder synchrone Teil in diesem UI-Thread ausgeführt; rufst du ihn jedoch von einem Threadpool-Thread aus auf, wird jeder synchrone Teil in einem beliebigen Threadpool-Thread ausgeführt.
Du kannst dieses Standardverhalten vermeiden, indem du das Ergebnis der ConfigureAwait
Erweiterungsmethode abwartest und false
als continueOnCapturedContext
Parameter übergibst. Der folgende Code wird auf dem aufrufenden Thread gestartet und nachdem er durch einen await
angehalten wurde, wird er auf einem Threadpool-Thread fortgesetzt:
async
Task
DoSomethingAsync
()
{
int
value
=
13
;
// Asynchronously wait 1 second.
await
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
1
)).
ConfigureAwait
(
false
);
value
*=
2
;
// Asynchronously wait 1 second.
await
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
1
)).
ConfigureAwait
(
false
);
Trace
.
WriteLine
(
value
);
}
Tipp
Es ist eine gute Praxis, ConfigureAwait
immer in deinen zentralen "Bibliotheks"-Methoden aufzurufen und den Kontext nur dann wieder aufzunehmen, wenn du ihn brauchst - in deinen äußeren "Benutzeroberflächen"-Methoden.
Das await
Schlüsselwort ist nicht auf die Arbeit mit Tasks beschränkt; es kann mit jeder Art von erwartbaren Ergebnissen arbeiten, die einem bestimmten Muster folgen. Als Beispiel enthält die Base Class Library den Typ ValueTask<T>
, der die Speicherzuweisungen reduziert, wenn das Ergebnis in der Regel synchron ist, z. B. wenn das Ergebnis aus einem In-Memory-Cache gelesen werden kann. ValueTask<T>
ist nicht direkt in Task<T>
konvertierbar, aber es folgt dem awaitable-Muster, so dass du es direkt await
kannst. Es gibt noch weitere Beispiele, und du kannst deine eigenen erstellen, aber in den meisten Fällen wird await
ein Task
oder Task<TResult>
verwenden.
gibt es zwei grundlegende Möglichkeiten, eine Task
Instanz zu erstellen. Einige Aufgaben stellen tatsächlichen Code dar, den eine CPU ausführen muss; diese Rechenaufgaben sollten durch den Aufruf von Task.Run
(oder TaskFactory.StartNew
, wenn sie mit einem bestimmten Zeitplannungsprogramm ausgeführt werden sollen) erstellt werden. Andere Aufgaben stellen eine Benachrichtigung dar; diese Arten von ereignisbasierten Aufgaben werden durch TaskCompletionSource<TResult>
(oder eine seiner Abkürzungen) erstellt. Die meisten E/A-Aufgaben verwenden TaskCompletionSource<TResult>
.
Die Behandlung von Fehlern ist natürlich mit async
und await
möglich. In dem folgenden Codeschnipsel kann PossibleExceptionAsync
eine NotSupportedException
auslösen, aber TrySomethingAsync
kann die Ausnahme natürlich abfangen. Der Stack-Trace der abgefangenen Ausnahme bleibt erhalten und wird nicht künstlich in eine TargetInvocationException
oder AggregateException
eingepackt:
async
Task
TrySomethingAsync
()
{
try
{
await
PossibleExceptionAsync
();
}
catch
(
NotSupportedException
ex
)
{
LogException
(
ex
);
throw
;
}
}
Wenn eine async
Methode eine Ausnahme auslöst (oder weitergibt), wird die Ausnahme auf der zurückgegebenen Task
platziert und die Task
wird abgeschlossen. Wenn die Methode Task
erwartet wird, holt der Operator await
die Ausnahme zurück und wirft sie so, dass der ursprüngliche Stack-Trace erhalten bleibt. Code wie das folgende Beispiel würde also wie erwartet funktionieren, wenn PossibleExceptionAsync
eine async
Methode wäre:
async
Task
TrySomethingAsync
()
{
// The exception will end up on the Task, not thrown directly.
Task
task
=
PossibleExceptionAsync
();
try
{
// The Task's exception will be raised here, at the await.
await
task
;
}
catch
(
NotSupportedException
ex
)
{
LogException
(
ex
);
throw
;
}
}
Es gibt noch eine weitere wichtige Richtlinie, wenn es um async
Methoden geht: Wenn du anfängst, async
zu verwenden, ist es am besten, wenn du sie in deinem Code wachsen lässt. Wenn du eine async
Methode aufrufst, solltest du (irgendwann) await
die Aufgabe, die sie zurückgibt. Widerstehe der Versuchung, Task.Wait
, Task<TResult>.Result
oder GetAwaiter().GetResult()
aufzurufen, denn das könnte zu einem Deadlock führen. Betrachte die folgende Methode:
async
Task
WaitAsync
()
{
// This await will capture the current context ...
await
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
1
));
// ... and will attempt to resume the method here in that context.
}
void
Deadlock
()
{
// Start the delay.
Task
task
=
WaitAsync
();
// Synchronously block, waiting for the async method to complete.
task
.
Wait
();
}
Der Code in diesem Beispiel würde in eine Sackgasse geraten, wenn er von einem UI- oder ASP.NET Classic-Kontext aus aufgerufen würde, da beide Kontexte jeweils nur einen Thread zulassen. Deadlock
ruft WaitAsync
auf, wodurch die Verzögerung beginnt. Deadlock
wartet dann (synchron) auf die Beendigung der Methode und blockiert den Thread des Kontexts. Wenn die Verzögerung beendet ist, versucht await
, WaitAsync
innerhalb des eingefangenen Kontexts fortzusetzen, was aber nicht möglich ist, weil bereits ein Thread im Kontext blockiert ist und der Kontext nur einen Thread zur gleichen Zeit zulässt. Deadlock kann auf zwei Arten verhindert werden: Du kannst ConfigureAwait(false)
innerhalb von WaitAsync
verwenden (was dazu führt, dass await
seinen Kontext ignoriert), oder du kannst await
den Aufruf von WaitAsync
(wodurch Deadlock
zu einer async
Methode wird).
Warnung
Wenn du async
verwendest, ist es am besten, async
ganz zu verwenden.
Für eine umfassendere Einführung in async
, ist die Online-Dokumentation, die Microsoft für async
bereitgestellt hat, fantastisch; ich empfehle, zumindest den Überblick über die asynchrone Programmierung und den Überblick über das Task-based Asynchronous Pattern (TAP) zu lesen. Wenn du etwas tiefer einsteigen willst, gibt es auch die Dokumentation Async in Depth.
Asynchrone Streams bauen auf den Grundlagen von async
und await
auf und erweitern sie, um mehrere Werte zu verarbeiten. Asynchrone Streams basieren auf dem Konzept der asynchronen Enumerables, die wie normale Enumerables funktionieren, nur dass sie asynchrone Arbeit beim Abrufen des nächsten Elements in der Sequenz ermöglichen. Dies ist ein äußerst leistungsfähiges Konzept, das in Kapitel 3 näher erläutert wird. Asynchrone Streams sind besonders nützlich, wenn du eine Reihe von Daten hast, die entweder einzeln oder in Stücken ankommen. Wenn deine Anwendung zum Beispiel die Antwort einer API verarbeitet, die Paging mit den Parametern limit
und offset
verwendet, dann sind asynchrone Streams eine ideale Abstraktion. Zum Zeitpunkt der Erstellung dieses Artikels sind asynchrone Streams nur auf den neuesten .NET Plattformen verfügbar.
Einführung in die parallele Programmierung
Die parallele Programmierung sollte immer dann eingesetzt werden, wenn du eine größere Menge an Rechenarbeit hast, die in unabhängige Teile aufgeteilt werden kann. Parallele Programmierung erhöht vorübergehend die CPU-Auslastung, um den Durchsatz zu erhöhen. Das ist auf Client-Systemen wünschenswert, wo die CPUs oft im Leerlauf sind, aber für Serversysteme ist es normalerweise nicht geeignet. Die meisten Server haben ein gewisses Maß an Parallelität eingebaut; ASP.NET verarbeitet zum Beispiel mehrere Anfragen parallel. Das Schreiben von parallelem Code auf dem Server kann in manchen Situationen trotzdem sinnvoll sein (wenn du weißt, dass die Anzahl der gleichzeitigen Nutzer/innen immer gering sein wird), aber im Allgemeinen würde die parallele Programmierung auf dem Server der eingebauten Parallelität zuwiderlaufen und daher keinen wirklichen Nutzen bringen.
Es gibt zwei Formen der Parallelität: Datenparallelität und Aufgabenparallelität. Von Datenparallelität spricht man, wenn du eine Reihe von Daten zu verarbeiten hast und die Verarbeitung der einzelnen Daten weitgehend unabhängig von den anderen Daten ist. Von Aufgabenparallelität spricht man, wenn du einen Pool von Aufgaben zu erledigen hast, und jede Aufgabe weitgehend unabhängig von den anderen Aufgaben ist. Aufgabenparallelität kann dynamisch sein: Wenn eine Aufgabe mehrere zusätzliche Aufgaben nach sich zieht, können diese der Arbeitsgruppe hinzugefügt werden.
gibt es verschiedene Möglichkeiten, Daten zu parallelisieren. Parallel.ForEach
ähnelt einer foreach
Schleife und sollte, wenn möglich, verwendet werden. Parallel.ForEach
wird in Rezept 4.1 behandelt. Die Klasse Parallel
unterstützt auch Parallel.For
, die einer for
Schleife ähnelt und verwendet werden kann, wenn die Datenverarbeitung vom Index abhängt. Ein Code, der Parallel.ForEach
verwendet, sieht wie folgt aus:
void
RotateMatrices
(
IEnumerable
<
Matrix
>
matrices
,
float
degrees
)
{
Parallel
.
ForEach
(
matrices
,
matrix
=>
matrix
.
Rotate
(
degrees
));
}
Eine weitere Option ist PLINQ (Parallel LINQ), die eine AsParallel
Erweiterungsmethode für LINQ-Abfragen bietet. Parallel
ist ressourcenfreundlicher als PLINQ; Parallel
spielt besser mit anderen Prozessen im System zusammen, während PLINQ (standardmäßig) versucht, sich auf alle CPUs zu verteilen. Der Nachteil von Parallel
ist, dass es expliziter ist; PLINQ hat in vielen Fällen einen eleganteren Code. PLINQ wird in Rezept 4.5 beschrieben und sieht wie folgt aus:
IEnumerable
<
bool
>
PrimalityTest
(
IEnumerable
<
int
>
values
)
{
return
values
.
AsParallel
().
Select
(
value
=>
IsPrime
(
value
));
}
Unabhängig davon, welche Methode du wählst, gibt es eine wichtige Richtlinie für die Parallelverarbeitung.
Tipp
Die Arbeitsabschnitte sollten so unabhängig wie möglich voneinander sein.
Solange dein Arbeitsabschnitt unabhängig von allen anderen Abschnitten ist, maximierst du deine Parallelität. Sobald du anfängst, den Zustand zwischen mehreren Threads zu teilen, musst du den Zugriff auf diesen gemeinsamen Zustand synchronisieren, und deine Anwendung wird weniger parallel. In Kapitel 12 wird die Synchronisierung ausführlicher behandelt.
Die Ausgabe deiner Parallelverarbeitung kann auf verschiedene Weise gehandhabt werden. Du kannst die Ergebnisse in einer Art gleichzeitiger Sammlung ablegen oder die Ergebnisse in einer Zusammenfassung zusammenfassen. Die Aggregation ist in der Parallelverarbeitung weit verbreitet; diese Art von Map/Reduce-Funktionalität wird auch von den Methodenüberladungen der Klasse Parallel
unterstützt. In Rezept 4.2 wird die Aggregation ausführlicher beschrieben.
Jetzt wenden wir uns der Aufgabenparallelität zu. Bei der Datenparallelität geht es um die Verarbeitung von Daten; bei der Aufgabenparallelität geht es nur um die Ausführung von Arbeit. Auf einer hohen Ebene sind Datenparallelität und Aufgabenparallelität ähnlich; "Datenverarbeitung" ist eine Art von "Arbeit". Viele Parallelitätsprobleme können auf beide Arten gelöst werden; es ist praktisch, die API zu verwenden, die für das jeweilige Problem am besten geeignet ist.
Parallel.Invoke
ist eine Art von Parallel
Methode, die eine Art Fork/Join-Aufgabenparallelisierung durchführt. Diese Methode wird in Rezept 4.3 behandelt; du gibst einfach die Delegierten an, die du parallel ausführen willst:
void
ProcessArray
(
double
[]
array
)
{
Parallel
.
Invoke
(
()
=>
ProcessPartialArray
(
array
,
0
,
array
.
Length
/
2
),
()
=>
ProcessPartialArray
(
array
,
array
.
Length
/
2
,
array
.
Length
)
);
}
void
ProcessPartialArray
(
double
[]
array
,
int
begin
,
int
end
)
{
// CPU-intensive processing...
}
Der Typ Task
wurde ursprünglich für die Task-Parallelität eingeführt, wird aber heutzutage auch für die asynchrone Programmierung verwendet. Eine Task
Instanz - wie sie bei der Aufgabenparallelität verwendet wird - repräsentiert eine Arbeit. Mit der Methode Wait
kannst du auf den Abschluss einer Aufgabe warten, und mit den Eigenschaften Result
und Exception
kannst du die Ergebnisse dieser Arbeit abrufen. Code, der Task
direkt verwendet, ist komplexer als Code, der Parallel
verwendet, aber er kann nützlich sein, wenn du die Struktur der Parallelität erst zur Laufzeit kennst. Bei dieser Art von dynamischer Parallelität ( ) weißt du zu Beginn der Verarbeitung nicht, wie viele Arbeitsschritte du ausführen musst; du findest es erst im Laufe der Verarbeitung heraus. Im Allgemeinen sollte ein dynamischer Teil der Arbeit alle benötigten untergeordneten Aufgaben starten und dann warten, bis sie abgeschlossen sind. Der Typ Task
hat ein spezielles Flag, TaskCreationOptions.AttachedToParent
, das du dafür verwenden kannst. Dynamische Parallelität wird in Rezept 4.4 behandelt.
Die Aufgabenparallelität sollte genau wie die Datenparallelität unabhängig sein. Je unabhängiger deine Delegierten sein können, desto effizienter kann dein Programm sein. Wenn deine Delegierten nicht unabhängig sind, müssen sie synchronisiert werden, und es ist schwieriger, korrekten Code zu schreiben, wenn dieser Code synchronisiert werden muss. Bei der Aufgabenparallelität solltest du besonders vorsichtig mit Variablen sein, die in Closures erfasst werden. Erinnere dich daran, dass Closures Referenzen (und nicht Werte) erfassen, so dass du am Ende eine gemeinsame Nutzung haben kannst, die nicht offensichtlich ist.
Die Fehler behandlung ist bei allen Arten der Parallelität ähnlich. Da die Operationen parallel ablaufen, ist es möglich, dass mehrere Ausnahmen auftreten. Diese werden in einer AggregateException
zusammengefasst und an deinen Code weitergegeben. Dieses Verhalten ist bei Parallel.ForEach
, Parallel.Invoke
, Task.Wait
usw. gleich. Der Typ AggregateException
hat einige nützliche Flatten
und Handle
Methoden, die die Fehlerbehandlung vereinfachen:
try
{
Parallel
.
Invoke
(()
=>
{
throw
new
Exception
();
},
()
=>
{
throw
new
Exception
();
});
}
catch
(
AggregateException
ex
)
{
ex
.
Handle
(
exception
=>
{
Trace
.
WriteLine
(
exception
);
return
true
;
// "handled"
});
}
Normalerweise musst du dir auf keine Gedanken darüber machen, wie die Arbeit vom Thread-Pool erledigt wird. Daten- und Aufgabenparallelität nutzen dynamisch anpassbare Partitionierer, um die Arbeit auf die Worker-Threads zu verteilen. Der Thread-Pool erhöht seine Thread-Anzahl je nach Bedarf. Der Threadpool hat eine einzige Arbeitswarteschlange, und jeder Threadpool-Thread hat auch seine eigene Arbeitswarteschlange. Wenn ein Threadpool-Thread zusätzliche Arbeit in die Warteschlange stellt, schickt er sie zuerst an seine eigene Warteschlange, da die Arbeit in der Regel mit dem aktuellen Workitem zusammenhängt; dieses Verhalten ermutigt die Threads, an ihrer eigenen Arbeit zu arbeiten, und maximiert die Cache-Treffer. Wenn ein anderer Thread keine Arbeit zu erledigen hat, stiehlt er die Arbeit aus der Warteschlange eines anderen Threads. Microsoft hat viel Arbeit in den Thread-Pool gesteckt, um ihn so effizient wie möglich zu machen, und es gibt viele Regler, an denen du drehen kannst, wenn du maximale Leistung brauchst. Solange deine Aufgaben nicht extrem kurz sind, sollten sie mit den Standardeinstellungen gut funktionieren.
Tipp
Die Aufgaben sollten weder extrem kurz, noch extrem lang sein.
Wenn deine Aufgaben zu kurz sind, wird der Aufwand für die Aufteilung der Daten in Aufgaben und die Planung dieser Aufgaben im Thread-Pool erheblich. Wenn deine Aufgaben zu lang sind, kann der Thread-Pool seine Arbeitsverteilung nicht dynamisch und effizient anpassen. Es ist schwierig zu bestimmen, wie kurz zu kurz und wie lang zu lang ist; es hängt wirklich von dem zu lösenden Problem und den ungefähren Fähigkeiten der Hardware ab. Generell versuche ich, meine Aufgaben so kurz wie möglich zu machen, ohne dass es zu Leistungsproblemen kommt (du wirst feststellen, dass deine Leistung plötzlich abnimmt, wenn deine Aufgaben zu kurz sind). Noch besser ist es, wenn du anstelle von direkten Aufgaben den Typ Parallel
oder PLINQ verwendest. Diese übergeordneten Formen der Parallelität haben eine integrierte Partitionierung, die dies automatisch für dich erledigt (und zur Laufzeit bei Bedarf angepasst wird).
Wenn du tiefer in die parallele Programmierung eintauchen willst, ist das beste Buch zu diesem Thema Parallel Programming with Microsoft .NET, von Colin Campbell et al. (Microsoft Press).
Einführung in die reaktive Programmierung (Rx)
Die reaktive Programmierung hat eine höhere Lernkurve als andere Formen der Gleichzeitigkeit, und der Code kann schwieriger zu warten sein, wenn du deine reaktiven Fähigkeiten nicht aufrechterhältst. Wenn du bereit bist, sie zu lernen, ist die reaktive Programmierung jedoch extrem leistungsfähig. Reaktive Programmierung ermöglicht es dir, einen Strom von Ereignissen wie einen Strom von Daten zu behandeln. Als Faustregel gilt: Wenn du eines der Ereignisargumente verwendest, die an ein Ereignis übergeben werden, sollte dein Code System.Reactive anstelle eines normalen Event-Handlers verwenden.
Tipp
System.Reactive wurde früher Reactive Extensions genannt, was oft mit "Rx" abgekürzt wurde. Alle drei Begriffe beziehen sich auf dieselbe Technologie.
Die reaktive Programmierung basiert auf dem Konzept der beobachtbaren Streams. Wenn du einen beobachtbaren Stream abonnierst, erhältst du eine beliebige Anzahl von Datenelementen (OnNext
), und dann kann der Stream mit einem einzigen Fehler (OnError
) oder einer "Ende des Streams"-Benachrichtigung (OnCompleted
) enden. Einige beobachtbare Streams enden nie. Die eigentlichen Schnittstellen sehen wie folgt aus:
interface
IObserver
<
in
T
>
{
void
OnNext
(
T
item
);
void
OnCompleted
();
void
OnError
(
Exception
error
);
}
interface
IObservable
<
out
T
>
{
IDisposable
Subscribe
(
IObserver
<
TResult
>
observer
);
}
solltest du diese Schnittstellen jedoch nie implementieren. Die System.Reactive (Rx) Bibliothek von Microsoft enthält alle Implementierungen, die du jemals brauchen wirst. Reaktiver Code sieht LINQ sehr ähnlich; du kannst ihn dir als "LINQ to Events" vorstellen. System.Reactive hat alles, was LINQ hat, und fügt eine große Anzahl eigener Operatoren hinzu, insbesondere solche, die mit der Zeit zu tun haben. Der folgende Code beginnt mit einigen unbekannten Operatoren (Interval
und Timestamp
) und endet mit einem Subscribe
, aber in der Mitte befinden sich einige Where
und Select
Operatoren, die dir von LINQ vertraut sein sollten:
Observable
.
Interval
(
TimeSpan
.
FromSeconds
(
1
))
.
Timestamp
()
.
Where
(
x
=>
x
.
Value
%
2
==
0
)
.
Select
(
x
=>
x
.
Timestamp
)
.
Subscribe
(
x
=>
Trace
.
WriteLine
(
x
));
Der Beispielcode beginnt mit einem Zähler, der über einen periodischen Timer läuft (Interval
) und fügt jedem Ereignis einen Zeitstempel hinzu (Timestamp
). Dann filtert er die Ereignisse so, dass nur gerade Zählerwerte enthalten sind (Where
), wählt die Zeitstempelwerte aus (Timestamp
) und schreibt sie dann, sobald die einzelnen Zeitstempelwerte eintreffen, in den Debugger (Subscribe
). Mach dir keine Sorgen, wenn du die neuen Operatoren wie Interval
nicht verstehst: Sie werden später in diesem Buch behandelt. Für den Moment solltest du dir einfach vor Augen halten, dass diese LINQ-Abfrage den Abfragen, die du bereits kennst, sehr ähnlich ist. Der Hauptunterschied besteht darin, dass LINQ to Objects und LINQ to Entities ein "Pull"-Modell verwenden, bei dem die Aufzählung einer LINQ-Abfrage die Daten durch die Abfrage zieht, während LINQ to Events (System.Reactive) ein "Push"-Modell verwendet, bei dem die Ereignisse ankommen und von selbst durch die Abfrage wandern.
Die Definition eines beobachtbaren Streams ist unabhängig von seinen Abonnements. Das letzte Beispiel ist dasselbe wie der folgende Code:
IObservable
<
DateTimeOffset
>
timestamps
=
Observable
.
Interval
(
TimeSpan
.
FromSeconds
(
1
))
.
Timestamp
()
.
Where
(
x
=>
x
.
Value
%
2
==
0
)
.
Select
(
x
=>
x
.
Timestamp
);
timestamps
.
Subscribe
(
x
=>
Trace
.
WriteLine
(
x
));
Normalerweise definiert ein Typ die beobachtbaren Streams und stellt sie als IObservable<TResult>
Ressource zur Verfügung. Andere Typen können dann diese Streams abonnieren oder sie mit anderen Operatoren kombinieren, um einen weiteren beobachtbaren Stream zu erstellen.
Ein System.Reactive Abonnement ist auch eine Ressource. Die Subscribe
Operatoren geben ein IDisposable
zurück, das das Abonnement darstellt. Wenn dein Code mit dem Abhören eines beobachtbaren Streams fertig ist, sollte er das Abonnement entsorgen.
Abonnements verhält sich bei heißen und kalten Observablen unterschiedlich. Ein Hot Observable ist ein Strom von Ereignissen, der immer weiterläuft, und wenn es keine Abonnenten gibt, wenn die Ereignisse eintreffen, gehen sie verloren. Eine Mausbewegung ist zum Beispiel eine Hot Observable. Ein Cold Observable ist ein Observable, bei dem nicht ständig Ereignisse eintreffen. Eine kalte Observable reagiert auf ein Abonnement, indem sie die Abfolge der Ereignisse startet. Ein HTTP-Download ist zum Beispiel eine Cold Observable; das Abonnement bewirkt, dass die HTTP-Anfrage gesendet wird.
Der Subscribe
Operator sollte immer auch einen Fehlerbehandlungsparameter haben. In den vorangegangenen Beispielen ist das nicht der Fall; das folgende Beispiel ist besser und reagiert angemessen, wenn der beobachtbare Stream in einem Fehler endet:
Observable
.
Interval
(
TimeSpan
.
FromSeconds
(
1
))
.
Timestamp
()
.
Where
(
x
=>
x
.
Value
%
2
==
0
)
.
Select
(
x
=>
x
.
Timestamp
)
.
Subscribe
(
x
=>
Trace
.
WriteLine
(
x
),
ex
=>
Trace
.
WriteLine
(
ex
));
Subject<TResult>
ist ein Typ, der beim Experimentieren mit System.Reactive nützlich ist. Dieses "Subjekt" ist wie eine manuelle Implementierung eines beobachtbaren Streams. Dein Code kann OnNext
, OnError
und OnCompleted
aufrufen, und das Subjekt leitet diese Aufrufe an seine Abonnenten weiter. Subject<TResult>
eignet sich hervorragend zum Experimentieren, aber im produktiven Code solltest du dich bemühen, Operatoren wie die in Kapitel 6 behandelten zu verwenden.
gibt es viele nützliche System.Reactive-Operatoren, von denen ich in diesem Buch nur ein paar ausgewählte behandle. Für weitere Informationen über System.Reactive empfehle ich das ausgezeichnete Online-Buch Introduction to Rx.
Einführung in Datenflüsse
TPL Dataflow ist eine interessante Mischung aus asynchronen und parallelen Technologien. Er ist nützlich, wenn du eine Reihe von Prozessen auf deine Daten anwenden musst. Du musst zum Beispiel Daten von einer URL herunterladen, sie parsen und dann parallel zu anderen Daten verarbeiten. TPL Dataflow wird üblicherweise als einfache Pipeline verwendet, bei der die Daten an einem Ende einlaufen und am anderen Ende wieder herauskommen. TPL Dataflow ist jedoch viel leistungsfähiger als das; es kann jede Art von Mesh verarbeiten. Du kannst Verzweigungen, Verknüpfungen und Schleifen in einem Netz definieren, und TPL Dataflow wird sie entsprechend behandeln. In den meisten Fällen werden TPL Dataflow-Maschen jedoch als Pipeline verwendet.
Die Grundeinheit eines Datenflussnetzes ist ein Datenflussblock. Ein Block kann entweder ein Zielblock (der Daten empfängt), ein Quellblock (der Daten produziert) oder beides sein. Quellblöcke können mit Zielblöcken verknüpft werden, um das Netz zu erstellen; die Verknüpfung wird in Rezept 5.1 behandelt. Blöcke sind halb-unabhängig; sie versuchen, die Daten zu verarbeiten, sobald sie ankommen, und die Ergebnisse weiterzuleiten. Die übliche Art, TPL Dataflow zu verwenden, besteht darin, alle Blöcke zu erstellen, sie miteinander zu verknüpfen und dann an einem Ende mit der Eingabe von Daten zu beginnen. Die Daten kommen dann am anderen Ende von selbst wieder heraus. Auch hier ist Dataflow viel leistungsfähiger. Es ist möglich, Verbindungen zu unterbrechen, neue Blöcke zu erstellen und sie dem Netz hinzuzufügen , während Daten durch das Netz fließen, aber das ist ein sehr fortgeschrittenes Szenario.
Die Zielblöcke von haben Puffer für die Daten, die sie empfangen. Dank der Puffer können sie neue Daten auch dann annehmen, wenn sie noch nicht bereit sind, sie zu verarbeiten; so bleiben die Daten im Netz im Fluss. Diese Pufferung kann in Fork-Szenarien, in denen ein Quellblock mit zwei Zielblöcken verbunden ist, Probleme verursachen. Wenn der Quellblock Daten nach unten zu senden hat, bietet er sie den verknüpften Blöcken nacheinander an. In der Standardeinstellung würde der erste Zielblock die Daten nur annehmen und zwischenspeichern, während der zweite Zielblock keine Daten erhalten würde. Die Lösung für diese Situation besteht darin, die Puffer der Zielblöcke zu begrenzen, indem man sie zu nicht-lässigen Blöcken macht; Rezept 5.4 behandelt dies.
Ein Block wird fehlerhaft, wenn etwas schief läuft, z. B. wenn der Verarbeitungsdelegierte bei der Verarbeitung eines Datenelements eine Ausnahme auslöst. Wenn ein Block einen Fehler hat, wird er keine Daten mehr empfangen. In der Standardeinstellung wird nicht das gesamte Netz heruntergefahren; so kannst du diesen Teil des Netzes neu aufbauen oder die Daten umleiten. In den meisten Fällen möchtest du, dass sich die Fehler entlang der Verbindungen zu den Zielblöcken ausbreiten. Dataflow unterstützt diese Option ebenfalls; der einzige Haken an der Sache ist, dass eine Exception, die sich entlang eines Links ausbreitet, in eine AggregateException
eingewickelt wird. Wenn du also eine lange Pipeline hast, kann es passieren, dass du eine tief verschachtelte Exception bekommst; mit der Methode AggregateException.Flatten
kannst du dies umgehen:
try
{
var
multiplyBlock
=
new
TransformBlock
<
int
,
int
>(
item
=>
{
if
(
item
==
1
)
throw
new
InvalidOperationException
(
"Blech."
);
return
item
*
2
;
});
var
subtractBlock
=
new
TransformBlock
<
int
,
int
>(
item
=>
item
-
2
);
multiplyBlock
.
LinkTo
(
subtractBlock
,
new
DataflowLinkOptions
{
PropagateCompletion
=
true
});
multiplyBlock
.
Post
(
1
);
subtractBlock
.
Completion
.
Wait
();
}
catch
(
AggregateException
exception
)
{
AggregateException
ex
=
exception
.
Flatten
();
Trace
.
WriteLine
(
ex
.
InnerException
);
}
Rezept 5.2 behandelt die Datenfluss-Fehlerbehandlung ausführlicher.
Auf den ersten Blick klingen Datenflussnetze sehr ähnlich wie beobachtbare Datenströme, und sie haben tatsächlich viel gemeinsam. Sowohl Maschen als auch Ströme haben das Konzept, dass Datenelemente durch sie hindurchgehen. Außerdem gibt es sowohl bei Meshes als auch bei Streams den Begriff des normalen Abschlusses (eine Benachrichtigung, dass keine weiteren Daten kommen) und des fehlerhaften Abschlusses (eine Benachrichtigung, dass bei der Datenverarbeitung ein Fehler aufgetreten ist). System.Reactive (Rx) und TPL Dataflow haben jedoch nicht die gleichen Möglichkeiten. Rx-Observables sind im Allgemeinen besser als Dataflow-Blöcke, wenn es um das Timing geht. Dataflow-Blöcke sind in der Regel besser als Rx-Observables, wenn es um Parallelverarbeitung geht. Vom Konzept her funktioniert Rx eher wie das Einrichten von Rückrufen: Jeder Schritt in der Observable ruft direkt den nächsten Schritt auf. Im Gegensatz dazu ist jeder Block in einem Dataflow-Netz sehr unabhängig von allen anderen Blöcken. Sowohl Rx als auch TPL Dataflow haben ihren eigenen Nutzen, wobei es einige Überschneidungen gibt. Sie funktionieren auch sehr gut zusammen; Rezept 8.8 behandelt die Interoperabilität zwischen Rx und TPL Dataflow.
Wenn du mit Actor-Frameworks vertraut bist, scheint TPL Dataflow Ähnlichkeiten mit ihnen zu haben. Jeder Dataflow-Block ist unabhängig in dem Sinne, dass er Aufgaben auslöst, um die benötigte Arbeit zu erledigen, z. B. die Ausführung eines Transformationsdelegats oder die Weitergabe der Ausgabe an den nächsten Block. Du kannst jeden Block auch so einrichten, dass er parallel läuft und mehrere Aufgaben auslöst, um zusätzliche Eingaben zu verarbeiten. Aufgrund dieses Verhaltens hat jeder Block eine gewisse Ähnlichkeit mit einem Akteur in einem Actor-Framework. TPL Dataflow ist jedoch kein vollständiges Actor-Framework; insbesondere gibt es keine eingebaute Unterstützung für eine saubere Fehlerbehebung oder Wiederholungen jeglicher Art. TPL Dataflow ist eine Bibliothek, die sich wie ein Actor anfühlt, aber es ist kein vollwertiges Actor-Framework.
Die gebräuchlichsten TPL Dataflow-Blocktypen sind TransformBlock<TInput, TOutput>
(ähnlich dem Select
von LINQ), TransformManyBlock<TInput, TOutput>
(ähnlich dem SelectMany
von LINQ) und ActionBlock<TResult>
, die für jedes Datenelement einen Delegaten ausführen. Für mehr Informationen über TPL Dataflow empfehle ich die MSDN-Dokumentation und den "Guide to Implementing Custom TPL Dataflow Blocks".
Einführung in die Multithreading-Programmierung
Ein Thread ist ein unabhängiger Executor. In jedem Prozess gibt es mehrere Threads, und jeder dieser Threads kann gleichzeitig verschiedene Dinge tun. Jeder Thread hat seinen eigenen unabhängigen Stack, teilt sich aber denselben Speicher mit allen anderen Threads in einem Prozess. In manchen Anwendungen gibt es einen speziellen Thread. Zum Beispiel haben Benutzeroberflächenanwendungen einen speziellen UI-Thread und Konsolenanwendungen einen speziellen Hauptthread.
Jede .NET Anwendung hat einen Thread-Pool. Der Thread-Pool verwaltet eine Reihe von Worker-Threads, die darauf warten, die von dir gestellten Aufgaben auszuführen. Der Threadpool ist dafür verantwortlich, wie viele Threads sich zu einem bestimmten Zeitpunkt im Threadpool befinden. Es gibt Dutzende von Konfigurationseinstellungen, mit denen du dieses Verhalten ändern kannst, aber ich empfehle dir, es dabei zu belassen; der Thread-Pool wurde sorgfältig abgestimmt, um die große Mehrheit der realen Szenarien abzudecken.
Es ist fast nie nötig, dass du selbst einen neuen Thread erstellst. Du solltest nur dann eine Thread
Instanz erstellen, wenn du einen STA-Thread für COM-Interop benötigst.
Ein Thread ist eine niedrige Abstraktionsebene. Der Thread-Pool ist eine etwas höhere Abstraktionsebene; wenn der Code die Arbeit in den Thread-Pool einspeist, kümmert sich der Thread-Pool selbst um die Erstellung eines Threads, falls nötig. Die Abstraktionen, die in diesem Buch behandelt werden, sind noch höher: Parallele und Datenflussverarbeitungs-Warteschlangen arbeiten bei Bedarf mit dem Thread-Pool. Code, der diese höheren Abstraktionen verwendet, ist leichter zu korrigieren als Code, der Low-Level-Abstraktionen verwendet.
Aus diesem Grund werden die Typen Thread
und BackgroundWorker
in diesem Buch überhaupt nicht behandelt. Sie hatten ihre Zeit, und diese Zeit ist vorbei.
Sammlungen für gleichzeitige Anwendungen
gibt es einige Sammlungskategorien, die für die gleichzeitige Programmierung nützlich sind: gleichzeitige Sammlungen und unveränderliche Sammlungen. Diese beiden Sammlungskategorien werden in Kapitel 9 behandelt. Gleichzeitige Sammlungen ermöglichen es mehreren Threads, sie gleichzeitig auf sichere Weise zu aktualisieren. Die meisten nebenläufigen Sammlungen verwenden Snapshots, damit ein Thread die Werte aufzählen kann, während ein anderer Thread Werte hinzufügt oder entfernt. Concurrent Collections sind in der Regel effizienter, als eine reguläre Collection mit einer Sperre zu schützen.
Unveränderliche Sammlungen sind ein bisschen anders. Eine unveränderliche Sammlung kann nicht geändert werden. Um eine unveränderliche Sammlung zu ändern, musst du eine neue Sammlung erstellen, die die geänderte Sammlung darstellt. Das klingt furchtbar ineffizient, aber unveränderliche Sammlungen teilen sich so viel Speicher wie möglich zwischen den Sammlungsinstanzen, also ist es nicht so schlimm, wie es klingt. Das Schöne an unveränderlichen Sammlungen ist, dass alle Operationen rein sind, sodass sie sehr gut mit funktionalem Code funktionieren.
Modernes Design
Die meisten nebenläufigen Technologien haben einen ähnlichen Aspekt: Sie sind funktional. Mit funktional meine ich nicht "sie erledigen den Job", sondern eher einen Programmierstil, der auf der Komposition von Funktionen basiert. Wenn du eine funktionale Denkweise annimmst, werden deine nebenläufigen Designs weniger kompliziert sein.
Ein Prinzip der funktionalen Programmierung ist die Reinheit (d.h. die Vermeidung von Nebeneffekten). Jeder Teil der Lösung nimmt einen oder mehrere Werte als Eingabe und erzeugt einen oder mehrere Werte als Ausgabe. Du solltest so weit wie möglich vermeiden, dass diese Teile von globalen (oder gemeinsamen) Variablen abhängen oder globale (oder gemeinsame) Datenstrukturen aktualisieren. Das gilt unabhängig davon, ob es sich um eine async
Methode, eine parallele Aufgabe, eine System.Reactive Operation oder einen Datenflussblock handelt. Natürlich müssen deine Berechnungen früher oder später eine Auswirkung haben, aber du wirst feststellen, dass dein Code sauberer ist, wenn du die Verarbeitung mit reinen Teilen abwickelst und dann mit den Ergebnissen Updates durchführst.
Ein weiteres Prinzip der funktionalen Programmierung ist die Unveränderlichkeit. Unveränderlichkeit bedeutet, dass sich ein Teil der Daten nicht ändern kann. Ein Grund, warum unveränderliche Daten für nebenläufige Programme nützlich sind, ist die Tatsache, dass du für unveränderliche Daten keine Synchronisierung brauchst. Unveränderliche Daten helfen dir auch, Seiteneffekte zu vermeiden. Entwicklerinnen und Entwickler verwenden zunehmend unveränderliche Typen, und dieses Buch enthält mehrere Rezepte für unveränderliche Datenstrukturen.
Zusammenfassung der Schlüsseltechnologien
Das .NET-Framework unterstützt asynchrone Programmierung schon seit seinen Anfängen. Allerdings war die asynchrone Programmierung bis 2012 schwierig, als mit .NET 4.5 (zusammen mit C# 5.0 und VB 2012) die Schlüsselwörter async
und await
eingeführt wurden. In diesem Buch wird der moderne async
/await
Ansatz für alle asynchronen Rezepte verwendet, und es gibt einige Rezepte, die zeigen, wie async
und die älteren asynchronen Programmiermuster zusammenarbeiten. Wenn du Unterstützung für ältere Plattformen brauchst, sieh dir Anhang A an.
Die Task Parallel Library wurde in .NET 4.0 eingeführt und unterstützt sowohl Daten- als auch Task-Parallelität. Heutzutage ist sie sogar auf Plattformen mit weniger Ressourcen, wie z. B. Mobiltelefonen, verfügbar. Die TPL ist in .NET integriert.
Das System.Reactive Team hat hart daran gearbeitet, so viele Plattformen wie möglich zu unterstützen. System.Reactive, wie async
und await
, bietet Vorteile für alle Arten von Anwendungen, sowohl für Client- als auch für Serveranwendungen. System.Reactive ist verfügbar im System.Reactive
NuGet-Paket.
Die TPL Dataflow-Bibliothek wird offiziell mit dem NuGet-Paket für System.Threading.Tasks.Dataflow
.
Die meisten gleichzeitigen Sammlungen sind in .NET integriert; einige zusätzliche gleichzeitige Sammlungen sind im System.Threading.Channels
NuGet-Paket. Unveränderliche Sammlungen sind im System.Collections.Immutable
NuGet-Paket.
Get Concurrency in C# Cookbook, 2. Auflage now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.