Asynchron und Spaß dabei (Linux-Magazin, Dezember 2014)

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.

Ein Thread reicht

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.

Hipster, hilf!

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.

Pyramide der Verdammnis

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?

Listing 1: http-get-nested

    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!

Listing 2: CountServer.pm

    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;

Falsche Fehler

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.

PubSub

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.

Listing 3: http-get-pubsub

    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.

Leere Versprechen

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.

Listing 4: promise-cat

    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.

Listing 5: http-get-promise

    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.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2014/12/Perl

[2]

"Async JavaScript: Build More Responsive Apps with Less Code", Trevor Burnham, Pragmatic Express, 2012

[3]

"You're missing the point of promises", Domenic Denicola, http://domenic.me/2012/10/14/youre-missing-the-point-of-promises,

[4]

Promises/A+ Spezifikation, https://promisesaplus.com/

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.