Parallele Programmierung

Vergangenheit, Gegenwart und Zukunft der Parallelisierung von .NET-Anwendungen

Stephen Toub

Der Geist der vergangenen Parallelisierung

Traditionellerweise haben Entwickler mittels der direkten Threadmanipulierung versucht, schnell reagierende Clientanwendungen, parallelisierte Algorithmen und skalierbare Server zu erhalten. Die gleichen Techniken führten in der Vergangenheit jedoch zu Deadlocks, Livelocks, Sperrkonvoys, zweistufige Prozessen, Racebedingungen, Überabonnierungen und einer ganzen Reihe weiterer unerwünschter Probleme mit Anwendungen. Seit seiner Einführung hat das Microsoft .NET Framework eine unzählige Zahl an einfachen Tools für die Erstellung parallelisierter Anwendungen bereitgestellt, einschließlich eines gesamten Namespace, der diesem Zweck gewidmet ist: System.Threading. In diesem Namespace in den .NET Framework 3.5-Kernassemblys gibt es ungefähr 50 Typen (darunter Typen wie Thread, ThreadPool, Timer, Monitor, ManualResetEvent, ReaderWriterLock und Interlocked). Es kann also nicht behauptet werden, dass das .NET Framework die Threadingunterstützung vernachlässigt. Dennoch muss ich hier sagen, dass frühere Versionen des .NET Framework nicht die gesamte Unterstützung bereitgestellt hat, den Entwickler auf der ganzen Welt für die Entwicklung skalierbarer und hoch parallelisierter Anwendungen benötigen. Dies ist ein Problem, dass in .NET Framework 4 gelöst ist, und zukünftige Versionen von .NET Framework werden diesen Weg weiter gehen.

Die Frage kann gestellt werden, welchen Wert ein umfangreiches Subsystem in einer verwalteten Sprache für die Erstellung von Parallelcode hat. Parallelismus hat die Verbesserung der Leistung zum Ziel, und Entwickler, die hieran interessiert sind, sollten systemeigene Sprachen in Betracht ziehen, die robusten Zugriff auf die Hardware sowie die vollständige Kontrolle über sämtliche Bits, die Zwischenspeicherbearbeitung und integrierte Prozesse bereitstellen, richtig? Wenn dies richtig ist, mache ich mir Sorgen über den Stand der Branche. Verwaltete Sprachen wie C#, Visual Basic und F# stellen allen Entwicklern, sowohl den Sterblichen als auch den Helden mit übernatürlichen Kräften – eine sichere, produktive Umgebung bereit, in der sie schnell leistungsfähigen und effizienten Code entwickeln können. Entwicklern stehen Tausende von vorentwickelten Bibliotheksklassen zur Verfügung, zusammen mit Sprachen, die über alle modernen Dienste verfügen, die wir heute erwarten. Dennoch bieten diese Sprachen eine beeindruckende Leistung, auch für rechenintensivste Anwendungen und anspruchsvolle Fließkommaanwendungen. All dies bedeutet, dass verwaltete Sprachen und die mit diesen verbundenen Frameworks über tief greifende Unterstützung für die Entwicklung paralleler Hochleistungsanwendungen verfügen, sodass Entwickler die Vorteile der aktuellen Hardware voll nutzen können.

Ich habe immer gewusst, dass Muster eine gute Gelegenheit darstellen, mehr zu lernen. Daher beginne ich hier mit der Betrachtung eines Musters. Für das Muster der „begrenzten Parallelität“ oder der „unbegrenzten Parallelität“ stellt eines der am häufigsten benötigten Konstrukte die Parallelschleife dar. Diese soll jede unabhängige Iteration einer Schleife parallel verarbeiten. Es ist interessant zu sehen, wie die Verarbeitung mittels der einfachen Primitiven durchgeführt werden kann, die ich zuvor erwähnt habe. Daher zeige ich Ihnen hier eine einfache Implementierung einer einfachen parallelen Schleife, die in C# implementiert wird. Betrachten Sie diese typische Schleife:

for (int i=0; i<N; i++) {
  ... // Process i here
}

Wir können Threads direkt verwenden, um die Parallelisierung dieser Schleife zu bewirken, wie in Abbildung 1 gezeigt.

Abbildung 1 Parallelisierung einer For-Schleife

int lowerBound = 0, upperBound = N;
int numThreads = Environment.ProcessorCount;
int chunkSize = (upperBound - lowerBound) / numThreads;

var threads = new Thread[numThreads];
for (int t = 0; t < threads.Length; t++) {
  int start = (chunkSize * t) + lowerBound;
  int end = t < threads.Length - 1 ? start + chunkSize : upperBound;
  threads[t] = new Thread(delegate() {
    for (int i = start; i < end; i++) {
      ... // Process i here
    }
  });
}

foreach (Thread t in threads) t.Start(); // fork
foreach (Thread t in threads) t.Join();  // join

