PHP (Pseudo-) Threading

Bewertung [ Bewertung abgeben ] Artikel geschrieben am 25.03.2021 um 17:48 Uhr, aktualisiert am 22.07.2023, um 21:29 Uhr.

Bleibt noch die Hauptdatei cron.php, die dafür sorgt, dass alle Aufgaben im Hintergrund angesoßen werden.
Der Grundaufbau bleibt naturgemäß für eine Datei, die grunsätzlich nicht aktiv ausgeführt wird, sehr nah an dem, was wir bereits für die Aufgaben programmiert haben. Nur im Hauptprogrammteil werden wir entsprechend nicht die Aufgabe ausführen, sondern dafür sorgen, dass die einzelnen Aufgaben angestoßen werden.

Die zentrale Frage ist: Wie führt man eine PHP-Datei im Hintergrund aus? Dazu gibt es grundsätzlich zwei Möglichkeiten:
1. per Shell-Befehl mithilfe des Command Line Interface
oder
2. über einen HTTP(S)-Request

Ich persönlich nutze gerne den HTTP-Request. Damit sind wir in der typischen Umgebung, wie bei einem typischen Cronjob-Aufruf, der meist ebenfalls eine URL aufruft.
Der Vorteil eines HTTP(S)-Requests ist die Übermittlung von zusätzlichen Daten, die relativ leicht gesendet und verarbeitet werden können. Über die Kommandozeile gibt es zwar die Möglichkeit von Parameter, allerdings sind diese nicht so bequem und leicht zu verarbeiten wie mit typischen POST- oder GET-Variablen und für den normalen PHP-Entwickler ist das Auslesen der Superglobals schlicht geläufiger als die Nutzung in einer CLI-Anwendung.

Konzentrieren wir uns für dieses Beispiel auf einen HTTP(S)-Webaufruf. Doch setzt man die Idee in die Tat um?

Auf der letzten Seite haben wir in unseren eigenen Aufgaben bereits einen Befehl genutzt, der dafür sorgt, dass PHP-Skripte immer bis zum Ende durchlaufen. Genau diesen "Trick" nutzen wir aus! Wir spielen Besucher und basteln uns einen GET-Request (wenn man Daten übertragen möchte, dann gerne auch POST) zusammen, der unsere Aufgabe aufruft.
Mit ein paar zusätzlichen Tricks trennen wir die Verbindung direkt nachdem die wichtigen Daten übertragen wurden. Mithilfe der Anweisung \ignore_user_abort() läuft die Aufgabe munter weiter und wir können uns direkt der nächsten Aufgabe widmen. Nachteil: Es gibt keine Prozesskontrolle und wir können den Aufruf nicht steuern. Wichtig hierbei: der Server sollte immer eine maximale Ausführungszeit besitzen, damit man sich mit falsch programmierten Aufgaben die Ressourcen blockiert. Gerade auf Hosting-Servern wird das nicht gerade gerne gesehen.

Sollte man Zugriff auf die Shell haben, dann könnte man hier eventuell eine Variante basteln mit der man die Prozess-IDs der Aufgaben erhält. Speichert man diesen, dann wäre eine Prozesskontrolle denkbar, wenngleich sie deutlich mehr Last / Speicher bedeutet.
Auch hier gilt wieder der Grundgedanke: Je nachdem, wie kritisch das System geplant ist, lohnt sich der Forschungs- und Entwicklungsaufwand für eine solche Variante.

Das Kernstück unserer Datei wird also das Starten des Hintergrundprozesses. Entsprechend unspektakulär ist der Aufruf in unserer Beispieldatei:

for( $i=1; $i<=5; $i++ )
{
	\starteHintergrundprozess($i, $ProzessID);
}

Um den Prozess zu starten benötigen wir eine Verbindung auf unseren eigenen Server / Domain, die wir steuern können. Die meisten werden dabei an die Funktion file_get_contents() denken, aber diese ist für uns nicht zielführend, da sie die Antwort des Servers abwartet.

Für unseren Aufruf nutze ich fsockopen(), allerdings ist auch die Nutzung mithilfe der curl-Erweiterung denkbar.

