Event- und Thread-basierter Ansatz zur asynchronen Verarbeitung von Daten mit PHP

Asynchronous I/O in PHP, oder doch lieber Threads?
Kommentare

In den letzten Monaten erobern, angelehnt an Node.js, zunehmend Event-driven non-blocking Lösungen für die ansynchrone Verarbeitung von Daten ihren Platz im PHP-Ökosystem. Neben dem auch als Asynchronous I/O bezeichneten Programmierkonzept haben mittlerweile auch Threads Einzug in PHP gefunden. So entwickelt Joe Watkins seit ca. 6 Monaten an einer PHP-Extension, die Threads in PHP zur Verfügung stellt. Dieser Artikel soll Vor- und Nachteile beider Ansätze sowie denkbare Einsatzmöglichkeiten aufzeigen.

Node.js hat sich mittlerweile zu einer Art Trend entwickelt, der nach und nach auch auf die PHP-Community übergreift. So konnte der interessierte Entwickler das Interesse der Community an asynchroner Verarbeitung in PHP auf der International PHP Conference in Berlin am eigenen Leib spüren. Sowohl die Session „Asnychronous IO in PHP“ von Thomas Weinert als auch die „Einführung in Node.js“ von Sebastian Springer waren vollkommen überlaufen.

Mit Node.js hat die JavaScript-Community endlich eine stabile und leistungsfähige Möglichkeit bekommen, die Sprache auch serverseitig einzusetzen. Für die bisher eher belächelten JavaScript-Entwickler haben sich damit völlig neue Möglichkeiten eröffnet: Plötzlich lassen sich Anwendungen komplett in JavaScript realisieren, die Unterstützung durch Entwickler serverseitiger Sprachen wie PHP ist nicht mehr notwendig. PHP-Entwickler hingegen haben mittlerweile das Problem, dass sie aufgrund der ständig wachsenden Anforderungen und Komplexität von JavaScript, HTML5 und CSS mehr und mehr auf Entwickler, die sich auf diese Bereiche spezialisiert haben, angewiesen sind. Der Spieß hat sich sozusagen umgedreht.

Node.js führt eingefleischten PHP-Entwicklern vor Augen, dass sich ihre JavaScript-Kollegen mittlerweile auf Augenhöhe befinden und die Technologie sowie die damit verbundenen Möglichkeiten PHP z. T. vielleicht sogar überholt haben. So haben sicherlich die wenigsten PHP-Entwickler jemals versucht, einen HTTP-Server auf Basis von PHP zu entwickeln, auch wenn dieser Ansatz zweifellos Vorteile mit sich bringen würde. Node.js zeigt, dass es anscheinend nicht nur Einsatzmöglichkeiten für derartige Lösungsansätze gibt, sondern Entwickler vielmehr danach verlangen, auch derartige Lösungen selbst entwickeln, erweitern und anpassen zu können.

Nach dem Vorbild von Node.js haben sich mittlerweile einige Projekte in der Community etabliert, die dem „großen“ JavaScript-Bruder nacheifern und ähnliche Ansätze auch im PHP-Umfeld etablieren wollen. Neben dem bereits zuvor angesprochenen Carica IO von Thomas Weinert gehören phpDaemon und ReactPHP zu den wohl renommiertesten Lösungen. ReactPHP wird mittlerweile von einigen Projekten sogar im Livebetrieb eingesetzt, was die Themen sinnhaftig und Machbarkeit obsolet erscheinen lässt.

Event- vs. Thread-basierte Verarbeitung

Für die asynchrone Verarbeitung von Daten gibt es aktuell zwei favorisierte Möglichkeiten. Node.js setzt hierbei auf einen Event-driven Ansatz. Dabei werden Daten über ein non-blocking I/O-Model angenommen und in einem Event-Loop asynchron verarbeitet. Beim Aufruf des Event-Loops wird eine Callback-Funktion angegeben, die, sobald sie die asynchrone Verarbeitung beendet hat, das Ergebnis an den aufrufenden Prozess zurückgibt. Dieser kann dann das Ergebnis, das z. B. HTML-Code sein kann, über das I/O-Modell, z. B. einen Socket, wieder an den Client zurückgeben. Da der Event-Loop als Single Thread läuft, ist die eigentliche Verarbeitung der eingehenden Anfragen immer synchron, sobald jedoch ein Event getriggert wird, können weitere Anfragen entgegengenommen und ebenfalls asynchron verarbeitet werden.

Entgegen dem zuvor beschriebenen Ansatz haben PHP-Entwickler mittlerweile auch die Möglichkeit, Threads für die asynchrone Verarbeitung von Daten einzusetzen. Hierbei wird die in C geschriebene PHP-Extension pthreads, die das systeminterne POSIX-Threads-API kapselt, verwendet. Die eigentliche Ausführung und die Kontrolle der einzelnen Threads liegen auch hier beim jeweiligen Betriebssystem.

Was benötige ich, um asynchron programmieren zu können?

