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.
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:
- Windows kennt kein
fork(2)
. - Windows kennt kein
execve(2)
(und deren Wrapper in der libc). - 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:
- Erzeuge ein Paar verbundener Sockets und konfiguriere sie für nicht blockierende Ein- und Ausgabe.
- Erzeuge eine anonyme Pipe.
- 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.
- Erzeuge den Kindprozess mit der Systemfunktion
CreateProcess()
und übergebe ihr einen Zeiger auf das STARTUPINFO aus dem vorhergehenden Schritt. - Erzeuge für jeden System-Dateideskriptor, der verbunden werden soll, einen neuen Thread.
- 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.
- 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:
- Erzeuge ein Paar verbundener Sockets und konfiguriere sie für nicht blockierende Ein- und Ausgabe.
- 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. - Erzeuge den Kindprozess mit der Systemfunktion
CreateProcess()
und übergebe ihr einen Zeiger auf dasSTARTUPINFO
aus dem vorhergehenden Schritt. - 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:
- 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. - Dupliziere die Datei-Handles für
STDOUT
undSTDERR
und speichere sie. - Dupliziere die Socket-Paare aus dem ersten Schritt und überschreibe damit
STDOUT
undSTDERR
. - Erzeuge den Kindprozess mit der Methode
Win32::Process::Create()
. - Stelle
STDOUT
undSTDERR
wieder her. - 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, dassWSASocket()
und nicht BSD-socket(2)
bei der Erzeugung des Listener-Sockets verwendet wird. - Statt Emulationen für
fork()
undexecvp()
sollte die FunktionsfamilieCreateProcess()
verwendet werden.
Leave a comment