PHP-Scripts im Hintergrund per Tasksystem ausführen

Text: Peter Bieling - 18.04.2023

Wer im betrieblichen Umfeld programmiert, hat es häufiger mit Aufgaben zu tun, die regelmäßig und möglichst automatisch im Hintergrund ausgeführt werden sollten.

Beispiel: Eine Firma betreibt einen Online-Shop, aus dem neue Aufträge heruntergeladen und ins lokale System überführt werden sollen. Es ist wenig produktiv, die Mitarbeiter damit zu beauftragen, in gewissen Abständen im Browser ein PHP-Script aufzurufen, das diesen Job erledigt. Ich kann mich allerdings an ein Projekt erinnern, bei dem das anfangs tatsächlich so gemacht wurde und man später an den Logs sehen konnte, dass der Button viel zu häufig und von unterschiedlichen Personen gleichzeitig geklickt wurde. Darüber hinaus blockieren länger laufende Scripts den Browser.

Ein Behelf und Lösungen von früher sind Meta-Refreshs oder JavaScript-Reloads, die die geöffnete Seite in einem festgelegten Intervall neu aufrufen. Auch hier muss der Browser geöffnet bleiben. - Keine gute Idee.

Eine Verbesserung bieten Cronjobs, die die Scripte im Hintergrund regelmäßig starten. Der direkte Aufruf über einen eigenen Cronjob bringt jedoch einige Nachteile mit sich:

  • Die Verwaltung der Cronjobs ist umständlich und benötigt spezielle Kenntnisse. Das gilt besonders, wenn Tasks häufiger pausieren sollen oder es sehr viele Tasks gibt.
  • Trotz Fehlern wird ein Task immer wieder aufgerufen.
  • Lang laufende Tasks könnten sich durch nachfolgende Aufrufe duplizieren und ggf. gegenseitig behindern.

Für die genannten Probleme lassen sich auch mit dem Cronjob Lösungen finden. Die sollte man jedoch lieber in ein umfassendes System integrieren, das komfortabel bedient und gewartet werden kann.

Auf die Minute

Wie lässt sich also die Situation verbessern und Übersicht über unterschiedliche Aufgaben erreichen? Eine gängige Lösung ist eine Tabelle, in die alle Tasks eingetragen werden.

Ohne Cronjob geht es jetzt zwar auch noch nicht, wir benötigen jedoch nur noch einen einzigen, der jede Minute die Tabelle prüft, ob der Zeitpunkt für den nächsten Lauf eines Scripts erreicht ist. Natürlich lassen sich auch kürze oder längere Abstände einstellen. Im Normalfall ist die Minute jedoch ein bewährtes Intervall.

In die Tabelle gehören mindestens diese Attribute:

  • Eine ID, um die Scripts auseinander zu halten
  • Der Pfad des Scripts, bzw. der verwendeten Klasse
  • Der Zeitpunkt des nächsten Starts
  • Die Zeitdauer in Minuten zwischen den Aufrufen eines Scripts
  • Der Status des Scripts (z.B. RUNNING, wenn es gerade läuft)

Ausschnitt eines Tabellen basierten Tasksystems

Nützlich sind weitere Spalten (LastUpdate, LastStarted, Heartbeat usw.), die bei der Fehlersuche hilfreich sein können. Mit dem nachfolgenden SQL-Code können Sie eine Tabelle erzeugen, die zunächst das Nötigste liefert.

CREATE TABLE IF NOT EXISTS `tasks` (
  `ID` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `Description` varchar(100) NOT NULL DEFAULT '',
  `Scriptfile` varchar(100) NOT NULL DEFAULT '',
  `Minutes` int(11) NOT NULL DEFAULT '1440',
  `Nextrun` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `Status` enum('ON','OFF','RUNNING','ERROR','STOP') NOT NULL DEFAULT 'OFF',
  PRIMARY KEY (`ID`)
)
Der Motor des Ganzen

Wie bereits angesprochen, muss diese Tabelle jetzt jede Minute auf Datensätze überprüft werden, die Datum und Uhrzeit in NextRun erreicht haben und mit Status ON bereit für die Ausführung sind.