Im Gegensatz zum Event-driven Ansatz muss PHP für die Verwendung von Threads zuerst kompiliert werden. Das bedeutet leider, dass derzeit nach unserem Kenntnisstand bei keinem der bekannteren Hoster die Verwendung von Threads möglich ist. Seit einigen Monaten existiert jedoch das Projekt appserver.io, das, ähnlich wie XAMP, eine fertig kompilierte Runtime für Mac OS X und Debian zur Verfügung stellt. Die Runtime für Mac OS X (aktuell nur Mountain Lion) kann bequem als .pkg-Paket heruntergeladen und installiert werden. Das Debian-Paket lässt sich über den Paketmanager oder das Debian-Repository deb.appserver.io installieren. Die Abhängigkeiten werden hierbei automatisch nachinstalliert. In beiden Fällen findet man die Runtime anschließend im Verzeichnis /opt/appserver.

Die Runtime stellt eine für den Einsatz von Threads und Asynchronous I/O optimierte Laufzeitumgebung bereit. Neben einer thread-save kompilierten Version von PHP 5.4.17 bringt die Runtime eine aktuelle Version von nginx, memcached sowie die PECL-Libraries pthreads und libev in der jeweils aktuellsten Version mit. Zusätzlich werden einige andere PECL-Pakete mit ausgeliefert, die jedoch über die entsprechenden php.ini-Einträge aus Performancegründen deaktiviert wurden.

Wie funktioniert ein Thread?

Da die Verwendung von Threads für PHP-Entwickler, im Gegensatz zu Entwicklern, die mit anderen Programmiersprachen wie Java arbeiten, eher Neuland sein dürfte, möchte ich im ersten Schritt kurz zeigen, wie sie eingesetzt werden können. Listing 1 zeigt die Erstellung einer neuen Klasse AThread, die die Methode run() implementiert. Diese Methode wird automatisch aufgerufen, wenn man auf das Objekt die start()-Methode aufruft. Alles, was in der run()-Methode implementiert oder aufgerufen wird, läuft in einem eigenen Thread, also sozusagen asynchron ab.

Listing 1

class AThread extends Thread {
  protected $counter = 0;
  public function run() {
    for ($i = 0; $i counter++;
    }
    echo "Counter is: " . $this->counter . PHP_EOL;
  }
}

$someThread = new AThread();
$someThread->start();

echo "Finished script" . PHP_EOL;

Dass die Ausführung auch tatsächlich asynchron erfolgt, zeigt die Ausgabe. Das Script läuft durch, gibt „Finished script“ aus und wird beendet, bevor in der run()-Methode des Threads die Klassenvariable $counter auf 100 hochgezählt wurde. Da dies in einem eigenen Thread stattfindet, läuft dieser weiter und gibt, obwohl das eigentliche Script bereits beendet war, anschließend den String „Counter is: 100“ aus.

Wann kann ich einen Event-Loop einsetzen?

Für unseren Vergleich verwenden wir libev. libev deswegen, da ich persönlich das objektorientierte Interface gegenüber dem prozeduralen Ansatz von libevent vorziehe. Im Gegensatz zur Verwendung von Threads ist es beim Einsatz der libev nicht erforderlich, PHP thread-save zu kompilieren. Für die Installation kann das PECL-Modul ev entweder selbst kompiliert oder ebenfalls die mit der Runtime ausgelieferte Version verwendet werden.

Beim Einsatz von libev wird die Asynchronität, wie bereits zuvor beschrieben, durch einen Event-Loop erreicht, der den Aufruf des asynchron auszuführenden Codes auslöst, sobald ein Event auf einen Dateizeiger oder ein Signal getriggert bzw. ein Timeout erreicht wird.

Listing 2

$counter = 0;

$periodic = new EvPeriodic(0, 1, null, function() use(&$counter) {
  $counter++;
  if ($counter == 5) {
    Ev::stop();
  }
  echo "Periodic timer counts: $counter" . PHP_EOL;
});

$timer = new EvTimer(2, 0, function() use(&$counter) {
  $counter++;
  echo "Timer counts: $counter" . PHP_EOL;
});

Ev::run();

Listing 2 zeigt einen Event-Loop mit zwei Timer-Events, die einen global verfügbaren Counter hoch zählen. Der erste Timer wird jede Sekunde aufgerufen, solange der Counter kleiner fünf ist. Der Zweite nur einmal und zwar nach zwei Sekunden. An der Ausgabe kann man sehr gut erkennen, dass der zweite Timer den globalen Counter nach exakt zwei Sekunden um eins erhöht und anschließend der erste Timer diesen schließlich bis fünf hoch zählt.

Wie sieht es mit der Performance aus?

Eines der häufigsten Beispiele, die man zum Thema asynchrone Datenverarbeitung findet, ist überraschenderweise ein HTTP-Server. Es scheint so, dass viele Leute planen, einen eigenen solchen Server zu bauen. Da man einen HTTP-Server auf Basis von Threads oder auch libev bauen kann, bietet er sich für einen Performancevergleich an.

Die folgenden Beispiele sind möglichst einfach gehalten und stellen eine sehr rudimentäre, jedoch funktionierende Implementierung ohne Anspruch auf Vollständigkeit dar. Sie dienen lediglich der Durchführung eines einfachen Lasttests. Mit beiden Ansätzen lässt sich mit relativ wenig Code und Aufwand ein funktionierendes und belastbares Beispiel implementieren.