Es gibt natürlich bei diesem Parallelisierungsansatz unzählige Probleme. Wir erstellen neue Threads für die Schleife, die nicht nur Overhead hinzufügen (besonders dann, wenn die Schleife trivial für die Aufgabe ist), sondern auch zu einer erheblichen Überlastung in einem Prozess führen können, der gleichzeitig auch noch andere Aufgaben durchführt. Wir verwenden statische Partitionierung, um die Auslastung auf die Threads aufzuteilen. Dies kann zu einer erheblich ungleichmäßig verteilten Auslastung führen, wenn diese nicht gleichmäßig über den Iterationsspace hinweg verteilt wird. Darüber hinaus wird der letzte Thread mit der restlichen Auslastung belastet, wenn die Anzahl der Iterationen nicht gleichmäßig auf die Anzahl der genutzten Threads verteilt wird. Unbestritten die schlimmste Folge ist, dass der Entwickler den Code selbst erstellen muss. Jeder Algorithmus, den wir parallelisieren, erfordert ähnlichen Code – Code, der mindestens fehleranfällig ist.

Das im gezeigten Code dargestellte Problem wird größer, wenn wir berücksichtigen, dass Parallelschleifen nur ein Muster von vielen sind, die in parallelen Programmen vorhanden sind. Es ist kein gutes Programmiermodell, wenn Entwickler all diese Parallelmuster auf dieser einfachen Kodierungsstufe schreiben müssen. Die zahlreichen Entwickler auf der Welt, die massiv parallelisierte Hardware nutzen müssen, haben wenig Aussichten auf Erfolg.

Der Geist der gegenwärtigen Parallelisierung

Betrachten wir nun .NET Framework 4. Diese Version des .NET Framework wurde um zahlreiche Funktionen erweitert, die es einfacher für Entwickler machen, parallele Anwendungen zu entwickeln und sicherzustellen, dass dieser Parallelismus effektiv ausgeführt wird. Dies erstreckt sich auf mehr als parallele Schleifen, wir beginnen jedoch mit diesen.

Der Namespace System.Threading wurde in.NET Framework 4 durch einen neuen Sub-Namespace optimiert: System.Threading.Tasks. Dieser Namespace enthält einen neuen Typ namens Parallel, der eine Vielzahl statischer Methoden für die Implementierung paralleler Schleifen und strukturierter Abzweigungsmuster enthält. Betrachten Sie als Beispiel für die Nutzung die zuvor bereits gezeigte FOR-Schleife:

for (int i=0; i<N; i++) {
  ... // Process i here
}

Mittels der Klasse Parallel können Sie diese auf einfache Weise wie folgt parallelisieren:

Parallel.For(0, N, i => {
  ... // Process i here
});

In diesem Beispiel ist der Entwickler weiterhin dafür verantwortlich, die unabhängige Iteration der einzelnen Schleifen verantwortlich. Das Konstrukt Parallel.For übernimmt jedoch alle anderen Aspekte der Parallelisierung dieser Schleife. Es behandelt die dynamische Partitionierung des Eingabebereichs für alle zugrunde liegenden Threads, die an der Berechnung beteiligt sind, und minimiert gleichzeitig den Overhead für die Partitionierung, der durch Implementierungen statischer Partitionierungen verursacht würde. Es sorgt für die dynamische Auf- und Abwärtsskalierung der Anzahl der Threads, die an der Berechnung beteiligt sind, um die optimale Anzahl der Threads für eine bestimmte Auslastung zu ermitteln. (Diese ist nicht immer gleich der Anzahl der Hardwarethreads, auch wenn dies die landläufige Meinung ist.) Es stellt Funktionen für die Ausnahmebehandlung bereit, die in der einfachen Implementierung, die ich Ihnen zu Anfang gezeigt habe, nicht enthalten sind, usw. Am wichtigsten ist, dass sich Entwickler keine Gedanken mehr über die Parallelisierung auf der einfachen Betriebssystem-Abstraktionsstufe machen müssen. Sie müssen auch nicht mehr ständig spezielle Lösungen für die Partitionierung von Auslastungen, die Verteilung auf mehrere Kerne sowie die effiziente Synthese der Ergebnisse kodieren. Stattdessen können sich Entwickler auf das konzentrieren, was wichtig ist: die Geschäftslogik, wodurch die Arbeit des Entwicklers profitabel wird.

Parallel.For stellt auch Funktionen für Entwickler bereit, die die Ausführung von Schleifen detaillierter steuern müssen. Mittels einer Optionsmenge, die für die For-Methode bereitgestellt wird, können Entwickler den zugrunde liegenden Planer steuern, auf dem die Schleife ausgeführt wird. Sie können so auch die maximale Geschwindigkeit für den Parallelismus sowie das Abbruchtoken steuern, das von einer externen Entität verwendet wird, um die Schleife zum Beenden der Ausführung aufzufordern:

var options = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.For(0, N, options, i=> {
  ... // Process i here
});

Diese Anpassungsfunktion hebt eines der Ziele dieses Parallelisierungsansatzes innerhalb des .NET Framework hervor: Es für Entwickler deutlich einfacher zu machen, die Parallelisierung zu nutzen, ohne die Programmierung zu erschweren, gleichzeitig jedoch fortgeschrittenen Entwicklern all die Funktionen bereitzustellen, die sie für die Optimierung von Verarbeitung und Ausführung benötigen. Zu diesem Zweck werden zusätzliche Anpassungen unterstützt. Andere Überladungen von Parallel.For ermöglichen Entwicklern, die Schleife zu einem frühen Zeitpunkt zu verlassen:

