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.

Das Kernstück sind natürlich die Aufgaben selbst. Diese können sich durchaus stark in der Ausprägung unterscheiden, aber einen bestimmten Grundstandard sollten alle Dateien als Basis teilen.
Das ist zwar nicht grundsätzlich notwendig, aber macht die Fehlersuche deutlich bequemer, gerade während der Entwicklung. Die Fehlersuche ist schon bei normalen Threads nicht immer die leichteste Übung und bei einem System, dass eine asynchrone Verarbeitung ohne Prozesszugehörigkeit vornimmt, ist es umso schwieriger.

Für die Praxis gehe ich von einer lokalen XAMPP-Installation (oder vergleichbares) aus und die Entwicklung findet im Hauptordner statt, der über die lokale Adresse erreichbar ist.
D.h. unsere Aufgabendatei muss über http://127.0.0.1/taskX.php aufrufbar sein.

Oft werden Skripte heutezutage gar nicht mehr abgebrochen, gerade wenn man entsprechende Header-Angaben schickt. Dennoch verwende ich in Dateien, die definitiv ohne zutun immer bis zum Ende laufen sollen, immer am Anfang die Codezeile:

\ignore_user_abort(true);

Das dient dazu, dass auch bei einem Abbruch durch den Benutzer die Seite weiterläuft. Ein kurzer Schwenk, warum oft die PHP-Skripte trotzdem bis zum Ende laufen. Es ist reine Vermutung, allerdings ist die Verbindungssteuerung kein Echtzeitstatus. Man liest darüber auch viel in der Kommentar-Sektion auf php.net . Demnach wird der aktuelle Zustand erst aktualisiert, wenn man eine Ausgabe an den Browser sendet, z.B. via echo. Dabei ist noch zu beachten, dass PHP einen Ausgabepuffer besitzt, d.h. selbst bei einer Ausgabe kann es sein, dass der Verbindungsstatus bis zum Überlauf dieses Ausgabepuffers nicht neu ausgewertet wird!
Wird dem Client etwas gesendet, dann beginnt die Auswertung der aktuellen Verbindung und triggert ggf. den Abbruch des Skripts. Sollte ein Fehler im Skript sein, könnt es abbrechen, sobald diese Fehlermeldung an den Client geschickt wird und dabei der Client-Abbruch ausgewertet wird. Meistens ist bei einem Fehler aber auch unser Skript am Ende mit der Ausführung, weshalb sich dieser Umstand nicht groß bemerkbar macht.
Kurzum: Den Abbruch durch den Nutzer gibt es, aber ihn wirklich mitzubekommen ist sogar in normalen Anwendungen nicht ganz trivial. Diese Anweisung gibt bei Hintergrundaufgaben in jedem Fall Sinn, daher: nutzen schadet nicht!

Wir nutzen die Stärken der neueren PHP-Versionen aus und alle Anweisungen kommen in einen vernünftigen try { /* [...] */ } catch( \Throwable $Exception ) { /* [...] */ } finally { /* [...] */ }-Block. Seitdem auch PHP-Fehler im catch-Bereich abgefangen werden können, ist das eine perfekte Grundlage um Fehler nicht nur abzufangen, sondern vor allem zu protokollieren. Somit lassen sich gerade in passiven Abläufen Fehler loggen.

Sollten Fehler auftreten, so sind diese idealerweise in einer Datei oder Tabelle zu speichern. In unserem Beispiel werden wir in eine zentrale Datei schreiben. Dies sollte je nach Einsatzgebiet abgewandelt werden.

file_put_contents("threads.log", date("Y-m-d H:i:s")." | ".$Exception->getMessage()." (#".$Exception->getCode().")\r\n", \FILE_APPEND);