Listing 3

$socket = stream_socket_server('tcp://0.0.0.0:' . $argv[1]);
stream_set_blocking($socket, 0);

$response = array(
  "head" => "HTTP/1.0 200 OKrnContent-Type: text/htmlrn",
  "body" => '

Some content

 

'
);

$read = new EvIo($socket, Ev::READ, function ($w) use ($socket, $response) {

  while ($client = stream_socket_accept($socket)) {

    $write = new EvIo($client, Ev::WRITE, function ($w) use ($client, $response) {

      $buffer = '';

      while ($buffer .= stream_socket_recvfrom($client, 1024)) {
        if (false !== strpos($buffer, "rnrn")) {
          break;
        }
      }

      stream_socket_sendto($client, implode("rn", $response));
      stream_socket_shutdown($client, STREAM_SHUT_RDWR);

      $w->stop();

    });

    Ev::run();
    $w->stop();
  }
});

printf("waiting on port %dn", $argv[1]);

Ev::run();

Startet man den HTTP-Server über die Konsole z. B. mit

bash-3.2$ /opt/appserver/bin/php listing_03.php 8181

und lässt mit ApacheBench einen Lasttest mit 1 000 Requests und zehn gleichzeitigen Verbindungen fahren, so erhält man, je nach Rechner, ein durchaus vielversprechendes Ergebnis mit fast 17 000 Request/Sekunde. Abbildung 1 zeigt einen Lasttest mit ApacheBench auf einen libev-basierten HTTP-Server.

Abb. 1: Lasttest mit ApacheBench auf einen libev-basierten HTTP-Server

Abb. 1: Lasttest mit ApacheBench auf einen libev-basierten HTTP-Server

Listing 4 zeigt einen Thread-basierten HTTP-Server, mit dem wir den gleichen Lasttest machen. Hierzu starten wir vier Threads, da mein Rechner eine CPU mit vier Cores hat und somit kein oder nur ein geringer Performancegewinn zu erwarten wäre, wenn wir zusätzliche Threads starten.

Listing 4

class Test extends Thread {

  public function __construct($socket) {
    $this->socket = $socket;
  }

  public function run() {

    $response = array(
      "head" => "HTTP/1.0 200 OKrnContent-Type: text/htmlrn",
      "body" => '

Some content

 

'
    );

    while ($client = socket_accept($this->socket)) {

      $buffer = '';

      while ($buffer .= socket_read($client, 1024)) {
        if (false !== strpos($buffer, "rnrn")) {
          break;
        }
      }

      socket_write($client, implode("rn", $response));
      socket_close($client);
    }
  }
}

$workers = array();

$socket = socket_create_listen($argv[1]);

if ($socket) {

  $worker = 0;

  while (++$worker start();
  }

  printf("%d threads waiting on port %dn", count($workers), $argv[1]);
}

Das Ergebnis fällt ähnlich aus, auch hier kommen wir beim Lasttest mit den gleichen Parametern auf ca. 17 000 Requests/Sekunde. Abbildung 2 zeigt einen Lasttest mit ApacheBench auf einen Thread-basierten HTTP-Server.

Abb. 2: Lasttest mit ApacheBench auf einen Thread-basierten HTTP-Server

Abb. 2: Lasttest mit ApacheBench auf einen Thread-basierten HTTP-Server

Ein erstaunlicher Unterschied hierbei ist allerdings, dass der libev-basierte HTTP-Server in etwa die gleiche Durchsatzrate erzielt wie der Thread-basierte, obwohl dieser als Single-Thread-Anwendung nur einen Core der CPU nutzt. Das lässt den Rückschluss zu, dass bei Verwendung von Threads ein beträchtlicher Teil der Rechenzeit für die Verwaltung dieser verwendet wird und sich nur unter sehr speziellen Bedingungen wirklich nennenswerte Performancevorteile erzielen lassen. Denkbar wäre etwa der Einsatz beim parallelen Import von mehreren Dateien auf einem Rechner mit vielen Cores, da hier alle vorhandenen Cores optimal ausgenutzt werden könnten. Ob und welcher Ansatz in der Praxis dann tatsächlich mehr Performance verspricht, muss durch die Entwickler, abhängig von den Anforderungen, geprüft werden. Grundsätzlich kann man festhalten, dass Threads wie auch asynchrone Programmierung hohe Performance versprechen und somit in vielen Projekten sinnvolle Einsatzmöglichkeiten vorhanden sind.

Wie kann ich die beste Systemauslastung erzielen?

Wie bereits zuvor angesprochen, laufen Anwendungen, die auf Basis von libevent oder libev implementiert werden, grundsätzlich als Single-Thread-Anwendung; sprich, es wird immer nur genau ein Core der CPU ausgelastet. Daran ändert sich nur wenig, wenn über Events Lese- oder Schreibvorgänge asynchron ausgeführt werden. Das Potenzial der restlichen Ressourcen des Systems, also CPU und Speicher, lässt sich somit nur relativ schwer ausnutzen.

