Von parallelen Collections, dynamischen Methodenaufrufen und Eclipse-Integration

Neues in Scala 2.9

Arno Haase

Scala 2.9 ist frisch erschienen. Und damit halten endlich die erwarteten parallelen Collections Einzug, die die Arbeit im Hintergrund auf mehrere Prozessorkerne verteilen. Außerdem kann man mit Scala jetzt Klassen schreiben, die wie in Groovy oder Ruby beliebige Methodenaufrufe entgegennehmen und dynamisch verarbeiten.

Im Gegensatz zum Bruch, der mit dem Versionswechsel von 2.7 auf 2.8 einherging, ist der Wechsel zu 2.9 ein sanfter Schritt. Er ist voll rückwärtskompatibel und auf Robustheit und Performancesteigerungen ausgerichtet.

Scala im Java Magazin

Mehr zu Scala gibt es in der aktuellen Java-Magazin-Artikelserie von Arno Haase:

  • Teil 1: Neues in Scala 2.8
  • Teil 2: Collection-Bibliothek
  • Teil 3: Scala 2.9

Seit Martin Odersky seine Firma gegründet hat, arbeiten vier seiner Entwickler an der Weiterentwicklung der Scala IDE für Eclipse, und das zeigt Erfolge: Die Version 2.0 dieses Plug-ins ist inzwischen in einer stabilen Betaversion verfügbar [1]. Das dürfte besonders Umsteigern die ersten Schritte mit Scala erheblich erleichtern.

Scala 2.9 ist jetzt – nach diesmal vier RC-Releases – in der finalen Version erschienen. Ein großer Teil der Arbeiten an der Version 2.9 ist wie gesagt in Bugfixes und kleine Aufräumarbeiten geflossen, gerade auch im Bereich des Toolings. Aber die größeren Neuerungen sind spannender, und um die soll es im Rest des Artikels deshalb auch gehen.

Parallele Collections

Das wohl prominenteste Feature von Scala 2.9 sind die parallelen Collections. Das sind Collection-Klassen, die beim Aufruf normaler Methoden wie find()oder map()im Hintergrund die Arbeit auf mehrere Threads verteilen. Dadurch können mehrere Prozessorkerne Teilaufgaben bearbeiten und die Ausführung wird potentiell schneller.

Die parallelen Collection-Klassen liegen im neuen Package scala.collection.parallel mit den Subpackages _.mutable und _.immutable. Ihre Namen haben den Präfix Par, und sie erben wie alle anderen Collections auch von Iterable bzw. Seq. Sie haben also dieselben Methoden wie die bereits bekannten Collections. So gibt z. B. println (ParVector (1, 2, 3, 4, 5, 6, 7, 8).map (_+1)) einen neuen Vector mit den Zahlen von 2 bis 9 aus. Das ist keine Überraschung, und das ist auch gut so.

Interessanter ist ein Blick hinter die Kulissen. Listing 1 inkrementiert ebenfalls elementweise die Zahlen von 1 bis 8, gibt dabei aber für jedes verarbeitete Element den Namen des Threads aus, der das tut.

Listing 1: Ausgabe der Threads bei der parallelen Verarbeitung
def myTrans (i: Int) = {
  println (i + ": " + Thread.currentThread.getName)
  i+1
}
println (ParVector (1, 2, 3, 4, 5, 6, 7, 8).map (myTrans))

Der genaue Output dieses Codes unterscheidet sich von Lauf zu Lauf, wie das für nebenläufige Programme typisch ist. Außerdem hängt das genaue Verhalten von der verwendeten Java-Version sowie der Anzahl der Prozessorkerne (bzw. Hyperthreads) des Computers, auf dem das Programm läuft, ab.

Der Laptop, auf dem dieser Artikel entstanden ist, hat zwei Prozessorkerne. Ein beispielhafter Start mit SUN JRE 1.6 erzeugt den folgenden Output:

5: ForkJoinPool-1-worker-0
1: ForkJoinPool-1-worker-1
2: ForkJoinPool-1-worker-1
6: ForkJoinPool-1-worker-0
7: ForkJoinPool-1-worker-0
8: ForkJoinPool-1-worker-0
3: ForkJoinPool-1-worker-1
4: ForkJoinPool-1-worker-1
ParVector(2, 3, 4, 5, 6, 7, 8, 9)

Das gibt Einblick in einige Interna. Zunächst einmal sieht man, dass die Arbeit auf zwei Threads aufgeteilt wurde. Die „parallele Collection“ hat die Arbeit also tatsächlich parallelisiert.

Außerdem sind die Elemente nicht in streng aufsteigender Reihenfolge verarbeitet worden – sofern man bei echt nebenläufigen Prozessen überhaupt von einer Reihenfolge reden kann. In diesem Beispiel hat zumindest jeder Thread Elemente in aufsteigender Reihenfolge verarbeitet, aber auch das wird nicht garantiert und ist bei etwas größeren Collections auch tatsächlich nicht der Fall.

Das Dritte, was einem an diesem Output auffallen kann, ist der Name der Threads: Er beginnt mit „ForkJoinPool“. Scala verwendet ab JRE 1.6 eine Vorabversion des Fork-Join-Frameworks aus JSR 166y, das Teil von Java 7 sein wird (Kasten: „Fork/Join-Framework“).

Fork/Join-Framework

Ein nützliches und verbreitetes Paradigma zur parallelen Verarbeitung von Daten besteht darin, die Daten aufzuteilen (fork), die Teilmengen in verschiedenen Threads zu verarbeiten und anschließend die Teilergebnisse wieder zusammenzuführen (join). JSR 166y definiert eine entsprechende Bibliothek für Java, die in Java 7 integriert werden soll.

Die konkrete Implementierung arbeitet auf einer festen Menge an Threads, die jeweils fest an einen der Prozessorkerne gebunden sind. Jeder dieser Threads hat eine Queue mit „ihren“ Jobs [2].

Ein neuer Job wird zunächst komplett in die Queue eines beliebigen, zufällig ausgewählten Threads eingestellt. Der unterteilt ihn in Teilaufgaben, die er wieder zufällig verteilt usw., bis die Teilaufgaben so klein sind, dass sie zur sequenziellen Verarbeitung geeignet sind.

Wenn einer der Threads keine Aufgabe mehr in seiner Queue findet, holt er sich eine Aufgabe vom Ende der Queue eines anderen Threads (Work Stealing). Dadurch erfolgt Load Balancing zwischen den Threads. Und weil ein gestohlener Job vom Ende einer Queue kommt, gibt es nur selten Lock Contention mit dem Eigentümer der Queue, der seine Jobs ja vom Anfang der Queue liest.

Scala verwendet ab der JRE-Version 1.6 die Fork/Join-Bibliothek, die als JAR-Datei Teil der Scala-Distribution ist. Für frühere JRE-Versionen (oder solche, die nicht von Sun sind) verwendet es eine eigene Implementierung auf Threadpool-Basis.

Geschrieben von
Arno Haase
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.