Dafür verwenden wir ein PHP-CLI-Script, z.B. im Verzeichnis admin/system/tasks.php, das für eine einfache Lösung stichwortartig wie folgt aussehen kann:

<?php
//[...]
//Aus Tabelle tasks alle Abfragen mit Bedingung:
$where = "Status = 'ON' AND Nextrun < now()";
//[...]

//Das Ergebnis durchlaufen und Tabelle aktualisieren
foreach ($res as $row) {
    $query = "update " . TASKS 
                        . " set Nextrun = "
            . " if (Minutes = 1440, DATE_ADD(Nextrun, INTERVAL 1 DAY), "
            . " DATE_ADD(Nextrun, INTERVAL Minutes MINUTE)), "
                        . " Status = 'Running'"
            . " where ID = " . $row['ID'] . " AND Status = 'ON'";
    //[...]
    //Query ausführen
    //[...]
    
    //Prozess in Linuxumgebung in den Hintergrund verlegen
    $scriptfile =    __DIR__ . '/' . $row['Scriptfile'];
    $cmd = 'php '. $scriptfile . '  > /dev/null &';
    exec($cmd);
}

Dieser Code sollte ausreichen, um das Prinzip zu verstehen. Sie können den Code an Ihre Bedürfnisse anpassen und ergänzen.

In der Praxis sieht es so aus, dass das Abfragen der Tasks und das Anstoßen der Ausführung im Hintergrund wenig Zeit benötigt. Durch geschickte Wahl der Uhrzeit kann man auch dafür sorgen, dass nicht alle Scripte gleichzeitig gestartet werden. Damit entlastet man das Gesamtsystem.

Für eine größere Anzahl von Scripten, die z.B. jede Stunde laufen, sollte man die Anfangszeiten verteilen. Ebenso können Tasks mit langer Ausführungszeit, die einmal am Tag laufen und das System belasten, vorzugsweise auf die Nachtstunden verteilt werden.

Ein Blick auf den einzelnen Task

Was in der eigentlichen Task-Datei steht, spielt für das System selbst keine große Rolle. Wichtig ist, dass nach dem erfolgreichen Lauf der Status von RUNNING wieder auf ON gesetzt wird. Bei Fehlern, die die zeitweilige Unterbrechung des gesamten Tasks ratsam erscheinen lassen, empfiehlt es sich, diesen auf ERROR zu setzen. Eine Protokollierung der abgefangenen Fehler hilft bei der Behebung von aufgetretenen Problemen.

Die Verwaltungsaufgaben sind natürlich nicht Kernaufgabe des aufgerufenen Scripts. Man will auch nicht in jedes einzelne Script die selben Routinen schreiben. Daher sollten diese Aufgaben ausgelagert werden.

Beispiel eines Tasks für den Artikel-Download

Der folgende Code zeigt ein Beispiel aus einer Testumgebung, die mein Framework PbClasses verwendet:

<?php
namespace mgadmin\Tasks;

class ProductsUpdate {
    public function run(\PbClasses\Tasks\SingleTask $taskObj) {
        try {
            $counter = 0;
            for ($i = 1; $i <= 450; $i++) {
                $counter++;
                $taskObj->setHeartbeat('Durchgang ' . $counter);

                $obj = new \mgadmin\Api\BaseProductsSync();
                $succ = $obj->run();

                if (!$succ) {
                    $taskObj->log('Success = false  ');
                    //Trotz Fehlers weiter
                    //oder mit return true; den Lauf abbrechen, ohne
                    //den Task auf ERROR zu setzen.
                }
            }
        } catch (\Exception $exc) {
            new \PbClasses\Exception\Logging($exc->getMessage(), __FILE__, __LINE__);
            return false;
        }

        return true;

    }
}

Im einfachsten Fall wird das übergebene Objekt gar nicht benötigt. Durch die Rückgabe von true oder false wird darüber entschieden, ob der Status auf ON oder auf ERROR gesetzt wird. Man kann aber Methoden des Objekts dazu verwenden, zusätzliche Aktionen auszuführen, wie z.B. Logeinträge zu machen oder bei Schleifendurchläufen einen Timestamp zu setzen. Man sieht dann bei Ausfällen, wann es zum Script-Abbruch gekommen ist und der letzte Heartbeat stattgefunden hat.

