C# Multithreading mit Parallel.For am Beispiel der Berechnung von PI

So gut wie jeder Computer, jedes Tablet und jedes Smartphone hat heute einen Multicore-Prozessor verbaut. Leider nutzt eine Software nicht zwangsläufig automatisch alle Kerne der CPU aus (Multithreading). Es muss also durch den Programmierer einiges an Vorarbeit geleistet werden, damit seine Software die volle Leistung des Systems ausnutzen kann und in mehreren Threads läuft.

Die Leibniz-Reihe

In diesem Tutorial wollen wir eine Annäherung der Kreiszahl PI berechnen. Dazu benutzen wir die sogenannte Leibniz-Reihe, welche PI durch folgende Summe berechnet:

Bild: Darstellung der Leibniz Reihe

Die Formel ist prinzipiell einfach mit einer einfachen for Schleife umzusetzen. Für die Summe von k = 0 bis k = 1000 könnte man mit folgender Schleife eine Annäherung von PI berechnen:

Das Ergebnis dieser Berechnung ergibt pi = 3,14259165433954.

Wir sehen, dass das Ergebnis noch ziemlich ungenau ist – und das obwohl wir bereits k bis auf 1000 durchlaufen haben. Aus diesem Grund brauchen wir ein deutlich größeres k und damit deutlich mehr Schleifendurchläufe, was sehr zeitaufwändig ist. Im Moment läuft jedoch alles auf einem einzigen Kern der CPU. Um also unsere Rechenzeit zu verkürzen möchten wir PI unter voller Auslastung der CPU in mehreren Threads auf allen Kernen berechnen. Gleichzeitig wollen wir zumindest die Möglichkeit haben, PI in einer von uns festlegbaren Präzision zu berechnen. Dazu brauchen wir eine unbegrenzte Anzahl an Nachkommastellen sowie ein unbegrenzt maximal großes k (= Anzahl der Schleifendurchläufe).

Da uns C# von sich aus leider keine Möglichkeit bietet mit einer endlosen Anzahl von Nachkommastellen zu arbeiten, müssen wir etwas tricksen und bedienen uns der Klasse BigInteger (System.Numerics), welche zumindest unendlich große (durch RAM begrenzt) Zahlen aus der Reihe der Ganzen Zahlen ( …; -3; -2; -1; 0; 1; 2; 3; …) ermöglicht.

Umsetzung in C#

Beginnen wir mit einer Klasse, die wir PICalculator nennen, und einigen Variablen die wir benötigen:

Die Variable f steht für unseren Faktor. Da wir nicht mit Gleitkommazahlen rechnen sondern mit Ganzen Zahlen, multiplizieren wir den Zähler unserer Summe einfach mit einem Faktor f, welcher einer Zehnerpotenz entspricht, und verschieben am Ende das Komma um die Höhe der Potenz nach vorne.
Beispiel mit f = 10^4 = 10000:

Bild: Beispiel für die Berechnung von PI mit ganzen Zahlen

Nach der Kommaverschiebung würde hier also pi = 3,2849 herauskommen.

Die Variable i ist unser k. Da wir in separaten Threads arbeiten, wird das Array die Größe der Anzahl der Threads haben. Somit kann für jeden Thread i separat erhöht werden.

Das gleiche gilt für die Variable sum, welche für jeden Thread die Summe speichert.

Die Variable depth entspricht unserem maximalen k – also der Gesamtanzahl der Schleifendurchläufe.

Die Variable finish speichert, ob der jeweilige Thread seine Schleife beendet hat.

 

Beispiel: Berechnung mit 4 Threads, einem f von 10000 und einer Tiefe (depth) von 1000.

Bild: Beispiel für die Berechnung von PI in 4 Threads

Es gilt nun: pi = (10000 + sum[0] + sum[1] + sum[2] + sum[3]) * 4 = 31428

Nach Division durch f oder der Verschiebung des Kommas erhalten wir pi = 3,1428.

Wir teilen also die Berechnung der Summe in 4 gleichgroße Schleifen auf. Jeder Thread hat also (depth / Anzahl Threads) Schleifendurchläufe.

 

Der Konstruktor

Der Konstruktor übernimmt drei Parameter: Die Präzision, welche der Zehnerpotenz unseres f entspricht, die maximale Gesamtanzahl der Schleifendurchläufe und die Anzahl der Threads.

Im weiteren werden die Variablen initialisiert und die Arrays gefüllt. Dabei wird jedes i auf den Startwert abhängig von der Anzahl der Threads gesetzt.

