POE-tisch (Linux-Magazin, April 2004)

POE, das ``Perl Object Environment'', bietet kooperatives Multi-Tasking ohne dass mehrere Prozesse oder Threads die Applikation verkomplizieren. Zusammen mit GTK, dem Grafik-Toolkit des Gimp lässt sich so ein simpler aber ruckfrei operierender Aktienticker mit graphischer Oberfläche implementieren.

Graphische Oberflächen arbeiten typischerweise Event-basiert: In einer Hauptschleife wartet das Programm auf Ereignisse wie Mausklicks oder Tastatureingaben des Benutzers. Es ist wichtig, dass das Programm diese Events verzögerungsfrei bearbeitet und sofort wieder in die Haupt-Eventschleife eintritt -- sonst ist die Oberfläche ``tot'' und der Benutzer kriegt den Rappel.

Abbildung 1: Der Gtk-basierte Aktienticker in Aktion

Die heute vorgestellte Aktienticker-Applikation (Abbildung 1) kontaktiert in regelmäsigen Abständen die Yahoo-Finance-Webseite, um die neuesten Börsenkurse einzuholen. So ein Webrequest kann je nach Internetwetterlage inklusive der DNS-Auflösung des Servernamens schon schon mal ein paar Sekunden dauern -- während dieser Zeit muss allerdings die Oberfläche der Applikation weiterackern.

Wir kriegen 'nen Komplex

Derartige Anforderungen kann man mit Multi-Processing oder Multi-Threading lösen. Damit erhöht sich allerdings die Komplexität eines Programms beträchtlich: Kritische Sektionen müssen vor parallelen Zugriffen geschützt werden, um die Integrität der Daten zu gewährleisten und es schleichen sich oft schwer zu analysierende und kaum zu reproduzierende Fehler ein. Wer sich schon einmal die Haare gerauft hat, weil er einen Core-Dump mit 200 laufenden Threads untersuchen musste, weiss, wovon ich rede.

Eine weitere Möglichkeit ist kooperatives Multitasking mit POE, dem Perl Object Enviroment. In dieser als ``State-Machine'' implementierten Umgebung läuft zu jedem Zeitpunkt genau ein Prozess mit nur einem Thread, aber ein auf Benutzerebene realisierter ``Kernel'' sorgt dafür, dass verschiedene Aufgaben scheinbar gleichzeitig abgearbeitet werden.

Um Aktienpreise einzuholen, nimmt man in Perl üblicherweise das CPAN-Modul Yahoo::FinanceQuote, aber das hat wegen unserer Ruckfrei-Anforderung genau ein Problem: Es arbeitet synchron.

         use Finance::YahooQuote;
         my @quote = getonequote($symbol);

Die Funktion getonequote setzt einen HTTP-Request an den Yahoo-Server ab, wartet auf die Antwort und kehrt erst dann mit einem Ergebnis zurück. Während der Wartezeit hätten wir allerdings lieber unsere Oberfläche in Schuss gehalten -- wie's der Teufel will hat womöglich gerade jemand ein anderes Fenster über den Ticker gezogen, sodass dieser einen Teil seines Zuständigkeitsbereichs neu zeichnen muss. Aber der gerade laufende Thread zieht es vor, untätig herumzusitzen, was ein gräuliches Loch auf dem Desktop des Benutzers erzeugt. So geht's nicht.

Asynchron glücklich

Es wäre geschickter, einen Web-Request auszuschicken, und uns, ohne auf das Ergebnis zu warten, einfach wieder um die graphische Oberfläche zu kümmern. Kommt die Antwort vom Yahoo-Server, wollen wir geweckt werden, schnell das Aktientickerfenster aktualisieren und ebenfalls sofort wieder zurück in die GUI-Hauptschleife springen.

Genau dies erledigt das POE-Framework. Es besteht aus einem ``Kernel'', in dem einzelne Applikationen ``Sessions'' registrieren. Dort springen State-Maschinen von Zustand zu Zustand und schicken sich gegenseitig Nachrichten. I/O-Aktivitäten erfolgen asynchron: Statt blockierend über ein File-Handle Daten einzulesen, sagt man: ``Hey, Kernel, ich will die Datei einlesen, wecke mich, wenn die Daten verfügbar sind.'' Zwar findet kein ``richtiges'' asynchrones Schreiben oder Lesen statt (POE nutzt unter der Haube lediglich nicht-blockierende syswrite/sysread-Funktionen), aber die bereitstehenden Häppchen werden mit Volldampf rausgepustet oder reingepfiffen.

