Plattformunabhängige asynchrone Interprozess-Kommunikation mit Kindprozessen

Kindprozesse zu erzeugen und dabei ihre Standardausgabe und Standardfehlerausgabe abzufangen ist eigentlich ein triviales Unterfangen, allerdings nicht, wenn es auch unter Windows funktionieren soll. Die Plattform birgt leider eine Menge Überraschungen. Machbar ist es trotzdem, wie die folgende Anleitung zeigt. Für C und Perl gibt es Beispielcode. Eine Version für andere Skriptsprachen sollte relativ einfach zu implementieren sein.

Metallrohre und Maschinen
Foto von Crystal Kwok auf Unsplash

Die Aufgabe

Die aktuelle Entwicklerversion von Qgoda funktioniert größtenteils auch mit einigermaßen aktuellen Windowsversionen. Eine Ausnahme ist leider noch die Anbindung von Hilfsprogrammen.

Qgoda erlaubt es, eine beliebige Zahl solcher Hilfsprozesse zu konfigurieren, die dann im Hintergrund ausgeführt werden. Typische Hilfsprogramme sind Bundler wie webpack, Parcel, oder Vite und Entwicklungs-Web-Server wie http-server oder Browsersync. Qgoda leitet die Standardausgabe und Standardfehlerausgabe um und integriert sie ins eigene Logging, jeweils mit einem Präfix info oder warning.

Parallel dazu überwacht Qgoda das Dateisystem auf Änderungen und generiert die Webseite neu, sobald eine Quelldatei modifiziert, gelöscht oder neu erzeugt wurde. Getreu dem fundamentalem Un*x-Prinzip "Alles ist eine Datei" läuft die Überwachung des Dateisystems auf ein select() auf einen Dateideskriptor für eine spezielle Gerätedatei hinaus, welche die Änderungen bereitstellt. Das trifft für das Linux Inotify-API, für macOS FSEvents, und den BSD kqueue(2) Systemaufruf zu.

Mit anderen Worten macht Qgoda im Watch-Modus lediglich ein select(2) auf verschiedene Dateideskriptoren und verarbeitet die entsprechenden Ereignisse innerhalb der Hauptschleife. Tatsächlich verwendet Qgoda nicht direkt select(2), sondern stattdessen Marc A. Lehmanns ausgezeichnete Bibliothek AnyEvent, die eine einheitliche Schnittstelle zu verschiedenen Eventloop-Implementierungen bietet. Unter der Haube ist AnyEvent einfach ein aufgebohrtes select(2). Jedenfalls gilt das für die Eventloop-Implementierung in Perl, die in AnyEvent standardmäßig verwendet wird.

GitHub-Repository mit Beispiel-Code

Die vollständigen Code-Beispiele in C und Perl finden sich im Git-Repository https://github.com/gflohr/platform-independent-ipc. Der Branch "main" kompiliert und funktioniert sowohl auf POSIX-Systemen wie auch auf Windows. Der Branch "unix" zeigt den normalen Ansatz, der allerdings nur auf POSIX-Systemen funktioniert.

Traditionelle Interprozess-Kommunikation

Der traditionelle Ansatz um asynchron mit Kindprozessen zu kommunizieren, besteht aus einer Kombination von pipe(2), dup2(2), fork(2), exec*(2) und select(2). Dieses Muster findet sich in etlichen Netzwerk-Daemons und ähnlicher Software.

Unter Windows funktioniert das aus verschiedenen Gründen nicht:

  1. Windows kennt kein fork(2).
  2. Windows kennt kein execve(2) (und deren Wrapper in der libc).
  3. Windows unterstützt select(2) nur für Sockets, aber nicht für andere Dateideskriptoren.

