Asynchroner Programmfluss artet schnell in unlesbaren Code aus, falls kein übergreifendes Konzept die Struktur vorgibt. Zum Glück hat die JavaScript-Gemeinde einige funktionale Tricks erfunden, die auch helfen, asynchronen Perl-Code zu zähmen.
Selbst Experten bauen in ihren Code nicht selten unbeabsichtigt Wettlaufsituationen (Race-Conditions) durch unsachgemäße Programmierung ein. Allzu leicht übersehen selbst erfahrene Programmierer Seiteneffekte, wenn Multi-Threading an unerwarteten Stellen im Code plötzlich dazwischen funkt. Die Ursachen müssen Spürtrupps später mit viel Aufwand erkunden. Der Fehler, den der Kunde meldet, kommt nur sporadisch hoch, denn er tritt oft nur beim zeitbedingten Zusammentreffen von Ereignissen auf und lässt sich in der Entwicklungsabteilung nicht einfach reproduzieren.
Läuft andererseits wie zum Beispiel in einer JavaScript-Anwendung nur ein Thread, gibt es per Definition keine Race-Conditions, denn der Code läuft wie aufgeschrieben ab und nichts kommt unerwartet dazwischen und fragt etwa noch inkonsistente Daten ab. Ein klares Plus also, doch damit ein Programm mit einem Thread genauso schnell läuft wie eines mit vielen, darf es nicht untätig herumsitzen, während zum Beispiel eine um Größenordnungen langsamere Netzwerkoperation läuft. Hierzu gibt der Programmierer die Kontrolle in ausgewählten Programmteilen an eine Eventschleife ab, die Ereignisse dann asynchron abarbeitet. Ist das Programmierergehirn einmal auf den asynchronen Programmfluss umgestellt, schreiben sich auch komplizierte Abläufe wunderbar leicht. Aber bis es soweit ist, sind einige Hürden zu nehmen.
Manch einer kämpft lange damit, asynchrone Programmflüsse zu begreifen. Die Auferstehung von JavaScript als Hipstersprache, besonders auf der Serverseite mit dem komplett asynchronen NodeJS, brachte nicht nur einige der altbekannten typischen Stolperfallen zutage. Vielmehr entstanden in letzter Zeit einige elegante Lösungen wie PubSub, Promises oder ganze Frameworks wie Async.js, die den asynchronen Fluß auch komplexer Applikationen bändigen helfen ([2]).
Asynchronen Fluss empfinden Neulinge auch in Perl oft als Einschränkung, denn auf einmal können sie den Inhalt einer Webseite nicht mehr einfach mit dem CPAN-Modul LWP::UserAgent und dem Aufruf
$ua->get("http://foo.com");
einholen, denn während get()
auf die Antwort des Webservers und die
zäh eintrudelnden Daten wartet, stehen alle Räder der Applikation still.
So geht es also nicht,
vielmehr läuft nun alles über Callbacks, wie im AnyEvent-Framework
etwa mit
http_get("http://foo.com", sub { print $_[1] } );
Das Programm gibt dabei nur den Auftrag, die Webseite einzuholen, und kehrt
dann unmittelbar nach dem Aufruf von http_get
zum Hauptprogramm zurück. Die
Eventschleife hat den Auftrag entgegen genommen und kümmert sich um das
Einholen und Aufsammeln der Daten erst dann, wenn das Programm wieder eine
Verschnaufpause einlegt. Falls alles komplett vorliegt, ruft die globale
Eventschleife den vorher beigefügten Callback mit den Daten auf, der letztere
mit der print
-Anweisung ausgibt.
Diese Struktur wirft den Programmfluss einer Applikation allerdings gehörig über den Haufen. Stehen zum Beispiel mehrere Web-Requests hintereinander an, deren Anfragen vorher eingeholte Ergebnisse benötigen, nimmt die Verschachtelung schnell schwer zu durchschauende Ausmaße an:
http_get($url1, sub { http_get($url2, sub { # ... }); });
Das Callback-Verfahren führt so bei unsachgemäßer Handhabung schnell zur sogenannten "Pyramid of Doom". Dabei handelt es sich um verschachtelte Funktionen, in denen ein Callback jeweils einen weiteren Callback definiert. Irgendwann ist aber auch der breiteste Bildschirm nicht mehr breit genug, um den Programmfluss so auszuschreiben, ganz zu schweigen davon, dass derartiger Code äußerst schwer zu lesen und zu begreifen ist. Wer wollte dies bei Listing 1 bestreiten?
01 #!/usr/local/bin/perl -w 02 use strict; 03 use AnyEvent::HTTP; 04 use CountServer; 05 06 my $cs = CountServer->new(); 07 $cs->start(); 08 09 my $start_url = $cs->url() . "/test-1.txt"; 10 11 http_get $start_url, sub { 12 my ($body, $hdr) = @_; 13 14 print "Got: $body\n"; 15 http_get $body, sub { 16 my ($body, $hdr) = @_; 17 18 print "Got: $body\n"; 19 http_get $body, sub { 20 my ($body, $hdr) = @_; 21 22 print "Got: $body\n"; 23 } 24 } 25 }; 26 27 my $cv = AnyEvent->condvar(); 28 $cv->recv();
Es simuliert das Problem, das entsteht, wenn das Ergebnis einer
Webanfrage in die nächste Anfrage einfließ, mehrere Webanfragen
also voneinander abhängen und zwar asynchron, aber in einer vorgegebenen
Reihenfolge abgearbeitet werden. Hierzu zieht das Skript http-get-nested
mit CountServer
einen Testserver herein, der auf Anfragen auf
http://localhost:9090 jeweils einen URL mit einer um eins hochgezählten
numerischen Pfadkomponente zurückgibt:
$ curl -w "\n" http://localhost:9090/test-1.txt http://localhost:9090/test-2.txt $ curl -w "\n" http://localhost:9090/test-2.txt http://localhost:9090/test-3.txt $ curl -w "\n" http://localhost:9090/test-5.txt http://localhost:9090/test-6.txt Listing 1 startet den ersten asynchronen Webrequest mit C</test-1.txt>, bekommt C</test-2.txt> zurück, den dann der zweite Request abschickt, und C<test-3.txt> zurückbekommt. In der Tat lautet die Ausgabe von C<http-get-nested> in Listing 1:
$ ./http-get-nested Got: http://localhost:9090/test-2.txt Got: http://localhost:9090/test-3.txt Got: http://localhost:9090/test-4.txt
was bestätigt, dass der Code zwar schwer zu verstehen, aber durchaus
funktionsfähig ist. Der Code des CountServers ist in Listing 2 zu sehen,
es handelt sich um eine simple Perl-Klasse, deren start()
-Methode
einen Webserver des CPAN-Moduls AnyEvent::HTTPD anwirft. Mit
reg_cb()
in Zeile 22 registriert er einen Handler für eingehende
Anfragen und gibt lediglich um Eins erhöhte Pfadangaben an den anfragenden
Webclient zurück. Wer denkt, man müsse einen externen Webserver starten,
um einen Webclient zu testen, wird sich zwar verwundert am Kopf kratzen
und Hexenwerk vermuten, aber in der asynchronen Welt ist es gang und gäbe,
sowohl Server als auch Client zu Testzwecken gleichzeitig im selben
Programm ticken zu lassen. So wird der Unit-Test zum Integration-Test!
01 use strict; 02 use URI::URL (); 03 use AnyEvent::HTTPD; 04 05 my $PORT = 9090; 06 my $BASE_URL = "http://localhost:$PORT"; 07 08 ########################################### 09 sub new { 10 ########################################### 11 bless {}, $_[0]; 12 } 13 14 ########################################### 15 sub start { 16 ########################################### 17 my( $self ) = @_; 18 19 $self->{ httpd } = 20 AnyEvent::HTTPD->new( port => $PORT ); 21 22 $self->{ httpd }->reg_cb( 23 "" => sub { 24 my ($hdr, $req) = @_; 25 26 my $path = $req->url()->as_string; 27 ( my $newpath = $path ) =~ 28 s/(\d+)/$1 + 1/ge; 29 30 my $url = URI::URL->new( $BASE_URL ); 31 $url->path( $newpath ); 32 33 $req->respond ({ 34 content => ['text/txt', 35 $url->as_string] } ); 36 } 37 ); 38 } 39 40 ########################################### 41 sub url { 42 ########################################### 43 my( $self ) = @_; 44 45 return $BASE_URL; 46 } 47 48 1;
Doch zurück zum zwar funktionsfähigen, aber verschachtelten Client-Code: Auch auftretende Fehler behandelt die Pyramide der Verdammnis nicht ordnungsgemäß. Was passiert zum Beispiel, wenn in der zweiten Stufe ein Fehler passiert? Der Callback der ersten Stufe ist nach erfolgreichem Abschluss des Web-Requests in seinen Callback eingesprungen, und hat die Event-Schleife aufgefordert, einen weiteren Request abzusetzen. Schlägt dieser dann fehl, lässt sich nur schwer der Zusammenhang mit der ersten Stufe oder gar dem ursprünglichen Programm herstellen, das eigentlich von dem Problem Wind bekommen und Details über die Art des Fehlers und dessen Entstehungsort ausgeben sollte.
Oder wie soll das Hauptprogramm feststellen, ob bei parallel abgesetzten Web-Requests schon alle Ergebnisse bereitliegen oder ob immer noch ein paar fehlen? Oder wie den Programmfluss unterbrechen, sobald ein Request fehlschlägt, und die folgenden ignorieren? Kontrolle über den Programmfluss sollte auch in der asynchronen Programmierung gegeben sein und genau dieses Problem schickten sich JavaScript-Programmierer in den letzten Jahren an, mittels mehrerer konkurrierender Ansätze zu lösen.
Software-Komponenten, die miteinander durch gesendete und empfangene Events kommunizieren, sind in der Lage, die Pyramide der Verdammnis zu zersprengen. Zum Einsatz kommt ein Publish/Subscribe-Modell, in dem Objekte sich an eingehenden Events interessiert zeigen, und, falls sie diese empfangen, Methoden anwerfen. Weiterhin bieten sie Methoden an, die es anderen Objekten erlauben, diese Events zu senden. Das Ganze läuft asynchron ab, denn einen gesendeten Event verarbeitet das Zielobjekt nicht sofort, sondern erst, wenn es dazu Zeit findet. Der Sender wartet auch nicht synchron auf ein sofort zu lieferndes Resultat, sondern kehrt zu seinen eigenen Angelegenheiten zurück und wartet seinerseits auf einen eingehenden Event, der das Ergebnis der Anfrage beinhaltet.
Für Perl bietet das CPAN-Modul Object::Event eine simple Implementierung
des Verfahrens. Eine Klasse erbt von Object::Event und registriert dann
mit der Methode reg_cb()
(für "register callback") einen Callback
zu einem vorgegebenen Event. Diesen liefert später die ebenfalls
ererbte Methode event()
an, aufgerufen von externen Objekten, die
Aktionen im Objekt ihrer Begierde auszulösen trachten.
01 #!/usr/local/bin/perl -w 02 package WgetPubSub; 03 use base 'Object::Event'; 04 use AnyEvent::HTTP; 05 06 ########################################### 07 sub new { 08 ########################################### 09 my $self = bless {}, $_[0]; 10 11 $self->reg_cb( "request", sub { 12 my ( $c, $url ) = @_; 13 14 http_get( $url, sub { 15 my ($body, $hdr) = @_; 16 17 $self->event( "response", $body ); 18 } ); 19 20 21 } ); 22 23 return $self; 24 } 25 26 package main; 27 use strict; 28 use AnyEvent::HTTP; 29 use CountServer; 30 31 my $cs = CountServer->new(); 32 $cs->start(); 33 34 my $start_url = $cs->url() . "/test-1.txt"; 35 36 my $wget = WgetPubSub->new(); 37 my $wget2 = WgetPubSub->new(); 38 my $wget3 = WgetPubSub->new(); 39 40 $wget->reg_cb( "response", sub { 41 my( $c, $body ) = @_; 42 43 print "Got: $body\n"; 44 $wget2->event( "request", $body ); 45 } ); 46 47 $wget2->reg_cb( "response", sub { 48 my( $c, $body ) = @_; 49 50 print "Got: $body\n"; 51 $wget3->event( "request", $body ); 52 } ); 53 54 $wget3->reg_cb( "response", sub { 55 my( $c, $body ) = @_; 56 57 print "Got: $body\n"; 58 } ); 59 60 $wget->event( "request", $start_url ); 61 62 my $cv = AnyEvent->condvar(); 63 $cv->recv();
Listing 3 definiert hierzu zunächst einmal eine Klasse WgetPubSub
, deren
Objekte im Konstruktor einen Callback auf den Event "request" registrieren.
Den mit dem Event eintrudelnden URL holen sie sich vom Web ein, und
schicken das Ergebnis mittels eines neuen Events mit dem Namen "response"
wieder zurück. In Zeile 40 hat der Code (zeitlich) vorher mittels
reg_cb()
einen Callback auf "response" definiert, schnappt sich
das übermittelte Ergebnis und leitet einen neuen Web-Request durch
den zweiten Webschnapper $wget2
ein.
Der Code ist nicht verschachtelt sondern linear aufgedröselt, was die Lesbarkeit erhöht, und es bleibt dem Programmierer vorbehalten, die einzelnen Objekte zur Weiterverarbeitung an weitere Funktionen oder gar fremde Pakete zur Weiterverarbeitung schicken, wenn dies der logischen Aufteilung dient.
Mit PubSub geht auch die Fehlerbehandlung einfacher von der Hand.
Da die zweite Stufe des Web-Requests ohne weiteres über die Variable
$wget
auf den Web-Agenten der ersten Stufe zugreifen kann, kann sie diesem
im Fehlerfall auch eine Nachricht, wie zum Beispiel einen Event mit dem
Namen error
schicken. Lauscht die Komponente auf diese Events, bekommt
sie von dem weiter hinten passierten Problem Wind und kann ihrerseits eine
Komponente im Hauptprogramm benachrichtigen.
Eine vielversprechende (sic!) neue Methode bei der asynchronen Programmierung sind Promises ([3]). Hierbei gibt eine asynchrone Funktion mit einem Callback ein abstraktes Objekt vom Type Promise zurück, eine Art Fenster in die Zukunft. Dabei ist zunächst noch nicht bekannt, ob das Promise irgendwann einmal ein Ergebnis zugespielt bekommt oder ihm ein Fehler entsteigt. Bis zum Zeitpunkt der Entscheidung ist es eine Art hybrides fremdgesteuertes Zwitterwesen, das darauf wartet, dass irgendjemand eine Entscheidung trifft, einen Schalter umlegt, und eventuell Ergebnisdaten hineinpumpt.
An einem Beispiel lässt sich die Funktion eines Promise am einfachsten
erklären. Listing 4 definiert in Zeile 5 ein Deferred-Objekt, eine Art
Promise mit Entscheidungsgewalt. Aus ihm leitet Zeile 7 ein Promise ab,
in das die then()
-Methode zwei verschiedene alternative Zustände
einhaucht: Einmal ist Schrödingers Katze am Leben, einmal ist sie tot.
Beide sind offensichtlich unvereinbar, nur einer von beiden kann sich
irgendwann bewahrheiten. Der Zeitpunkt der Entscheidung kommt in dem
Augenblick, in dem das übergeordnete Deferred-Objekt eine von zwei
Methoden aufruft: Ruft es resolve()
, bewahrheitet sich der erste
Zustand, ruft es reject()
, wird der zweite Wirklichkeit. Ist der
Schalter einmal umgelegt, gibt es kein Zurück mehr, der Zustand des
Promise ist dann ein für allemal festgelegt.
Der Unterschied zwischen einem Deferred und einem Promise ist nur der
Zugriff auf die Methoden reject()
und resolve()
. Ein Deferred
kann diese auslösen, und damit das Schicksal des von ihm abgeleiteten
Promise besiegeln. Ein Promise kann nur reagieren, nicht selbst
entscheiden.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use Promises qw( deferred ); 04 05 my $schroedingers_cat = deferred(); 06 07 $schroedingers_cat->promise->then( 08 sub { print "The cat is alive.\n" }, 09 sub { print "The cat is dead.\n" }, 10 ); 11 12 $schroedingers_cat->resolve(); 13 # "The cat is alive." 14 15 # or: $schroedingers_cat->reject(); 16 # "The cat is dead."
Im Falle einer asynchronen Funktion, die ein Promise zurückgibt, ist dieses
zunächst noch nicht auf ein Ergebnis oder eine Fehlermeldung festgelegt.
Den Schalter
für die Entscheidung legt später der Callback um, wenn Daten eintrudeln
oder ein Fehler vorliegt.
Das passiert aber erst später, nachdem der gegenwärtige Programmfluss die
Kontrolle an die Eventschleife abgegeben hat. Deshalb kann ein Promise auch
erst nach dem Aufruf der asynchronen Funktion seine Instruktionen für
den resolve()
-Callback beziehungsweise den reject
-Callback
entgegennehmen, denn zu diesem Zeitpunkt liegt noch kein Ergebnis vor.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use AnyEvent::HTTP; 04 use CountServer; 05 use Promises qw[ deferred ]; 06 07 my $cs = CountServer->new(); 08 $cs->start(); 09 10 sub fetch_url { 11 my ($url) = @_; 12 my $d = deferred; 13 http_get $url => sub { 14 my ($body, $headers) = @_; 15 $headers->{Status} == 200 16 ? $d->resolve( $body ) 17 : $d->reject( $headers->{Reason} ) 18 }; 19 $d->promise; 20 } 21 22 my $start_url = $cs->url() . "/test-1.txt"; 23 24 my $prom1 = fetch_url( $start_url ); 25 26 my $prom2 = $prom1->then( sub { 27 my( $body ) = @_; 28 print "Got: $body\n"; 29 return fetch_url( $body ); 30 }); 31 32 my $prom3 = $prom2->then( sub { 33 my( $body ) = @_; 34 print "Got: $body\n"; 35 return fetch_url( $body ); 36 }); 37 38 $prom3->then( sub { 39 my( $body ) = @_; 40 print "Got: $body\n"; 41 }); 42 43 my $cv = AnyEvent->condvar(); 44 $cv->recv();
Listing 5 wandelt die Funktion http_get()
aus dem Fundus des AnyEvent-Moduls in Zeile 10 zunächst in eine
Funktion fetch_url()
um, die einen URL erwartet und ein Promise
zurückgibt. Der Callback, den http_get()
aufruft, falls Webdaten
oder ein Fehler vorliegen, legt später dann den Schalter um mit resolve()
oder reject()
um.
Zeile 26 definiert dann mit der Methode then()
den Callback für
einen erfolgreichen Webrequest, und macht sich die Eigenschaft von
Promises laut der neuesten Promise/A+-Spezifikation ([4]) zunutze, dass
ein solcher Callback wiederum ein Promise zurückgeben darf. Dieses
schnappt sich $prom2
auf und geht ab Zeile 32 in die dritte Runde.
Die Ausgabe von Listing 5 ist auch wieder identisch mit der von Listing 1 und 3, alle drei Skripts fragen die gleichen URLs ab und erhalten die gleichen Antworten vom Server.
Da die resolve
/reject
-Callbacks und damit die then()
-Methode
wiederum ein Promise zurückgeben, lässt sich die Kette der Requests auch
ohne temporäre Variablen als
->then( sub { # success } ) ->then( sub { # success } )-> ...
schreiben. Das exakt nach der Spezifikation programmierte
Promises-Modul stellt dann sicher, dass es die ganze
Kette abarbeitet, und sofort damit aufhört, falls in einem Glied ein
Fehler mit einem reject
-Aufruf auftritt.
Wieder ist der Code einfacher zu lesen als in der ursprünglichen verschachtelten Callback-Pyramide. Und wieder fand eine moderne Programmiertechnik aus einer völlig anderen Sprache zurück ins gute alte Perl.
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2014/12/Perl
"Async JavaScript: Build More Responsive Apps with Less Code", Trevor Burnham, Pragmatic Express, 2012
"You're missing the point of promises", Domenic Denicola, http://domenic.me/2012/10/14/youre-missing-the-point-of-promises,
Promises/A+ Spezifikation, https://promisesaplus.com/