Der ``kooperative'' Aspekt bei POE ist, dass die einzelnen Sessions sich darauf verlassen, dass niemand herumtrödelt, sondern sofort, wenn eine Aufgabe den Prozessor nicht voll auslastet, die Kontrolle freiwillig an den Kernel zurückgibt. Eine einzige unkooperative Stelle im Programm, und schon leidet das ganze System.

Dieses Multi-Tasking mit einem einzigen Thread erleichtert die Programmentwicklung erheblich -- kein Lock muss her, keine bösen Überraschungen mit Race-Conditions passieren, und wenn doch mal ein Fehler passiert, kann man ihn üblicherweise einfach aufspüren.

Und POE arbeitet auch schön mit den Haupt-Event-Schleifen einiger graphischer Umgebungen zusammen: Perl/Tk und Gtkperl erkennt POE automatisch und bindet sie und ihre Anforderungen nahtlos in den kooperativen Reigen ein.

Alarmierender Kernel

Um den Aktienticker zu implementieren, wird ein Zustandsautomat POE::Session nach Abbildung 2 benötigt. Nach dem Initialisierungszustand _start, der unter anderem die Gtk-Oberfläche aufbaut und den Alias-Namen ticker setzt, damit wir die Session später leicht identifizieren können, geht die Kontrolle an den Kernel. Alle 60 Sekunden (per Alarm) oder sofort, wenn jemand den ``Update''-Knopf der Oberfläche drückt, kommt der ``wake_up'' Zustand an die Reihe, der einen weiteren Zustandsautomaten vom Typ POE::Component::Client::HTTP anstösst und dann sofort wieder die Kontrolle an den Kernel zurückgibt. PoCoCli::HTTP ist eine sogenannte Komponente (Component) aus dem POE-Framework, ein Zustandsautomat, der seine eigene Session (im Listing "useragent" genannt) definiert, im request-Zustand Web-Anfragen entgegennimmt und dann im POE-Framework mitspielt, bis er eine HTTP-Antwort vollständig erhalten hat. Dann teilt er dem Kernel mit, dass die Session, die ihn aufgerufen hat (ticker), einen ihm vorher mitgeteilten Zustand (yhoo_response) anspringen soll. Veranlasst der Kernel die ticker Session dazu, nimmt sie die bereitliegende HTTP-Antwort entgegen, frischt die Aktien-Widgets in der Anzeige auf und gibt die Kontrolle sofort wieder an den Kernel zurück. Ab Zeile 59 startet die POE::Component::Client::HTTP-Komponente mit spawn() und legt fest, dass beim Server UserAgent-String gtkticker/0.01 auftaucht und und hängende Anfragen nach 60 Sekunden abgebrochen werden.

Abbildung 2: Die POE::Session des gtktickers.

Yahoo von Hand

In Listing gtkticker definiert Zeile 9 den URL von Yahoos Aktienkursservice. Dessen CGI-Schnittstelle nimmt einen Formatparameter (f=) mit den geforderten Feldern (s: Symbol, l1: Aktienkurs c1: Veränderung in Prozent seit dem letzten Börsentag) und einen Symbolparameter entgegen, der die Börsenkürzel der geforderten Aktiengesellschaften kommasepariert enthält (z.B. "YHOO,MSFT,TWX"). In der Antwort des Yahoo-Servers steht dann etwas wie

    "YHOO",45.38,+0.35
    "MSFT",27.56,+0.19
    "TWX",18.21,+0.75

was gtkticker einfach zeilenweise und an den Kommas auseinandernimmt und in die grafische Oberfläche einschleust.

Zeile 11 legt mit .gtkicker im Heimatverzeichnis des Benutzers die Datei fest, in der die vom Ticker anzuzeigenden Symbole stehen. Die Zeilen 25 bis 31 lesen sie ein und verwerfen mit '#' beginnende Kommentarzeilen. Die implizite for-Schleife

    ... for /(\S+)/g;