In vielen größeren Projekten, wie beispielsweise großen Shops, fällt jedoch häufig auf, dass die Ein- und Ausgabe über die Standardimport-/-exportschnittstelle sehr langsam läuft. So hat z. B. Magento mittlerweile zwar einen stark performanceoptimierten Importer, dieser wird allerdings über die Kommandozeile oder den CRON-Job gestartet und nutzt somit nur einen Core der CPU. Bei Shops mit vielen Produkten tritt so häufig das Problem auf, dass der Import sehr lange dauert und der Shop während dieser Zeit gar nicht bzw. nur schlecht erreichbar ist oder der Katalog nicht korrekt angezeigt wird.

Natürlich gibt es für solche Fälle diverse Lösungsansätze. Neben dem Aufruf mehrerer PHP-Prozesse existieren Anbindungen über Node.js oder an verschiedene Message-Queues. Listing 5 zeigt, wie ein Lösungsansatz auf Basis von Threads aussehen könnte.

Listing 5

class Importer extends Thread {

  protected $filename;

  public function __construct($filename) {
    $this->filename = $filename;
  }

  public function run() {

    $db = new PDO('mysql:host=localhost;dbname=test', 'test', 'test');
    $fp = fopen($this->filename, "r");

    while (($data = fgetcsv($fp, 0, ",")) !== false) {
      $sql = "INSERT INTO test (field1, field2, field3) VALUES ('%s', '%s', '%s')";
      $db->exec(sprintf($sql, $data[0], $data[1], $data[2]));
    }

    fclose($fp);
  }
}

$files = array('file-0.csv', 'file-2.csv', 'file-2.csv', 'file-3.csv');
$writers = array();
$counter = 0;

foreach ($files as $file) {
  $writers[$counter] = new Importer($file);
  $writers[$counter]->start();
  $counter++;
}

for ($z = 0; $z join();
}

Im Beispiel werden vier CSV-Dateien eingelesen und über PDO in die Datenbank geschrieben. Jeder Thread verwendet eine eigene Datenbankverbindung, um parallel schreiben zu können. Um eine ähnliche Lösung für Magento bereitstellen zu können, sind natürlich größere Anpassungen notwendig, die gerade beim Import jedoch erhebliche Performancevorteile bringen würden.

Da aktuell keine non-blocking Connection für MySQL existiert, lässt sich dieses Beispiel mit libev nicht einfach nachprogrammieren. Man könnte zwar die Daten in einem Callback über PDO in die Datenbank einlesen, dieser Vorgang würde jedoch die weitere Verarbeitung blockieren und deswegen nicht asynchron ausgeführt. Optional besteht die Möglichkeit z. B. mit phpDaemon eine non-blocking Connection zur MySQL-Datenbank aufzubauen. Wie das in etwa aussehen könnte, veranschaulicht Listing 11, auf das ich später noch eingehen möchte.

Aktuell lässt sich mit Threads die Systemlast relativ einfach und für den Programmierer transparent verteilen. Aufgrund der fehlenden non-blocking Verbindungen beschränken sich die Einsatzgebiete für Implementierung auf Basis von Asynchronous I/O mit libev auf Anwendungsfälle, bei denen es sich um reine Dateiein- und -ausgaben handelt. Weiterhin bleibt die Einschränkung bestehen, dass die Anwendung Single-Threaded läuft und zur optimalen Auslastung des Systems hier andere Wege, wie z. B. der Start eines zusätzlichen Event-Loops, gesucht werden müssen.

Wie sieht der Speicherverbrauch aus?

Als Nächstes wollen wir uns den Speicherverbrauch der beiden Ansätze näher ansehen. Gerade bei der Verarbeitung großer Datenmengen treten mit PHP häufig Probleme auf. Sobald man versucht, innerhalb eines Prozesses eine große CSV- oder XML-Datei einzulesen und zu verarbeiten, läuft der Speicher voll und PHP beendet die Verarbeitung mit einem Fatal Error.

Mit beiden Ansätzen lassen sich entsprechende Lösungen realisieren, mit denen der Speicher auf einem gleichbleibend, niedrigen Niveau bleibt, vorausgesetzt man setzt die entsprechende Technik richtig ein und macht sich klar, wo die Speicherprobleme ursächlich entstehen.

Listing 6

$start = time();

$downloads = array(
  'file-0-output.csv' => 'file-0-big.csv'
);

foreach ($downloads as $name => $url) {

  $in = fopen($url, "r");
  stream_set_blocking($in, 0);

  $out = fopen($name, "w");
  stream_set_blocking($out, 0);

  new EvIo($in, Ev::READ, function($ei) use ($name, $in, $out) {
    while (($row = fgetcsv($in)) !== false) {
      new EvIo($out, Ev::WRITE, function($eo) use($name, $out, $row) {
        fputcsv($out, $row);
        $eo->stop();
      });
      Ev::run();
    }
    $ei->stop();
  });
}

Ev::run();

Listing 6 zeigt ein Beispiel, in dem über libev eine 500 MB große CSV-Datei in eine andere kopiert wird. Da wir ausschließlich mit Streams arbeiten, bleibt der Speicherverbrauch konstant bei ca. 45 MB. Ein etwas realitätsnäheres Beispiel veranschaulicht, wie mit Threads Daten effektiv in eine Datenbank eingelesen werden können.