Eine Möglichkeit, diese Schwierigkeiten zu umschiffen und einen Kommunikationskanal zwischen dem Eltern- und dem Kindprozess aufzusetzen, sieht folgendermaßen aus:

  1. Erzeuge ein Paar verbundener Sockets und konfiguriere sie für nicht blockierende Ein- und Ausgabe.
  2. Erzeuge eine anonyme Pipe.
  3. Setze das "Handle" für den entsprechenden System-Dateideskriptor (Standardeingabe, Standardausgabe oder Standardfehlerausgabe) in der STARTUPINFO-Struktur auf das Schreibende der Pipe aus dem vorhergehenden Schritt.
  4. Erzeuge den Kindprozess mit der Systemfunktion CreateProcess() und übergebe ihr einen Zeiger auf das STARTUPINFO aus dem vorhergehenden Schritt.
  5. Erzeuge für jeden System-Dateideskriptor, der verbunden werden soll, einen neuen Thread.
  6. Lies innerhalb dieser Threads synchron von einer der anonymen Pipes und kopiere alles verbatim in einen der Sockets. Sowohl das Lesen als auch das Schreiben blockiert, was aber nicht stört, solange der Haupt-Thread nicht blockiert ist.
  7. Im Haupt-Thread wird ein normales, select(2) auf die Leseenden der Socket-Paare gemacht.

Es hat sich herausgestellt, dass der zusätziche Thread nicht erforderlich ist, weder in C noch in Perl.

In C geht man so vor:

  1. Erzeuge ein Paar verbundener Sockets und konfiguriere sie für nicht blockierende Ein- und Ausgabe.
  2. Setze das "Handle" für den entsprechenden System-Dateideskriptor (Standardeingabe, Standardausgabe oder Standardfehlerausgabe) in der STARTUPINFO-Struktur auf das Schreibende des Socket-Paars aus dem vorhergehenden Schritt.
  3. Erzeuge den Kindprozess mit der Systemfunktion CreateProcess() und übergebe ihr einen Zeiger auf das STARTUPINFO aus dem vorhergehenden Schritt.
  4. Mache ein normales select(2) auf das Leseende der Socket-Paare.

In Perl hat man keine Kontrolle über das STARTUPINFO-Argument für CreateProcess(). Es muss deshalb ein leicht veränderter Ansatz verfolgt werden:

  1. Verwende die socketpair(2)-Emulation von Perl, um ein Paar verbundener Sockets zu erzeugen, und aktiviere nicht-blockierende Ein- und Ausgabe für das Leseende.
  2. Dupliziere die Datei-Handles für STDOUT und STDERR und speichere sie.
  3. Dupliziere die Socket-Paare aus dem ersten Schritt und überschreibe damit STDOUT und STDERR.
  4. Erzeuge den Kindprozess mit der Methode Win32::Process::Create().
  5. Stelle STDOUT und STDERR wieder her.
  6. Mache ein normales select(2) auf das Leseende der Socket-Paare.

Bemerkung: Wenn man die konventionelle Methode mit Hilfs-Threads für blockierende Ein- und Ausgabe aus anonymen Pipes bevorzugt, findet man dafür eine funktionierende Implementierung im Commit e9c71ac.

Detaillierte Beschreibung

Schauen wir uns die Lösung genauer an. Spätestens jetzt sollte man das begleitende Git-Repository https://github.com/gflohr/platform-independent-ipc klonen. Der Beispiel-Code geht davon aus, dass man die Standardausagabe und Standardfehlerausgabe von drei simplen Kommandozeilen-Tools lesen will.

Hier wird nur die Windows-Version erklärt. Die POSIX-Version ist Standard und wird als bekannt vorausgesetzt.

Der Code für den Kindprozess

Das Beispielprogramm schreibt in einer Endlosschleife eine Zeile auf die Standardausgabe und Standardfehlerausgabe, und macht dazwischen jeweils eine Pause wechselnder Länge, siehe child.c und child.pl im Beispiel-Repository.

Aus kosmetischen Gründen sollte die Standardausgabe und Standardfehlerausgabe der Kindprozesse zeilengepuffert sein. Ansonsten wird die Ausgabe in großen Paketen von normalerweise 4096 oder 8192 Bytes gesammelt, und es dauert ziemlich lange, bis der erste Puffer vollläuft und ausgegeben wird.

In Perl wird die Pufferung folgendermaßen abgeschaltet:

autoflush STDOUT, 1;
autoflush STDERR, 1;

Für ältere Perl-Versionen muss eventuell eine Zeile use IO::Handle; zugefügt werden, damit autoflush zur Verfügung steht.

In C würde man normalerweise zeilenbasiert puffern, weil immer nur eine Zeile auf einmal herausgeschrieben wird.

setvbuf(stdout, NULL, _IOLBF, 0);
setvbuf(stderr, NULL, _IOLBF, 0);

Die libc allokiert so einen Puffer optimaler Größe und aktiviert Zeilenpufferung für stdout und stderr. Aber - Überraschung, Überraschung - das funktioniert nicht unter Windows (`setvbuf(3)`` gibt EOF zurück, was einen Fehler bedeutet). Stattdessen muss man sich mit ungepufferter Ausgabe der Streams behelfen:

setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);

Unsinn auf die Konsole auszugeben ist einfach. In Perl geht es so:

while (1) {
    print STDOUT "Perl child pid $$ writing to stdout.\n";
    sleep 1;
    print STDERR "Perl child pid $$ writing to stderr.\n";
    sleep 1;
}

Und die C-Version sieht folgendermaßen aus:

pid_t pid = getpid();

while (1) {
    fprintf(stdout,
            "C child pid %d writing to stdout.\n",
            pid);
    sleep(1);
    fprintf(stdout,
            "C child pid %d writing to stderr.\n",
            pid);
    sleep(1);
}

Die echte Version pausiert für eine zufällige Dauer von bis zu drei Sekunden. Das lässt sich im Git-Repository genauer sehen.

Ein Paar verbundener Sockets erzeugen

Diese Aufgabe ist in Perl einfach umzusetzen, weil der Interpreter socketpair(2) emuliert, falls die Plattform den Systemaufruf nicht unterstützt. Allerdings erfordert es etwas Umdrehungen, um nicht blockierendes Lesen des Sockets zu aktivieren, weil die notwendige Konstante FIONBIO für ioctl in Perl nicht verfügbar ist. Der Wert 0x8004667e muss deshalb - in der Hoffnung, dass er sich in absehbarer Zeit nicht ändert - hartkodiert werden.

Der folgende Code findet sich jeweils in parent.c und parent.pl im Beispiel-Repository.

Aus Platzgründen wird hier lediglich der Code für die Standardausgabe beschrieben. Der Code für die Standardfehlerausgabe unterscheidet sich nur in den Namen der Variablen und Konstanten:

use constant FIONBIO => 0x8004667e;

socketpair my $stdout_read, my $stdout_write,
        AF_UNIX, SOCK_STREAM, PF_UNSPEC
    or die "cannot create socketpair: $!\n";

ioctl $child_stdout, FIONBIO, \$true
    or die "cannot set child stdout to non-blocking: $!";

In C müssen wir uns unsere eigene Version von socketpair(2) bauen:

#if IS_MS_DOS
# define socketpair(domain, type, protocol, sockets) \
    win32_socketpair(sockets)
static int win32_socketpair(SOCKET socks[2]);
static int convert_wsa_error_to_errno(int wsaerr);
#endif

Die Implementierung von convert_wsa_error_to_errno() kann man in parent.c sehen. Windows setzt im Fehlerfall normalerweise nicht errno. Stattdessen muss man proprietäre Fehlercodes mittels WSAGetLastError() abfragen. Die Funktion convert_wsa_error_to_errno() wandelt die relevanten Windows-Codes einfach in Standard-Codes um.

Schauen wir uns jetzt die Emulation von socketpair(2) an, die Funktion win32_socketpair():

static int
win32_socketpair(SOCKET socks[2])
{
    SOCKET listener = SOCKET_ERROR;

    listener = WSASocket(AF_INET, SOCK_STREAM, PF_UNSPEC, NULL, 0, 0);
    if (listener < 0) {
        return SOCKET_ERROR;
    }

    ...
}

Wie man sieht, wird das Protokoll AF_INET erzwungen, der Typ ist immer SOCK_STREAM, und das Protokoll wird nicht angegeben (PF_UNSPEC). Das weicht vom normalen Verhalten von socketpair(2) ab, weil diese Flexibilität hier nicht erforderlich ist.

Es ist unumgänglich, hier WSASocket() und nicht die Standard-BSD-Funktion socket(), die unter Windows ebenfalls zur Verfügung steht, zu benutzen, weil es einen subtilen und scheinbar undokumentierten Unterschied zwischen beiden Funktionen gibt: Nur Sockets, die mit WSASocket() erzeugt wurden, können als Systemdeskriptoren für Kindprozesse verwendet werden.

Der nächste Schritt besteht darin, die Socketadresse auf die Loopback-Schnittstelle 127.0.0.1 zu setzen, und den Socket in den Empfangsmodus (Listen-Modus) zu schalten. Das ist alles normale Netzwerkprogrammierung:

struct sockaddr_in listener_addr;
memset(&listener_addr, 0, sizeof listener_addr);
listener_addr.sin_family = AF_INET;
listener_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
listener_addr.sin_port = 0;

errno = 0;
if (bind(listener, (struct sockaddr *) &listener_addr,
    sizeof listener_addr) == -1) {
    goto fail_win32_socketpair;
}

if (listen(listener, 1) < 0) {
    goto fail_win32_socketpair;
}

Die Port-Angabe von 0 in Zeile 5 hat den Effekt, dass das Betriebssystem einen zufälligen freien Port wählt.

Der Code an der Sprungmarke fail_win32_socketpair räumt lediglich auf und gibt die Ressourcen frei, die bereits erzeugt wurden. Was genau notwendig ist, sieht man im Quelltext.

Als nächstes muss das Leseende des Socket-Paars erzeugt werden. Dies wird connector genannt:

SOCKET connector = socket(AF_INET, SOCK_STREAM, 0);
if (connector == -1) {
    goto fail_win32_socketpair;
}

struct sockaddr_in connector_addr;
addr_size = sizeof connector_addr;
if (getsockname(listener, (struct sockaddr *) &connector_addr,
                &addr_size) < 0) {
    goto fail_win32_socketpair;
}
if (addr_size != sizeof connector_addr) {
    goto abort_win32_socketpair;
}

if (connect(connector, (struct sockaddr *) &connector_addr,
            addr_size) < 0) {
    goto fail_win32_socketpair;
}

An dieser Stelle steht es frei, ob man socket(2) aus Standard-BSD oder WSASocket() benutzt. Es schadet aber nicht, die Windows-Version zu verwenden.

Mit dem Aufruf von getsockname(2) in Zeile 8 wird die Verbindungsinformation vom Socket listener in die des Sockets connector kopiert. Das brauchen wir, um die Port-Nummer, die das Betriebssystem gewählt hat, herauszubekommen.

Der Aufruf von connect(2) initiiert schließlich die Verbindung mit dem Socket.

Wenden wir uns jetzt dem Schreibende zu:

socklen_t addr_size;
SOCKET acceptor = accept(listener, (struct sockaddr *) &listener_addr, &addr_size);
if (acceptor < 0) {
    goto fail_win32_socketpair;
}
if (addr_size != sizeof listener_addr) {
    goto abort_win32_socketpair;
}

closesocket(listener);

if (getsockname(connector, (struct sockaddr *) &connector_addr,
                &addr_size) < 0) {
    goto fail_win32_socketpair;
}

if (addr_size != sizeof connector_addr
    || listener_addr.sin_family != connector_addr.sin_family
    || listener_addr.sin_addr.s_addr != connector_addr.sin_addr.s_addr
    || listener_addr.sin_port != connector_addr.sin_port) {
    goto abort_win32_socketpair;
}

Der Socket acceptor des Schreibendes akzeptiert die Verbindung mit accept(2):

Der Code an der Sprungmarke abort_win32_socketpair setzt errno auf die ähnlichste Entsprechung von ECONNABORTED, die auf dem System verfügbar ist. Danach passiert dasselbe wie für fail_win32_socketpair.

Der Socket listener wird jetzt nicht weiter benötigt. Er muss mit closesocket() und nicht mit close() geschlossen werden, weil Sockets für Windows keine Dateien sind, was etwas befremdlich ist.

Am Ende sollte noch die Verbindungsinformation der Sockets listener und connector verglichen werden, um sicherzustellen, dass die IP-Adresse und der Port identisch sind.

Als letzter Schritt werden die beiden verbundenen Sockets zum Aufrufer zurückgegeben:

sockets[0] = connector;
sockets[1] = acceptor;

return 0;

Diese Emulation von socketpair(2) baut sowohl auf der in Perl als auch auf dem Beispielcode für selektierbare Sockets von Nathan Myers in https://github.com/ncm/selectable-socketpair auf.

Übergabe der Systemdeskriptoren an den Kindprozess

Dieser Schritt unterscheidet sich zwischen Perl und C.

Perl - Socket-Paar auf STDOUT/STDERR umlenken

Augenscheinlich erben Kindprozesse, die mit Win32::Process::Create() erzeugt werden, automatisch alle Systemdeskriptoren vom Elternprozess. Deshalb müssen STDOUT/STDERR temporär auf die Schreibenden des jeweiligen Socket-Paars umgebogen werden:

open SAVED_OUT, '>&STDOUT' or die "cannot dup STDOUT: $!\n";

open STDOUT, '>&' . $stdout_write->fileno
    or die "cannot redirect STDOUT to pipe: $!\n";

Zuerst wird der reguläre Deskriptor für die Standardausgabe mit dup() in ein neues Handle SAVED_OUT kopiert, und dann wird das Schreibende des Socket-Paars $stdout_write auf STDOUT umgeleitet.

Muss an diesem Punkt etwas an die eigentliche Standardausgabe ausgegeben werden, muss jetzt natürlich das Duplikat SAVED_OUT verwendet werden!

C - STARTUPINFO befüllen

Die C-Version kommt ohne dup(2) aus und schreibt stattdessen die jeweiligen Deskriptoren in die Struktur STARTUPINFO, die später an CreateProcess() übergeben wird:

STARTUPINFO si;

memset(&si, 0, sizeof(si)); 
si.cb = sizeof(si);
si.hStdOutput = (HANDLE) stdout_sockets[1];
si.dwFlags = STARTF_USESTDHANDLES;

Die Namen der entsprechenden Felder für die Standardfehlerausgabe und Standardeingabe in STARTUPINFO lauten jeweils hStdError und hStdInput.

Ob es tatsächlich notwendig ist, dwFlags auf STARTF_USESTDHANDLES zu setzen, muss im Selbststudium herausgefunden werden, jedenfalls, bis jemand sich erbarmt, und die Antwort als Kommentar hinterlässt. Zur Zeit ist die Zeile einfach aus Beispielcode von Microsoft kopiert.

Der Typecast in Zeile 5 auf ein HANDLE sieht dubious aus, ist aber korrekt.

Offensichtlich war ein HANDLE ursprünglich lediglich eine Ganzzahl für einen Dateideskriptor. Irgendwann hat jemand in der Entwicklungsabteilung von Microsoft aber beschlossen, dass mehr Informationen als die Deskriptornummer gespeichert werden sollte, und fand es schlau, den Typ als Zeiger auf void umzuwidmen. Interessanterweise zeigen diese Zeiger aber auf sehr niedrige Adressen wie 0x10a, was zu der Annahme verleitet, dass es sich eigentlich um Offets in eine Liste von Deskriptoren handelt, aber das ist mehr Spekulation denn gesichertes Wissen.

Den Kindprozess erzeugen

Das unterscheidet sich nur unwesentlich zwischen Perl und C:

Perl - Verwende Win32::Process::Create()

require Win32::Process;

my $process;
Win32::Process::Create(
    $process,
    $^X,
    "perl child.pl",
    0,
    0,
    '.',
) or die "cannot exec: ", Win32::FormatMessage(Win32::GetLastError());

my $child_pid = $process->GetProcessID;

Das Perl-Binding Win32::Process::Create() hat ein leicht schräges Design. Statt einfach ein Prozess-Objekt zurückzugeben, kopiert es dieses ins erste Argument.

Die Argumente 2 und 3 in Zeile 6-7 sind ebenfalls erklärungsbedürftig. Argument 2, das auch undef sein kann, ist als absoluter Pfad zur ausführbaren Datei definiert. Die spezielle Perl-Variable $^X enthält den absoluten Pfad zum Perl-Interpreter. Argument 3 dagegen soll die vollständige Kommandozeile enthalten.

Falls Argument 2 undef ist, wird das erste Token von Argument 3 in $PATH gesucht, und der Rest als Argumente interpretiert. Allerdings ist es mir nicht gelungen, ein Perl-Skript auszuführen, ohne sowohl Argument 2 als auch Argument 3 zu übergeben.

Zu beachten ist auch, dass man selber verantwortlich für das Escapen der Kommandozeile in Argument 3 ist. Das geht relativ einfach: Alle Argumente, die Leerzeichen enthalten müssen in doppelte Anführungszeichen (") eingeschlossen werden, und doppelte Anführungszeichen werden als zwei doppelte Anführungszeichen ("") escaped. Das gleiche gilt für den Namen der ausführbaren Datei (das erste Token der Kommandozeile).

Das letzte Argument '.' in Zeile 10 ist das aktuelle Arbeitsverzeichnis des Kindprozesses.

Übrigens sollte man nicht darauf verfallen, die exec()-Emulation von Perl zu verwenden! Win32::Process::Create() verhällt sich nicht wie exec(), sondern wie eine Kombination aus fork() und exec(), also eher wie posix_spawn. Bei der Verwendung von exec() würde der aktuelle Prozess einfach durch den Kindprozess ersetzt, und das will man nicht.

C - CreateProcess() verwenden

In C sieht alles sehr ähnlich aus:

const char *cmd = "child.exe";
PROCESSINFO pi;

memset(&pi, 0, sizeof pi);

if (!CreateProcess(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) {
    printf("CreateProcess failed: %s.\n", strerror(errno));
    goto create_process_failed;
}

CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

Das erste und zweite Argument für CreateProcess() hat die gleiche Semantik wie die Argumente 2 und 3 in Perl. Das erste Argument ist optional und enhält den absoluten Pfad zur ausführbaren Datei. Das zweite Argumente enthält die komplette Kommandozeile (die selbst escaped werden muss) und das erste Token ist der Name der ausführbaren Datei, der in $PATH gesucht wird.

Der Zeiger auf si zeigt auf ein STARTUPINFO, das Informationen über die zu vererbenden Deskriptoren enthält.

Informationen zum Prozess wie die PID werden in der PROCESSINFO-Struktur zurückgegeben.

STDOUT und STDERR wiederherstellen

Wir erinnern uns, dass die Perl-Version zur Zeit die Schreibenden der Sockets als Standardausgabe und Standardardfehlerausgabe verwendet. Das muss jetzt wieder rückgängig gemacht werden:

if (!open STDERR, '>&SAVED_ERR') {
    print SAVED_ERR "cannot restore STDERR: $!\n";
    exit 1;
}

open STDOUT, '>&SAVED_OUT' or die "cannot restore STDOUT: $!\n";

In C ist das nicht notwendig, weil im Elternprozess keine Deskriptoren umgebogen werden.

Asynchrones Lesen der Kindprozess-Ausgabe mit `select()``

Dies kann in den Quelltextdateien parent.c und parent.pl detailliert nachgelesen werden (dort einfach nach "select" suchen).

In Perl ist es bei der Verwendung von select() immer angeraten, die gepufferte Ein- und Ausgabe von Perl zu vermeiden, indem man sysread() statt read() verwendet!

Alternative Herangehensweise mit einem dazwischengeschalteten Thread

Wie oben erwähnt, besteht ein alternativer Ansatz zur Erreichung des gewünschten Verhaltens darin, dass man einen Thread, der blockierend aus einer Pipe liest, zwischenschaltet, und alles wieder zurück in das Schreibende des Socket-Paars schreibt. Diese Version ist als Commit e9c71ac im Beispiel-Repository verfügbar.

Obwohl der zusätzliche Thread nicht notwendig ist, kann diese Technik dennoch nützlich sein, um beliebige asynchrone Ereignisse (Events) in einem Dateideskriptor für select(2) zur Verfügung zu stellen. Dazu muss der Thread lediglich auf das Ereignis warten und dann alle relevanten Informationen in das Schreibende des Sockets kopieren, so dass am Leseende ein "Daten verfügbar" ausgelöst wird.

Ich habe diese Technik im Perl-Modul AnyEvent::Filesys::Watcher in der Implementierung für Windows, die Filesys::Notify::Win32::ReadDirectoryChanges verwendet, angewandt. Das verwendete Modul erzeugt einen Thread, der synchron auf Change-Events des Dateisystems wartet und kommuniziert diese über ein Thread::Queue-Objekt an den Hauptthread. Meine Implementierung übergibt dem Modul einen Wrapper um Thread::Queue, der mit einem zusätzlichen Socket-Paar dekoriert ist, und sich in die Methoden enqueue() und dequeue() von Thread::Queue einhängt, um die gesamte Kommunikation in das Socket-Paar zu kopieren.

Die Perl-Emulationen für fork() und exec()

Perl emuliert fork() and exec() auf Windows-Systemen. Diese Funktionen können allerdings nicht für asynchrones Lesen der Ein- und Ausgabe verwendet werden.

Tatsächlich ist das fork() für Windows in Perl eher ein modifiziertes CreateThread(). Im Elternzweig wird keine Prozess-ID, sondern eine (negative) Thread-ID zurückgegeben, weil es keine Kindprozess sondern nur einen Thread gibt. Wenn man nun naiv versucht, den Pseudo-Prozess mittels kill(2) zu terminieren, wartet eine unangenehme Überraschung: Meistens schießt man den aktuellen Prozess selbst ab, weil das Signal an die komplette Prozessgruppe gesendet wurde.

Weil fork() keine Kindprozess sondern nur einen Thread erzeugt, ersetzt exec() unter Windows auch nicht den aktuellen Prozess, sondert startet den Kindprozess und terminiert dann den Thread, der vorher mit fork() erzeugt wurde.

Dies kann man sich jedoch zunutze machen, wenn man mit dem Kindprozess über einen dazwischengeschalteten Thread kommunizieren will. Wenn man fork() aufruft, weiß man, dass der "Kindprozess" eigentlich nur ein Thread mit seinen eigenen privaten Kopien der System-Dateideskriptoren ist. Deshalb kann man sich folglich das Sichern und Wiederherstellen der System-Deskriptoren bei der Erzeugung des Kindprozesses schenken.

Benutzbarkeit des Beispiel-Codes

Der Beispiel-Code wurde auf macOS mit Perl 5.34 und auf Windows 10 mit Strawberry Perl 5.32.1.1 getestet. Der Code kann als Grundlage für eigene Applikationen verwendet werden. Man sollte aber die Fehlerbehandlung und Fehlermeldungen verbessen. Ja nach Anforderungen braucht man auch noch Code, der Kindprozesse bei Bedarf beendet und Ressourcen freigibt.

Anregungen für Verbesserungen kann man am besten in einen Pull-Request packen.

Zusammenfassung

Um asynchron mit Kindprozessen unter Windows zu kommunizieren, sollte man die folgenden Regeln beherzigen:

  • Die Kommunikation muss über Sockets auf dem Loopback-Interface und nicht über Pipes erfolgen.
  • Bei der Erzeugung des Socket-Paars (oder Verwendung einer socketpair(2)-Emulation) ist darauf zu achten, dass WSASocket() und nicht BSD-socket(2) bei der Erzeugung des Listener-Sockets verwendet wird.
  • Statt Emulationen für fork() und execvp() sollte die Funktionsfamilie CreateProcess() verwendet werden.
Leave a comment
Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.