führt den links von ihr stehenden Ausdruck für alle Wörter einer Zeile aus und setzt jeweils das Börsensymbol in $_ -- so dürfen auch mehrere Symbole durch Leerzeichen getrennt in einer Zeile stehen. Listing 1 zeigt zeigt eine Beispieldatei. Trotz POE-Frameworks verwendet gtkticker hier die regulären synchronen I/O-Funktionen, denn die Konfigurationsdatei ist kurz und der Kernel läuft noch gar nicht.

Der Tanz beginnt

Zeile 33 definiert den Zustandsautomaten des Tickers. Der Parameter inline_states weist mit einer Hashreferenz den Zuständen Funktionen zu, die der Kernel anspringt, falls sie erreicht sind. Dann schiebt Zeile 44 mit

    $poe_kernel->post("ticker", "wake_up");

über die von POE exportierte Variable $poe_kernel den Zustand ``wake_up'' für die ``ticker''-Session in den Kernel und Zeile 45 startet mit

    $poe_kernel->run();

die Kernel-Hauptschleife, die das Programm bis zum Shutdown nie mehr verlässt. Das war's!

GUIs mit Gtk

Die vorher gezeigte Konstruktion des POE::Session-Objekts hatte noch einen Seiteneffekt: Die dem _start-Zustand zugewiesene und ab Zeile 48 definierte Routine start() wurde ausgeführt. Sie setzt den Alias-Namen der Session auf ticker, und springt sodann in my_gtk_init(), eine ab Zeile 82 definierte Funktion, die die Gtk-Oberfläche zusammenbaut.

Gtk ist ein CPAN-Modul von Marc Lehmann (der freundlicherweise diesen Artikel korrekturlas!), und ist eigentlich schon von Gtk2 abgelöst. Allerdings spielen POE und Gtk2 noch nicht so recht zusammen, und das ehrwürdige Gtk erledigte den Job hervorragend.

Ein Objekt der Klasse Gtk::Window ist das Hauptfenster der Applikation, in dem oben ein typisches Pull-Down-Menü hängt. Dieses besteht aus einem Menübalken mit einem Eintrag File, dessen ausziehbares Menü nur den Eintrag Quit enthält, der die Applikation mit Gtk->exit(0) beendet. Dass der Benutzer auch mit der Tastenkombination CTRL-Q das Programm verlassen kann, dafür sorgt ein Gtk::AccelGroup-Objekt, das die Menüsteuerung mit sogenannten Accelerators bestückt.

Menüs aus der Fabrik

Aufgebaut wird das Menü mittels einer Gtk::ItemFactory, die zunächst einen Menübalken vom Typ Gtk::MenuBar erzeugt und dort mittels create_items() die Einträge und hängt ihm untergeordnete ausklappbare Menüs ein. Der path-Parameter gibt dabei einfach die Lage des Menüpunktes an -- so spezifiziert /_File/_Quit den den Eintrag Quit unter dem File-Eintrag im Menübalken. Der callback-Parameter setzt eine Funktion, die Gtk anspringt, falls der Benutzer den Eintrag mit der Maus anwählt oder die über den accelerator-Parameter definierte Tastenkombination anspringt.

Um Widgets geometrisch anzuordnen, kommen zwei verschiedene Verfahren zum Einsatz: Gtk::VBox und Gtk::Table.

Das Container-Element Gtk::VBox ordnet in ihm enthaltene Widgets vertikal an. Seine pack_start()-Methode platziert dabei die Elemente vom oberen Rand nach unten, während pack_end() seine Widgets von unten nach oben packt.

Der Aufruf

    $vb->pack_start($mb, $expand, $fill, $padding);

packt den Menübalken $mb (dessen Widget gtkticker in Zeile 108 per Namenseintrag mit $factory->get_widget('<main>') aus der Factory holt) oben in die VBox. $expand gibt an, ob die Fläche, in der das Widget ``schwimmt'', sich vergrößert, falls das Hauptfenster mit der Maus vergrößert wird. Falls ja, gibt $fill an, ob das Widget sich selbst ausdehnt -- so können kleine Druckknöpfe riesengroß werden. Und $padding schließlich spezifiziert die Anzahl der Pixel, die das Widget mindestens vertikal zu seinen Nachbarn hält.

