SystemExit und exception handlers

Immer wieder gerne genommen: SystemExit. Eine Python-Exception, die viele nicht kennen. Das besondere an dieser Exception: sie ist kein Fehler. Sie tritt auch nicht unerwartet auf. Sie wird nämlich einfach von sys.exit ausgelöst. Die Idee dahinter ist, das man so im dynamischen Ablauf eine Ende-Bearbeitung einhängen kann (z.B. irgendwelche Dateibereinigungen), ohne sich in globale Exitbearbeitung einzuklinken (mit all den Problemen die das hat).

Das Problem ist jetzt, das viele Programme und Bibliotheken einen globalen Exception-Handler installieren. Einen, der jeden Fehler abfängt und hübsch formatiert per Mail verschickt, irgendwo logged oder ähnliches. Mache ich auch ständig. Geht auch klasse - ausser wenn man in seinem Programm tatsächlich mal explizit ein vorzeitiges Ende einleiten will. Dann klappt da garnix mehr - denn man erhält entsprechende Fehler für einen Nicht-Fehler.

Besonders kritisch wird das ganze im Zusammenhang mit mehreren Prozessen. Wenn man nämlich im Laufenden Betrieb einen Prozess anstartet, will man diesen auch beenden, ohne das eventueller nachgelagerter Code ausgeführt wird. Am besten sieht man das an einem Beispielprogramm:

import signal
import os

try:
    pid = os.fork()
    if pid:
        print "Elternprozess", os.getpid()
    else:
        print "Kindprozess", os.getpid()
        sys.exit(0)
except:
    print 'Fehler aufgetreten in Prozess', os.getpid()

print "Das darf nur der Elternprozess ausführen", os.getpid()

Dieser Code hat einfach einen globalen Fehlerbehandler, der Fehler recht unspezifisch abfängt. Innerhalb des Codes wird ein paralleler Prozess mit fork gestartet. Dadurch, das SystemExit wie alle anderen Exceptions behandelt wird, wird aber der Kindprozess nicht korrekt beendet - ein Prozess kopiert den gesamten Zustand des Elternprozesses, inklusive Rücksprungadressen, offene Fehlerbehandlungen, Dateien, Datenbankverbindungen und so weiter.

Das ist natürlich fatal - denn hier wird ja sys.exit abgefangen. Es gibt also eine Fehlermeldung für den ganz normalen sys.exit(0) Aufruf. Und noch schlimmer: da SystemExit nicht extra behandelt wird, gehts danach normal weiter - und der Kindprozess rennt in Code für den Elternprozess rein. Code läuft also doppelt, was unter Umständen kritische Ergebnisse haben kann.

Wenn man den ganzen Stack an Software voll kontrollieren kann, ist die Lösung einfach:

import signal
import os

try:
    pid = os.fork()
    if pid:
        print "Elternprozess", os.getpid()
    else:
        print "Kindprozess", os.getpid()
        sys.exit(0)
except SystemExit:
    raise
except:
    print 'Fehler aufgetreten in Prozess', os.getpid()

print "Das darf nur der Elternprozess ausführen", os.getpid()

Dadurch wird einfach der SystemExit neu geworfen - also neu ausgelöst - ohne eine Meldung zu machen. Im Regelfall wird dann die Standardbehandlung von Python zuschlagen und den SystemExit in eine normale Beendigung umsetzen.

Was aber machen, wenn man mehrere gestapelte Varianten des falschen Fehlerhandlings hat? Ich hab sowas zum Beispiel bei Django und FLUP (dem FCGI/SCGI Server für Python). In Django hab ich es geändert, dann hat der Fehler im FLUP zugeschlagen. Was macht man dann?

Die Lösung ist ein wenig brutaler:

import signal
import os

try:
    pid = os.fork()
    if pid:
        print "Elternprozess", os.getpid()
    else:
        print "Kindprozess", os.getpid()
        os.kill(os.getpid(), signal.SIGTERM)
except:
    print 'Fehler aufgetreten in Prozess', os.getpid()

print "Das darf nur der Elternprozess ausführen", os.getpid()

Letzten Endes begeht der Prozess einfach Selbstmord - er schickt sich selber ein SIGTERM, also ein Beendigungssignal. Das gleich, das man normalerweise von der Shell schicken würde. Allerdings muss man dann sicherstellen, das alle eventuell nötigen Nachbereinigungen entweder schon gemacht sind, oder dann in einer SIGKILL Behandlungsroutine laufen - sonst hat man unter Umständen Problemen (z.B. sollten Datenbanktransaktionen schon commited sein).

Auch bei dieser Lösung muss man aufpassen, das nicht irgendwelche offenen Resourcen den Prozess blockieren - sonst produziert man unter Umständen Zombiprozesse. Oftmals ist es daher besser für solches Multiprozessing einen Verwaltungsprozess sehr viel früher im System zu starten - ausserhalb der Fehlerbehandlungskette - und diesen dann zu benutzen um Bearbeitungsprozesse zu starten. Allerdings hat das dann den Nachteil, das diese solcherart gestarteten Prozesse nicht die Umgebung des Elternprozess erben. Man muss daher dann in der Regel mehr Vorbereitungen treffen, um die gewünschten Aktionen auszuführen. Einen ähnlichen Ansatz verfolgt übrigens Apache - dort werden die Prozesse aus einem sehr frühen Basiszustand heraus erzeugt, so das sie möglichst Resourcenfrei daherkommen.

tags: Programmierung, Python