Freitag, 24. Juli 2015

Ein Exkurs - Fast CGI

Das Projekt swepar, dem dieser Blog gewidmet ist, ist ein hartes Brot, an dem ich noch lange zu kauen haben werde.

"Die Kunst ist lang, das Leben schwierig." Beruflich Software entwickelnd, ist meine Lust oft begrenzt, nach einem anstrengenden Entwicklungstag auch abends noch weiter an Code herumzudrechseln - vor allem wenn es um ein Megaprojekt geht, in das man besser eine Serie voller Arbeitstage investieren sollte.

So bin ich auch immer auf Ausschau nach Alternativen. Der Reiz einer parallelisierten Version der Swiss Ephemeris liegt ja darin, dass sie, auf einem Server angeboten, eine hohe Zahl von Anfragen (scheinbar) gleichzeitig abarbeiten könnte. Dieses Ziel mag aber auch auf andere Weise erreichbar sein.

Der Ephemeridenservice

Auf den Webseiten astrotexte.ch verwende ich derzeit Anfragen in einem einfachen Query-Response-Stil:

Eine Anfrage der Form

ephemeris?planets&houses&jd=2451544.5&lon=7.2833&lat=52.3333
liefert dabei die Antwort
planets:279.8592,217.2933,271.1118,240.9614,327.5755,25.2331,40.4059,314.7841,303.1753,251.4372
houses:192.0288,217.1332,248.6104,285.8994,321.3361,349.8952,12.0288,37.1332,68.6104,105.8994,141.3361,169.8952

Die Syntax des Querystrings wäre natürlich nach Bedarf erweiterbar. In dieser Form benutze ich aber solche Anfragen bereits in einigen Anwendungen.

Das einfache Schema mit einem Querystring, der die gewünschten Daten angibt, und einem Klartextformat (text/plain) als Antwort, strukturiert in Form von Zeilen, denen jeweils ein Bezeichner vorangestellt ist, lässt sich mit wenig Aufwand in jeder beliebigen Programmiersprache weiterverarbeiten.

Natürlich ist ein solcher Aufruf nur als Low-Level-Funktion vorgesehen, um in einer Applikation den Ephemeridenservice aufzurufen.

Eine Webanwendung, in der durch Operationen mit der Maus die Parameter geändert werden können, indem beispielsweise:

  • ein Zeitstrahl als Slider Control verwendet wird, um parallel die entsprechenden Planetenstände im Horoskop anzuzeigen,
  • die Planetensymbole in einem Horoskop mittels Drag und Drop auf eine andere Position gebracht werden können, wobei das Horoskop auf den nächsten Zeitpunkt aktualisiert, zu dem der Planet diese Position erreicht,
  • eine frei definierbare Reihe von abgeleiteten Horoskopen errechnet und angezeigt wird (Auslösungsketten),
erfordert pro Benutzer eine Vielzahl von Ephemerideaufrufen in kurzer Zeit. Hier wäre es wichtig, dass der Server möglichst gute Antwortzeiten für viele Requests liefert.

Alternativen zu Multithreading

Für eine Bibliothek wie SwissEph, die sich so sehr gegen das Parallelisiertwerden sträubt, stehen auch andere Lösungen zur Auswahl.

Wenn der Server ein paar freie Ressourcen hat, ist z.B. FastCGI eine gute Idee und eine interessante Alternative. Es ist eine dieser gut abgehangenen, soliden Lösungen, die uns aus dem letzten Jahrtausend überliefert wurden.

Folgendes Statement am Ende ihrer Dokumentation hat mich gleich für die Entwickler eingenommen:

There is not much development on FastCGI because it is a very stable protocol / application.

Bei klar definierten Infrastrukturentwicklungen geht es eben anders zu als bei Business-Software! Während letztere praktisch nie fertig wird und bis zum Tag der Abschaltung Defects und Änderungswünsche hereinschneien, gibt es für technische Komponenten, sind sie einmal implementiert, praktisch nichts mehr hinzuzufügen - auch die Zahl der sinnvoll einbaubaren Features ist begrenzt.

Die Idee

Die Idee von fastcgi ist einfach erklärt. Statt - wie beim klassischen CGI - für jeden Request einen Prozess zu starten, ein Programm darin auszuführen und dessen Standardausgabe als Antwort an den Client zurückzusenden, steht bei fastcgi eine festgelegte, frei wählbare Anzahl von Prozessen bereit, in einer Warteschleife hängend, um die eingehenden Requests zu beantworten, die reihum an diese Prozesse verteilt werden. Der Start der Prozesse wird dabei auf den Start des gesamten Servers vorverlegt - dann bleiben sie "up and running" bis zum Herunterfahren. So müssen Bibliotheken und Daten nicht pro Prozess neu gelesen werden, sondern stehen in jedem Prozess zur Verfügung.