Listing 7

class Importer extends Thread {

  protected $filename;

  public function __construct($filename) {
    $this->filename = $filename;
  }

  public function run() {

    $fp = fopen($this->filename, "r");
    $db = new PDO('mysql:host=localhost;dbname=test', 'test', 'test');
    
    while (($data = fgetcsv($fp, 0, ",")) !== false) {
      $sql = "INSERT INTO test (f1, f2, f3) VALUES ('%s', '%s', '%s')";
      $db->exec(sprintf($sql, $data[0], $data[1], $data[2]));
    }

    fclose($fp);
  }
}

$files = array('file-0-big.csv', 'file-2.csv', 'file-2.csv', 'file-3.csv');
$writers = array();
$counter = 0;

foreach ($files as $file) {
  $writers[$counter] = new Importer($file);
  $writers[$counter]->start();
  $counter++;
}

for ($z = 0; $z join();
}

Mein Beispiel aus Listing 7 hat ebenfalls einen konstant niedrigen Speicherverbrauch, der sogar nur bei 11,5 MB liegt. Das Nadelöhr in diesem Beispiel stellt die Datenbank dar, die die Daten nicht so schnell importieren kann, wie die Threads diese auslesen.

Häufig werden beim Datenimport die aus einer CSV- oder XML-Datei eingelesenen Daten in Objekte transformiert, was zwangsläufig zu Speicherproblemen führen muss, wenn nicht z. B. mit Chunks gearbeitet wird und die transformierten und importierten Objekte nach jedem Durchlauf wieder freigegeben werden.

Listing 8

$rows = array(); 
while (($data = fgetcsv($fp, 0, ",")) !== false) {
  $rows[] = new Row($data[0], $data[1], $data[2]);
}
$db = new PDO('mysql:host=localhost;dbname=test', 'test', 'test');    
foreach ($rows as $row) {
  $sql = "INSERT INTO test (f1, f2, f3) VALUES ('%s', '%s', '%s')";
  $db->exec(sprintf($sql, $row->getCol1(), $row->getCol2(), $row->getCol3()));
}

Die in Listing 8 gezeigten Änderungen am Quelltext würden beim Einlesen der CSV-Datei dazu führen, dass weit über 4 GB Speicher benötigt werden. Ein Lösungsansatz könnte sein, dass jeweils 200 Datensätze eingelesen und einem Thread, in dem der eigentliche Import implementiert wird, übergeben werden. Nach Ablauf des Threads wird der Speicher automatisch wieder freigegeben, wodurch Speicherprobleme vermieden werden.

Listing 9

$rows = array();
$chunks = array();

$counter = 0;
$chunkCounter = 0;

while (($data = fgetcsv($fp, 0, ",")) !== false) {
  $rows[] = new Row($data[0], $data[1], $data[2]);
  if ($counter++ == 200) {
    $chunks[$chunkCounter] = new Chunk($rows);
    $chunks[$chunkCounter]->start();
    $rows = array();
    $counter = 0;
    $chunkCounter++;
  }
}

Erweitern wir unseren Thread, wie in Listing 9 gezeigt, so wächst der Speicherverbrauch bei über 2 000 Threads zwar auf mehr als 3 GB an, bleibt dann jedoch konstant, da nach erfolgtem Import die Threads wieder geschlossen werden und der Speicher freigegeben wird. Abbildung 3 veranschaulicht die konstante Speichernutzung bei der Verwendung von Threads.

Abb. 3: Konstante Speichernutzung bei der Verwendung von Threads

Abb. 3: Konstante Speichernutzung bei der Verwendung von Threads

Läuft das System stabil?

Neben der Performance ist die Stabilität wahrscheinlich ein zentrales Thema, wenn es um den Einsatz einer neuen Technologie in einem Projekt geht. Hierbei hat sich bei der Arbeit mit der pthreads-Library in den letzten Monaten gezeigt, dass die Stabilität mit jeder Version zugenommen hat. Mittlerweile lässt sich, wie auch das Projekt appserver.io zeigt, eine durchaus stabile Software, die intensiv Threads einsetzt, entwickeln. Das Beispiel aus Listing 9 importiert ca. 38 000 000 Datensätze. Hierbei ist die CPU- und Speicherauslastung konstant auf einem relativ hohen Niveau. Natürlich geben derartige Tests keinen Rückschluss auf die tatsächliche Stabilität auf einem Livesystem, zeigen aber, dass Threads auch unter Last stabil eingesetzt werden können.

Gleiches gilt für das Beispiel aus Listing 5, in dem wir asynchron die gleiche CSV-Datei mit libev kopieren. Auch dieser Test läuft ohne Unterbrechung und mit konstantem Speicherverbrauch durch.

Nichtsdestotrotz ist es bei der Erstellung der Beispiele in verschiedenen Fällen immer wieder zu Segementation Faults gekommen, die nur schwer nachvollziehbar waren. In den meisten Fällen stellt sich heraus, dass es sich um ein Problem, wie z. B. die Überschreitung der maximal offenen Dateien des Betriebssystems, handelt. Diese und ähnliche Dinge werden in den aktuellen Versionen der Libraries häufig noch nicht korrekt abgefangen und führen, anstatt einer Exception oder einem Fatal Error, häuft eben zu einem Segmentation Fault.

