DIGITAL
Nicht nur Rechenkluster und die haushaltsüblichen Multicpu-Systeme fördern das Arbeiten mit parallelisierbaren Algorithmen. Auch Benutzeroberflächen (Sie möchten nicht, dass Ihr Programm stehen bleibt, wenn sie eine aufwändige Rechnung durchführen) und Webprotokolle, die das Internet mit asynchronen (non-blocking) Ansätzen zu mehr gleichzeitig möglichen Anfragen pro Anwendung führte, benötigen mehr als einen Thread pro Programm. Auf diesem Übungsblatt werden wir uns einige einfache Ansätze zum Thema „Parallelisierung“ anschauen.
Die Anlage enthält ein Tutorial mit den nötigsten Befehlen, um dieses Blatt bearbeiten zu können.
void parallel_transform(std::vector<float> &v, size_t num_threads, std::function<float(float)> f);
num_threads
viele std::thread
s um die Einträge im Vektor mit f
zu transformieren. std::vector<std::thread> workers
verwenden, reservieren Sie den Speicher (workers.reserve()
) des Vectors bevor Sie Threads darin ablegen. Bei der Vergrößerung des Vectors durch push_back
werden die Werte kopiert, was bei Threads jedoch nicht funktioniert.push_back
können Sie Thread-Objekte auch ohne Kopien direkt mit emplace_back
auf dem Vector-Speicher allokieren. emplace_back
erhält dabei die selben Argumente wie der Konstruktor von std::thread
.void parallel_transform(std::vector<float> &v, std::function<float(float)> f)
std::async
s um die selbe Aufgabe zu lösen. C++ kümmert sich hierbei um das Management der Threads.time
) #include <iostream>
#include <chrono>
#include <thread>
int main(){
using namespace std::chrono_literals;
std::cout << "Hello waiter\n" << std::flush;
auto start = std::chrono::high_resolution_clock::now();
std::this_thread::sleep_for(2s);
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> elapsed = end-start;
std::cout << "Waited " << elapsed.count() << " ms\n";
}
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mutex1, mutex2;
void ThreadA() {
mutex2.lock();
std::cout << "Thread A" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
mutex1.lock();
mutex2.unlock();
mutex1.unlock();
}
void ThreadB() {
mutex1.lock();
std::cout << "Thread B" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
mutex2.lock();
mutex1.unlock();
mutex2.unlock();
}
int main() {
std::thread t1( ThreadA );
std::thread t2( ThreadB );
t1.join();
t2.join();
std::cout << "Finished" << std::endl;
return 0;
}
parallel_transform
-Threads keine Ausgabe vornehmen, ohne dass die Zeichen wild durchmischt werden, etwa hier: float v_new = f(v);
std::cout << "alte :" << v << ", neue:" << v_new << std::endl;
parallel_transform
so, dass nach jeder Berechnung eines Wertes durch f
das Ergebnis und der Index ausgegeben wird, es aber keine Vermischung der Ausgabe gibt. Bei der Verwendung von std::async
haben Sie bereits mit dem std::future
-Objekt gearbeitet. In dieser Aufgabe wollen wir verstehen was im Hintergrund eigentlich passiert. Dazu schauen wir uns das Konzept von Futures und Promises an:
In der letzten Aufgabe („Einfache Parallelisierung“) haben wir bereits bemerkt, dass auf vielen Systemen die Ausführungsreihenfolge von Threads Variabel sein können. Bei Ausgaben ist dies nicht ganz so schlimm. Bei Berechnungen kann dies aber fatale Folgen haben1 Wir haben das Problem in der letzten Aufgabe so gelöst, dass verschiedene Threads nur in zugewiesenen Speicherbereichen (im Vector) Daten verändern. Dadurch konnte inkonsistentes Verhalten oder unnötiges Neuerrechnen trivialerweise verhindert werden (aus der Grund nennt man dieses Vorgehen „triviale Parallelisierung“.
Futures und Promises sind eine andere Lösung für das selbe Problem: Sie implementieren einen Mechanismus, der es ermöglicht Daten von einem Thread an einen anderen zu übergeben, ohne den Speicher explizit aufteilen zu müssen. Gleichzeitig ermöglichen Sie die Synchronization selbst zu kapseln.
[1] Erst \(x \leftarrow x + 2\) und dann \(x \leftarrow 2\cdot x\) zu berechnen ergibt eine andere Zahl als erst \(x \leftarrow 2\cdot x\) und dann \(x \leftarrow x + 2\) zu berechnen
In C++ sieht die Verwendung von std::promise
und std::future
wie folgt aus:
(PS: Durch sleep_for
werden teure Rechnungen simuliert, zu warten ergibt in diesem Beispiel natürlich keinen Sinn).
#include <future>
#include <iostream>
#include <thread>
#include <utility>
#include <chrono>
void mult(std::promise<int>& prom, int a, int b){
prom.set_value(a*b);
std::this_thread::sleep_for(std::chrono::seconds(2));
}
int main() {
int a = 12;
int b = 34;
// Initialisieren der Promises für einen int-Wert
std::promise<int> prod_promise;
std::promise<int> square_promise;
// Initialisieren der zugehörigen Futures
std::future<int> prod_result = prod_promise.get_future();
std::future<int> square_result = square_promise.get_future();
// Berechnen der jeweilgen Werte in seperablen Threads
std::thread prod_thread(mult, std::ref(prod_promise), a, b);
std::thread square_thread([&](int a)->void {
std::this_thread::sleep_for(std::chrono::seconds(1));
square_promise.set_value(a*a);
}, a);
// Werte holen
std::cout << "Ergebnisse der Threads:" << std::endl;
std::cout << a << "*" << b << prod_result.get() << std::endl;
std::cout << a << "^2" << square_result.get() << std::endl;
prod_thread.join();
square_thread.join();
std::cout << "beide Threads sind fertig." << std::endl;
}
Konzeptionell erstellen Sie eine Variable, die aus einem anderen Thread gefüllt werden kann. Zu jedem Promise gibt es genau ein Future, das diese Variable einmalig zurückgeben kann. Das interessante dabei ist, dass das Locking, also das Warten auf die Fertigstellung der Threads erst dann erfolgt, wenn der Wert auch benötigt wird. Der Vorteil davon gegenüber direkter Verwendung von thread.join() besteht darin, dass ein Promise bereits vor der Fertigstellung eines Threads abrufbar ist. Im Beispiel erhält man das Ergebnis von prod_promise
bevor der Thread fertig ist. Es ist also damit möglich, voneinander abhängige Threads in gewisser Weise zu synchronisieren.
std::async
, dass im übrigen automatisch ein Promise/Future-Paar erstellt, einen Thread mit der übergebenen Aufgabe startet und derweil das Future-Objekt zurückgibt.
Implementieren Sie eine einfache Version der Klassen future
und promise
, die Sie statt std::future
und std::promise
im Beispiel oben verwenden können.
(D.h. das Ergebnis des Codes oben lässt sich auch mit Ihrer eigenen Implementierung, also future/promise
statt std::future/std::promise
ausführen und die Ausgabe ist identisch).
Hierzu ein paar Tipps:
std::shared_ptr
darauf verweisen müssen.std::shared_ptr
nutzen wollen, müssen Sie darauf achten, dass Ihre eigene Implementierung Referenzzähler atomar inkrementiert/dekrementiert. Die Standardimplementierung macht dies von sich aus. Es gibt eigentlich keinen Grund, diese hier nicht zu verwenden.Bevor wir uns der Mandelbrotmenge selbst widmen, sollen an dieser Stelle nochmals die komplexen Zahlen wiederholt werden.
Die komplexen Zahlen sind die Menge \[\mathbb{C} := \mathbb{R} + i\cdot \mathbb{R} := \{a+ib \,|\, a,b\in \mathbb{R}\},\] die mit der folgenden Struktur versehen sind: Eine komplexe Zahl \(z = a+ib\) besteht damit aus zwei reellen Zahlen \(a,b\)1 und der imaginären Einheit \(i\), die die Eigenschaft \(i^2=-1\) besitzt. Aus dieser Eigenschaft und der herkömmlichen Multiplikation und Addition auf reellen Zahlen folgen die Rechenregeln: \[\begin{aligned} \text{Addition:}\quad &(a+ib)+(c+id) &= \quad &\, (a+c) + i(b+d)\\ \text{Multiplikation:}\quad &(a+ib)\cdot(c+id) &= \quad &\, (ac-bd) + i(bc+ad)\\ \text{Absolutbetrag (entspr. Länge im } \mathbb{R}^2\text{):} \quad &|(a+ib)| &= \quad &\, \sqrt{a^2 + b^2} \end{aligned}\] Komplexe Zahlen sind glücklicherweise bereits fest in C++ implementiert. Hier ein Beispiel aus der Dokumentation:
#include <iostream>
#include <iomanip>
#include <complex>
#include <cmath>
int main()
{
using namespace std::complex_literals;
std::cout << std::fixed << std::setprecision(1);
std::complex<double> z1 = 1i * 1i; // imaginary unit squared
std::cout << "i * i = " << z1 << '\n';
std::complex<double> z2 = std::pow(1i, 2); // imaginary unit squared
std::cout << "pow(i, 2) = " << z2 << '\n';
double PI = std::acos(-1);
std::complex<double> z3 = std::exp(1i * PI); // Euler's formula
std::cout << "exp(i * pi) = " << z3 << '\n';
std::complex<double> z4 = 1. + 2i, z5 = 1. - 2i; // conjugates
std::cout << "(1+2i)*(1-2i) = " << z4*z5 << '\n';
}
Wir beginnen mit einer beliebigen komplexen Zahl \(c\in \mathbb{C}\) und definieren die Folge
\[\begin{aligned} z_0 &= 0 z_{n+1} &= z_n^2 + c, \quad \text{ für } n\ge 0. \end{aligned}\]Die Mandelbrotmenge \(M\) ist die Menge, für die der Absolutbetrag dieser Folge nach oben hin beschränkt ist, mathematisch ausgedrückt:
\[c \in M \Leftrightarrow \limsup_{n\rightarrow \infty} |z_n| \le 2. \]repaint
forciert ein neuzuzeichnen des Fensters.)MousePressEvent
verwenden. (Siehe Skript: e.X(),e.Y()
liefert Ihnen die Koordinaten in Ihrem selbstdefinierten Gitter.[1] Die komplexen Zahlen unterscheiden sich von der Menge \(\mathbb{R}\times\mathbb{R}\) der zwei-dimensionalen reellen Vektoren nur dadurch, dass zusätzlich ein „richtiges“ Produkt zwischen Paaren von solchen Vektoren (also den komplexen Zahlen) definiert ist. Die Schreibweise mit dem „\(i\)“ ist historisch bedingt und verwirrt vielleicht eher.