Dasselbe gilt für Pufferung: Gepufferte Daten bleiben über den Request hinaus erhalten - sogar Daten im Stack können beim nächsten Request weiterverwendet werden (in der Funktion, die die zentrale Warteschleife enthält, oder mit Zeigen auf Daten in darüberliegenden Stackebenen).

Das Programm, das die Anfragen entgegennimmt, ist meist mit einer interpretierten Sprache wie Perl oder PHP geschrieben. Aber auch dies ist ein vermeidbarer Overhead. Es ist selbstverständlich auch ein Programm in Maschinencode möglich, womit man neben Ausführungszeit auch Speicher spart, da kein kompletter Interpreter in den Prozesspeicher geladen werden muss.

So erwuchs in mir der Plan, den obigen Ephemeridenservice als C-Programm zu erstellen.

Installation von fastcgi

Man kann sich die letzte Version von der Seite fastcgi.com herunterladen, ein Link befindet sich unter Application Libraries And Development Kits / Development Kit / Current und trägt den schönen Namen fcgi-2.4.1-SNAP-0311112127. Ich habe unter Ubuntu 14.04 nach Entpacken des heruntergeladenen Verzeichnisses den üblichen Weg probiert, ein Softwarepaket flott zu bekommen: Ich navigierte in die oberste Ebene des Verzeichnisses und führt die Befehlsfolge
./configure
make
make install
aus. Leider scheiterte schon der make-Schritt mit einigen Fehlermeldungen. Nach kurzer Google-Recherche wurde ich auf dem Blog von Muriel Salvan fündig, der vor drei Jahren exakt die gleichen Fehlermeldungen bekamn. Er hatte das analysiert und zwei Korrekturen angegeben, die man nach dem ./configure-Schritt ausführen kann.

Zur Bequemlichkeit für zukünftige Leser habe ich diesen Patch als Datei muriels.patch aufgezeichnet. Wenn man nach dem Download in die oberste Ebene des heruntergeladenen Verzeichnisses wechselt und den Befehl

patch -p1 <../muriels.patch
ausführt (ggf. ../muriels.patch durch den vollen Pfadnamen der Patchdatei ersetzen), so werden die beiden Änderungen automatisch ausgeführt. Danach kann man mit make und make install fortfahren.

Das Gerüst

Nun kann man mit der eigentlich interessanten Entwicklungsarbeit beginnen. Der grobe Programmaufbau ist sehr einfach: zunächst müssen gewisse einmalige Initialisierungsarbeiten vorgenommen werden; danach folgt die endlose Schleife, in der auf eingehende Requests gewartet wird.

Sehr rudimentär sieht das Programmgerüst also folgendermassen aus:

#include "fcgi_stdio.h"
#include ...

int main (int argc, char** argv) {

    initializations( );  

    while (FCGI_Accept() >= 0) {

// Parsen des Requests
      const char *qs = getenv("QUERY_STRING");

// Ausführung der angeforderten Aktion

// Antwort mit printf() ausgeben

     
    } /* while */

  return 0;

}
Statt im QUERY_STRING (wie für meinen Ephemeridenservice) könnte die Daten der Anfrage natürlich auch im HTTP-Body stehen. Dieser wird - wie bei CGI - über stdin zugänglich gemacht.

Das Gerüst des Ephemeridenservices

Der main()-Funktion des Ephemeridenservice habe ich folgende konkrete Gestalt gegeben:
/* ephemeris via fastcgi */

#include "ephemeris.h"

int main (int argc, char** argv) {

    query q = {};
    result r = {};
    
    char ephepath[MAXLEN_EPHE_PATH];
    initializations( ephepath );  

    swe_set_ephe_path( ephepath );

    while (FCGI_Accept() >= 0) {
      init_request(&q,&r);
      TRY {  
        const char *qs = getenv("QUERY_STRING");
        if (qs) {
          parse(qs, &q);
          compute(&q,&r);
          }
        output(&q,&r);
        }
      CATCH {
        do_header( );
        printf("error: %s\n",r.error);
        }  
     
    } /* while */

    return 0;

}
  • In den Initialisierungen (vor Beginn der while-Schleife) lese ich aus einer Konfigurationsdatei .ephemeris den Pfad ephepath des Verzeichnisses ein, in dem die Swiss Ephemeris die Ephemeridendateien vorfinden wird, und teile diesen Pfad der Bibliothek mit.
  • Geht ein Request ein, so initialisiere ich zunächst die Query-Struktur q und die Ergebnisstruktur r, damit nicht etwa noch irgendwelche Relikte aus dem letzten Aufruf übrigbleiben; auch gebe ich Speicher wieder frei, der durch den letzten Request auf dem Heap reserviert worden war.
  • parse(qs,&q): Nun wird der eingehende Querystring analysiert und in das strukturierte Datenobjekt q geschrieben.
  • compute(&q,&r): In dieser Funktion wird die eigentliche Rechnung ausgeführt. Die Ergebnisse landen in der result-Struktur r.
  • output(&q,&r): Hier - und nur hier - werden mit printf() die Ergebnisse in die HTTP-Antwort zurückgeschrieben.