Statusmeldungen zeigt gtkticker in einem unauffälligen Gtk::Label-Widget direkt über dem Update-Knopf an. Die set_alignment()-Methode gibt mit

    $STATUS->set_alignment(0.5, 0.5);

an, dass der Text horizontal und vertikal zentriert wird. Ein Wert von 0.0 wäre hingegen links, 1.0 rechtszentriert.

Das Container-Element Gtk::Table hingegen erlaubt es, andere Widgets bequem in Tabellenform zu arrangieren. Die attach_defaults()-Methode nimmt das anzuordnende Widget entgegen und jeweils zwei Spalten- und zwei Reihenkoordinaten, zwischen denen das Widget liegen soll.

    $table->attach_defaults($label, 
                       0, 1, 1, 2);

Legt zum Beispiel fest, dass das mit $label referenzierte Gtk::Label-Objekt in der ersten Reihe (``zwischen 0 und 1'') und in der zweiten Spalte (``zwischen 1 und 2'') der Tabelle $table aufgehängt ist.

Und ... Action!

Widgets vom Typ Gtk::Button kann man Aktionen zuordnen, die Gtk ausführt, falls der Knopf vom Benutzer gedrückt wird. Die in Zeile 144 aufgerufene Methode signal_connect() legt fest, dass Gtk einen "wake_up"-Event an den POE-Kernel schickt, falls der Benutzer auf den Update-Knopf klickt. Auch das Hauptfenster verknüpft eine Aktion mit dem Ergeignis, das der Benutzer auslöst, wenn er auf das ``X'' rechts oben klickt, um das Fenster zu schließen:

    $w->signal_connect('destroy',
        sub {Gtk->exit(0)});

Dies räumt die Gtk-Session auf und veranlasst das Programm zum Abbruch.

Sind alle Widgets definiert, befördert die show_all()-Methode des Hauptfensters in Zeile 148 sie alle auf den Bildschirm.

Der Kernel schlägt zurück

Im Zustand yhoo_response springt der POE-Kernel die ab Zeile 152 definierte Funktion resp_handler an. Per Definition legt POE::Component::Client::HTTP dabei ein Request- und Response-Paket in ARG0 und ARG1 ab. POE nutzt ja diese seltsam anmutende Parameterübergabe, nachdem es neue numerische Konstanten wie KERNEL, HEAP, ARG0, ARG1 einführt und dann erwartet, dass der Programmierer sie nutzt, um den Funktions-Parameter-Array @_ zu indizieren: $_[KERNEL] gibt so zum Beispiel immer das Kernel-Objekt zurück.

Die erwähnten Request- bzw. Response-Pakete sind wiederum Referenzen auf HTTP::Request bzw. HTTP::Response-Objekte, an denen wir eigentlich interessiert sind, also extrahiert der map-Befehl in den Zeilen 154 und 155 diese nach $req und $resp.

Im Fall eines HTTP-Fehlers setzt Zeile 159 eine entsprechende Meldung im Status-Widget und kehrt sofort zurück. Andernfalls wird der globale zweidimensionale Array der Label-Widgets aufgefrischt, die für jede zu überwachende Aktie das Börsensymbol, den aktuellen Kurs und die prozentuale Veränderung anzeigen. Ist die Veränderung 0, wird sie weggelassen.

Verzögerter Alarm

wake_up-Events im POE-Kernel lösen die ab Zeile 185 definierte Routine wakeup_handler() aus. Sie ruft die ab Zeile 67 definierte Funktion upd_quotes() auf, die ein HTTP::Request-Objekt definiert und es per Event an die Komponente POE::Component::Client::HTTP schickt. Als Zielzustand für den ticker gibt sie dabei yhoo_response an.

Nachdem dies erledigt ist, setzt wakeup_handler() mit der delay()-Methode des Kernels einen Weckruf, der nach der in $UPD_INTERVAL definierten Sekundenzahl (60) einen wake_up-Event der ticker-Session auslöst. So frischt der Ticker alle 60 Sekunden seine Aktiendaten auf, auch wenn der Benutzer nicht den Update-Knopf drückt.