Wie man hier leicht sieht, wird in der Task-Klasse nur noch die eigentliche Aufgabe ausgeführt, wenn man mal von Error-Handling und Log-Einträgen absieht. Perioden und Uhrzeiten werden hier nicht verarbeitet. Denkbar ist allerdings, dass ein Task anhand von Konfigurationsdaten Manipulationen der Task-Tabelle vornimmt, um z.B. Intervalle dynamisch anzupassen.

Geht's nicht auch jede Sekunde?

Für Tasks, die quasi pausenlos laufen sollen, kann man das mit einer Do-While-Schleife innerhalb eines Tasks lösen, der z.B. morgens gestartet wird und abends beendet wird. Das funktioniert dann ähnlich wie im eben gezeigten Beispiel, wo der PHP-Code 450 mal hintereinander ausgeführt wurde.

In einer Do-While-Schleife kann man z.B. ständig eine vorgegebene Endlaufzeit überprüfen und bei Erreichen die Schleife verlassen. Nach jedem Lauf sollte auch der Heartbeat-Wert in der Tabelle aktualisiert werden, damit man leicht von außen erkennen kann, ob der Prozess noch aktiv ist.

Hintergrundprozesse unter Windows

Unter Linux kappt die Verlagerung wie gezeigt mit

$cmd = 'php '. $scriptfile . '  > /dev/null &';
exec($cmd);

Unter Windows ist das etwas komplizierter:

$phpCommandPart = 'php tasks.php';

if (strtoupper(substr(php_uname(), 0, 3)) == "WIN") {
    $cmd = "start /B " . $phpCommandPart;
    pclose(popen($cmd, "r"));
}

Damit kann man z.B. das Skript im Hintergrund laufen lassen, das jede Minute die Tabelle überprüft und fällige Taks sucht. Auch diese müssen dann wieder im Hintergrund ausgeführt werden, damit sie als eigenständiger Prozess laufen können.

Task Scheduling mit Laravel

Wer besondere Ansprüche an die Bedingungen für die Ausführung von Task stellt oder direkt auf eine große fertige Lösung setzen will, für den ist vielleicht das PHP-Framework Laravel interessant. (Links s.u.)

Hier kann man z.B direkt im Code relativ einfach oder auch hochkomplex seine Tasks zeitgesteuert laufen lassen:

$schedule->call(new DeleteRecentUsers)->daily();

Aber auch die Aufgabe "Run hourly from 8 AM to 5 PM on weekdays" stellt kein Problem dar, wie das Onlinehandbuch verrät:

// Run hourly from 8 AM to 5 PM on weekdays...
$schedule->command('foo')
          ->weekdays()
          ->hourly()
          ->timezone('America/Chicago')
          ->between('8:00', '17:00');

Die Ausführun als Hintergrundprozess muss auch in Laravel ebenfalls über exec oder command erfolgen und eigens mit der Methode ->runInBackground() aufgerufen werden. Ansonsten laufen die Tasks nacheinander und müssen ggf. auf die Beendigung der vorigen warten.

Wer häufig die Parameter seiner Tasks verändern will, dem ist mit Methoden wie ->everyFiveMinutes() wenig gedient, wenn er für den Wechsel auf alle drei Minuten den PHP-Code in ->everyThreeMinutes() ändern muss. Für solche naheliegenden Änderungswünsche sollte man einfache Konfigurationsmäglichkeiten bereitstellen. Natürlich gibt es für Laravel fertige Lösungen. (Beispiel s. Linkliste)

How to Run PHP Scripts In The Background (Simple Examples)
Das Tutorial behandelt auch die Kontrolle der Hintergrundprozesse.

Laravel Task-Scheduling
Zeitgesteuerte Tasks mit dem PHP-Framework Laravel ausführen.

Building And Running a scheduled task in Laravel
Tiefergehende Informationen zu Vorder- und Hintergrundprozessen

robersonfaria/laravel-database-schedule
Datenbank gestützte Task-Verwaltung auf Basis von Laravel