$error_code = null;
$error_msg = null;
$Handler = \fsockopen("127.0.0.1", 80, $error_code, $error_msg, 5);
if( false === $Handler || !empty($error_code) || !empty($error_msg) )
{
	throw new \RuntimeException("fsockopen: ".$error_msg." (#".$error_code.")", 1);
}

Wir besitzen jetzt ein offenes Socket auf unseren eigenen Webserver. Im Internet sollte man zur Sicherheit die Verbindung über die offizielle Domain herstellen. Auch sollte man über eine Absicherung via SSL und Basic Auth nachdenken, um Angriffen vorzubeugen.
Wie man diese Daten übergibt, kann man über einschlägige Google-Suchen schnell erfahren.
Ich setze außerdem das Wissen voraus, wie ein HTTP-Header aussehen muss.

$request = [];
$request[] = "GET /taskX.php?task=".<var>$Nummer</var>."&prozess_id=".<var>$ProzessID</var>." HTTP/1.0";
$request[] = "Host: localhost";
$request[] = "Accept: */*";
$request[] = "Content-Type: application/x-www-form-urlencoded";
$request[] = "User-Agent: PHP-PseudoThreading API (v1.0.0)";
$request[] = "Connection: close";
$Header = implode("\r\n", $request);
$Header .= "\r\n\r\n";

Für unser Beispiel rufen wir natürlich immer die taskX.php auf und unterscheiden die Aufgabe allein durch die Variable $Nummer. In einem Projekt ist es sicherlich sinnvoller für jede Aufgabe eine separate, eigene Datei anzulegen.

  • Der Host-Eintrag ist relevant und sollte entsprechend korrekt gesetzt sein. Auf vielen Servern werden diverse Domains betrieben (Stichwort: Apache vHost) und der Webserver benötigt die Information für welches Ziel er eine Anfrage erhält.
  • Der Accept-Header ist schlicht für jede Rückgabe erlaubt. Da wir diese gar nicht erst abwarten spielt es keine Rolle.
  • Möchte man eine POST-Anfrage stattdesssen senden, so spielt der Parameter Content-Type eine bedeutende Rolle. Natürlich wählt man selbst, ob die Daten wie ein normales Formular oder gar als JSON übertragen wird. Da wir keine Daten übertragen ist dieser irrelevant.
  • Einen User-Agent sollte man immer setzen. Zum einen gehört es zum guten Ton und viele Netzwerkstrukturen erwarten eine gültige Angabe in diesem Feld, sonst verweigern Sie den Zugriff bzw. weisen die Anfrage ab.
    Der Inhalt ist relativ egal, aber um die eigenen Aufrufe auch ggf. in Log-Dateien verfolgen zu können, sollten sie möglichst eindeutig und klar erkennbar sein.
  • Wie immer kommt das wichtigste zum Schluss. Die Angabe Connection: close bildet die wohl relevanteste Angabe. Oft wird heutzutage die Eigenschaft keep-alive gesetzt. Wir teilen hiermit dem Server mit, dass die Verbindung wirklich getrennt werden soll, wenn wir sie aktiv beenden.

Ein HTTP-Header wird immer mit einem doppelten Zeichenumbruch (\r\n\r\n, Carriage Return und Newline) beendet und danach folgt der Inhalt. Da wir keine Daten senden wollen bleibt der Inhalt einfach leer.
Vergisst man den doppelten Zeilenumbruch nach dem Header, dann wird der Webserver abwarten, da die Anfrage nicht vollständig ist. Ein beliebter Fehler, wenn die Anfrage von Hand erstellt wird. Diese Gefahr besteht vor allem dann, wenn man gerne mit trim() arbeitet.

Hinweis: Falls eine POST-Anfrage gesendet wird, darf die Header-Angabe Content-Length nicht vergessen werden.

Am Ende schreiben wir mit \fwrite($Handler, $Header); auf unsere geöffnete Verbindung. Sobald der Webserver das letzte Bit erhalten hat wird er diese Anfragen direkt ausführen.
Für uns bedeutet das, dass wir die Verbindung nach dem Schreiben direkt mit \fclose($Handler); beenden können. Das PHP-Skript, also unsere Aufgabe, wird in jedem Fall bis zum Ende abgearbeitet.

