Gleichzeitig zum Erfolg

Werbung
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
Herunterladen