Herausforderungen und Fallstricke bei der Entwicklung für Multi-Core-Systeme

Parallelverarbeitung mit Java-Threads

Im Folgenden wird die Parallelverarbeitung am Beispiel von Java-Threads vorgestellt. Viele der diskutierten Herausforderungen gelten aber auch in anderen Programmiersprachen wie C++ oder C#. Ein Thread (oder auf das vorhergehende Beispiel bezogen: ein Koch) ist ein sequenzieller Ausführungsstrang innerhalb eines Anwendungsprogramms (Prozess). Dabei kann eine Anwendung aus mehreren Threads bestehen, die parallel ausgeführt werden. Die Threads innerhalb einer Anwendung teilen sich den Speicher des Prozesses, in dem sie gestartet wurden (Shared Memory).

Um einen Thread anzulegen, stellt Java, wie viele andere Sprachen auch, die Klasse Thread zur Verfügung. Wollen Entwickler eigene Threads erstellen, erzeugen sie eine vom Thread abgeleitete Klasse, die die Run-Methode überschreibt. Diese enthält den Programmcode, der durch den Thread zur Laufzeit ausgeführt werden soll. Wird ein Thread gestartet, ist zunächst eine Instanz der Klasse zu erstellen und dann die Start-Methode aufzurufen.

Alternativ lassen sich Threads auch durch Implementieren der Schnittstelle Runnable erstellen. So muss die Klasse nicht vom Thread abgeleitet sein, was Vorteile bei komplexeren Programmstrukturen bietet, da Java keine Mehrfachvererbung unterstützt. Um diese Vorgehensweise in Java zu veranschaulichen, dient als Beispiel die Summenberechnung über die Werte innerhalb eines Arrays (s. Abb. 4).

Beispiel: Zwei Threads summieren Werte eines Arrays (Abb. 4) (Bild: Volkswagen AG)

Dieses Beispiel lässt sich aufgrund der Datenparallelität effizient über mehrere Threads ausführen. Zwei Threads teilen sich die Berechnung der Summe auf. Während Thread 1 die erste Hälfte des Arrays berechnet, übernimmt Thread 2 dies für die zweite Hälfte. Anschließend werden Teilergebnisse addiert.

Das folgende Listing stellt die Klasse Summation (abgeleitet von Thread) dar. Die Run-Methode implementiert die Logik zur Berechnung der Summe. Hierbei wird eine Schleife über dem Array verwendet, die variabel von einem Start-Index bis zu einem End-Index läuft. So lässt sich der gleiche Programmcode für jeden Thread verwenden. Die Klasse Summation erhält über ihren Konstruktor Start und End sowie die in der Klasse Data gekapselten Berechnungsdaten. Data besteht aus einem eindimensionalen Array von Double-Werten und einer Variablen sum zum Speichern der berechneten Summe.

class Data {
double[] array;
double sum;
Data(double[] array) { this.array = array; }
}
class Summation extends Thread {
Data data;
int start, end;
Summation (Data data, int start, int end) {
this.data = data;
this.start = start;
this.end = end;
}
@Override
public void run() {
for (int i = start; i < end; i++) {
data.sum = data.sum + data.array[i];
}
}
}

Das nächste Listing zeigt die Main-Methode des Programms, in der das Array initialisiert und die Threads zur Summenberechnung gestartet werden. Kommt das Programm für ein Array mit 100 Millionen Einträgen auf einem aktuellen Mehrkernrechner zur Ausführung, ergibt sich für einen Thread eine Laufzeit von circa 400 Millisekunden, bei zwei Threads halbiert sich die Laufzeit auf circa 200 Millisekunden. Die Anwendung skaliert also optimal über zwei Threads.

public static void main(String[] args)
// Initialisieren und Füllen des Arrays
double[] array = new double[n];
Arrays.fill(array, 1);
Data data = new Data(array);

// Initialisieren und Starten der Threads 1 und 2
Thread thread1 = new Summation(data, 0, n/2);
thread1.start();
Thread thread2 = new Summation(data, n/2, n);
thread2.start();
// Warten bis Thread 1 und 2 fertig sind
thread1.join();
thread2.join();
// Ergebnisausgabe
System.out.println("sum: " + data.sum);
}