TRY - CATCH in C

Das Sprachfeature try - catch ermöglicht es, Fehler auf einer höheren Stackebene abzufangen, als dort, wo sie aufgetreten sind. Die Sprache C bietet kein try-catch-Idiom an, aber wir können es uns leicht mit den sogenannten "Long Jumps" emulieren. Hinter den Befehlen TRY und CATCH im obigen Listing verbergen sich zwei bescheidene Macros:
jmp_buf _state;
#define TRY if (setjmp(_state)==0) 
#define CATCH else 
#define THROW(errmsg,...) { if (errmsg) sprintf(errmsg,__VA_ARGS__); longjmp(_state, 1); }
Für die Details dieser Konstruktion verweise ich auf einen Blogpost von Francesco Nidito, dessen Idee ich mir hier passend zurechtgekürzt habe: Es funktioniert wunderbar: Mit dem dritten Macro THROW kann ich auf beliebiger Ebene unterhalb von main() alle Stackebenen bis main() löschen und die Programmausführung im CATCH-Block fortsetzen.

Testen

Man kann das Programm auch in der Konsole starten. FCGI erkennt dann, dass es nicht in der Web-Umgebung aufgerufen wird und durchläuft in diesem Fall die Schleife genau einmal. Zum Testen kann man vor dem Programmaufruf die Umgebungsvariable QUERY_STRING setzen.

So kann ich verschiedene erwartete Reaktionen des Programms in einer Datei ./t/test.pl notieren:

#!/usr/bin/perl

use strict;
use warnings;

use Test::More;

ephemeris_call( 
  "Planets for given julian date 2451545",
  "planets&jd=2451545",
  <<EXP );
Content-type: text/plain
planets:280.3689,223.3238,271.8893,241.5658,327.9633,25.2531,40.3957,314.8092,303.1930,251.4548
EXP

ephemeris_call( 
  "Sun And Moon for given julian date 2451545",
  "planets=0,1&jd=2451545",
  <<EXP );
Content-type: text/plain
planets:280.3689,223.3238
EXP

... usw ...

done_testing( );

sub ephemeris_call {
  my ($test,$query_string,$exp) = @_;
  $ENV{'SE_EPHE_PATH'} = "";
  $ENV{'QUERY_STRING'} = $query_string;
  my $act = `ephemeris`;    
  $exp =~ s/\s*$//gm;
  $act =~ s/\s*$//gm;
  is( $act, $exp, $test );
}

Konfiguration im Apache

Für fcgi-Programme habe ich mir in den Webressourcen ein eigenes Verzeichnis /var/www/fcgi eingerichtet: dorthin kam nun das fertige Binary ephemeris.

Um nun das Programm mit dem Apache Server zu "verheiraten", muss es ihm mit der Direktive FastCgiServer bekanntgemacht werden:

<IfModule mod_fastcgi.c>
    
    Alias /ephemeris /var/www/fcgi/ephemeris
    FastCgiServer /var/www/fcgi/ephemeris -processes 5
    
</IfModule>
Nach
  • Einrichtung im Verzeichnis /etc/apache2/mods-available,
  • Stoppen des Servers mit service apache2 stop,
  • Aktivierung der Einstellung mit dem Befehl a2enmod fastcgi
  • Neustart des Servers mit service apache2 start
sieht man mit dem Befehl ps -ef | grep ephemeris die fünf gestarteten Prozesse, wie sie in einer öden Schleife vor sich hindümpeln und, wie morgens auf dem Marktplatz die Tagelöhner, auf Arbeit warten.

Der %lf Bug

Einen Bug musste ich dennoch feststellen. Die Ausgabe von double-Werten mit dem in C dafür vorgesehenen Formatierer %lf im Formatstring von printf funktionierte leider nicht. Die so ausgegebenen Werte wurden rundweg verschluckt.

Eine diesbezügliche Frage in die Entwicklergemeinde bei StackOverflow blieb bislang unbeantwortet.

Vollständiger Quellcode

Hier sind das Programm ephemeris.c und seine Headerdatei ephemeris.h.

Zusammenfassung

Die ganze Technik funktioniert hervorragend und sehr performant. Wenn ich im Zugriffs-Log von Apache die Requestbearbeitungszeiten mit dem Formatierer %D protokolliere, liegen diese praktisch "unter der Nachweisgrenze", auch bei gleichem Input stark schwankend zwischen 100 und 1000 Mikrosekunden pro Request. Da es für 10 Planeten ähnlich lange dauert wie für einen einzelnen (den ich in meiner leicht erweiterten Syntax mit z.B. planets=5 aufrufen kann), vermute ich, dass der Löwenanteil dieser 100 bis 1000 Mikrosekunden in der Apache-Laufzeitumgebung liegt. Aber eine Bearbeitungszeit von einer Millisekunde ist bei Netzwerk-Antwortzeiten von 10-50 Millisekunden sowieso vernachlässigbar.

Keine Kommentare:

Kommentar veröffentlichen