Im Fall der pthreads-Extension kommt erschwerend hinzu, dass es beim Einsatz einiger gängiger Extensions mit einer Thread-safe-kompilierten PHP-Version angeblich zu nicht reproduzierbaren Segemantation Faults kommt. In unserem Fall ließen sich jedoch fast alle Fehler nachvollziehen, obwohl die Runtime, auf der wir unsere Beispiele laufen lassen, fast alle der gängigen Extensions mitbringt.

Viele Probleme treten immer dann auf, wenn man das System durch hohe Last an seine Grenzen bringt. Im Livebetrieb hat sich jedoch gezeigt, dass auch Umgebungen mit bewährten Komponenten wie einem LAMP-Stack unter Volllast immer wieder zu Problemen führen. Sprich eine hundertprozentige Garantie für einen ausfallfreien Betrieb wird es aus meiner Sicht mit keinem der aktuell eingesetzten Systeme geben.

Es liegt sicherlich noch einiges an Arbeit vor den Entwicklern und der Community, um Stabilität und Zuverlässigkeit der Libraries zu verbessern. Aufgrund des großen Potenzials beider Technologien kann man jedoch mit hoher Wahrscheinlichkeit davon ausgehen, dass sie nach und nach an Stabilität verbessern werden und somit auch dem Einsatz in einem Liveprojekt nichts mehr im Wege steht.

Können bestehende Ressourcen, Frameworks und Libraries verwendet werden?

Eine größere Herausforderung kann die optimale Verwendung von bereits bestehenden Drittanbieter-Resourcen, Frameworks und Libraries darstellen.

Die vorherigen Beispiele mit libev zeigen ausschließlich, wie über die Verwendung von Dateizeigern asynchron Daten verarbeitet werden können. Das hat einen Grund: Da für die asynchrone Verarbeitung non-blocking Dateizeiger benötigt werden, erwarten Events wie z. B. EvIo auch ausschließlich solche. Versucht man z. B., wie in Listing 10 gezeigt, eine mysqli resource zu übergeben, erhält man eine Warning und die Verarbeitung bricht ab.

Listing 10

$out = $db = mysql_connect('localhost', 'test', 'test');
mysql_select_db('test') or die('Could not select database');
new EvIo($out, Ev::WRITE, function($eo) use($out) {
  // do some asynchronous stuff here
});
/**
 * PHP Warning:  EvIo::__construct(): either valid PHP stream or valid PHP   * socket resource expected
*/

Keine der aktuell populären Datenbanken stellt derzeit eine non-blocking Schnittstelle zur Verfügung. Somit ist das asynchrone Schreiben von Daten in eine Datenbank wie MySQL derzeit mit Bordmitteln nicht möglich, da der Aufruf von Methoden auf diese Objekte die weitere Ausführung blockiert und der gesamte Prozess damit synchron wird.

MySQL stellt mit reap_async_query() zwar eine Funktion zur Verfügung, die Daten asynchron aus der Datenbank liest, diese funktioniert jedoch, da sie ebenfalls mit der MySQL-Standardklasse arbeitet, nicht beim Einsatz über libev. Um eine MySQL-Datenbank über eine non-blocking Schnittstelle anzusprechen, ist es notwendig, die Socketdatei mysql.sock direkt über einen Stream im non-blocking mode zu öffnen und darüber die Abfragen zu senden. Das Framework phpDaemon stellt hierzu bereits einige Connectoren, so z. B. für MySQL, der wie in Listing 11 verwendet werden kann, zur Verfügung.

Listing 11

job = new PHPDaemonCoreComplexJob(function () use ($req) { 
    // called when job is done
    $req->wakeup(); // wake up the request immediately

  });

  $job('select', function ($name, $job) use ($req) { 
    // registering job named 'showvar'
    $req->appInstance->sql->getConnection(function ($sql) use ($name, $job) {
      if (!$sql->isConnected()) {
        $job->setResult($name, null);
        return null;
      }
      $sql->query('SELECT 123, "string"', function ($sql, $success) use ($job, $name) {
        $job('showdbs', function ($name, $job) use ($sql) {
          // registering job named 'showdbs'
          $sql->query('SHOW DATABASES', function ($sql, $t) use ($job, $name) {
            $job->setResult($name, $sql->resultRows);
          });
        });
        $job->setResult($name, $sql->resultRows);
      });
      return null;
    });
  });

  $job(); // let the fun begin
  $this->sleep(5, true); // setting timeout
}

Wie aus Listing 11 ersichtlich, stellt sich der Programmablauf bei der Verwendung von Frameworks wie phpDaemon grundlegend anders dar als bei der Arbeit mit bekannten Kandidaten. So muss, um möglichst viele Prozesse asynchron laufen zu lassen, stark mit Callbacks gearbeitet werden. Bei eigenem Code stellt das sicherlich kein Problem dar, möchte man jedoch auf bestehende Libraries oder Frameworks zurückgreifen, was in fast allen Projekten der Fall sein dürfte, so hat man im Normalfall zwar die Möglichkeit, diese entsprechend zu optimieren oder anzupassen, verliert damit jedoch die Updatefähigkeit bzw. schränkt diese u. U. erheblich ein.