Der finally-Block werden wir im Beispiel-Code nutzen, um das Ende der Ausführung zu protokollieren. Es kommt immer auf die Häufigkeit der Ausführung an, ob eine dauerhafte Protokollierung wirklich Sinn macht. Nehmen wir an, wir schreiben eine kleine Hintergrundaufgabe, die den Status von Artikel anpasst, wenn ein fertiger Artikel z.B. um Punkt 0 Uhr Nachts online gehen soll. Eine solche Aufgabe macht selten etwas und dennoch wird sie im Extremfall jede Minute ausgeführt. Wenn jeder Aufruf grundsätzlich eine Anfangs- und Endzeile schreibt, dann hat das Protokoll einen imensen Platzbedarf, wobei 90% der Zeilen irrelevant ist.
Was wäre das Ziel? Man möchte mitbekommen, wenn eine Aufgabe nicht mehr verarbeitet wird. Dazu könnte man die letzte Ausführungszeit in einem Datenspeicher hinterlegen und an anderer Stelle, z.B. durch Besucher der Webseite, wird regelmäßig geschaut, ob der letzte Zeitpunkt einen kritischen Zeitraum nicht überschreitet. Damit besitzt man eine zuverlässige Kontrolle und bei der Prüfung des Protokolls betrachtet man zum Großteil nur die relevanten Zeilen.

Programmcode für das Beispiel

Da wir jetzt ein Grundgerüst haben, kommen wir zu dem relevanten Teil, der nur für unser Beispiel existiert.
Das Konzept wurde bereits vorgestellt. Die taskX.php werden wir öfters aufrufen und dabei immer die task_nr hochzählen.
Damit schreibt jeder Aufruf seine eigene Protokolldatei, in der wir die Ausführung verfolgen können. Gleichzeitig wird jeder auch in eine zentrale Protokolldatei schreiben, die sich alle Aufgaben teilen.

Zusätzlich überprüfen wir regelmäßig, ob die Schreibbefehle gut gegangen sind und falls nicht, beenden wir die Ausführung mit einer \RuntimeException(). Zu guter letzt schreibt jede Aufgabe eine .lock-Datei, damit eine versehentliche Doppelausführung erkannt und verhindert werden kann. Ein kleiner Zusatz, der bei Hintergrundprozessen oft eine zentrale Rolle spielt. Natürlich existiert damit auch wieder eine neue Gefahr: ein Deadlock. Bedeutet, falls ein Skript so stark abstürzt, dass es selbst im finally-Block nicht mehr die angelegt Sperrdatei entfernen kann, würde diese Hintergrundaufgabe nie wieder korrekt ausgeführt werden.
Hier gilt es kreativ zu sein! Es gibt genügend Methoden, wie man das verhindern, erkennen und lösen kann. Gleichzeitig sollte man sich immer die Frage stellen, ob man diesen Mechanismus überhaupt benötigt!

Nehmen wir an, wir hätten eine Hintergrundaufgabe, die jeden Tag Nachts läuft und eine Historien-Tabelle aufräumt. Alles was älter als Heute - 7 Tage ist wird schlicht gelöscht. Was passiert, wenn diese Aufgabe doppelt ausgeführt wird? Richtig, der erste Aufruf löscht die Einträge, der zweite beendet sich mit '0' angefassten Zeilen. In einem Protokoll wird man stutzen, aber die Daten bleiben zu jeder Zeit konsistent und das ist einzig relevant!
Umgekehrt: Wird so eine Aufgabe mal zwei Tage nicht ausgeführt, z.B. durch Server-Wartungsarbeiten, dann hat er am dritten Tag ggf. ein paar Einträge mehr zu löschen. Aber wie schon erwähnt kommt es auf die Umgebung an. Habe ich ein Kundenprojekt und bekomme pro Tag mehrere 100.000 Zeilen oder gar noch mehr, dann können tagelange Ausfälle durchaus schon an Relevanz gewinnen.

Nachfolgend die komplette Datei zusammenhängend

<?php
/** <taskX.php>
 * PHP (Pseudo-) Threading
 * Aufgabe (= Task), der als Hintergrundprozess aufgerufen werden kann.
 * 
 * @version 1.0.0
 */