Parallel.For(0, N, (i,loop) => {
  ... // Process i here
  if (SomeCondition()) loop.Break();
});

Es gibt weitere Überladungen, die Entwicklern die Führung durch Iterationen ermöglichen, die letzten Endes auf dem gleichen zugrunde liegenden Thread ausgeführt werden. Dies ermöglicht wesentlich effizientere Implementierungen von Algorithmen, wie z. B. Reduktionen:

static int SumComputations(int [] inputs, Func<int,int> computeFunc) {
  int total = 0;
  Parallel.For(0, inputs.Length, () => 0, (i,loop,partial)=> {
    return partial + computeFunc(inputs[i]);
  }, 
  partial => Interlocked.Add(ref total, partial));
}

Die Klasse Parallel unterstützt nicht nur Integralbereiche, sondern auch willkürliche IEnumerable<T>-Quellen, die .NET Framework-Darstellung einer aufzählbaren Sequenz: Der Code kann laufend MoveNext auf einem Enumerator aufrufen, um den nächsten Current-Wert abzurufen. Die Möglichkeit der Nutzung willkürlicher aufzählbarer Werte ermöglicht die Parallelverarbeitung willkürlicher Datensätze unabhängig von deren Darstellung im Arbeitsspeicher. Die Datenquellen können sogar auf Anfrage materialisiert und ausgelagert werden, wenn MoveNext-Aufrufe noch nicht materialisierte Abschnitte der Quelldaten erreichen:

IEnumerable<string> lines = File.ReadLines("data.txt");
Parallel.ForEach(lines, line => {
  ... // Process line here
});

Wie im Fall von Parallel.For weist auch Parallel.ForEach eine Vielzahl von Anpassungsfunktionen auf. Dabei bietet es mehr Steuerungsmöglichkeiten als Parallel.For. Mittels ForEach kann ein Entwickler beispielsweise die Partitionierung des Eingabedatensatzes anpassen. Dies erfolgt mittels eines Satzes von abstrakten Klassen für die Partitionierung, die Parallelisierungskonstrukten die Anforderung einer festen der variablen Anzahl von Partitionen ermöglichen. So kann der Partitionierer diese Partitionsabstraktionen über den Eingabedatensatz verteilen und den Partitionen Daten dynamisch oder statisch zuweisen, je nachdem.

Graph<T> graph = ...;
Partitioner<T> data = new GraphPartitioner<T>(graph);
Parallel.ForEach(data, vertex => {
  ... // Process vertex here
});

Parallel.For und Parallel.ForEach werden auf der Klasse Parallel durch eine Invoke-Methode ergänzt, die den Aufruf einer willkürlichen Anzahl von Aktionen mit dem höchsten Grad an Parallelismus akzeptiert, den das zugrunde liegende System bewältigen kann. Dieses klassische Abzweigungskonstrukt vereinfacht die Parallelisierung rekursiver Teilen-und-Herrschen-Algorithmen wie des häufig verwendeten Beispiels QuickSort:

static void QuickSort<T>(T [] data, int lower, int upper) {
  if (upper – lower < THRESHOLD) {
    Array.Sort(data, index:lower, length:upper-lower);
  }
  else {
    int pivotPos = Partition(data, lower, upper);
    Parallel.Invoke(
      () => QuickSort(data, lower, pivotPos),
      () => QuickSort(data, pivotPos, upper));
  }
}

Dies stellt einen großen Schritt nach vorne dar. Die Klasse Parallel berührt jedoch nur die Oberfläche dessen, was an Funktionen verfügbar ist. Einen der weitaus größeren Fortschritte in .NET Framework 4 im Hinblick auf die Parallelisierung stellt die Einführung von Parallel LINQ dar, das von Entwicklern auch liebevoll als PLINQ (ausgesprochen als Piii-Link) bezeichnet wird… LINQ (Language Integrated Query) wurde mit der Version 3.5 von .NET Framework eingeführt. LINQ hat zwei Funktionen. Es stellt eine Beschreibung eines Satzes von Operatoren dar, die als Methoden für die Bearbeitung von Datensätzen veröffentlicht werden. Außerdem bietet es kontextuelle Schlüsselwörter in C# und Visual Basic, um diese Abfragen direkt in der Sprache erstellen zu können. Zahlreiche der in LINQ enthaltenen Operatoren basieren auf den entsprechenden Prozessen, die in der Datenbankcommunity seit Jahren bekannt sind, darunter Select, SelectMany, Where, Join, GroupBy und ungefähr 50 weitere. Die Standard Query Operators-API von .NET Framework definiert das Muster für diese Methoden. Es definiert jedoch nicht, auf welche Datensätze genau diese Prozesse zielen oder wie diese Prozesse genau implementiert werden. Die einzelnen „LINQ-Anbieter“ implementieren dieses Muster für eine Vielzahl unterschiedlicher Datenquellen und Zielumgebungen (Arbeitsspeicherauflistungen, SQL-Datenbanken, Objektzuordnungssysteme/relationale Systeme, HPC-Server-Computingcluster, Streamingdatenquellen und mehr). Einer der am häufigsten verwendeten Anbieter ist "LINQ to Objects“. Dieser stellt sämtliche LINQ-Operatoren bereit, die auf IEnumerable<T> implementiert sind. Damit wird die Implementierung von Abfragen in C# und Visual Basic ermöglicht, wie im folgenden Ausschnitt gezeigt. Dieser Code liest Zeile für Zeile alle Daten aus einer Datei, filtert diejenigen Zeilen heraus, die das Wort „secret“ enthalten, und verschlüsselt diese. Das Endergebnis ist eine Aufzählung von Bytearrays:

