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

Problem

Du hast eine Sammlung von Daten und musst für jedes Element der Daten die gleiche Operation durchführen. Dieser Vorgang ist CPU-gebunden und kann einige Zeit dauern.

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

Problem

Unter musst du am Ende einer parallelen Operation die Ergebnisse zusammenfassen. Beispiele für das Aggregieren sind das Aufsummieren von Werten oder das Ermitteln ihres Durchschnitts.

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
  );
}

Diskussion

Wenn du bereits die Klasse Parallel verwendest, möchtest du vielleicht deren Aggregationsunterstützung nutzen. Ansonsten ist die PLINQ-Unterstützung in den meisten Fällen aussagekräftiger und hat kürzeren Code.

Siehe auch

Rezept 4.5 behandelt die Grundlagen von PLINQ.

4.3 Parallele Aufforderung

Problem

Du hast eine Reihe von Methoden, die du parallel aufrufen kannst, und diese Methoden sind (meistens) unabhängig voneinander.

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

Problem

Du hast eine komplexere parallele Situation, in der die Struktur und Anzahl der parallelen Aufgaben von Informationen abhängt, die nur zur Laufzeit bekannt sind.

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

Problem

Du musst eine Datenfolge parallel verarbeiten, um eine andere Datenfolge oder eine Zusammenfassung dieser Daten zu erstellen.

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.