try
{
	# Wir müssen ausschalten, dass das Skript automatisch beendet wird, wenn die
	# Verbindung getrennt wird, denn genau das nutzt die PseudoThreading-API.
	\ignore_user_abort(true);
	
	$ProzessID = (string)($_GET["prozess_id"] ?? "");
	$AufgabeSperrdateiLoeschen = false;
	$AufgabenNr = 0;
	$AktuellesVerzeichnis = dirname(__FILE__);
	$ZentraleLogdatei = $AktuellesVerzeichnis."/logs/threads.log";
	# Die Aufgabe wird anhand eines URL-Parameters empfangen. In unserem Beispiel 
	# wird es ein GET-Request sein, jedoch gibt es dafür natürlich eine Vorgabe.
	$AufgabenNr = (int)($_REQUEST["task"] ?? 0);
	if( 1 > $AufgabenNr )
	{
		throw new \RuntimeException("Keine gültige Aufgabennummer übermittelt.", 1);
	}
	
	$AufgabeSperrdatei = $AktuellesVerzeichnis."/logs/task-".$AufgabenNr.".lock";
	if( file_exists($AufgabeSperrdatei) )
	{
		throw new \RuntimeException("Die Sperrdatei ist noch vorhanden. Doppelausführung verhindert.", 2);
	}
	
	# Sperrdatei anlegen
	$AufgabeSperrdateiLoeschen = false !== file_put_contents($AufgabeSperrdatei, date("Y-m-d H:i:s"));
	# Ziel der Log-Datei für die Aufgabe festlegen
	$AufgabeLogdatei = $AktuellesVerzeichnis."/logs/task-".$AufgabenNr.".log";
	# Zufallszahl für die Log-Datei erstellen
	$ZufaelligeZahl = mt_rand(1, 9999);
	$Bytes = @ file_put_contents($AufgabeLogdatei, "[".$ProzessID."|task".$AufgabenNr."] ".date("Y-m-d H:i:s").": Ausführung beginnt. Zufällige Nummer: ".$ZufaelligeZahl."\r\n", \FILE_APPEND);
	if( false === $Bytes || 0 === $Bytes )
	{
		throw new \RuntimeException("In die persönliche Log-Datei konnte nicht geschrieben werden! Ausführung vorzeitig beenden.", 3);
	}
	# Wir verzögern die Threads künstlich, damit (hoffentlich) die einzelnen 
	# Aufgaben möglichst zeitgleich anlaufen werden.
	# Durch die unglaubliche Rechenpower, die heutzutage zur Verfügung steht, sind
	# die Aufgaben oft schneller beendet, als der Aufruf der nächsten Datei dauert.
	\sleep(mt_rand(1, 3));
	
	# In die zentrale Logdatei schreiben
	$Bytes = @ file_put_contents($ZentraleLogdatei, "[".$ProzessID."|task".$AufgabenNr."] ".date("Y-m-d H:i:s").": Ausführung beginnt. Zufällige Nummer: ".$ZufaelligeZahl."\r\n", \FILE_APPEND);
	if( false === $Bytes || 0 === $Bytes )
	{
		throw new \RuntimeException("In die zentrale Logdatei konnte nicht geschrieben werden! Ausführung vorzeitig beenden.", 3);
	}
	
	# Weitere 10-20 Sekunden einfach warten; damit kann eine Doppelausführung 
	# provoziert werden.
	\sleep(\mt_rand(10, 20));
}
catch( \Throwable $Exception )
{
	file_put_contents($ZentraleLogdatei, "[".$ProzessID."|task".$AufgabenNr."] ".date("Y-m-d H:i:s").": ".$Exception->getMessage()." (#".$Exception->getCode().")\r\n", \FILE_APPEND);
}
finally
{
	if( $AufgabeSperrdateiLoeschen && file_exists($AufgabeSperrdatei) )
	{
		file_put_contents($ZentraleLogdatei, "[".$ProzessID."|task".$AufgabenNr."] ".date("Y-m-d H:i:s").": Ausführung beendet (".\connection_status().")\r\n", \FILE_APPEND);
		
		if( false === @ unlink( $AufgabeSperrdatei ) )
		{
			file_put_contents($ZentraleLogdatei, "[".$ProzessID."|task".$AufgabenNr."] ".date("Y-m-d H:i:s").": Die Sperrdatei konnte nicht entfernt werden. Deadlock zu erwarten!\r\n", \FILE_APPEND);
		}
	}
}