IEnumerable<byte[]> encryptedLines = 
  from line in File.ReadLines("data.txt")
  where line.Contains("secret")
  select DataEncryptor.Encrypt(line);

Für rechenintensive Abfragen oder Abfragen, die eine hohe Anzahl von I/O mit langer Latenzzeit enthalten, stellt PLINQ Funktionen für die automatische Parallelisierung bereit. Dabei wird der gesamte Satz von LINQ-Operatoren implementiert, die durchgehende parallele Algorithmen nutzen. Die zuvor gezeigte Abfrage kann daher einfach parallelisiert werden, indem der Entwickler der Datenquelle „.AsParallel()“ hinzufügt:

IEnumerable<byte[]> encryptedLines = 
  from line in File.ReadLines("data.txt").AsParallel()
  where line.Contains("secret")
  select DataEncryptor.Encrypt(line);

Wie im Fall der Klasse Parallel muss sich der Entwickler aktiv für dieses Modell entscheiden, damit er gezwungen ist, die Auswirkungen der parallelen Ausführung der Berechnung zu evaluieren. Wenn diese Entscheidung getroffen wurde, übernimmt das System jedoch die einfacheren Details der tatsächlichen Parallelisierung, Partitionierung, Threaddrosselung usw. Genau wie im Fall von Parallel können auch diese PLINQ-Abfragen auf mehrere Weisen angepasst werden. Der Entwickler kann die Durchführung der Partitionierung, den tatsächlichen Umfang der Parallelisierung, die Kompromisse zwischen Synchronisierung und Latenz und mehr steuern:

IEnumerable<byte[]> encryptedLines = 
  from line in new OneAtATimePartitioner<string>(
    File.ReadLines("data.txt"))
    .AsParallel()
    .AsOrdered()
    .WithCancellation(someExternalToken)
    .WithDegreeOfParallelism(4)
    .WithMergeOptions(ParallelMergeOptions.NotBuffered)
  where line.Contains("secret")
  select DataEncryptor.Encrypt(line);

Diese leistungsfähigen und anspruchsvolleren Programmiermodelle für Schleifen und Abfragen werden auf der Basis leistungsfähiger aufgabenbasierter APIs entwickelt, die sich jedoch auf einer niedrigeren Stufe befinden. Diese APIs wurden für die Typen Task und Task<TResult> im Namespace System.Threading.Tasks entwickelt. Die Parallelschleifen und Abfragemodule sind effektiv Aufgabengeneratoren, die mittels der zugrunde liegenden Infrastruktur den entwickelten Parallelismus den Ressourcen zuweisen, die im zugrunde liegenden System verfügbar sind. Im Kern ist Task eine Darstellung einer Arbeitseinheit, oder allgemeiner ausgedrückt, einer Asynchronie-Einheit, d. h. einer Arbeitskomponente, die erzeugt und später mittels verschiedener Mittel angeschlossen wird. Task stellt die Methoden Wait, WaitAll und WaitAny bereit, die die synchrone Blockierung des Fortschritts ermöglichen, bis die Zielaufgabe (oder die Zielaufgaben) abgeschlossen ist (sind) oder bis zusätzliche Bedingungen für Überladungen dieser Methoden erfüllt wurden (z. B. ein Timeout- oder Abbruchtoken). Task unterstützt den Abruf des Abschlusses mittels der Eigenschaft IsCompleted property und allgemeiner den Abruf von Änderungen in der Lebenszyklusverarbeitung mittels der Eigenschaft Status. Unbestritten die wichtigste Komponente ist die Bereitstellung der Methoden ContinueWith, ContinueWhenAll und ContinueWhenAny. Diese ermöglichen die Erstellung von Aufgaben, die nur geplant werden, wenn bestimmte vorangehende Aufgaben abgeschlossen wurden. Diese Kontinuitätsunterstützung ermöglicht die einfache Implementierung unzähliger Szenarien, indem Abhängigkeiten zwischen Berechnungen kodiert werden können. Das System kann so die Aufgaben auf der Basis der Erfüllung dieser Abhängigkeitsbedingungen planen:

