GRUNDLAGEN Parallele Programmierung und Datenfluss mit .NET Gleichzeitig zum Erfolg Zur Implementierung von asynchroner und paralleler Verarbeitung bietet .NET unterschiedlichste Modelle an. Wer sie kennt, kann seine Anwendungen produktiver gestalten und für andere verständlicher machen. Auf einen Blick Dipl.-Inf. (FH) Peter Meinl (www.petermeinl.de) ist ITBerater, Trainer und passionierter Querdenker. Er entwirft und realisiert seit über 25 Jahren datenbankorientierte und verteilte Anwendungen. Sein fachlicher Schwerpunkt ist der Entwurf kundenspezifischer Lösungen für die Fertigungsindustrie. Er programmiert regelmäßig, weil Berater und Architekten Code schreiben müssen, um zu verstehen, worüber sie reden und was sie Programmierern zumuten. Inhalt ▸ Parallele Programmierung mit Task, Parallel.For*() und Datenfluss. ▸ Ein Web Crawler als Beispiel für die Anwendung von Datenfluss. ▸ Datenstrukturen für parallele Programmierung. ▸ Verwandte Frameworks. dnpCode A1306AsyncParallel A synchronität und Parallelität sind im .NET-Mainstream angekommen und jeder Berater, Architekt und Programmierer sollte ein Grundverständnis davon haben. Bisher ging es in dieser Serie um Grundlagen und die unterschiedlichen asynchronen Programmiermodelle [1]. Nun folgt ein Überblick zur parallelen Programmierung inklusive Datenfluss mit .NET und zu weiteren interessanten Frameworks in diesem Zusammenhang. Ein Praxisbeispiel mit einem Dateiprozessor, der Dateien mit Tasks, Datenfluss [2], Parallel. For() und dem Managed Extensibility Framework verarbeitet, war in Ausgabe 4/2013 der dotnetpro zu finden [3]. Spezielle Komplikationen bei der Fehlerbehandlung unter paralleler Programmierung behandelte ein anderer Beitrag in der dotnetpro [4]. Zur parallelen Programmierung bietet das .NET Framework : ◼ Parallelität über Daten ◼ Parallelität von Aufgaben ◼ asynchrone Datenflüsse ◼ paralleles LINQ (PLINQ) ◼ D atenstrukturen für parallele Programmierung Parallelität über Daten Mit parallelen Schleifen ist es möglich, dieselben Operationen parallel jeweils auf die Datenelemente einer Liste anzuwenden. Sie realisieren das Entwurfsmuster Fork/Join. In ihrer einfachsten Form sehen die Schleifen Parallel.ForEach() und .For() wie folgt aus: ForEach<TSource>(IEnumerable<TSource> source, Action<TSource> body); Parallel.For(int fromInclusive, int toExclusive, Action<int> body) Die Parallel.For*()-Methoden verwenden eine dynamische Anzahl von Tasks. Sie beginnen mit einer Task und erzeugen ein Replikat von sich, sobald diese Task ausgeführt wird. Wenn das Replikat ausgeführt wird, erzeugt es wieder ein Replikat von sich und immer so weiter. Damit verwendet Parallel so viele Tasks wie der zugrunde liegende Scheduler bereitstellt. Parallel.For*() bietet folgende Features, also Merkmale: ◼ A bbruch bei Ausnahmen und Auslösen einer AggregateException 126 ◼ m anueller Abbruch via ParallelLoopState. Break() und .Stop() ◼ kooperativer Abbruch ◼ ParallelLoopState ◼ MaxDegreeOfParallelism ◼ adaptive Partitionierung und Lastverteilung ◼ eigene Partitionierung ◼ eigene Scheduler Parallele Schleifen müssen unbedingt so codiert werden, dass ihre Durchläufe unabhängig voneinander sind. Im folgenden Beispiel wird der Zähler primeCount mittels Interlocked. Partitionierung der Daten Mit Partitionierung bezeichnet man im Rahmen der Datenparallelität das Aufteilen einer Liste von Datenelementen zur parallelen Verarbeitung durch mehrere Rechnerkerne. Ohne Partitionierung würden alle Elemente seriell auf einem Kern verarbeitet. Bei der Bereichspartitionierung wird die Liste einmalig in feste gleich große Abschnitte aufgeteilt. Bei der Blockpartitionierung wird jeweils eine Anzahl von Datenelementen als Partition geliefert. Dabei wird deren Zahl (chunk size) zunehmend größer (1, 2, 4, 8 …). Die Bereichspartitionierung hat den geringeren Overhead beim Partitionieren, weil sich die verarbeitenden Threads dazu nicht abstimmen müssen. Sie bietet aber keine Lastverteilung für den Fall, dass die einzelnen Verarbeitungen verschieden aufwendig sind. Die Blockpartitionierung bietet zwar Lastverteilung, aber der Overhead für das Partitionieren ist hierbei höher, weil sich die Threads für die jeweils nächste Partition abstimmen müssen. Die Möglichkeiten der Partitionierung hängen vom Typ der Datenliste ab. Bei IList und Array ist im Unterschied zu IEnumerable die Anzahl der Elemente bekannt und sie lassen sich indizieren. Falls die Reihenfolge der Daten eine Rolle spielt, können Order­able­Partitioner verwendet werden. Wenn zum Beispiel PLINQ oder Parallel.ForEach() ein IEnumera­ ble zum Parallelisieren übergeben wird, dann verwenden diese normalerweise einen Blockpartitionierer, der jedes Mal, wenn ein Thread neue Daten braucht, ein oder mehrere Datenelemente zurückgibt. Dieses Verhalten können Sie überschreiben, indem Sie zum Beispiel Parallel.ForEach() einen statischen Bereichspartitionierer übergeben oder für PLINQ einen LoadBalancing-Partitionierer konfigurieren [20, 21]. 6.2013 www.dotnetpro.de GRUNDLAGEN Increment() über Parallel.ForEach() synchronisiert: Dim primeCount As Integer Parallel.ForEach( Enumerable.Range(0, 100000).ToArray(), Sub(n) If IsPrime(n) Then Interlocked.Increment(primeCount) End Sub) Mithilfe von Überladungen mit Loop­ State sowie der Actions localInit und lo­ calFinally lässt sich das Leistungsverhalten solcher Konstrukte optimieren, aber mit deutlich höherer Komplexität. Häufig ist es in solchen Fällen einfacher, PLINQ zu verwenden: Dim primeCount = ParallelEnumerable. Range(0, 100000). Sum(Function(i) IIf(IsPrime(i), 1, 0)) Die ParallelOptions-Eigenschaft Max­ DegreeOfParallelism kann begrenzen, wie viele ThreadPool-Threads der Scheduler maximal injizieren darf. Dieses Merkmal ist für Ausnahmefälle gedacht, zum Beispiel wenn die Scheduler-Heuristik beim Ermitteln der angemessenen Anzahl Threads­versagt oder wenn mehrere Algorithmen gleichzeitig parallelisiert werden sollen. Der Threadpool-Scheduler kann zum Beispiel nicht unterscheiden zwischen Threads,­die lange Schleifendurchläufe berechnen, und solchen Threads, die durch synchrone Ein- und Ausgabeoperationen blockiert sind. Aus diesem Grund erzeugt er bei langen Schleifendurchläufen zu viele Threads, um einen vermeintlichen Mangel an Threads zu vermeiden. In solchen Fällen können Sie die Schleifendurchläufe verkürzen oder MaxDegreeOf­ Parallelism einsetzen. Die häufig zitierte Annahme, ein ­Thread pro Kern sei optimal, passt für viele Szenarien nicht; zum Beispiel würden damit Kerne leer laufen, während Threads auf Datei-I/O, Netzwerk-I/O, Speicherzuteilungen oder Locks warten. Beim Begrenzen der Thread-Anzahl sollte die Skalierbarkeit nicht aus dem Blickfeld geraten und wenigstens mit Environment.Pro­ cessorCount die Anzahl der Kerne (nicht der CPUs!) auf dem aktuellen Rechner als Basis dienen. Ein unbedachtes Begrenzen der Anzahl von Threads kann zusammen mit synchronen Ein-/Ausgabeoperationen oder Locks leicht zu Thread-Mangel führen. Die Partitionierung der Daten ist wesentlich für die Performanz von parallelen Schleifen, siehe Kasten Partitionierung der Daten. Im folgenden Beispiel ist die Ver­ arbeitung in der Schleife sehr kurz und deshalb wird ein Bereichspartitionierer verwendet, der Abschnitte von jeweils 50 000 Werten bildet. Damit führt die optimierte Schleife nur noch 20 parallele Iterationen aus : Dim myData As Double() = New Double(1000000) {} Parallel.ForEach(data, Sub(item, state, i) result(i) = item * item End Sub) Parallel.ForEach(Partitioner.Create(0, 1000000, 50000), Sub(range) For i As Double = range.Item1 To range.Item2 - 1 myData(i) = i * i Next End Sub) Parallelität von Aufgaben Mit Parallel.Invoke() ist es möglich, eine willkürliche Anzahl unterschiedlicher Aufgaben parallel auszuführen, indem ein Array von Actions übergeben wird. Am einfachsten geht das mittels LambdaAusdrücken: Parallel.Invoke(Sub() GetProductInfoFromDB, Sub() ComputeDiscount) Parallel.Invoke() realisiert genauso wie Parallel.For*() das Entwurfsmuster Fork/ Join, wartet also auf das Ende der Verarbeitungen. Für mehr Kontrolle über das Scheduling lassen sich Tasks explizit starten: Dim t1 = Task.Factory.StartNew() (Sub()ComputeDiscount, TaskScheduler. FromCurrentSynchronizationContext) Dim t2 = ... Task.WaitAll(t1, t2) Parallel LINQ Parallel LINQ, kurz PLINQ, bietet Parallelität über Daten im deklarativen LINQStil. AsParallel() leitet die parallele Verarbeitung in LINQ ein: Dim myObjects As Double() = New Double(1000000) {} Dim q = myObjects.AsParallel. Where(Function(d) ExpensiveFilter(d)) Mit .ForAll() lassen sich PLINQ-Ergebnisse parallel verarbeiten. Damit sind Verarbeitungen wie in einer Parallel.For*()Schleife realisierbar, aber mit der Eleganz von LINQ und mit dessen Merkmalen wie Filterung. PLINQ verwendet immer eine fixe Anzahl von Tasks, der voreingestellte Wert ist Environment.ProcessorCount. Die Methode WithDegreeOfParallelism() kann dafür einen anderen Wert setzen. From site In New String() { "www.petermeinl.de", "..."}. AsParallel.WithDegreeOfParallelism(4) Let p = New Ping().Send(site) Select p.RoundtripTime Mit einem expliziten Partitioner lässt sich Lastenverteilung (siehe Kasten) erzwingen: Dim files = Directory.GetFiles("c:\") Dim partitioner = Concurrent.Partitioner. Create(files, True) Dim ufiles = partitioner.AsParallel. Select(Function(f) ParseFile(f)) Mit ParallelExecutionMode.ForceParal­ lelism lässt sich PLINQ zum Parallelisieren zwingen und für Unterabfragen mit AsSequential() zur seriellen Verarbeitung bewegen. Viele Probleme lassen sich sowohl mit Parallel.For*() als auch mit PLINQ lösen. Pamela Vagata beschreibt in dem Artikel When Should I Use Parallel.ForEach? When Should I Use PLINQ? einige Vor- und Nachteile beider Lösungen [5]. Die Website zum Buch C# in a Nutshell [6] zeigt viele kleine Codebeispiele mit Parallel und PLINQ, die als Anregungen für mögliche Lösungen hilfreich sind. Speicherbereinigung Beim Parallelisieren sollte auch die Speicherbereinigung nicht zum Engpass werden. Vermeiden Sie, viele kleine Objekte zu allokieren, und prüfen Sie String-Verkettungen, Boxing und andere Speicherallokationen kritisch. Die .NETSpeicherbereinigung läuft normalerweise im Hintergrund in einem Einzel-Thread, sie skaliert also nicht über mehrere Kerne. Deshalb ist für parallelisierte Anwendungen die Speicherbereinigung für Server erwägenswert; diese nutzt einen Thread pro logischen Prozessor. Die Speicherbereinigung für Server lässt sich im Konfigurationsschema mit der Einstellung <gcSer­ ver enabled="true"> erzwingen. Asynchrone Datenflüsse (Data­flow) TPL Dataflow (TDF) [7] ist für asynchrone Producer-Consumer-Szenarien mit Nachrichtenübergabe innerhalb eines Prozesses gedacht. Für prozessübergreifenden www.dotnetpro.de 6.2013127 GRUNDLAGEN Parallele Programmierung und Datenfluss mit .NET Listing 1 Ein mit Task Dataflow realisierter Dateiprozessor. Sub Main() 'Reading files sequentially and asynchronously Dim reader As New TransformBlock(Of String, String)( Async Function(path) Using sr As New StreamReader(path) Dim fileString = Await sr.ReadToEndAsync().ConfigureAwait(False) Return fileString End Using End Function) 'Processing file content concurrently Dim totalWordCount As Integer Dim processor As New ActionBlock(Of String)( Sub(content) Dim allWordsRegEx As New Regex("\S+") Dim wordCount = allWordsRegEx.Matches(content).Count Debug.Print("Processing result={0}", wordCount) Interlocked.Add(totalWordCount, wordCount) End Sub, New ExecutionDataflowBlockOptions With {.MaxDegreeOfParallelism = Environment.ProcessorCount}) processor.Completion.ContinueWith( Sub() Console.WriteLine("totalWordCount={0}", totalWordCount) End Sub) reader.LinkTo(processor, New DataflowLinkOptions With {.PropagateCompletion = True}) For Each path In Directory.EnumerateFiles("x:\temp1\testfiles\in", "*.txt") reader.Post(path) Next reader.Complete() PromptUser("Working... Press <Enter> to exit:", ConsoleColor.White) End Sub [Abb. 1] Architektur eines mit Datenfluss realisierten Web Crawlers. Nachrichtenaustausch gibt es andere Lösungen, dazu mehr weiter unten im Abschnitt „Verwandte Frameworks“. 128 Das Datenfluss-Programmiermodell ist am schnellsten anhand eines kleinen Beispiels zu verstehen. Listing 1 zeigt ei- nen mit Datenfluss realisierten Dateiprozessor. Der TransformBlock reader liest Dateien sequentiell und asynchron. Der ActionBlock processor verarbeitet diese parallel, indem er mittels RegEx die Anzahl Worte in der Datei ermittelt. Dabei wird die Parallelität auf die Anzahl der Kerne des Rechners begrenzt. processor ist mit reader verlinkt. Die Verarbeitung wird mittels reader.Post() ausgelöst. Mit reader.Complete() wird reader mitgeteilt, dass es für den TransformBlock nichts mehr zu verarbeiten gibt. Mit Setzen der Eigenschaft PropagateCompletion auf True reagiert auch processor auf das Ende der Verarbeitung. Das Ergebnis der Verarbeitung wird in einer Fortsetzungs-Task von processor ausgegeben. Um die Dateien in einem Verzeichnis zu ermitteln, wird übrigens die neue Methode Directory.Enumerate­ Files() verwendet, die im Unterschied zu Directory.GetFiles() mittels eines IEnume­ rable sofort damit beginnt, Dateinamen zu liefern und nicht erst, nachdem sie alle ermittelt hat. Task.Run() setzt bereits ein einfaches Producer-Consumer-Muster um, denn Tasks werden ja asynchron auf dem Thread­pool in eine Queue gestellt und konsumiert. Mit den Auflistungen aus dem Namensraum System.Collections. Concurrent lassen sich komplexere Producer-Consumer-Lösungen realisieren; dabei sind die Consumer aber synchron, weil sie beim Warten auf Nachrichten ­blockiert sind. Zum Thema eigene Producer-Consumer-Queues hat in der dotnetpro­Michael Scheffler einen Artikel ver­öffentlicht [8]. Der Datenfluss der Task Parallel Library führt Aktor-basierte Programmierung ein, bei der unabhängige Einheiten, sogenannte Aktoren oder Agenten, mittels Nachrichten synchron oder asynchron miteinander kommunizieren und diese asynchron verarbeiten. Die interne Verarbeitung in den Aktoren selbst kann dabei seriell oder parallel erfolgen. Es ist oft leichter, solche auf einzelnen Aktoren basierende Lösungen zu entwerfen und zu verstehen, als das ganze Problem auf einmal betrachten oder parallelisieren zu müssen. Auch skalieren sie meist besser und liefern höheren Durchsatz. Das folgende Beispiel zeigt einen Aktor vom Typ ActionBlock. Bei seiner Definition wird der Typ der Nachrichten in seiner Eingangs-Queue als Integer festgelegt und für die Verarbeitung wird die Funktion 6.2013 www.dotnetpro.de GRUNDLAGEN DoProcessMessage() definiert. Die Methode Post() übergibt die Nachrichten an ihn. Mit SendAsync() lassen sich Nachrichten asynchron verschicken. Dim ab = New ActionBlock(Of Integer) _ (Function(i) DoProcessMessage(i)) ab.Post(data1) ab.Post(data2) ab.Post(data3) Der Datenfluss der TPL bietet viele vordefinierte Datenflussblöcke. Ihre Namen geben zu erkennen, welche Merkmale sie bieten, Näheres siehe [7] und [2]. Buffering-Blöcke: ◼ BufferBlock ◼ BroadcastBlock ◼ WriteOnceBlock Ausführungsblöcke: ◼ ActionBlock ◼ TransformBlock ◼ TransformManyBlock Gruppierungsblöcke: ◼ BatchBlock ◼ JoinBlock ◼ BatchedJoinBlock Zur Verknüpfung der Blöcke gibt es die Methode LinkTo(), die auch programmierbare Filter unterstützt. Die Queues der Blöcke werden in FIFO-Reihenfolge abgearbeitet. Voreingestellt ist MaxDe­ greeOfParallelism mit dem Wert 1, Nachrichten werden also seriell abgearbeitet. Der maximale Grad der Parallelität lässt sich explizit festlegen; mit Dataflow­ BlockOptions.Unbounded kann er dem Task Scheduler überlassen werden. Weil es recht wenige konkrete Beispiele zum Datenfluss gibt, zeigen Abbildung 1 und Listing 2 einen Web Crawler. Er demonstriert insbesondere das Zusammenspiel der Blöcke. Der TransformBlock downLoader liest den Inhalt einer Webseite asynchron und liefert je Seite einen String. Dabei verwendet er maximal vier Threads, lädt also maximal vier Seiten gleichzeitig aus dem Netz. Die TransformManyBlock-Objekte linkParser und imageParser lesen den Inhalt einer Seite mittels Html Agility Pack [9] und liefern je Seite mehrere URLs zu weiteren Seiten oder Bildern. Die beiden Parser sind über den BroadastBlock contentBrodcaster mit downLoader verlinkt. Wären die beiden Consumer direkt mit downLoader verlinkt, käme es zum „Verhungern“ eines Listing 2 Mit Task Dataflow realisierter Web Crawler. Sub Main() 'Define Blocks Dim downLoader As New TransformBlock(Of String, String)(Async Function(url) WriteLineInColor(url, ConsoleColor.White) Return Await New HttpClient().GetStringAsync(url).ConfigureAwait(False) End Function, New ExecutionDataflowBlockOptions With {.MaxDegreeOfParallelism = 4}) Dim contentBroadcaster As New BroadcastBlock(Of String)(Function(html) html) Dim linkParser As New TransformManyBlock(Of String, String)( Function(html) Dim doc As New HtmlDocument() doc.LoadHtml(html) Return From n In doc.DocumentNode.Descendants("a") Where n.Attributes.Contains("href") Let url = n.GetAttributeValue("href", "") Where url.StartsWith("http://") Select url End Function) Dim imageParser As New TransformManyBlock(Of String, String)( Function(html) Dim doc As New HtmlDocument() doc.LoadHtml(html) Return From n In doc.DocumentNode.Descendants("img") Where n.Attributes.Contains("src") Let url = n.GetAttributeValue("src", "") Where url.StartsWith("http://") Select url End Function) Dim linkBroadCaster As New BroadcastBlock(Of String)(Nothing) Dim imageProcessor As New ActionBlock(Of String)( Async Function(url) Dim uri As New Uri(url) Dim fileUrl = If(String.IsNullOrWhiteSpace(uri.Query), uri.AbsoluteUri, uri. AbsoluteUri.Replace(uri.Query, "")) WriteLineInColor(fileUrl, ConsoleColor.Yellow) Dim webStream = Await (New HttpClient().GetStreamAsync(url)).ConfigureAwait(False) Dim filePath = New DirectoryInfo(Path.Combine(My.Application.Info.DirectoryPath, "..\..\..\Images", IO.Path.GetFileName(fileUrl)) ).FullName Using fileStream = IO.File.OpenWrite(filePath) Await webStream.CopyToAsync(fileStream).ConfigureAwait(False) End Using End Function) 'Handle unexpected errors downLoader.Completion.ContinueWith(Sub(ant) WriteLineInColor(ant.Exception. GetBaseException.ToString, ConsoleColor.Red), , TaskContinuationOptions.OnlyOnFaulted)) ... imageProcessor.Completion.ContinueWith(Sub(ant) WriteLineInColor(ant.Exception. GetBaseException.ToString, ConsoleColor.Red), ,TaskContinuationOptions.OnlyOnFaulted)) 'Link Blocks downLoader.LinkTo(contentBroadcaster) contentBroadcaster.LinkTo(linkParser) contentBroadcaster.LinkTo(imageParser) linkParser.LinkTo(linkBroadCaster) linkBroadCaster.LinkTo(downLoader) '.jpg just to demonstrate a link filter linkBroadCaster.LinkTo(imageProcessor, Function(url) url.EndsWith(".jpg")) imageParser.LinkTo(imageProcessor) 'Let's start downLoader.Post("http://www.bbc.co.uk/news/") PromptUser("Crawling... Press <Enter> to abort:", ConsoleColor.White) www.dotnetpro.de 6.2013129 GRUNDLAGEN Parallele Programmierung und Datenfluss mit .NET der beiden, weil die meisten TDF-Blöcke – im Unterschied zu den Reactive Extensions [10] – Nachrichten nur an das erste verlinkte Ziel weitergeben, das diese akzeptiert. Ein BroadastBlock hat die Aufgabe, Nachrichten an viele Ziele gleichzeitig zu verteilen. Dabei werden diese im Allgemeinen geklont, um zu verhindern, dass alle Consumer mit derselben Nachrichteninstanz arbeiten. Somit ist ThreadSicherheit ohne Aufwand für die Synchronisation gewährleistet. contentBroadcas­ ter im Beispiel leitet die Nachricht einfach weiter, ohne sie zu klonen, weil sie vom Typ String ist und damit unveränderlich. Der ActionBlock imageProcessor kopiert Bilder aus dem Web auf den lokalen PC. Dabei nutzt er mit Stream.CopyToAsync() eine der neuen asynchronen I/O-Methoden, die nach dem Task-basierten asynchronen Entwurfsmuster [1] realisiert sind. Das Abbrechen des Crawlers ist mittels eines CancellationToken realisiert, das an alle Blöcke übergeben wird. Nun soll es dem Leser überlassen bleiben, darüber zu grübeln, warum es auch noch einen BroadcastBlock linkBroad­ caster gibt und wie sich der Web Crawler, nachdem er alle Seiten durchforstet hat, mittels IDataflowBlock.Complete() und DataflowLinkOptions.Propagate­ Completion selbst beenden könnte. Der dotnetpro-­Beitrag Unter Beobachtung erklärt und liefert einen kompletten Dateiprozessor als Windows-Dienst, der unter anderem BufferBlocks verwendet [3]. TPL Dataflow wird nicht automatisch mit dem .NET Framework 4.5 installiert, sondern muss in Visual Studio mit NuGet nachinstalliert werden. Wie beim Ma­ naged­ Extensibility Framework (MEF) verspricht Microsoft, durch die Abkoppelung vom Versionszyklus von Visual Studio und .NET, schneller neue Funktionen bereitstellen zu können. Datenstrukturen für parallele Verarbeitung Seit .NET 4 gibt es einige Klassen, die bei der parallelen Programmierung hilfreich sind. Sie befinden sich im Namensraum System.Collection.Concurrent und bieten Thread-sichere Operationen, um Elemente hinzuzufügen und zu entfernen; dabei vermeiden sie Locks wo irgend möglich oder nutzen feingranulare Locks. Neben garantierter Robustheit ermöglichen sie eine deutlich bessere Performanz als eigene Locks um Standardauflistungen wie List. Diese Klassen sind: 130 [Abb. 2] Windows Task-Manager und Ressourcenmonitor. ◼ ◼ ◼ ◼ ◼ BlockingCollection ConcurrentBag ConcurrentDictionary ConcurrentQueue ConcurrentStack Die mit .NET 4.x neu eingeführten Synchronisierungs-Primitiven [11] helfen bei der Koordination feingranularer paralleler Verarbeitung und verbessern deren Performanz gegenüber den älteren Vertretern wie ManualResetEvent. ◼ Barrier ◼ CountdownEvent ◼ ManualResetEventSlim ◼ SemaphoreSlim ◼ SpinLock ◼ SpinWait Mit sogenannter Lazy-Initialisierung wird der Speicher für ein Objekt erst dann bereitgestellt, wenn er benötigt wird. Mit den auf diese Weise initialisierenden Klassen lassen sich Startzeiten verkürzen und die Performanz verbessern, weil die Allokierung über die Zeit verteilt wird. Diese Klassen sind: ◼ Lazy (Of T) ◼ ThreadLocal (Of T) ◼ LazyInitializer Diagnosewerkzeuge Mit der Registerkarte Leistung des Windows Task-Managers und dem von dort aus erreichbaren Ressourcenmonitor können Sie den Ressourcenverbrauch von Prozessen beobachten, wie in Abbildung 2 zu sehen ist. Zusätzlich lässt sich die Spalte ­Threads einblenden. Mit Windows 8 zeigt sich der Task-Manager deutlich übersichtlicher gestaltet. Auch der bewährte Process Explorer von Mark Russinovich ist hilfreich. Visual Studio 2012 bietet einige Merkmale, die bei der Analyse paralleler Lösungen hilfreich sind : ◼ das Fenster Parallel Stacks ◼ das Fenster Parallel Tasks ◼ den Concurrency Visualizer Die Windows-Parallel-Stacks und -Tasks lassen sich in Visual Studio über Debug | Windows anzeigen, siehe Abbildung 3. Den Concurrency Visualizer starten Sie in der IDE über Analyze | Concurrency (Abbildung 4). Mit der Klasse StopWatch sind Zeitmessungen möglich. Beim Debuggen paralleler Programme kann das Werkzeug CHESS von Microsoft Research helfen. CHESS dient zum Auffinden und Reproduzieren sogenannter Heisen-Bugs [4]. Wann und wie parallelisieren? Die Erfahrung zeigt, dass Annahmen zur Performanz häufig falsch sind. Die Parallelisierung seriellen Codes sollten Sie nur angehen, wenn wesentliche parallelisierbare Engpässe identifiziert sind. Alle Entwurfs- und Implementierungsalterna- 6.2013 www.dotnetpro.de GRUNDLAGEN tiven sollten durch frühzeitige und begleitende Tests und Messungen immer wieder abgesichert werden. Bei allem Hype um parallele Programmierung sollten die Einfachheit und andere Vorteile serieller Verarbeitung nicht aus dem Blick geraten. Manche Probleme lassen sind nur seriell lösen oder es gibt einfach keine sinnvoll parallelisierbaren Teile. Manche parallelen Lösungen erfordern mehr Koordinationsaufwand zwischen den parallelen Teilen, als durch ihre Parallelisierung zu gewinnen ist. Fehler in parallelem Code sind häufig obskur und [Abb. 3] Die Visual-Studio-Fenster Parallel Task und Stack. lassen sich schwer finden und beheben. Ist die Entscheidung für die Parallelisierung gefallen, empfiehlt es sich, immer den einfachsten Ansatz zu wählen. Dazu wählen Sie den höchsten verfügbaren Abstraktionsgrad, also zum Beispiel Datenflussblöcke statt eigener, auf den Klassen aus Systems.Collections.Concurrent basierender Konstrukte. Threads und Locks sind dabei die letzte Wahl. Oft hilft es, die generelle Architektur einer Anwendung zu überdenken, statt gleich Engpässe in der Performanz zu optimieren. Konkurrierender Zugriff auf Daten aus parallelen Tasks sollte immer minimiert werden. Der größte Gewinn ergibt sich im Allgemeinen durch Parallelisierung auf der obersten Ebene von Algorithmen. Um ein Gefühl für Zeitspannen zu bekommen, in denen sich eigener Code bewegt, stellt Abbildung 5 typische Zeitspannen in einer Übersicht einander gegenüber. Die Angaben beruhen auf naiven Messungen ohne Berücksichtigung von Datenvolumina, gleichzeitiger Last oder Netzwerkbandbreite et cetera (siehe Code zum Artikel). In realen Anwendungen gehen Unterschiede in der Performanz zwischen verschiedenen Ansätzen oft im Aufwand für die eigentliche Verarbeitung unter. Vergleiche hierzu sollten immer unter möglichst realen Bedingungen erfolgen, zum Beispiel mit der entsprechenden Anzahl von Clients, Transaktionsvolumina, Anzahl der Datensätze pro Tabelle, Latenzzeit des Netzwerks, virtuelle oder physische Rechner, Zahl und Leistung der Kerne, Ausstattung mit RAM und so weiter. Zum Experimentieren mit I/O-lastiger und CPU-lastiger Verarbeitung lassen sich I/O-Lasten mit Task.Delay() und CPULasten mit Thread.SpinWait() simulieren. Bibliotheken mit paralleler Verarbeitung sollten auch serielle Versionen ihrer Methoden bereitstellen oder einen optionalen Parameter degreeOfParallelism bieten. Intern sollten sie bei degreeOfParalle­ lism=1 serielle Verarbeitung verwenden, um den generellen Overhead von Parallel. For*() oder PLINQ zu vermeiden. Verwandte Frameworks [Abb. 4] Der Concurrency Visualizer der IDE. Neben den Standardmerkmalen des .NET Frameworks gibt es weitere interessante Frameworks und Bibliotheken, die sich Parallelität und Asynchronität widmen: ◼ PollTimer ◼ ereignisorientierte Ansätze ◼ C oncurrency and Coordination Runtime (CCR) www.dotnetpro.de 6.2013131 GRUNDLAGEN Parallele Programmierung und Datenfluss mit .NET [Abb. 5] Typische Zeitspannen üblicher Operationen. ◼ SQL Server StreamInsight ◼ B ibliotheken zum Rechnen auf Grafikkarten (GPGPU) ◼ C++ Concurrency Runtime ◼ Windows Workflow Foundation (WF) Der erste Artikel zum Thema [4] hat unter den asynchronen Programmiermodellen auch Timer aufgeführt, denn ihre Callbacks werden ja asynchron in Threadpool-Threads ausgeführt. Eine spezielle Form von Timern sind Poll-Timer. Auf der Homepage LeanWork [12] findet sich ein Poll-Timer, der seinen ersten Tick nicht erst nach dem ersten Verstreichen seines Intervalls auslöst, sondern sofort und dessen Ticks nicht in die laufende Verarbeitung hineinfeuern, falls die Verarbeitung länger dauert als sein Intervall. Eine Alternative zum Pollen mit Timern ist die Verwendung von Ereignissen. Ein Beispiel ist das Lauschen auf neue Dateien in einem Verzeichnis oder einem Verzeichnisbaum mittels FilesystemWatcherEreignissen. 132 Auch Message Queuing und SQL-Datenbanken bieten ereignisorientierte Verarbeitung. Das Microsoft Message Queuing (MSMQ) oder Oracle Advanced Queuing ermöglichen es, auf Messages zu lauschen. Mit SqlDependency im ADO.NET-Provider für den MS SQL Server oder OracleDepen­ dency des entsprechenden Oracle-Providers ist es möglich, auf Änderungen von Daten in der Datenbank zu lauschen. Der Windows Azure AppFabric Service Bus bietet unter anderem eine Verteilung von Ereignissen in der Cloud. SignalR ist ein interessantes ereignis­ orientiertes asynchrones SignalisierungsFramework, entwickelt für Webanwendungen wie Aktienticker. Es ermöglicht permanente Verbindungen zwischen Client und Server in ASP.NET, kann aber auch für Rich-Client-Anwendungen genutzt werden. Das Tool bietet Client-Bibliotheken für JavaScript, .NET und auch speziell für Windows Phone. Es lässt sich anstelle von Long-Polling , also dem hängenden „Anpollen“ von Diensten, verwen- den. Mit SignalR lassen sich außerdem Methoden im Client mit Ereignissen in SignalR-Webdiensten verbinden, sodass ein Dienst diese auslösen kann. Um das Prinzip zu verstehen, ist das Video zu SignalR auf Channel 9 sehr hilfreichen [13]. Seit 2006 gibt es mit dem Microsoft Robotics Developer Studio die Concurrency and Coordination Runtime (CCR) Library [14]. CCR kann auch außerhalb der Programmierung von Robotern in normalen .NET-Anwendungen verwendet werden. Vieles aus der CCR findet sich auch im Task-Datenfluss, siehe oben. Microsoft StreamInsight (früher SQL Server Notification Services) ist eine SQLServer-Komponente, die Datenströme fast in Echtzeit analysieren kann [15]. Mit StreamInsight lassen sich Datenströme überwachen, analysieren und Aktionen auf ihnen auslösen. Es verkraftet Tausende Ereignisse pro Sekunde bei Antwortzeiten von unter einer Sekunde. Es gibt einige .NET-Bibliotheken für die parallele Programmierung mit der immensen Rechenleistung von MehrkernProzessoren auf Grafikkarten (GPU), sogenanntes „General-Purpose Computing on Graphics Processing Units“ (GPGPU), zum Beispiel: ◼ Microsoft Accelerator [16] ◼ Brahma [17] ◼ CUDA.NET [18] CUDA.NET ermöglicht die Verwendung des proprietären Frameworks CUDA von Nvidia in .NET; Microsoft Accelerator unterstützt mittels DirectX alle modernen GPUs, aber auch mehrkernige CPUs; Brahma ist eine Open-Source-Bibliothek für das parallele Verarbeiten von Streams auf GPUs mittels LINQ. Die C++ Concurrency Runtime bietet für C++ alles, was die Task Parallel Library für das .NET Framework bietet: Tasks, Loops und Actors. Mit der Windows Workflow Foundation (WF) lassen sich Arbeitsabläufe beschreiben und ausführen. Mittels Code oder Diagrammen werden die Abläufe definiert und dann von der WF-Laufzeitumgebung ausgeführt. Die Workflow Foundation bietet zudem einen für Endanwender geeigneten grafischen Designer, der sich auch in eigene Anwendungen einbinden lässt. Die Betrachtungen in dieser Artikelserie beschränkten sich auf parallele Verarbeitung mit einem Mehrkernrechner. Der Artikel Horizontal Scalability for Parallel Execution of Tasks [19] beschreibt einen 6.2013 www.dotnetpro.de GRUNDLAGEN Ansatz, parallele Verarbeitung über mehrere Rechner zu verteilen. Fazit Der erste Teil dieser Artikelserie [1] hat gezeigt, wie einfach feingranulare Asynchronität und Parallelität mit Task und Await zu realisieren sind. Zur Vorsicht sei auf die besonderen Komplikationen bei der Fehlerbehandlung hingewiesen [4]. Mit Parallel.For*() und .Invoke() bietet .NET mächtige Funktionen zur parallelen Programmierung; PLINQ bringt Eleganz ins Spiel; die Datenflussmerkmale helfen, komplexe asynchrone Datenflüsse übersichtlich und effizient zu gestalten. Mit diesen mächtigen Merkmalen von .NET ist es deutlich einfacher geworden, typische Qualitätsanforderungen an Reaktivität, Durchsatz, Leistungsverhalten, Skalierbarkeit, Wartbarkeit, Robustheit und Korrektheit zu erfüllen. Es ist eine Freude, damit zu entwerfen und zu programmieren. Versuchen Sie es doch mal! [jp] [1] P eter Meinl, Thread is Dead! Überblick über asynchrone und parallele Verarbeitung mit .NET, dotnetpro 5/2013, Seite 132 ff., www.dotnetpro.de/A1305AsyncParallel [2] M SDN, Datenfluss (Task Parallel Library), www.dotnetpro.de/SL1306AsyncParallel1 [3] P eter Meinl, Unter Beobachtung, Dateien per Multithreading und MEF verarbeiten, dotnetpro 4/2013, Seite 128 ff., www.dotnetpro.de/A1304AsyncParallel [4] P eter Meinl, Lass da mal was sein, ­ Saubere und robuste Fehlerbehandlung in .NET-Anwendungen, 3/2013, Seite 132 ff., www.dotnetpro.de/A1303ParallelFehler [5] P amela Vagata, When Should I Use Parallel. ForEach? When Should I Use PLINQ?, www.dotnetpro.de/SL1306AsyncParallel2 [6] J oseph Albahari, Code Listings zu C# in a Nutshell, www.dotnetpro.de/ SL1306AsyncParallel3 [7] S tephen Toub, Introduction to TPL Dataflow, www.dotnetpro.de/SL1306AsyncParallel4 [8] M ichael Scheffler, Schneller durch die Warteschlange, Große Datenmengen richtig parallel verarbeiten, dotnetpro 9/2012, Seite 54 ff., www.dotnetpro.de/A1209Producer [9] H tml Agility Pack, http://htmlagilitypack.codeplex.com [10] R eactive Extensions, www.dotnetpro.de/SL1306AsyncParallel5 [11] M SDN, Übersicht über Synchronisierungs­ primitiven, www.dotnetpro.de/ SL1306AsyncParallel6 [12] L eanWork, FileWatcher, FilePoller Components, www.dotnetpro.de/ SL1306AsyncParallel7 [13] C hannel 9, SignalR, www.dotnetpro.de/SL1306AsyncParallel8 [14] M SDN, CCR Introduction, www.dotnetpro.de/SL1306AsyncParallel9 [15] S QL Server StreamInsight, www.dotnetpro.de/SL1306AsyncParallel10 [16] Microsoft Accelerator, www.dotnetpro.de/SL1306AsyncParallel11 [17] Brahma, www.dotnetpro.de/SL1306AsyncParallel12 [18] N vidia CUDA, www.dotnetpro.de/SL1306AsyncParallel13 [19] J esus Aguilar, Horizontal Scalability for Parallel Execution of Tasks, MSDN Magazine 10/2012, www.dotnetpro.de/SL1306AsyncParallel14 [20] C ustom Partitioners for PLINQ and TPL, www.dotnetpro.de/SL1306AsyncParallel15 [21] Adam Freeman, Pro .NET 4 Parallel Programming in C#, ISBN 10: 1-4302-2968-3 codekicker.de Die deutschsprachige Q&A-Plattform für Software-Entwickler codekicker.de – Antworten für Entwickler