Beispiel für depth = 1000 und threads = 4:

 

Der Task oTask (outputTask) dient lediglich dazu einen Task im Hintergrund laufen zu haben, der es ermöglicht auf Tastendruck den Fortschritt der einzelnen Threads in Prozent anzuzeigen. Die Implementierung kann man am Ende dieses Tutorials im vollständigem Codebeispiel angucken und spielt hier zunächst keine Rolle.

 

Die Berechnung eines Schleifenschritt im jeweiligen Thread

Die Methode performSteps(int thread) berechnet einen einzelnen Schleifendurchlauf und erhöht den Zähler i im entsprechenden Thread um 1.

Zunächst wird überprüft, ob bereits das Ende der für diesen Thread maximalen Höhe von i erreicht wurde, indem das aktuelle i mit dem Startwert des nächsten Threads (thread+1) verglichen wird. Wenn das Ende erreicht wurde, wird finish für diesen Thread auf true gesetzt und keine weitere Berechnung mehr durchgeführt.

Hier wird überprüft ob es sich beim aktuellen i um eine gerade oder eine ungerade Zahl handelt. Gemäß der Leibniz-Reihe wird bei ungeradem i subtrahiert und bei geradem i addiert. Die Funktion BigInteger.Remainder entspricht im Prinzip einer Modulo Operation.

 

Addition aller einzelnen Ergebnisse der Threads

Die Methode getResult() sumiert alle sum der Threads, multipliziert das Ergebnis mit 4 und gibt das Ergebnis als ganze Zahl (ohne Kommaverschiebung) zurück.

 


Die Hauptklasse und void Main()

In unserer Main Methode legen wir in Variablen zunächst fest mit wie vielen Threads wir arbeiten wollen, wie groß unser f ist und wie viele maximale Schleifendurchläufe wir haben wollen.

In diesem Beispiel wollen wir 25 Threads haben, wovon das fertige Programm immer maximal die maximal mögliche Anzahl auslastet. Die Präzision in Höhe von 20 entspricht einem f von 10^20 = 100000000000000000000. Insgesamt sollen eine Milliarden Schleifendurchläufe gemacht werden. Die Berechnung wird auch mit Multithreading einige Zeit in Anspruch nehmen. Dennoch kann man davon ausgehen, dass bei einer Quadcore CPU mit aktiviertem Hyperthreading in diesem Fall die Berechnung nur ca. ein Achtel der Zeit in Anspruch nimmt, wie wenn man die Berechnung ohne Multithreading (wie im ersten Beispiel ganz oben) durchführen würde.

Parallel.For (…)

Kommen wir also nun zum wichtigsten Teil. C# bietet uns die Funktion Parallel.For (aus System.Threading.Tasks), welche einer for-Schleife gleicht, die allerdings automatisch auf eine maximale Anzahl an Threads verteilt wird. Das heißt diese Parallel.For-Schleife durchläuft i von 0 bis numThreads und startet für jedes i einen neuen Thread. In jedem dieser Threads rufen wir in einer while-Schleife die oben implementierte Methode performSteps auf. Als Parameter übergeben wir das i, welches dem aktuellen Thread entspricht. Die while-Schleife läuft so lange, bis die Methode performSteps das Ende ihrer abzuarbeitenden Berechnungen erreicht hat und false zurückliefert.

Letztendlich haben wir in unserem Beispiel 25 while-Schleifen, die prinzipiell parallel laufen sollten. Dadurch, dass aber die wenigstens CPUs 25 parallel laufende Threads unterstützen, laufen einige Threads nur in den Momenten, wo andere Threads gerade keine Berechnung durchführen.

Im letzten Teil der Main Methode setzen wir ganz einfach hinter die erste Position des Ergebnis das Komma und geben es aus. Anschließend warten wir darauf, dass der Benutzer die Escape Taste drückt um das Programm zu beenden.

 


Der komplette Code

Im folgenden könnt ihr den kompletten Code studieren, kopieren und Testen. Er enthält noch zwei Methoden, die hier nicht explizit besprochen wurden. Dabei geht es um die Feststellung des Fortschritts der Berechnung im einzelnen Thread und der Ausgabe in Prozent.

 

Die Ausgabe nach dem Drücken der Leertaste nach einigen Sekunden. Man sieht genau, das einige Threads noch nicht oder kaum mit ihrer Arbeit begonnen haben:

 

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.