Task t1 = Task.Factory.StartNew(() => BuildProject(1));
Task t2 = Task.Factory.StartNew(() => BuildProject(2));
Task t3 = Task.Factory.StartNew(() => BuildProject(3));
Task t4 = Task.Factory.ContinueWhenAll(
  new [] { t1, t2 }, _ => BuildProject(4));
Task t5 = Task.Factory.ContinueWhenAll(
  new [] { t2, t3 }, _ => BuildProject(5));
Task t6 = Task.Factory.ContinueWhenAll(
  new [] { t4, t5 }, _ => BuildProject(6));
t6.ContinueWith(_ => Console.WriteLine("Solution build completed."));

Die von Task abgeleitete Klasse Task<TResult> ermöglicht die Übergabe der Ergebnisse aus dem abgeschlossenen Prozess, sodass .NET Framework eine „zukünftige“ Kernimplementierung bereitgestellt wird:

int SumTree<T>(Node<T> root, Func<T,int> computeFunc) {
  if (root == null) return 0;
  Task<int> left  = Task.Factory.StartNew(() => SumTree(root.Left));
  Task<int> right = Task.Factory.StartNew(() => SumTree(root.Right));
  return computeFunc(root.Data) + left.Result + right.Result;
}

Bei all diesen Modellen (Schleifen, Abfragen und Aufgaben) nutzt .NET Framework Verfahren, die Arbeit stehlen, um die effizientere Verarbeitung spezieller Auslastungen zu ermöglichen. Per Voreinstellung nutzt es ansteigende heuristische Verfahren, um die Anzahl der genutzten Threads über die Zeit zu variieren, um die optimale Verarbeitungsstufe zu ermitteln. Heuristische Verfahren sind auch in Teile dieser Komponenten integriert, sodass diese automatisch zu einer sequenziellen Verarbeitung zurückkehren, wenn das System feststellt, dass die Parallelisierung zu Verarbeitungszeiten führt, die länger als die Verarbeitungszeiten für die sequenzielle Verarbeitung sind. Wie im Fall der anderen bereits behandelten Voreinstellungen können auch diese heuristischen Verfahren außer Kraft gesetzt werden.

Task<TResult> stellt nicht nur rechengebundene Prozesse dar. Die Klasse kann auch zur Darstellung willkürlich asynchroner Prozesse verwendet werden. Betrachten Sie die Klasse .NET Framework System.IO.Stream, die die Methode Read für das Extrahieren von Daten aus dem Stream bereitstellt:

NetworkStream source = ...;
byte [] buffer = new byte[0x1000];
int numBytesRead = source.Read(buffer, 0, buffer.Length);

Dieser Read-Prozess ist synchron und blockierend, sodass der Thread, der den Read-Aufruf durchführt, nicht für andere Aufgaben verwendet werden kann, bis der I/O-basierte Read-Prozess abgeschlossen ist. Für die bessere Skalierbarkeit stellt die Klasse Stream der Methode Read ein asynchrones Gegenstück bereit. Dabei handelt es sich um zwei Methoden: BeginRead und EndRead. Diese Methoden verwenden ein Muster, das in .NET Framework seit dessen Einführung verfügbar ist. Dieses Muster ist als APM oder Asynchronous Programming Model bekannt. Im Folgenden finden Sie ein Beispiel für die asynchrone Version des eben gezeigten Codes:

NetworkStream source = …;
byte [] buffer = new byte[0x1000];
source.BeginRead(buffer, 0, buffer.Length, delegate(IAsyncResult iar) {
  int numBytesRead = source.EndRead(iar);
}, null);

Dieser Ansatz führt jedoch zu schlechter Zusammensetzbarkeit. Der Typ TaskCompletionSource<TResult> behebt dies, indem er die Veröffentlichung eines solchen asynchronen Leseprozesses als Aufgabe ermöglicht:

public static Task<int> ReadAsync(
  this Stream source, byte [] buffer, int offset, int count) 
{
  var tcs = new TaskCompletionSource<int>();
  source.BeginRead(buffer, 0, buffer.Length, iar => {
    try { tcs.SetResult(source.EndRead(iar)); }
    catch(Exception exc) { tcs.SetException(exc); }
  }, null);
  return tcs.Task;
}

Dies ermöglicht die Zusammensetzung mehrerer asynchroner Prozesse, wie im Fall der rechengebundenen Beispiele. Das folgende Beispiel liest alle Quellstreams parallel und schreibt diese erst dann in die Konsole, wenn alle Prozesse abgeschlossen wurden.

NetworkStream [] sources = ...;
byte [] buffers = ...;
Task.Factory.ContinueWhenAll(
  (from i in Enumerable.Range(0, sources.Length)
   select sources[i].ReadAsync(buffers[i], 0, buffers[i].Length))
  .ToArray(), 
  _ => Console.WriteLine("All reads completed"));

