Ein Exportwerkzeug schreibt 300 MB Text auf die Standardausgabe, und das PHP-Skript, das diese Ausgabe mit shell_exec() einsammelt, bricht mit „Allowed memory size exhausted“ ab. Das Speicherlimit hochzusetzen verschiebt das Problem nur auf die nächste, größere Datei. Wie man einen Befehl startet und sein Ergebnis auf einen Schlag bekommt, zeigt das Tutorial Kommando auf der Kommandozeile ausführen. Hier geht es um die beiden Fälle, in denen das nicht reicht: Die Ausgabe ist zu groß zum Einsammeln, oder Daten sollen in den Prozess hinein. PHP popen() liefert dafür keinen Text, sondern einen Stream.
Statt eines fertigen Textes bekommt das Skript von PHP popen() eine Leitung, aus der es Zeile für Zeile liest, während der Befehl noch läuft.
Was PHP popen() liefert: einen Stream statt Text
exec() und shell_exec() warten, bis der Befehl fertig ist, und geben dann alles zurück, was er ausgegeben hat. PHP popen() kehrt dagegen sofort zurück und reicht einen Dateizeiger heraus. Hinter diesem Zeiger steckt eine Pipe, also eine Leitung zwischen zwei Prozessen: Am einen Ende schreibt der gestartete Kindprozess, am anderen Ende liest PHP.
Der Zeiger verhält sich fast wie einer aus fopen(). Gelesen wird mit fgets(), fread() oder stream_get_contents(), geschrieben mit fwrite(). Die Funktionen und ihre Moduszeichen erklärt der Artikel Dateihandling mit PHP: fopen, fread, fwrite und fclose. Zwei Unterschiede zur Datei bleiben. Eine Pipe lässt sich nicht zurückspulen, fseek() endet mit einer Warnung und dem Wert -1. Und sie funktioniert nur in eine Richtung: Entweder liest PHP die Standardausgabe des Befehls, oder es schreibt in seine Standardeingabe.
Die Funktion sieht harmlos aus, trotzdem gilt: Der Befehl läuft über die Shell, unter Linux /bin/sh, unter Windows cmd.exe. Er hat dieselben Rechte wie der PHP-Prozess, im Webserver also die von dessen Benutzer, oft www-data. Für die Sicherheit macht es keinen Unterschied, ob ein Shell-Befehl über exec() oder über PHP popen() startet.
Die Signatur von popen() und pclose() im Original
Erst pclose() liefert das Ergebnis des Befehls. So stehen beide im Handbuch:
popen(string $command, string $mode): resource|false
/* $command command line, executed by the
system shell
$mode "r" = read the command's stdout
"w" = write to the command's stdin
"rb" / "wb" = binary variants
returns one-way file pointer,
false on error */
pclose(resource $handle): int
/* returns exit status of the command,
-1 on error */
Erlaubt sind genau vier Modi. Alles andere, auch das von Dateien bekannte r+, beantwortet PHP 8 mit einem ValueError: popen(): Argument #2 ($mode) must be one of "r", "rb", "w", or "wb". Das gilt gemessen unter Windows und Linux gleichermaßen.
Befehlsausgabe und Logfiles zeilenweise lesen
Der Normalfall bei PHP popen() ist der Lesemodus r. Eine Schleife holt mit fgets() eine Zeile nach der anderen ab, bis der Befehl fertig ist:
<?php
$pipe = popen('df -h', 'r');
if ($pipe === false) {
exit("Befehl nicht gestartet\n");
}
while (($zeile = fgets($pipe)) !== false) {
echo rtrim($zeile), "\n";
}
$code = pclose($pipe);
echo "Exit-Code: $code\n";
/* Filesystem Size Used Avail Use% Mounted on
...
Exit-Code: 0 */
Der strikte Vergleich mit false ist keine Pedanterie. Liefert der Befehl eine Zeile, die nur aus 0 besteht, würde eine lockere Prüfung die Schleife an dieser Stelle beenden.
Ein Lesezugriff kann an zwei weit auseinanderliegenden Stellen scheitern. Das Diagramm zeigt, warum der Exit-Code erst am Ende feststeht.
flowchart TD
A[popen mit Modus r] --> B{Rückgabe false?}
B -->|ja| C[Start gescheitert]
B -->|nein| D[fgets liest eine Zeile]
D --> E{Zeile erhalten?}
E -->|ja| F[Zeile verarbeiten]
F --> D
E -->|nein| G[pclose wartet auf Ende]
G --> H{Exit-Code 0?}
H -->|ja| I[Erfolg]
H -->|nein| J[Ausgabe protokollieren]
Die Zeilen kommen an, sobald der Prozess sie schreibt, nicht erst am Ende. Das macht PHP popen() zum passenden Werkzeug für lang laufende Befehle, deren Fortschritt protokolliert werden soll:
<?php
$befehl = 'echo eins; sleep 2; echo zwei';
$pipe = popen($befehl, 'r');
while (($zeile = fgets($pipe)) !== false) {
echo date('H:i:s'), ' ', $zeile;
}
pclose($pipe);
/* 13:30:35 eins
13:30:37 zwei */
Die Kehrseite: fgets() blockiert. Es wartet, bis eine Zeile kommt oder der Prozess endet, und pclose() wartet ebenfalls auf das Prozessende. stream_set_timeout() ändert daran nichts. Im Container gemessen wartete fgets() trotz eines Zeitlimits von einer Sekunde die vollen drei Sekunden eines sleep 3, und timed_out blieb false.
Ein typischer Fall aus dem Betrieb ist das Filtern einer großen Logdatei mit grep. PHP bekommt nur die Treffer zu sehen, und das Skript zählt sie, gibt aber nur die ersten zehn aus:
<?php
$begriff = 'Fatal error';
$log = '/var/log/php/fehler.log';
$befehl = 'grep -F ' . escapeshellarg($begriff)
. ' ' . escapeshellarg($log);
$pipe = popen($befehl, 'r');
if ($pipe === false) {
exit(1);
}
$treffer = 0;
while (($zeile = fgets($pipe)) !== false) {
$treffer++;
if ($treffer <= 10) {
echo $zeile;
}
}
$code = pclose($pipe);
printf("%d Treffer, Code %d\n", $treffer, $code);
Bei grep lohnt ein Blick auf den Exit-Code, denn ungleich null heißt hier nicht automatisch Fehler. Im Test kam 0 bei Treffern, 1, wenn nichts gefunden wurde, und 2, wenn die Logdatei fehlt.
Speicherverbrauch: PHP popen() gegen shell_exec()
Ein kleines Hilfsskript erzeuge.php gibt 200.000 Zeilen zu je rund 100 Zeichen aus, zusammen etwa 20 MB. Einmal sammelt shell_exec() die Ausgabe ein, in einem zweiten Lauf liest PHP popen() sie Zeile für Zeile:
<?php
$vorher = memory_get_peak_usage();
$pipe = popen('php erzeuge.php', 'r');
$zeilen = 0;
while (fgets($pipe) !== false) {
$zeilen++;
}
$code = pclose($pipe);
$plus = memory_get_peak_usage() - $vorher;
printf(
"%d Zeilen, plus %.2f MB, Exit %d\n",
$zeilen,
$plus / 1048576,
$code
);
/* 200000 Zeilen, plus 0.00 MB, Exit 0 */
Die Gegenprobe ersetzt die Schleife durch $text = shell_exec('php erzeuge.php');. Gemessen wurde jeweils der Zuwachs des Spitzenspeichers, ohne Xdebug:
| Variante | Windows, PHP 8.4.8 | Linux, PHP 8.4.17 |
shell_exec(), alles auf einmal | plus 19,97 MB | plus 19,06 MB |
popen() mit fgets()-Schleife | plus 0,00 MB | plus 0,00 MB |
Der eine lange String belegt so viel Speicher, wie die Ausgabe groß ist. Mit PHP popen() bleibt der Zuwachs unter 10 KB, gleich wie lang die Ausgabe wird.
In einen Prozess schreiben mit Modus w
Im Modus w kehrt sich die Richtung um. Jedes fwrite() landet in der Standardeingabe des Befehls. Damit entfällt die Zwischendatei samt Aufräumen. Das Beispiel schreibt 1000 Zeilen direkt in gzip:
<?php
$ziel = __DIR__ . '/export.csv.gz';
$befehl = 'gzip -c > ' . escapeshellarg($ziel);
$gz = popen($befehl, 'w');
if ($gz === false) {
exit(1);
}
for ($i = 1; $i <= 1000; $i++) {
fwrite($gz, "$i;Artikel $i;9,99\n");
}
$code = pclose($gz);
echo $code === 0 ? 'fertig' : "Fehler $code";
/* fertig, die Datei hat 4679 Byte */
Die Umleitung mit > im Befehl ist hier Pflicht. Im Schreibmodus geht die Ausgabe des Befehls nicht an PHP zurück, sondern an die Standardausgabe des PHP-Prozesses. Ein tr a-z A-Z im Modus w schrieb im Test HALLO PIPE direkt auf die Konsole, das Skript bekam davon nichts mit. Wer die Antwort eines Programms in PHP auswerten muss, braucht beide Richtungen und damit eine andere Funktion. Geht es nur ums Komprimieren, ist PHP gzencode() der kürzere Weg, ganz ohne externes Programm.
Für die Gegenrichtung braucht es nicht einmal eine Schleife. stream_copy_to_stream() schiebt die entpackte Ausgabe direkt in eine Datei:
<?php
$quelle = __DIR__ . '/export.csv.gz';
$befehl = 'gzip -dc ' . escapeshellarg($quelle);
$pipe = popen($befehl, 'r');
$ziel = fopen(__DIR__ . '/export.csv', 'wb');
$bytes = stream_copy_to_stream($pipe, $ziel);
fclose($ziel);
$code = pclose($pipe);
echo "$bytes Byte, Exit-Code $code\n";
/* 20786 Byte, Exit-Code 0 */
Exit-Code und Fehlermeldungen: was pclose() zurückgibt
Bei exec() kommt der Rückgabewert über den dritten Parameter. PHP popen() hat keinen solchen Parameter, der Exit-Code steht erst fest, wenn der Befehl beendet ist, und pclose() liefert ihn: 0 bei Erfolg, 3 nach exit 3, 2 bei ls auf einen fehlenden Pfad. Bricht ein Signal den Befehl ab, kam unter Linux der Rohwert zurück, nach kill -9 also 9. fclose() schließt den Zeiger zwar ebenfalls ohne Fehler, verschluckt aber den Exit-Code. Deshalb endet jede Pipe mit pclose().
Die häufigste Fehlannahme betrifft den Rückgabewert false. Ein falsch geschriebener Befehl erzeugt ihn nicht. Die Shell startet, sucht den Befehl, findet ihn nicht und endet mit einem Fehlercode. Für PHP popen() ist der Start damit gelungen, und erst pclose() verrät mit 127 unter Linux, dass nichts gelaufen ist. Wer nur if ($pipe === false) prüft, übersieht genau diesen Fall.
Dazu kommt die zweite Lücke: Durch die Pipe kommt nur die Standardausgabe. Fehlermeldungen schreibt ein Programm auf stderr, und die landen ohne Umleitung in der Konsole oder im Fehlerprotokoll des Webservers, nicht im Stream. Im Browser bleibt die Ausgabe dann leer, obwohl der Befehl einen Grund genannt hat. Ein angehängtes 2>&1 mischt stderr in die Pipe:
<?php
$archiv = '/var/backup/heute.tar';
$befehl = 'tar -tf ' . escapeshellarg($archiv)
. ' 2>&1';
$pipe = popen($befehl, 'r');
$ausgabe = (string) stream_get_contents($pipe);
$code = pclose($pipe);
if ($code !== 0) {
error_log("tar Code $code: $ausgabe");
}
/* tar Code 2: tar: /var/backup/heute.tar:
Cannot open: No such file or directory */
Wer die Meldungen bewusst nicht sehen will, verwirft sie mit 2>/dev/null, unter Windows mit 2>NUL. Wie die Umleitung im Einzelnen funktioniert, steht im Tutorial zur Kommandozeile. Ausgabe und Fehlermeldungen getrennt zu lesen, gelingt mit PHP popen() nicht.
Werte sicher einsetzen mit escapeshellarg()
Der Befehl für PHP popen() ist ein Shell-String, und die Shell wertet Semikolon, Pipe-Zeichen und Backticks aus. Ein Wert aus einem Formular kann so einen zweiten Befehl einschleusen. Ein Versuch mit dem Wert a b; echo HACK:
<?php
$wert = 'a b; echo HACK';
$p = popen('echo ' . $wert, 'r');
echo stream_get_contents($p);
pclose($p);
/* a b
HACK */
$p = popen('echo ' . escapeshellarg($wert), 'r');
echo stream_get_contents($p);
pclose($p);
/* a b; echo HACK */
Ungeschützt laufen zwei Befehle, geschützt kommt der Wert als ein einziges Argument an. Die Regel ist deshalb einfach: Jeder Wert, der nicht fest im Code steht, geht einzeln durch escapeshellarg(), auch Dateinamen aus dem eigenen Upload-Verzeichnis. Warum escapeshellcmd() dafür nicht der Ersatz ist, erklärt ein eigener Abschnitt im Tutorial zur Kommandozeile.
PHP popen() unter Windows
Ja, PHP popen() gibt es auch unter Windows, der Befehl läuft dort über cmd.exe. Einige Werte weichen aber ab, und gerade auf diese Werte bauen Skripte gern ihre Fehlerprüfung:
| Gemessen | Windows 8.4.8 | Linux 8.4.17 |
| Shell | cmd.exe | /bin/sh |
unbekannter Befehl, pclose() | 1 | 127 |
| Meldung dazu | deutscher Text: „ist entweder falsch geschrieben oder konnte nicht gefunden werden“ | sh: 1: ...: not found |
Modi rb und wb | angenommen | angenommen, wirken wie r und w |
| stderr verwerfen | 2>NUL | 2>/dev/null |
Das b in rb und wb ist für Windows gedacht. Es verhindert dort, dass Zeilenenden umgewandelt werden, und gehört deshalb zu jedem Aufruf, der Binärdaten durch die Pipe schickt. Wichtiger ist die letzte Zeile der Fehlerprüfung: Wer auf 127 oder auf einen Meldungstext prüft, hat Code, der nur auf einer Plattform stimmt. Verlässlich ist allein die Unterscheidung zwischen 0 und allem anderen.
<?php
$befehl = PHP_OS_FAMILY === 'Windows'
? 'dir /b'
: 'ls -1';
$pipe = popen($befehl . ' 2>&1', 'rb');
$eintraege = 0;
while (fgets($pipe) !== false) {
$eintraege++;
}
$code = pclose($pipe);
echo $code === 0
? "$eintraege Einträge\n"
: "Fehler, Code $code\n";
Wenn popen() fehlt oder nicht reicht
Auf vielen Shared-Hosting-Paketen existiert die Funktion gar nicht. Der Hoster sperrt sie zusammen mit exec() und Verwandten über disable_functions in der php.ini. Mit -d disable_functions=popen gestartet: function_exists('popen') liefert false, und der Aufruf endet mit Error: Call to undefined function popen(). Portabler Code prüft deshalb vorher und gibt eine Meldung aus, die der Betreiber versteht:
<?php
if (!function_exists('popen')) {
throw new RuntimeException(
'popen() ist auf diesem Server gesperrt'
);
}
Die zweite Grenze liegt in der Bauart. PHP popen() öffnet genau eine Richtung, bietet keinen getrennten Kanal für stderr und kennt kein Zeitlimit. Wer gleichzeitig schreiben und lesen, Fehlermeldungen getrennt auswerten oder einen hängenden Befehl nach einer Frist abbrechen muss, braucht proc_open(). Diese Funktion legt getrennte Pipes für stdin, stdout und stderr an, setzt Arbeitsverzeichnis und Umgebung und erlaubt nicht blockierendes Lesen. Eine erste Einordnung steht im Abschnitt „Fortgeschritten“ im Tutorial zur Kommandozeile.
Fazit
PHP popen() ist die Antwort auf zwei Fragen, die exec() offenlässt: Wie verarbeite ich eine Ausgabe, die nicht in den Speicher passt, und wie schicke ich Daten in einen Prozess? Gemessen blieb der Speicherzuwachs der Leseschleife bei null, während shell_exec() für 20 MB Ausgabe auch rund 20 MB belegte. Der Preis ist eine Richtung pro Aufruf und ein fgets(), das wartet, solange der Befehl schweigt.
Zu jedem Aufruf gehört pclose() statt fclose(), weil nur so der Exit-Code ankommt, und der verrät auch den falsch geschriebenen Befehl, für den PHP popen() kein false liefert. Wird die Fehlermeldung gebraucht, kommt 2>&1 an den Befehl. Und escapeshellarg() braucht jeder Wert, der nicht fest im Code steht, weil hinter dem unscheinbaren Dateizeiger eine vollwertige Shell arbeitet.