Parallelverarbeitung ist immer wieder für eine Überraschung gut.

Das Grundproblem: Ein Administrationstool führt regelmäßig mittels eines Daemons bestimmte Prozesse aus, welche teilweise 5-10 Minuten brauchen, bis sie abgeschlossen sind. Allerdings sind es nicht immer exakt die gleichen Befehlszeilen, sondern die Argumente unterscheiden sich, und es ist nicht vorhersehbar wann ein solcher Prozess benötigt wird — dies wird vom Benutzer angestoßen.

Der Daemon erhält seine Aufträge per RPC. Klarer Fall in einer solchen Situation: Asynchrones Interface. Der Aufruf welcher einen Prozess startet kehrt sofort zurück, der Prozess wird in den Hintergrund geschickt.

Das Hauptproblem dabei ist, dass manchmal eine Reihe von Befehlen ausgeführt werden muss. Dies lässt sich am einfachsten mit klassischen synchron programmiertem Code erreichen, welcher einfach schrittweise ausgeführt wird. Es gilt also einen Kompromiss zwischen synchronem und asynchronem Code zu finden, indem man einen Prozess in den Hintergrund schickt, welcher dann seinerseits die eigentlichen Zielprozesse der Reihe nach ausführt und auf deren Beendigung wartet, bevor er den nächsten Prozess startet.

Grundsätzlich gibt es für Nebenläufigkeit zwei Vorgehensweisen:

  1. Erzeugen eigenständiger Unterprozesse
  2. Erzeugen von Threads im Haupt-Prozess

Die eigentliche Arbeit: invoke

Den eigentlichen Zielprozess erzeugt eine Funktion namens invoke, welche über das subprocess-Modul den ihm übergebenen Befehl ausführt und auf dessen Beendigung wartet. Der Code dafür ist sehr simpel:

import subprocess

def invoke(args, stdin=None, close_fds=True):
  proc = subprocess.Popen(args,
    stdin  = (None if stdin is None else subprocess.PIPE),
    stdout = subprocess.PIPE,
    stderr = subprocess.PIPE,
    close_fds = close_fds
    )
  procout, procerr = proc.communicate(stdin)
  # Procout und procerr werden ins Log geschrieben
  return proc.returncode

invoke(["/bin/sleep", "30"])
# läuft NICHT im Hintergrund, sondern wartet bis "sleep" beendet ist.

Prozesse

Für die Behandlung von Unterprozessen gibt es in Python das Modul multiprocessing, mit welchem dieses Ziel sehr einfach zu erreichen ist. Man braucht nur eine Funktion schreiben, welche die eigentliche Arbeit übernimmt, und kann diese dann folgendermaßen in einen eigenen Prozess verpacken:

def runcommands(commands):
  for cmd in commands:
    invoke(cmd)

import multiprocessing

cmds = [["/bin/echo", "hallo"], ["/bin/sleep", "10"]]
proc = multiprocessing.Process(target=runcommands, args=(cmds,))
proc.start()
# "echo hallo" und "sleep 10" laufen jetzt
# nacheinander im Hintergrund.

Sieht auf den ersten Blick einfach genug aus. Diese Vorgehensweise birgt allerdings dadurch Gefahren, dass Dateideskriptoren an Kindprozesse vererbt werden. Insbesondere offene Verbindungen sind hier problematisch: Wenn der Kindprozess sich beendet, schließt er seine Dateideskriptoren, und macht damit vielleicht Verbindungen (beispielsweise zu Datenbanken) unbrauchbar, die der Elternprozess noch braucht.

Threads

Dieses Problem lässt sich durch die Nutzung von Threads umgehen. Threads erzeugen keine neuen Prozesse, sondern nur neue Ausführungsstränge innerhalb eines Prozesses. Damit kann der Prozess mehrere Abläufe parallel abarbeiten, diese teilen sich allerdings denselben Speicherbereich. Auf das Problem der Verbindungen wirkt sich dies dahingehend aus, dass beim Ende eines Threads kein Prozess beendet wird, was zum Schließen der Dateideskriptoren führen würde. Der Code dafür ist nahezu identisch:

import threading

cmds = [["/bin/echo", "hallo"], ["/bin/sleep", "10"]]
proc = threading.Thread(target=runcommands, args=(cmds,))
proc.start()
# "echo hallo" und "sleep 10" laufen jetzt
# nacheinander im Hintergrund.

Aber auch Threads sind nicht ohne Fallen.

In Python sorgt das Global Interpreter Lock (GIL) dafür, dass nur ein Thread innerhalb eines Prozesses Python-Code ausführen kann. Dies bedeutet dass Threads in Python zwar geeignet sind, wenn man mehrere I/O-lastige Abläufe parallelisieren möchte, allerdings beispielsweise für die Parallelisierung rechenintensiver Aufgaben nicht tauglich sind.

Das GIL bereitet allerdings auch Schwierigkeiten, wenn der Hauptprozess nicht in der Python-Schicht läuft, sondern seine Hauptschleife in einem C-Modul implementiert. Beispiele dafür sind die DBus-Schnittstelle oder das Qt-Modul, deren Main Loops in C-Modulen laufen. In diesem Fall sperrt der Main Loop das GIL, wodurch die erzeugten Threads nicht laufen können!

In unserem Beispiel führt dies letztendlich dazu, dass invoke() zwar dazu kommt Prozesse zu erzeugen, und diese auch laufen — aber dass die Threads welche sich um die Kommunikation mit den erzeugten Prozessen kümmern nicht laufen, und daher die Zielprozesse nach ihrer Beendigung als Zombies im System herumgeistern.

Um dies zu beheben, muss man den Main Loop aus dem gobject-Modul darüber informieren, dass es mit Python-Threads zusammenarbeiten muss:

import gobject
gobject.threads_init()
loop = gobject.MainLoop()
loop.run()

Fazit

Parallelverarbeitung birgt eine Reihe subtiler Gefahren, welche nicht auf den ersten Blick ersichtlich sind — selbst bei solch simplem Code wie dem hier gezeigten. Die meisten dieser Gefahren gehen auch nicht direkt aus dem Code hervor, sondern daraus, wie er aufgerufen wird und in welcher Umgebung er läuft (siehe das Beispiel mit gobject.MainLoop). Auch weitere Probleme wie das Verhindern von Deadlocks sind nicht offensichtlich und erfordern daher besondere Umsicht bei der Programmierung. Pythons einfache multiprocessing- und threading-Interfaces machen zwar den Umgang mit Threads und Prozessen sehr einfach, können aber die grundlegenden Probleme nicht verhindern.

Add post to: Delicious Reddit Slashdot Digg Technorati Google