Außer Mechanismen für den Start der parallelisierten und parallelen Verarbeitung stellt .NET Framework 4 auch Primitive für die weitere Koordinierung der Auslastung zwischen Aufgaben und Threads bereit. Dazu gehört ein Satz threadsicherer und skalierbarer Auflistungstypen, die die manuelle Synchronisierung des Zugriffs auf freigegebene Auflistungen durch die Entwickler zum großen Teil überflüssig macht. ConcurrentQueue<T> stellt eine threadsichere, sperrungsfreie First-in-First-out-Auflistung bereit, die von einer unbegrenzten Anzahl von Producern und Verbrauchern parallel verwendet werden kann. Zusätzlich unterstützt er Schnappschusssemantik für parallele Enumeratoren, sodass der Code den Status der Warteschlange überprüfen kann, auch wenn andere Threads die Instanz belasten. ConcurrentStack<T> ist ähnlich, stellt jedoch Last-in-First-out-Semantik bereit. ConcurrentDictionary<T> verwendet sperrenfreie und fein granulierte Sperrverfahren, um ein threadsicheres Dictionary bereitzustellen, das eine beliebige Anzahl paralleler Reader, Writer und Enumeratoren unterstützt. Er stellt auch mehrere atomisierte Implementierungen mehrschrittiger Prozesse bereit, wie GetOrAdd und AddOrUpdate. Ein weiterer Typ namens ConcurrentBag<T> stellt eine ungeordnete Auflistung bereit, die arbeitstehlende Warteschlangen verwendet.

Das .NET Framework gibt sich mit Auflistungstypen zufrieden. Lazy<T> stellt die Lazy-Initialisierung einer Variablen bereit und verwendet hierfür konfigurierbare Ansätze für die Threadsicherheit. ThreadLocal<T> stellt thread- und instanzenbasierte Daten bereit, die beim ersten Zugriff ebenfalls mittels des Lazy-Verfahrens initialisiert werden können. Der Typ Barrier ermöglicht abgestufte Prozesse, sodass mehrere Aufgaben oder Threads mittels eines Algorithmus im Gleichschritt durchgeführt werden können. Die Liste ist nicht vollständig. Alle Elemente beruhen jedoch auf dem gleichen Grundsatz: Die Entwickler sollten sich nicht mit den einfachen und rudimentären Aspekten der Parallelisierung ihres Algorithmus beschäftigen müssen. Stattdessen sollten sie .NET Framework gestatten, die Mechanismen und Effizienzoptimierungen zu übernehmen.

Der Geist der zukünftigen Parallelisierung

Anders als bei Dickens, ist die Zukunft des Parallelismus un der Parellisierung im .NET Framework viel versprechend und faszinierend. Sie beruht auf den Grundlagen, die mit .NET Framework 4 gelegt wurden. Ein Schwerpunkt zukünftiger Versionen des .NET Framework, abgesehen von der Verbesserung der Leistung der vorhandenen Programmiermodelle, wird auf der Erweiterung der Zahl komplexerer Modelle liegen, um mehr Muster für parallele Verarbeitungen behandeln zu können. Eine solche Verbesserung stellt eine neue Bibliothek für die Implementierung paralleler Systeme auf der Basis des Datenflusses und für die Architektur von Anwendungen mit agentenbasierten Modellen dar. Die neue Bibliothek namens System.Threading.Tasks.Dataflow stellt eine Vielzahl von „Datenflussblöcken“ bereit, die als Puffer, Prozessoren sowie der Verbreitung von Daten dienen. Daten können auf diesen Blöcken veröffentlicht werden. Diese Daten werden anschließend verarbeitet und automatisch an verknüpfte Ziele weitergeleitet, abhängig von der Semantik des Quellblocks. Die Datenflussbibliothek basiert außerdem auf Aufgaben, wobei die Blöcke im Hintergrund Aufgaben behandeln, um die Daten zu verarbeiten und zu verbreiten.

Aus der Sicht der Muster ist die Bibliothek eine besonders gute Lösung für die Behandlung von Datenflussnetzwerken, die Ketten von Produzenten und Verbrauchern bilden. Denken Sie an die Notwendigkeit, dass Daten komprimiert, verschlüsselt und anschließend in eine Datei geschrieben werden müssen, während die Daten in der Anwendung eintreffen und durch diese hindurch fließen. Dies kann dadurch erreicht werden, indem ein kleines Netzwerk aus Datenflussblöcken konfiguriert wird, wie im folgenden Beispiel gezeigt:

static byte [] Compress(byte [] data) { ... }
static byte [] Encrypt(byte [] data) { ... }
...
var compressor = new TransformBlock<byte[],byte[]>(Compress);
var encryptor = new TransformBlock<byte[],byte[]>(Encrypt);
var saver = new ActionBlock<byte[]>(AppendToFile);
compressor.LinkTo(encryptor);
encryptor.LinkTo(saver);
...
// As data arrives
compressor.Post(byteArray);

Abgesehen von der Datenflussbibliothek stellt jedoch unbestritten die erstklassige Sprachunterstützung in C# und Visual Basic für das Generieren und asynchrone Warten auf Aufgaben das wichtigste zukünftige Merkmal im .NET Framework im Hinblick auf Parallelismus und Parallelisierung dar. Diese Sprachen werden um Status-Maschinen-Funktionen für Umschreibungen erweitert, mittels derer alle Konstrukte der Sprachen für die Steuerung des sequenziellen Flusses genutzt werden können, während sie gleichzeitig asynchron auf den Abschluss von Aufgaben warten (F# in Visual Studio 2010 unterstützt eine verwandte Form der Asynchronie im Rahmen der Funktion für asynchrone Workflows, eine Funktion, die ebenfalls in Aufgaben integriert werden kann). Betrachten Sie die folgende Methode, die Daten asynchron von einem Stream zu einem anderen Stream kopiert und die Anzahl der kopierten Bytes zurückgibt:

static long CopyStreamToStream(Stream src, Stream dst) {
  long numCopied = 0;
  byte [] buffer = new byte[0x1000];
  int numRead;
  while((numRead = src.Read(buffer,0,buffer.Length)) > 0) {
    dst.Write(buffer, 0, numRead);
    numCopied += numRead;
  }
  return numCopied;
}

Die Implementierung dieser Funktion einschließlich der Bedingungen und Schleifen sowie die Unterstützung für Methoden wie BeginRead/EndRead auf dem Stream, die zuvor gezeigt wurden, führen zu alptraumhaften Rückruf- und Logikszenarien, die fehleranfällig und extrem schwer zu debuggen sind. Stattdessen werden Sie die bereits vorgestellte Methode ReadAsync verwenden, die einen Task<int> zurückgibt, sowie die entsprechende Methode WriteAsync, die eine Aufgabe zurückgibt. Mittels der neuen C#-Funktionalität können wir die zuvor gezeigte Methode wie folgt umschreiben:

static async Task<long> CopyStreamToStreamAsync(Stream src, Stream dst) {
  long numCopied = 0;
  byte [] buffer = new byte[0x1000];
  int numRead;
  while((numRead = await src.ReadAsync(buffer,0,buffer.Length)) > 0) {
    await dst.WriteAsync(buffer, 0, numRead);
    numCopied += numRead;
  }
  return numCopied;
}

Beachten Sie die wenigen geringfügigen Änderungen, die erforderlich sind, um die synchrone Methode in eine asynchrone Methode umzuwandeln. Die Funktion wird nun als „asynch“ kommentiert, um den Compiler darüber zu informieren, dass er die Funktion umschreiben muss. Damit wird jedes Mal, wenn für Task oder Task<TResult> ein „await“-Prozess angefordert wird, die restliche Ausführung der Funktion an diese Aufgabe als Fortsetzung gebunden: Diese Methode wird keinen Thread belegen, bis die Aufgabe abgeschlossen ist. Die Methode Read wurde in den Aufruf ReadAsync konvertiert, sodass das kontextabhängige Schlüsselwort „await“ zur Bezeichnung des Punktes verwendet werden kann, an dem die restliche Ausführung in eine Fortsetzung umgewandelt werden soll. Genau so verhält es sich mit WriteAsync. Wenn diese asynchrone Methode schließlich abgeschlossen wird, wird der Wiedergabelangwert zu Task<long> angehoben, das an den ursprünglichen Aufruf von CopyStreamToStreamAsync zurückgegeben wurde. Dazu wird ein Mechanismus vergleichbar dem Mechanismus verwendet, den ich zuvor anhand von TaskCompletionSource<TResult> vorgestellt habe. Nun kann ich den Rückgabewert aus CopyStreamToStreamAsync wie jede andere Aufgabe verwenden. Ich kann auf ihn warten, eine Fortsetzung an ihn anschließen, ihn zum Zusammensetzen über andere Aufgaben verwenden oder ihn sogar erwarten. Mittels Funktionen wie ContinueWhenAll und WaitAll kann ich mehrere asynchrone Prozesse initialisieren und später zusammenfügen, um höhere Stufen der Parallelisierung zu erzielen und den Gesamtdurchsatz meiner Anwendung zu verbessern.

Diese Sprachunterstützung für die Asynchronie verbessert nicht nur I/O-gebundene, sondern auch CPU-gebundene Prozesse und insbesondere die Fähigkeit von Entwicklern, reagierende Clientanwendungen zu entwickeln (d. h. Anwendungen, die den Oberflächenthread nicht binden, sodass die Anwendung weiter reagiert). Gleichzeitig können sie die Vorteile der massiven parallelen Verarbeitung weiter nutzen. Es ist seit jeher eine aufwendige Aufgabe für Entwickler, einen Oberflächenthread zu verlassen, eine Verarbeitung durchzuführen und anschließend zum Oberflächenthread zurückzukehren, um die Oberflächenelemente zu aktualisieren und mit den Benutzern zu interagieren. Die Sprachunterstützung für die Asynchronie interagiert mit wichtigen Komponenten des .NET Framework, um per Voreinstellung Prozesse zurück zum ursprünglichen Kontext zu führen, wenn ein „await“-Prozess abgeschlossen wurde (wenn z. B. ein „await“-Prozess vom Oberflächenthread initialisiert wird, wird die angeschlossene Fortsetzung weiterhin auf dem Oberflächenthread ausgeführt). Das bedeutet, dass eine Aufgabe gestartet werden kann, um rechenintensive Auslastungen im Hintergrund zu verarbeiten. Der Entwickler kann einfach auf die Ergebnisse warten und diese in Oberflächenelementen speichern, wie im folgenden Beispiel gezeigt:

async void button1_Click(object sender, EventArgs e) {
  string filePath = txtFilePath.Text;
  txtOutput.Text = await Task.Factory.StartNew(() => {
    return ProcessFile(filePath);
  });
}

Diese Hintergrundaufgabe kann selbst mehrere Aufgaben generieren, um die Hintergrundberechnung zu parallelisieren, z. B. mittels einer PLINQ-Abfrage:

async void button1_Click(object sender, EventArgs e) {
  string filePath = txtFilePath.Text;
  txtOutput.Text = await Task.Factory.StartNew(() => {
    return File.ReadLines(filePath).AsParallel()
      .SelectMany(line => ParseWords(line))
      .Distinct()
      .Count()
      .ToString();
  });
}

Die Sprachunterstützung kann auch in Verbindung mit der Datenflussbibliothek verwendet werden, um die natürliche Erstellung von asynchronen Produzent/Verbraucherszenarien zu vereinfachen. Denken Sie an den Wunsch, einen Satz gedrosselter Produzenten zu implementieren, von denen jeder einige Daten generiert, die an mehrere Verbraucher gesendet werden sollen. In der synchronen Programmierung wird dies mittels eines Typs wie BlockingCollection<T> durchgeführt (siehe Abbildung 2), der mit .NET Framework 4 eingeführt wurde.

Abbildung 2 Verwenden von BlockingCollection

static BlockingCollection<Datum> s_data = 
  new BlockingCollection<Datum>(boundedCapacity:100);
...
static void Producer() {
  for(int i=0; i<N; i++) {
    Datum d = GenerateData();
    s_data.Add(d);
  }
  s_data.CompleteAdding();
}

static void Consumer() {
  foreach(Datum d in s_data.GetConsumingEnumerable()) {
    Process(d);
  }
}
...
var workers = new Task[3];
workers[0] = Task.Factory.StartNew(Producer);
workers[1] = Task.Factory.StartNew(Consumer);
workers[2] = Task.Factory.StartNew(Consumer);
Task.WaitAll(workers);

Dies ist ein gutes Muster, solange es das Ziel der Anwendung erfüllt, dass sowohl die Produzenten als auch die Verbraucher Threads sperren. Wenn dies nicht möglich ist, können Sie ein asynchrones Gegenstück schreiben, für das Sie einen anderen Datenflussblock namens BufferBlock<T> und die Fähigkeit des asynchronen Sendens an und Empfanges von einem Block nutzen, wie in Abbildung 3 gezeigt.

Abbildung 3 Verwenden von BufferBlock

static BufferBlock<Datum> s_data = new BufferBlock<Datum>(
  new DataflowBlockOptions { BoundedCapacity=100 });
...
static async Task ProducerAsync() {
  for(int i=0; i<N; i++) {
    Datum d = GenerateData();
    await s_data.SendAsync(d);
  }
  s_data.Complete();
}

static async Task ConsumerAsync() {
  Datum d;
  while(await s_data.OutputAvailableAsync()) {
    while(s_data.TryReceive(out d)) {
      Process(d);
    }
  }
}
...
var workers = new Task[3];
workers[0] = ProducerAsync();
workers[1] = ConsumerAsync();
workers[2] = ConsumerAsync();
await Task.WhenAll(workers);

Hier geben sowohl die Methode SendAsync als auch die Methode OutputAvailableAsync Aufgaben zurück und ermöglichen so dem Compiler, Fortsetzungen anzuschließen, sodass der gesamte Prozess asynchron ausgeführt werden kann.

Kein Geiz mehr

Die parallele Programmierung ist seit langem die Domäne erfahrener Entwickler, die sich hervorragend mit der Skalierung von Code für mehrere Kerne auskennen. Diese Experten haben Jahre mit Schulungen und praktischen Erfahrungen angesammelt. Sie sind hoch angesehen – und selten. In der Welt des Multicore- und Manycore-Computings ist dieses Modell, in dem der Parallelismus den Experten vorbehalten ist, nicht mehr ausreichend. Unabhängig davon, ob eine Anwendung oder eine Komponente ein öffentlich verfügbares Softwarepaket, nur für interne Zwecke vorgesehen oder einfach nur ein Tool ist, um eine wichtigere Aufgabe zu lösen, ist Parallelismus heute ein Modell, das jeder Entwickler zumindest in Betracht ziehen muss. Die vielen Millionen Entwickler, die verwaltete Sprachen verwenden, müssen in der Lage sein, die Vorteile des Parallelismus zu nutzen, auch wenn dies über Komponenten erfolgt, die Parallelismus einkapseln. Die Modelle für die parallele Programmierung, die im .NET Framework 4 bereits enthalten sind und in zukünftigen Versionen des .NET Framework enthalten sein werden, sind die Voraussetzung, damit die Zukunft Wirklichkeit wird.

Stephen Toub ist Hauptarchitekt im Parallel Computing Platform-Team bei Microsoft.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Joe Hoag und Danny Shih