Kapitel 4. Grundlagen der Parallelen
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Dieses Kapitel behandelt Muster für die parallele Programmierung. Die parallele Programmierung wird verwendet, um CPU-gebundene Arbeit aufzuteilen und auf mehrere Threads zu verteilen. Diese Rezepte für die Parallelverarbeitung berücksichtigen nur die CPU-gebundene Arbeit. Wenn du natürlich asynchrone Operationen (z. B. E/A-gebundene Arbeit) hast, die du parallel ausführen möchtest, dann lies Kapitel 2 und insbesondere Rezept 2.4.
Die in diesem Kapitel behandelten Abstraktionen der Parallelverarbeitung sind Teil der Task Parallel Library (TPL). Die TPL ist in das .NET Framework integriert.
4.1 Parallele Verarbeitung von Daten
Lösung
Der Typ Parallel
enthält eine ForEach
Methode, die speziell für dieses Problem entwickelt wurde. Das folgende Beispiel nimmt eine Sammlung von Matrizen und rotiert sie alle:
void
RotateMatrices
(
IEnumerable
<
Matrix
>
matrices
,
float
degrees
)
{
Parallel
.
ForEach
(
matrices
,
matrix
=>
matrix
.
Rotate
(
degrees
));
}
Es gibt Situationen, in denen du die Schleife vorzeitig beenden möchtest, zum Beispiel, wenn du auf einen ungültigen Wert stößt. Das folgende Beispiel invertiert jede Matrix, aber wenn eine ungültige Matrix gefunden wird, bricht es die Schleife ab:
void
InvertMatrices
(
IEnumerable
<
Matrix
>
matrices
)
{
Parallel
.
ForEach
(
matrices
,
(
matrix
,
state
)
=>
{
if
(!
matrix
.
IsInvertible
)
state
.
Stop
();
else
matrix
.
Invert
();
});
}
Dieser Code verwendet ParallelLoopState.Stop
, um die Schleife anzuhalten und weitere Aufrufe des Schleifenkörpers zu verhindern. Bedenke, dass es sich um eine parallele Schleife handelt, so dass bereits andere Aufrufe des Schleifenkörpers laufen können, einschließlich Aufrufen für Elemente nach dem aktuellen Element. Wenn in diesem Codebeispiel die dritte Matrix nicht invertierbar ist, wird die Schleife angehalten und es werden keine neuen Matrizen verarbeitet, aber andere Matrizen (wie die vierte und fünfte) können bereits verarbeitet werden.
Eine häufigere Situation ist, dass du eine parallele Schleife abbrechen möchtest. Das ist etwas anderes als das Anhalten der Schleife; eine Schleife wird innerhalb der Schleife angehalten und von außerhalb der Schleife abgebrochen. Um ein Beispiel zu zeigen, kann eine Abbrechen-Schaltfläche eine CancellationTokenSource
abbrechen und damit eine parallele Schleife wie in diesem Codebeispiel abbrechen:
void
RotateMatrices
(
IEnumerable
<
Matrix
>
matrices
,
float
degrees
,
CancellationToken
token
)
{
Parallel
.
ForEach
(
matrices
,
new
ParallelOptions
{
CancellationToken
=
token
},
matrix
=>
matrix
.
Rotate
(
degrees
));
}
Eine Sache, die du beachten musst, ist, dass jede parallele Aufgabe auf einem anderen Thread laufen kann, daher muss jeder gemeinsame Status geschützt werden. Das folgende Beispiel invertiert jede Matrix und zählt die Anzahl der Matrizen, die nicht invertiert werden konnten:
// Note: this is not the most efficient implementation.
// This is just an example of using a lock to protect shared state.
int
InvertMatrices
(
IEnumerable
<
Matrix
>
matrices
)
{
object
mutex
=
new
object
();
int
nonInvertibleCount
=
0
;
Parallel
.
ForEach
(
matrices
,
matrix
=>
{
if
(
matrix
.
IsInvertible
)
{
matrix
.
Invert
();
}
else
{
lock
(
mutex
)
{
++
nonInvertibleCount
;
}
}
});
return
nonInvertibleCount
;
}
Diskussion
Die Methode Parallel.ForEach
ermöglicht eine parallele Verarbeitung über eine Folge von Werten. Eine ähnliche Lösung ist Parallel LINQ (PLINQ), die viele der gleichen Möglichkeiten mit einer LINQ-ähnlichen Syntax bietet. Ein Unterschied zwischen Parallel
und PLINQ ist, dass PLINQ davon ausgeht, dass alle Kerne des Computers genutzt werden können, während Parallel
dynamisch auf sich ändernde CPU-Bedingungen reagiert.
Parallel.ForEach
ist eine parallele foreach
Schleife. Wenn du eine parallele for
Schleife brauchst, unterstützt die Klasse Parallel
auch eine Parallel.For
Methode. Parallel.For
ist besonders nützlich, wenn du mehrere Arrays mit Daten hast, die alle denselben Index haben.
Siehe auch
Rezept 4.2 behandelt das parallele Aggregieren einer Reihe von Werten, einschließlich Summen und Durchschnittswerten.
Rezept 4.5 behandelt die Grundlagen von PLINQ.
Kapitel 10 behandelt die Kündigung.
4.2 Parallele Aggregation
Lösung
Die Klasse Parallel
unterstützt die Aggregation durch das Konzept der lokalen Werte, d. h. Variablen, die lokal innerhalb einer parallelen Schleife existieren. Das bedeutet, dass der Schleifenkörper einfach direkt auf den Wert zugreifen kann, ohne dass eine Synchronisierung erforderlich ist. Wenn die Schleife bereit ist, jedes ihrer lokalen Ergebnisse zu aggregieren, tut sie dies mit dem Delegaten localFinally
. Beachte, dass der localFinally
Delegat den Zugriff auf die Variable, die das Endergebnis enthält , synchronisieren muss. Hier ist ein Beispiel für eine parallele Summe:
// Note: this is not the most efficient implementation.
// This is just an example of using a lock to protect shared state.
int
ParallelSum
(
IEnumerable
<
int
>
values
)
{
object
mutex
=
new
object
();
int
result
=
0
;
Parallel
.
ForEach
(
source
:
values
,
localInit
:
()
=>
0
,
body
:
(
item
,
state
,
localValue
)
=>
localValue
+
item
,
localFinally
:
localValue
=>
{
lock
(
mutex
)
result
+=
localValue
;
});
return
result
;
}
Parallel LINQ hat eine natürlichere Aggregationsunterstützung als die Klasse Parallel
:
int
ParallelSum
(
IEnumerable
<
int
>
values
)
{
return
values
.
AsParallel
().
Sum
();
}
OK, das war ein billiger Schuss, denn PLINQ hat eine eingebaute Unterstützung für viele gängige Operatoren (zum Beispiel Sum
). PLINQ bietet außerdem allgemeine Aggregationsunterstützung über den Operator Aggregate
:
int
ParallelSum
(
IEnumerable
<
int
>
values
)
{
return
values
.
AsParallel
().
Aggregate
(
seed
:
0
,
func
:
(
sum
,
item
)
=>
sum
+
item
);
}
Siehe auch
Rezept 4.5 behandelt die Grundlagen von PLINQ.
4.3 Parallele Aufforderung
Lösung
Die Klasse Parallel
enthält ein einfaches Invoke
Mitglied, das für dieses Szenario gedacht ist. In diesem Beispiel wird ein Array in zwei Hälften geteilt und jede Hälfte unabhängig verarbeitet:
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...
}
Du kannst auch ein Array von Delegierten an die Methode Parallel.Invoke
übergeben, wenn die Anzahl der Aufrufe erst zur Laufzeit bekannt ist:
void
DoAction20Times
(
Action
action
)
{
Action
[]
actions
=
Enumerable
.
Repeat
(
action
,
20
).
ToArray
();
Parallel
.
Invoke
(
actions
);
}
Parallel.Invoke
unterstützt die Stornierung genau wie die anderen Mitglieder der Klasse Parallel
:
void
DoAction20Times
(
Action
action
,
CancellationToken
token
)
{
Action
[]
actions
=
Enumerable
.
Repeat
(
action
,
20
).
ToArray
();
Parallel
.
Invoke
(
new
ParallelOptions
{
CancellationToken
=
token
},
actions
);
}
Diskussion
Parallel.Invoke
ist eine gute Lösung für einfache parallele Aufrufe. Beachte, dass sie nicht perfekt geeignet ist, wenn du für jedes Element der Eingabedaten eine Aktion aufrufen willst (verwende stattdessen Parallel.ForEach
) oder wenn jede Aktion eine Ausgabe erzeugt (verwende stattdessen Parallel LINQ).
Siehe auch
Rezept 4.1 behandelt Parallel.ForEach
, das für jedes Datenelement eine Aktion aufruft.
Rezept 4.5 behandelt paralleles LINQ.
4.4 Dynamische Parallelität
Lösung
Die Task Parallel Library (TPL) dreht sich um den Typ Task
. Die Klasse Parallel
und Parallel LINQ sind nur bequeme Umhüllungen des leistungsfähigen Task
. Wenn du dynamische Parallelität brauchst, ist es am einfachsten, den Typ Task
direkt zu verwenden.
Hier ist ein Beispiel, bei dem für jeden Knoten eines Binärbaums eine teure Verarbeitung durchgeführt werden muss. Die Struktur des Baums ist bis zur Laufzeit nicht bekannt, daher ist dies ein gutes Szenario für dynamische Parallelität. Die Methode Traverse
verarbeitet den aktuellen Knoten und erstellt dann zwei untergeordnete Aufgaben, eine für jeden Zweig unterhalb des Knotens (in diesem Beispiel gehe ich davon aus, dass die übergeordneten Knoten vor den untergeordneten Knoten verarbeitet werden müssen). Die Methode ProcessTree
beginnt mit der Verarbeitung, indem sie eine übergeordnete Aufgabe erstellt und wartet, bis diese abgeschlossen ist:
void
Traverse
(
Node
current
)
{
DoExpensiveActionOnNode
(
current
);
if
(
current
.
Left
!=
null
)
{
Task
.
Factory
.
StartNew
(
()
=>
Traverse
(
current
.
Left
),
CancellationToken
.
None
,
TaskCreationOptions
.
AttachedToParent
,
TaskScheduler
.
Default
);
}
if
(
current
.
Right
!=
null
)
{
Task
.
Factory
.
StartNew
(
()
=>
Traverse
(
current
.
Right
),
CancellationToken
.
None
,
TaskCreationOptions
.
AttachedToParent
,
TaskScheduler
.
Default
);
}
}
void
ProcessTree
(
Node
root
)
{
Task
task
=
Task
.
Factory
.
StartNew
(
()
=>
Traverse
(
root
),
CancellationToken
.
None
,
TaskCreationOptions
.
None
,
TaskScheduler
.
Default
);
task
.
Wait
();
}
Das AttachedToParent
Flag stellt sicher, dass das Task
für jeden Zweig mit dem Task
für den Elternknoten verknüpft wird. So entstehen Eltern-Kind-Beziehungen zwischen den Task
Instanzen, die die Eltern-Kind-Beziehungen in den Baumknoten widerspiegeln. Die übergeordneten Aufgaben führen ihren Delegierten aus und warten dann auf die Fertigstellung ihrer untergeordneten Aufgaben. Ausnahmen von untergeordneten Aufgaben werden dann von den untergeordneten Aufgaben an die übergeordnete Aufgabe weitergegeben. So kann ProcessTree
auf die Aufgaben für den gesamten Baum warten, indem es einfach Wait
für die einzelne Aufgabe Task
an der Wurzel des Baums aufruft.
Wenn du keine Eltern-Kind-Situation hast, kannst du jede Aufgabe so planen, dass sie nach einer anderen ausgeführt wird, indem du eine Aufgabenfortsetzung verwendest. Die Fortsetzung ist eine separate Aufgabe, die ausgeführt wird, wenn die ursprüngliche Aufgabe abgeschlossen ist:
Task
task
=
Task
.
Factory
.
StartNew
(
()
=>
Thread
.
Sleep
(
TimeSpan
.
FromSeconds
(
2
)),
CancellationToken
.
None
,
TaskCreationOptions
.
None
,
TaskScheduler
.
Default
);
Task
continuation
=
task
.
ContinueWith
(
t
=>
Trace
.
WriteLine
(
"Task is done"
),
CancellationToken
.
None
,
TaskContinuationOptions
.
None
,
TaskScheduler
.
Default
);
// The "t" argument to the continuation is the same as "task".
Diskussion
CancellationToken.None
und TaskScheduler.Default
werden im vorangegangenen Codebeispiel verwendet. Storno-Token werden in Rezept 10.2 behandelt und Zeitplannungsprogramme in Rezept 13.3. Es ist immer eine gute Idee, die TaskScheduler
, die von StartNew
und ContinueWith
verwendet werden, explizit anzugeben.
Diese Anordnung von Eltern- und Kindaufgaben ist bei dynamischer Parallelität üblich, aber nicht erforderlich. Es ist auch möglich, jede neue Aufgabe in einer thread-sicheren Sammlung zu speichern und dann zu warten, bis sie alle mit Task.WaitAll
abgeschlossen sind.
Warnung
Die Verwendung von Task
für die Parallelverarbeitung ist etwas völlig anderes als die Verwendung von Task
für die asynchrone Verarbeitung.
Der Typ Task
dient in der nebenläufigen Programmierung zwei Zwecken: Er kann eine parallele Aufgabe oder eine asynchrone Aufgabe sein. Parallele Aufgaben können blockierende Mitglieder verwenden, wie Task.Wait
, Task.Result
, Task.WaitAll
und Task.WaitAny
. Parallele Aufgaben verwenden häufig auch AttachedToParent
, um Eltern-Kind-Beziehungen zwischen Aufgaben zu erstellen. Parallele Aufgaben sollten mit Task.Run
oder Task.Factory.StartNew
erstellt werden.
Im Gegensatz dazu sollten asynchrone Aufgaben blockierende Mitglieder vermeiden und await
, Task.WhenAll
und Task.WhenAny
bevorzugen. Asynchrone Aufgaben sollten nicht AttachedToParent
verwenden, aber sie können eine implizite Art von Eltern-Kind-Beziehung bilden, indem sie auf eine andere Aufgabe warten.
Siehe auch
Rezept 4.3 behandelt den parallelen Aufruf einer Folge von Methoden, wenn alle Methoden zu Beginn der parallelen Arbeit bekannt sind.
4.5 Paralleles LINQ
Lösung
Die meisten Entwickler sind mit LINQ vertraut, mit dem du Pull-basierte Berechnungen über Sequenzen schreiben kannst. Parallel LINQ (PLINQ) erweitert diese LINQ-Unterstützung um die Parallelverarbeitung.
PLINQ funktioniert gut in Streaming-Szenarien, wenn du eine Folge von Eingaben hast und eine Folge von Ausgaben produzierst. Hier ist ein einfaches Beispiel, bei dem jedes Element in einer Folge mit zwei multipliziert wird (in der Realität sind die Szenarien viel rechenintensiver als eine einfache Multiplikation):
IEnumerable
<
int
>
MultiplyBy2
(
IEnumerable
<
int
>
values
)
{
return
values
.
AsParallel
().
Select
(
value
=>
value
*
2
);
}
Das Beispiel kann seine Ausgaben in beliebiger Reihenfolge erzeugen; dieses Verhalten ist der Standard für Parallel LINQ. Du kannst auch die Reihenfolge angeben, die beibehalten werden soll. Das folgende Beispiel wird weiterhin parallel verarbeitet, behält aber die ursprüngliche Reihenfolge bei:
IEnumerable
<
int
>
MultiplyBy2
(
IEnumerable
<
int
>
values
)
{
return
values
.
AsParallel
().
AsOrdered
().
Select
(
value
=>
value
*
2
);
}
Eine weitere natürliche Anwendung von Parallel LINQ ist die parallele Aggregation oder Zusammenfassung von Daten. Der folgende Code führt eine parallele Summierung durch:
int
ParallelSum
(
IEnumerable
<
int
>
values
)
{
return
values
.
AsParallel
().
Sum
();
}
Diskussion
Die Klasse Parallel
eignet sich für viele Szenarien, aber der PLINQ-Code ist einfacher, wenn es um Aggregation oder die Umwandlung einer Sequenz in eine andere geht. Bedenke, dass die Klasse Parallel
gegenüber anderen Prozessen auf dem System freundlicher ist als PLINQ; das ist besonders wichtig, wenn die Parallelverarbeitung auf einem Server stattfindet.
PLINQ bietet parallele Versionen einer Vielzahl von Operatoren, einschließlich Filterung (Where
), Projektion (Select
) und eine Vielzahl von Aggregationen, wie Sum
, Average
und die allgemeinere Aggregate
. Im Allgemeinen kannst du alles, was du mit regulärem LINQ machen kannst, auch parallel mit PLINQ machen. Das macht PLINQ zu einer guten Wahl, wenn du bereits LINQ-Code hast, der von der parallelen Ausführung von profitieren würde.
Siehe auch
InRezept 4.1 wird beschrieben, wie du die Klasse Parallel
verwendest, um Code für jedes Element in einer Sequenz auszuführen.
Rezept 10.5 beschreibt, wie du PLINQ-Abfragen abbrechen kannst.
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.