Parallelprogrammierung mit C++ und Qt, Teil 2: Bildsequenzen parallel berechnen
Seite 3: Thread-Synchronisation
Thread-Synchronisation mit Condition-Variablen
In den bisherigen Beispielen sind sämtliche Threads bis zum Programmende durchgelaufen. Dabei kam es nur dann zu seltenen und kurzen Unterbrechungen, wenn mindestens zwei Threads gleichzeitig einen durch Mutex geschützten Bereich erreicht haben. In der Praxis sind Berechnungen jedoch häufig in regelmäßigen Abständen zu unterbrechen, etwa um Zwischenergebnisse auszutauschen oder zu speichern. Man spricht in dem Zusammenhang von einer Barrier als einem Punkt, den alle Threads zunächst erreichen müssen, bevor die Berechnung weitergehen kann. Barriers treten häufig im High Performance Computing auf. Sie dienen beispielsweise bei iterativen Problemen dazu, das Ergebnis für die jeweils aktuelle Iteration aus den Einzelergebnissen der Rechenknoten zu bilden, bevor dann die nächste Iteration gestartet wird. Im Folgenden sei ein solches Szenario anhand von Fraktalberechnungen demonstriert. Das Beispiel QParallelJulia berechnet dazu auf Basis des Code von QParallelMandelbrot eine Sequenz von Julia-Mengen, wobei der Parameter c der Julia-Menge auf einer Ellipse in der komplexen Zahlenebene bewegt wird.
Die Bildsequenz wird im Zusammenspiel der von QThread abgeleiteten Klassen Frame und Movie generiert, wobei die Berechnung eines Bilds in Frame ähnlich funktioniert wie in der Calc-Klasse des Mandelbrot-Beispiels. Neu hinzugekommen ist die Verzahnung mit der Movie-Klasse anhand zweier QWaitCondition-Objekte. Die in der Klasse Data gespeicherte Condition-Variable frameWait implementiert eine Barrier für die parallel laufenden Frame-Threads. Gesteuert von der Variablen movieWait wartet die Klasse Movie darauf, dass alle Threads die Berechnung eines Frames abgeschlossen haben. Die Klasse Data verwaltet dazu die Variable threadsReady, die per data->threadReady() hochgezählt wird, sobald ein Thread die Berechnung beendet hat. Hier der gesamte Ablauf in Frame::run():
while(data->getFrameCount() < data->getMaxFrames()) {
data->getC(c_re, c_im);
while (data->getNextChunk(s, l)) {
calcChunk(c_re, c_im, s, l);
}
data->frameMutex.lock();
data->threadReady();
if (data->getThreadsReady() == data->getThreadCount()) {
data->setThreadsReady(0);
data->movieWait.wakeOne();
}
data->frameWait.wait(&data->frameMutex);
data->frameMutex.unlock();
}
data->frameMutex.lock();
data->movieWait.wakeOne();
data->frameMutex.unlock();
Sobald threadsReady gleich der Zahl der Frame-Threads ist, wird die Variable per data->setThreadsReady(0) zurückgesetzt. Der Aufruf von data->movieWait.wakeOne() weckt dann den Movie-Thread. Unabhängig vom Wert der Variablen threadsReady implementiert der Aufruf data->frameWait.wait(&data->frameMutex) die Barrier für die Berechnung eines Frames.
Der Funktion QWaitCondition::wait ist stets ein Mutex als Parameter zu übergeben, den vorher QMutex::lock() gesperrt hatte. In einer atomaren Operation werden dann der Mutex entsperrt und die Condition-Variable in den Wartezustand versetzt. Das verhindert, dass mehrere Threads gleichzeitig auf die Condition-Variable zugreifen. Ein Aufwecken des Threads sorgt unmittelbar für das erneute Sperren des Mutexes. Im Beispiel folgt daher noch ein Aufruf von QMutex::unlock(). Die letzten drei Zeilen des Abschnitts stellen sicher, dass der Movie-Thread auch dann noch einmal geweckt wird, wenn die while-Schleife terminiert. Der Code von Movie::run() besteht nur aus den folgenden Zeilen:
while(data->getFrameCount() < data->getMaxFrames()) {
data->movieMutex.lock();
data->frameWait.wakeAll();
data->movieWait.wait(&data->movieMutex);
data->movieMutex.unlock();
data->frameReady();
data->saveFrame();
data->nextFrame();
}
Zunächst werden sämtliche Frame-Threads per data->frameWait.wakeAll() geweckt. Mit dem Aufruf von data->movieWait.wait(&data->movieMutex) wartet der Thread dann darauf, dass alle Frame-Threads die Barrier erreicht haben. Anschließend sendet data->frameReady() ein Signal an die Gui-Klasse, das zum Neuzeichnen des View-Widgets führt. Die Funktion Data::saveFrame() speichert jeweils ein Einzelbild im PNG-Format, wobei die Frame-Nummer automatisch angehängt wird. Der Aufruf von data->nextFrame() initialisiert schließlich wieder die Zahl der zu berechnenden Abschnitte, inkrementiert die Variable frameCount und berechnet die nächsten Werte für die Parameter der Julia-Menge. Wie erwähnt umlaufen Letztere eine Ellipse in der komplexen Zahlenebene mit dem Mittelpunkt x0, y0 und den Hauptachsen a und b.
Die fertigen Bildsequenzen lassen sich mit der Diashow des im ersten Tutorialteil beschriebenen Programms QSimpleViewer darstellen. Da es sich hier nur um ein Beispielprogramm handelt, wird auf Antialiasing verzichtet, was sich durch Erhöhung der Auflösung und anschließende Verkleinerung kompensieren lässt. Um interessante Parameterwerte für Julia-Mengen aufzuspüren, kann der Leser das Programm QJulia verwenden. Klickt er hier mit der rechten Maustaste auf einen Punkt der Mandelbrot-Menge, wird die dazugehörige Julia-Menge mit den entsprechenden Parametern dargestellt. Mit der linken Maustaste lassen sich die Bildausschnitte verschieben. Im Dialog Settings | Colors modifiziert man die Farbpalette und fügt mit der rechten Maustaste neue Farben hinzu. Es sei hier noch einmal daran erinnert, dass beim Bauen eines Installers unter Windows die dll-Dateien für die Formatunterstützung mit einzupacken sind. Das gilt sowohl für QParallelMandelbrot als auch für QParallelJulia und QJulia. Letzteres Programm benötigt zusätzlich noch die Datei QtXml4.dll für die XML-Unterstützung.