Installation

Die erforderlichen POE-Module POE und POE::Component::Client::HTTP installiert man am besten mit einer CPAN-Shell.

Falls das Modul POE::Component::Client::DNS ebenfalls installiert ist, werden sogar DNS-Anfragen asynchron bearbeitet, sonst kann das eingesetzte gethostbyname() eine kleine Verzögerung verursachen.

Das Modul Gtk vom CPAN zieht noch einige weitere Abhängkeiten herein und bereitete bei meiner Installation einige Probleme. Aber ein

    touch ./Gtk/build/perl-gtk-ref.pod
    perl Makefile.PL --without-guessing

im Distributionsverzeichnis mit anschließendem make install löste das Problem.

Und wie immer lässt sich die Geschwätzigkeit der Logs auf dem Terminal mit Log::Log4perl (ebenfalls vom CPAN) und in Zeile 22 einstellen.

Es ist schon faszinierend, wie glatt sich die Oberfläche bedienen lässt. Selbst wenn man während eines automatischen Auffrischvorgangs über ein langsames Netzwerk im Menü herumfuhrwerkt, kommt die Applikation nicht ins Schleudern. Die ideale Technologie für alle Arten von grafischen Client-Applikationen!

Listing 1: .gtkticker

    1 # ~/.gtkticker
    2 TWX
    3 MSFT
    4 YHOO AMZN RHAT
    5 DODGX
    6 JNJ COKE IBM SUN