Und damit haben wir ein funktionstüchtiges Skript, dass in der Lage ist, Aufgaben im Hintergrund aufzurufen.
Das Beispiel ist ganz bewusst sehr einfach gehalten, damit das Grundprinzip verständlich bleibt. Eine mögliche Erweiterung ist sicherlich die zeitliche Einschränkung von Aufgaben, analog zu echten Cronjobs.

Wie Eingangs erwähnt muss es aber keine eigene Datei sein, die einen Hintergrundprozess anstößt. Nehmen wir ein typisches Blogging-System. Bei einem Klick auf veröffentlichen wird nicht nur der Beitrag selbst auf online gesetzt. Heutzutage werden zeitgleich immer diverse Meldungen auf Social Media-Kanälen gepostet, sei es Twitter, Facebook oder gar WhatsApp. Falls das Veröffentlichen eh Nachts mithilfe eines Cronjobs geschieht, dann ist das Ergebnis der externen Schnittstellen nicht in Echtzeit relevant. Im Prozess der Veröffentlichung könnte also ebenfalls einer oder mehrere Hintergrundprozesse angestoßen werden, die bei den entsprechenden Services eine Benachrichtigung vornimmt. Das Ergebnis schreiben sie beim Ende einfach in die Tabelle des Originalartikels. Was wären dabei die Vorteile? Das eigentliche System reagiert weiterhin schnell und alle externen Dienste werden nahezu gleichzeitig bedient. Falls ein Dienst mal mit Serverproblemen zu kämpfen hat, dann hat das keine Auswirkungen mehr auf die Arbeit.

Vollständiger Dateiinhalt

<?php
/** <cron.php>
 * PHP (Pseudo-) Threading
 * 
 * Zentrale Steuerung zum starten von Hintergrundprozessen.
 * 
 * @version 1.0.0
 */

function starteHintergrundprozess( int $Nummer, string $ProzessID )
{
	echo "<pre>";
	$error_code = null;
	$error_msg = null;
	$Handler = \fsockopen("127.0.0.1", 80, $error_code, $error_msg, 5);
	if( false === $Handler || !empty($error_code) || !empty($error_msg) )
	{
		throw new \RuntimeException("fsockopen: ".$error_msg." (#".$error_code.")", 1);
	}
	
	$request = [];
	$request[] = "GET /test/taskX.php?task=".$Nummer."&prozess_id=".$ProzessID." HTTP/1.0";
	$request[] = "Host: localhost";
	$request[] = "Accept: */*";
	$request[] = "Content-Type: application/x-www-form-urlencoded";
	$request[] = "User-Agent: PHP-PseudoThreading API (v1.0.0)";
	$request[] = "Connection: close";
	$Header = implode("\r\n", $request);
	# Der Header wird vom Body immer mit 2 Newlines (\r\n) abgetrennt. Wenn nur 
	# der Header gesendet wird, dann MUSS am Ende dieser Trenner ebenfalls 
	# gesendet werden!
	$Header .= "\r\n\r\n";
	\fwrite($Handler, $Header); # nur Header senden!
	var_dump($Handler);
	\fclose($Handler);
	var_dump($Header);
	echo "</pre>";
}
try
{
	$ProzessID = \uniqid();
	$AktuellesVerzeichnis = dirname(__FILE__);
	$ZentraleLogdatei = $AktuellesVerzeichnis."/logs/threads.log";
	file_put_contents($ZentraleLogdatei, "[".$ProzessID."|cron] ".date("Y-m-d H:i:s").": Ausführung gestartet.\r\n", \FILE_APPEND);
	
	for( $i=1; $i<=5; $i++ )
	{
		\starteHintergrundprozess($i, $ProzessID);
	}
}
catch( \Throwable $Exception )
{
	file_put_contents($ZentraleLogdatei, "[".$ProzessID."|cron] ".$Exception->getMessage()." (#".$Exception->getCode().")\r\n", \FILE_APPEND);
}
finally
{
	file_put_contents($ZentraleLogdatei, "[".$ProzessID."|cron] ".date("Y-m-d H:i:s").": Ausführung beendet (".\connection_status().").\r\n", \FILE_APPEND);
}