Anders sieht es hier bei der Verwendung von Threads aus. Threads können problemlos innerhalb und außerhalb aller bestehenden Frameworks verwendet werden, da sich diese wie ganz normale Klassen verhalten. Entgegen der Verwendung von libev funktionieren hier auch einige Ressourcen und können einem Thread im Konstruktor übergeben werden. So zeigt Listing 12 wie z. B. ein MySQL-Dateizeiger an einen Thread übergeben wird.

Listing 12

class Context extends Stackable {
  public function run() {
  }
}

class MySqlShared extends Thread {

  protected $context;
  protected $mysql;
  protected $mutex;
  protected $offset;
  protected $length;

  public function __construct($context, $mutex, $mysql, $offset, $length) {
    $this->context = $context;
    $this->mutex = $mutex;
    $this->mysql = $mysql;
    $this->offset = $offset;
    $this->length = $length;
  }

  public function run() {

    if ($this->mutex) {
      Mutex::lock($this->mutex);
    }

    $result = mysql_query("select * from test limit {$this->offset}, {$this->length}", $this->mysql);

    if ($result) {
      while (($row = mysql_fetch_assoc($result))) {
        $this->context[] = $row;
      }
    }

    if ($this->mutex) {
      Mutex::unlock($this->mutex);
    }
  }
}

$mysql = mysql_connect("127.0.0.1", "test", "test");
mysql_select_db('test', $mysql);

if ($mysql) {

  $mutex = Mutex::create();
  $context = new Context();

  $instances = array(
    new MySqlShared($context, $mutex, $mysql, 0, 2000),
    new MySqlShared($context, $mutex, $mysql, 2001, 2000),
    new MySqlShared($context, $mutex, $mysql, 4001, 2500)
  );

  foreach ($instances as $instance) {
    $instance->start();
  }

  foreach ($instances as $instance) {
    $instance->join();
  }

  Mutex::destroy($mutex);
}


Das Beispiel zeigt, wie über eine Datenbankverbindung parallel mit drei Threads Daten ausgelesen werden können. Einschränkend muss allerdings hinzugefügt werden, dass das Beispiel in der gezeigten Version nur mit der bereits als deprecated deklarierten mysql-Extension läuft und die Einschränkung hat, dass über ein Mutex-Objekt immer nur ein Thread lesend auf die Datenbank zugreifen kann. Ändert man das Beispiel und lässt das Mutex-Objekt weg, so hat nur der zuerst lesende Thread Zugriff auf die MySQL-Ressource, und es werden lediglich 2 000 Datensätze ausgelesen und im Context gesetzt.

Datenaustausch zwischen Threads

Im Gegensatz zur Verwendung von asynchroner Datenverarbeitung bei der der Event-Loop in einem einzelnen Thread abläuft und alle Objekte auf den gleichen Speicherbereich zugreifen, läuft jeder Thread in einem eigenen Speicherbereich und hat grundsätzlich keinen Zugriff auf den Speicher der anderen Threads, sprich auf ein Objekt, das in einem Thread erzeugt wird, kann man nicht einfach von einem anderen Thread aus zugreifen. Wie dieses Problem umgangen werden kann, zeigt Listing 13, das eine erweiterte Version unseres HTTP-Servers implementiert.

Listing 13

class Container extends Stackable {
  public function run() {}
  public function count() {
    return $this['counter'] = $this['counter'] + 1;
  }
  public function getCounter() {
    return $this['counter'];
  }
}

class Test extends Thread {

  protected $container;
  protected $socket;

  public function __construct($container, $socket) {
    $this->container = $container;
    $this->socket = $socket;
  }

  public function run() {

    $response = array(
      "head" => "HTTP/1.0 200 OKrnContent-Type: text/htmlrn",
      "body" => '

Some content

'
    );

    while ($client = socket_accept($this->socket)) {
      $buffer = '';
      while ($buffer .= socket_read($client, 1024)) {
        if (false !== strpos($buffer, "rnrn")) {
          break;
        }
      }
      socket_write($client, implode("rn", $response));
      socket_close($client);
      if ($this->container->count() > 96) {
        return;
      }
    }
  }
}

$workers = array();
$socket = socket_create_listen($argv[1]);

if ($socket) {

  $container = new Container();
  $worker = 0;

  while (++$worker start();
  }

  printf("%d threads waiting on port %d", count($workers), $argv[1]);

  foreach ($workers as $worker) {
    $worker->join();
  }

  printf("Successfully served %d requestsn", $container->getCounter());
}

Im Beispiel wird ein Datencontainer übergeben, der einen Counter enthält, der bei jedem abgearbeiteten Request um Eins erhöht wird. Sobald unser Server 100 Requests verarbeitet hat, wird mit jedem weiteren Request ein Thread beendet und der Server anschließend heruntergefahren.

Das funktioniert nur mit Objekten, die von der Klasse Stackable ableiten. Übergibt man mehreren Threads die gleiche Instanz eines Stackables im Konstruktor, so können alle Threads lesend und schreibend auf das Objekt zugreifen, wobei es sich automatisch synchronisiert.