Listing 2: gtkticker

    001 #!/usr/bin/perl
    002 ###########################################
    003 # gtkticker
    004 # Mike Schilli, 2004 (m@perlmeister.com)
    005 ###########################################
    006 use warnings;
    007 use strict;
    008 
    009 my $YHOO_URL = "http://quote.yahoo.com/d?".
    010                "f=sl1c1&s=";
    011 my $RCFILE   = "$ENV{HOME}/.gtkticker";
    012 my @LABELS       = ();
    013 my $UPD_INTERVAL = 1800;
    014 my @SYMBOLS;
    015 
    016 use Gtk;
    017 use POE qw(Component::Client::HTTP);
    018 use HTTP::Request;
    019 use Log::Log4perl qw(:easy);
    020 use Data::Dumper;
    021 
    022 Log::Log4perl->easy_init($DEBUG);
    023 
    024     # Read config file
    025 open FILE, "<$RCFILE" or
    026     die "Cannot open $RCFILE";
    027 while(<FILE>) {
    028     next if /^\s*#/;
    029     push @SYMBOLS, $_ for /(\S+)/g;
    030 }
    031 close FILE;
    032 
    033 POE::Session->create(
    034    inline_states => {
    035        _start         => \&start,
    036        _stop    => sub { INFO "Shutdown" },
    037        yhoo_response  => \&resp_handler,
    038        wake_up        => \&wake_up_handler,
    039    }
    040 );
    041 
    042 my $STATUS;
    043 
    044 $poe_kernel->post("ticker", "wake_up");
    045 $poe_kernel->run();
    046 
    047 ###########################################
    048 sub start {
    049 ###########################################
    050 
    051   DEBUG "Starting up";
    052 
    053   $poe_kernel->alias_set( 'ticker' );
    054 
    055   my_gtk_init();
    056 
    057   $STATUS->set("Starting up");
    058 
    059   POE::Component::Client::HTTP->spawn(
    060     Agent     => 'gtkticker/0.01',
    061     Alias     => 'useragent',
    062     Timeout   => 60,
    063   );
    064 }
    065 
    066 ###########################################
    067 sub upd_quotes {
    068 ###########################################
    069 
    070     my $request = HTTP::Request->new(
    071         GET => $YHOO_URL . 
    072                join ",", @SYMBOLS);
    073 
    074     $STATUS->set("Fetching quotes");
    075 
    076     $poe_kernel->post('useragent', 
    077       'request', 'yhoo_response', 
    078       $request);
    079 }
    080 
    081 #########################################
    082 sub my_gtk_init {
    083 #########################################
    084 
    085     my $w = Gtk::Window->new();
    086     $w->set_default_size(150,200);
    087 
    088         # Create Menu
    089     my $accel = Gtk::AccelGroup->new();
    090     $accel->attach($w);
    091     my $factory = Gtk::ItemFactory->new(
    092        'Gtk::MenuBar', "<main>", $accel);
    093 
    094     $factory->create_items(
    095       { path =>  '/_File',
    096         type =>  '<Branch>',
    097       },
    098       { path        =>  '/_File/_Quit',
    099         accelerator =>  '<control>Q',
    100         callback    =>  
    101                   [sub { Gtk->exit(0) }],
    102       });
    103 
    104     my $vb   = Gtk::VBox->new(0, 0);
    105     my $upd  = Gtk::Button->new(
    106                               'Update');
    107 
    108     $vb->pack_start($factory->get_widget(
    109                     '<main>'), 0, 0, 0);
    110 
    111         # Button at bottom
    112     $vb->pack_end($upd,  0, 0, 0);
    113 
    114         # Status line on top of buttons
    115     $STATUS = Gtk::Label->new();
    116     $STATUS->set_alignment(0.5, 0.5);
    117     $vb->pack_end($STATUS, 0, 0, 0);
    118 
    119     my $table = Gtk::Table->new(
    120                      scalar @SYMBOLS, 3);
    121     $vb->pack_start($table, 1, 1, 0);
    122 
    123     for my $row (0..@SYMBOLS-1) {
    124 
    125       for my $col (0..2) {
    126 
    127         my $label = Gtk::Label->new();
    128         $label->set_alignment(0.0, 0.5);
    129         push @{$LABELS[$row]}, $label;
    130 
    131         $table->attach_defaults($label, 
    132             $col, $col+1, $row, $row+1);
    133       }
    134 
    135     }
    136           
    137     $w->add($vb);
    138 
    139         # Destroying window
    140     $w->signal_connect('destroy',
    141         sub {Gtk->exit(0)});
    142 
    143         # Pressing update button
    144     $upd->signal_connect('clicked', 
    145         sub { DEBUG "Sending wake_up";
    146               $poe_kernel->post(
    147                 'ticker', 'wake_up')} );
    148     $w->show_all();
    149 }
    150 
    151 ###########################################
    152 sub resp_handler {
    153 ###########################################
    154     my ($req, $resp) = 
    155             map { $_->[0] } @_[ARG0, ARG1];
    156 
    157     if($resp->is_error()) {
    158         ERROR $resp->message();
    159         $STATUS->set($resp->message());
    160         return 1;
    161     }
    162 
    163     DEBUG "Response: ", $resp->content();
    164 
    165     my $count = 0;
    166 
    167     for(split /\n/, $resp->content()) {
    168         my($symbol, $price, $change) = 
    169                            split /,/, $_;
    170         chop $change;
    171         $change = "" if $change =~ /^0/;
    172         $symbol =~ s/"//g;
    173         $LABELS[$count][0]->set($symbol);
    174         $LABELS[$count][1]->set($price);
    175         $LABELS[$count][2]->set($change);
    176         $count++;
    177     }
    178 
    179     $STATUS->set("");
    180 
    181     1;
    182 }
    183 
    184 ###########################################
    185 sub wake_up_handler {
    186 ###########################################
    187     DEBUG("waking up");
    188 
    189         # Initiate update
    190     upd_quotes();
    191 
    192         # Re-enable timer
    193     $poe_kernel->delay('wake_up', 
    194                    $UPD_INTERVAL);
    195 }

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2003/04/Perl

[2]
Die POE Homepage: http://poe.perl.org

[3]
``A Beginner's Introduction to POE'', Jeffrey Goff, 2001, http://www.perl.com/pub/a/2001/01/poe.html

[4]
``Programming POE'', Matt Sergeant, Vortrag auf TPC 2002: http://axkit.org/docs/presentations/tpc2002

[5]
Gtkperl Homepage: http://gtkperl.org

[6]
Gtkperl Tutorial: http://personal.riverusers.com/~swilhelm/gtkperl-tutorial/

[7]
``Developing Linux Applications with GTK+ and GDK'', Eric Harlow, New Riders, 1999, ISBN 0735700214

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.