Parallelisierung mit anonymen Funktionen in Java
Die anonymen Funktionen gehören unbestritten zu den bedeutendsten Erweiterungen des kommenden Java 8. Besonders nützlich finde ich sie für die explizite Parallelisierung. Dabei geht es mir weniger um die Auslastung von Mehrkernprozessoren als vielmehr um die Reduktion von Netzwerklatenzen in verteilten Systemarchitekturen.
Ich gehe davon aus, dass sich dafür in Java ein paar wenige Implementierungsmuster herauskristallisieren werden, mit denen der größte Teil der Fälle sinnvoll abgedeckt werden kann. Aktuell sehe ich dafür vor allem die parallele Schleife und einen parallelen map
-Algorithmus, deren beider Einsatz ich kurz vorstellen möchte. Für das Beispiel orientiere ich mich am Domain-Name-System (DNS) und der Prüfung, ob für eine IP‑Adresse die Vorwärts- und Rückwärtsauflösung in Ordnung ist. Für die DNS-Abfragen gehe ich von dem folgenden Java-Interface aus:
interface Resolver {
List<IpAddress> fetchAddresses(String domainName);
List<String> fetchDomainNames(IpAddress address);
}
Die Implementierung der Funktion zur Validierung ist damit sehr einfach, wobei ich die Ausnahmebehandlung zur Vereinfachung ignoriere.
boolean isValidIpAddress(IpAddress address) {
List<String> domainNames = resolver.fetchDomainNames(address);
for (String domainName : domainNames) {
if (resolver.fetchAddresses(domainName).contains(address)) {
return true;
}
}
return false;
}
Ich finde dieses Beispiel besonders geeignet, da aufgrund der verteilten DNS-Organisation sogenannte "Bulk-Operationen" offensichtlich bei der Reduktion der Laufzeit kaum helfen. In vielen anderen Fällen ist das ähnlich – jedoch schwieriger zu argumentieren. In jedem Fall liegt es in der Verantwortung der aufrufenden Seite, die Operationen selbst geeignet zu parallelisieren. Bevor ich dazu komme brauche ich als Vorbereitung allerdings noch die drei folgenden Interfaces für zwei Funktionstypen und einen Service zur parallelen Ausführung:
interface Action<T> {
void call(T argument);
}
interface Function<T, U> {
U call(T argument);
}
interface Executor {
<T> void foreach(Iterable<T> iterable, Action<T> action);
<T, U> Iterable<U> map(Function<T, U> function, Iterable<T> iterable);
}
Die Methode Executor.foreach
führt die angegebene Funktion für alle Elemente in einer geeignet parallelen Weise aus. Die Methode Executor.map
ist ähnlich, liefert aber zusätzlich die Ergebnisse der Aufrufe entsprechend der ursprünglichen Reihenfolge zurück. Der Aufruf beider Methoden kehrt erst nach der vollständigen Ausführung aller parallelen Tasks zurück. Was zunächst wie eine hinderliche Einschränkung aussieht erweist sich in der Praxis als äußerst nützlich – insbesondere im Zusammenhang mit der hier vernachlässigten Ausnahmebehandlung.
Ich zeige zunächst eine parallele Implementierung meiner Beispielfunktion mit der Methode Executor.map
. Fachlich eignet sich diese Methode besonders gut für solche Situationen, und die Umsetzung ist einfach und hinreichend verständlich.
boolean isValidIpAddress(IpAddress address) {
List<String> domainNames = resolver.fetchDomainNames(address);
// store reference to anonymous function in local variable
Function<String, List<IpAddress>> resolve = d -> resolver.fetchAddresses(d);
for (List<IpAddress> addresses : executor.map(resolve, domainNames)) {
if (addresses.contains(address)) {
return true;
}
}
return false;
}
Unangenehm auffallend finde ich den für Java typischen, überflüssigen und nicht zur Verständlichkeit beitragende Zwang zur Typangabe lokaler Variablen. Aus meiner Sicht hat Java hier noch einen großen Nachholbedarf gegenüber den anderen, verbreiteten und statisch typisierten Programmiersprachen, so dass sich mehr Freiheitsgrade ergeben und die Implementierung beispielsweise so aussehen könnte:
boolean isValidIpAddress(IpAddress address) {
var mapped = executor.map(
domainName -> resolver.fetchAddresses(domainName),
resolver.fetchDomainNames(address));
for (var addresses : mapped) {
if (addresses.contains(address)) {
return true;
}
}
return false;
}
Die Implementierung der Beispielfunktion mit Hilfe der Methode Executor.foreach
sieht ein wenig einfacher aus. Aus meiner Sicht ist das eher negativ. Denn die Steuerung über globale Variablen (bezogen auf den Gültigkeitsbereich der anonymen Funktion) ist weit weniger elegant als die vorherige Implementierung.
boolean isValidIpAddress(IpAddress address) {
AtomicBoolean result = new AtomicBoolean(false);
executor.foreach(resolver.fetchDomainNames(address), domainName -> {
if (resolver.fetchAddresses(domainName).contains(address)) {
result.lazySet(true);
}
});
return result.get();
}
Meiner Ansicht nach zeigen die beiden Beispiele zumindest, dass anonyme Funktionen lesbare und wartbare, parallele Implementierungen unterstützen. Meine Hoffnung ist, dass wir dann auch in Zukunft mehr Services sehen, die davon Gebrauch machen und besser in der Anzahl der zu verarbeitender Entitäten skalieren als sie das gefühlt heute tun.