Welcher Ansatz ist für meinen Einsatzzweck nun der richtige?

Nachdem wir uns nun einen Überblick über beide Ansätze verschafft haben, stellt sich natürlich die Frage, für welchen Einsatzzweck sich welcher Ansatz eignet.

Das in den meisten Fällen wichtigste Kriterium dürfte, vorausgesetzt die Stabilität ist sichergestellt, die Performance sein, gefolgt vom Speicherverbrauch. Zusätzlich könnten je nach Einsatzzweck auch die Auslastung der verfügbaren Systemressourcen wie CPU, Speicher und Festplatte einer Anwendung eine Rolle spielen.

Ein Vergleich beider Technologien stellt sich als schwierig dar, da sich diese im ersten Augenblick zwar für ähnliche Einsatzmöglichkeiten zu eignen scheinen, sich bei näherem Hinsehen jedoch eigentlich sehr gut ergänzen.

Asynchonous I/O ist sehr schnell und verbraucht wenig Speicher. Ein gravierendes Problem stellt jedoch derzeit die Verfügbarkeit von streamingfähigen non-blocking Ressourcen dar. So existieren zwar über das Projekt phpDaemon einige Implementierungen, diese sind jedoch ausschließlich für den Einsatz mit phpDaemon optimiert und verwendbar. Hier müssen Hersteller wie z. B. MySQL reagieren, um die asynchrone Verarbeitung von Daten mit MySQL zeitnah zu ermöglichen. Ein weiterer möglicher Nachteil ist, dass nur ein Kern der CPU(s) ausgenutzt wird, was bei CPU-lastigen Implementierungen durchaus einen Flaschenhals darstellen kann.

Zusätzlich dürfte der Einstieg für viele PHP-Entwickler anfangs relativ schwierig sein, da ein Umdenken, weg von den gewohnten Lösungsansätzen, notwendig ist. Hinzu kommt, dass viele aktuell verfügbare Libraries und Frameworks nicht für asynchrone Programmierung entwickelt wurden und somit nicht die optimalen Voraussetzungen für deren Einsatz bieten. Hier wird sich zeigen, ob in naher und mittlerer Zukunft Entwickler verstärkt auf diesen Programmieransatz setzen und Anpassungen oder Optimierungen vornehmen. Projekte wie phpDaemon, React PHP und Carica.io legen bereits eine Schicht über die Low-Level-PECL-Implementierungen für libevent oder libev und verbessern somit die Verwendbarkeit erheblich.

Durch die PECL-Library von Joe Watkins können Threads mittlerweile relativ stabil eingesetzt werden. Großer Nachteil ist natürlich, dass PHP Thread-safe kompiliert sein muss. Hier hilft die PHP-Runtime beim Einstieg, da alle notwendigen Komponenten mitgeliefert werden, das eigene System jedoch nicht verändert wird und der mit dem System ausgelieferte Stack unverändert bleibt. Außerdem zeigt das Projekt appserver.io, wie effizient mit Threads gearbeitet werden kann und welches Potenzial in ihnen steckt.

Die Entwicklung mit Threads dürfte vielen Entwicklern einfacher fallen, da sie sich an der Verwendung von herkömmlichen Klassen orientiert und somit kein Umdenken erforderlich ist. Dadurch, dass Funktionalitäten in einem Thread vollständig unabhängig laufen, ergeben sich viele neue Möglichkeiten, durch die Nebenläufigkeit jedoch auch zahlreiche neue Probleme. So ist der Start zwar relativ einfach, man merkt jedoch schnell, dass man auf bisher nicht gekannte Probleme stößt, die den Entwickler zum Umdenken zwingen und neue Lösungsansätze erfordern.

Letztlich gibt es für beide Technologien Einsatzgebiete, an denen sie ihre Vorteile optimal ausspielen können. Wahrscheinlich stellt jedoch die Kombination aus beiden die optimale Lösung für viele Einsatzzwecke dar. Threads und Asynchronous I/O werden die PHP-Welt auf den Kopf stellen.

Fazit

Beide Technologien befinden sich in einem relativ frühen Entwicklungsstadium und führen bei größerer Last oder Datenmengen häufig noch zu unerwarteten und nur schwer nachvollziehbaren Abstürzen. Durch intensives Testen und Auswerten der Core Dumps lässt sich zwar in den meisten Fällen die Ursache des Problems nachvollziehen und kann umgangen werden, führt manchmal jedoch zu Lasten der Codequalität.

Ein weiteres Problem, auf das ich bei unseren Tests häufig gestoßen bin, ist das Problem, dass wir mit den offenen Sockets, die systemseitig auch als Dateizeiger gezählt werden, sehr schnell an Systemgrenzen stoßen. So wird bei einem Lasttest auf meinem MacBook Pro in kürzester Zeit die Standardeinstellung für Open Files überschritten, was dazu führt, dass die Threads zwar weiterlaufen und das System auslasten, aber nichts mehr machen. Derartigen und ähnlichen Problemen lassen sich häufig mit einem strace auf die Spur kommen. In anderen Fällen hilft leider nur noch die Auswertung eines Core Dumps.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -