Das fehlende Synchronize

PHP bietet von Haus aus leider nur wenig Features für parallele Datenverarbeitung an. Nebenläufigkeit ist oftmals kein Thema, da sie häufig vom Webserver übernommen wird. Natürlich gibt es auch das Modul PCNTL (siehe http://de3.php.net/manual/en/ref.pcntl.php, das es erlaubt per pcntl_fork() Kind-Prozesse zu erstellen. Jedoch muss man auch hier schauen, wie man die Nebenläufigkeit synchronisiert bekommt. Stellt man sich z.B. einen Webservice vor, der eine kritische Funktion für einen Benutzer nur nacheinander ausführen darf, muss man zugegebenermaßen als PHP-Programmierer neidisch in die Java-Ecke blicken, in der Entwickler das Problem mit dem Schlüsselwort synchronize leicht lösen können. Zusätzlich hält die Java-Welt seit Version 5.0 mit dem Paket java.util.concurrent einen ganzen Sack weiterer Tools bereit, die einem das Leben erleichtern.

Datei-Locks

PHP ist hier leider etwas mager bestückt. Oft helfen sich Programmierer daher mit flock() und generieren ein exklusives Lock per Datei. Das funktioniert oft auch ganz gut. Flock garantiert allerdings keineswegs, das es das Locking auch sicher durchführen kann. Wer sich allein die Warnings in den Notes ansieht (siehe http://de.php.net/manual/en/function.flock.php), stellt schnell fest, dass es hier einige Fallstricke gibt. Man ist vor allem abhängig vom Dateisystem. Hat man beispielsweise mehrere Webserver, die ein NFS-Share nutzen, wird es komplizierter. Exklusive Locks funktionieren auf NFS-Shares nicht. Es gibt zwar speziell für NFS einen Workaround mit statischen Links. Eine wirkliche Unabhängigkeit vom Dateisystem hat man damit aber nicht geschaffen.

APC

Ein Locking mit dem APC-Cache ist grundsätzlich möglich. Folgendes, rundimentäres Beispiel zeigt, wie es aussehen könnte:

<?php
  define('MYLOCK', 'mylock');

  echo 'acquire lock...';
  ob_flush();
  flush();
  $i = 0;
  while(false === apc_add(MYLOCK, 1)) {
    usleep(100);
  }

  echo "done\n";
  ob_flush();
  flush();
  sleep(5); //something else can be done here
  echo 'release lock...';
  apc_delete(MYLOCK);
  echo "done\n";

Über den Webserver ausgeliefert, funktioniert das auch soweit. Man kann das Skript mehrmals starten und dann kann man verfolgen, ab wann das Lock vergeben wird. Dabei darf man nicht vergessen, dass die meisten Browser den Output puffern. Am einfachsten testet man es daher mit telnet:

telnet servername 80
Trying 1.2.3.4...
Connected to servername.
Escape character is '^]'.
GET /test.php
acquire lock...done
release lock...done
Connection closed by foreign host.

ob_flush() und flush() sorgen dabei noch zusätzlich dafür, dass der Output nicht auf der Server-Seite gepuffert wird. Geht das auch auf der Kommandozeile? – Nein! Zwar kann man hier mit dem Setting „apc.enable_cli = 1“ für die PHPCLI-Ini APC einschalten. Das Lock wird dann jedoch nicht funktionieren. Anders als bei Apache, läuft jedes Kommandozeilenskript für sich und ohne gemeinsam verwendeten Speicher ab. Das trifft auch für APC zu. Deshalb ist diese Methode für Konsolenskripte ungeeignet. Außerdem funktioniert sie auch nicht, wenn man mehrere Webserver im Einsatz hat. Denn jede Apache-Instanz hat ihren eigenen APC.

Semaphore

Auch wenn die Funktionen etwas rar gesäht sind, hält PHP jedoch auch das ein oder andere (fast) von Haus aus mitgebrachte Feature bereit (Modulinstallation ist wieder nötig). Semaphore sind beispielsweise eine aus der Betriebssystemwelt stammende Begrifflichkeit. Diese gibt es auch in PHP: http://de.php.net/manual/en/book.sem.php. Ein simples Beispiel:

<?php

  define('MYSEMAPHORE', 1);

  $semaphore = sem_get(MYSEMAPHORE);
  echo 'sem_acquire...';
  sem_acquire($semaphore);
  echo "done\n";
  sleep(5); //something else can be done here
  echo 'sem_release...';
  sem_release($semaphore);
  echo "done\n";

Die „flushes“ habe ich mir hier gespart. Leider lösen Semaphore auch nicht das Problem, wenn man mehrere Webserver im Einsatz hat.

Memcache

Die letzte und mir am sinnvollsten erscheinende Lösung ist die Memcache-Lösung. Sie funktioniert genau wie der APC-Ansatz, nur mit dem Unterschied, dass der Memcache-Server auch über die Konsole und mehrere Webserver hinweg funktioniert. Dabei muss man sich klar machen: Memcached ist single-threaded. D.h. erfolgt ein Hinzufügen eines Keys, kann das nicht parallel stattfinden. Die Anfragen werden sozusagen serialisiert. Dies kann man sich für ein Lock zu Nutze machen:

<?php

  define('MYLOCK', 'mylock');

  echo 'acquire lock...';
  $memcache = memcache_connect("localhost", 11211);
  while(false === $memcache->add(MYLOCK, 1)) {
    usleep(100);
  }
  echo "done\n";
  sleep(5); //something else can be done here
  echo 'release lock...';
  $memcache->delete(MYLOCK);
  echo "done\n";

Genau wie bei dem APC-Vorschlag muss man sich auch hier Gedanken über mögliche Endlosschleifen und Deadlocks machen. Erstere könnte man mit einem Timeout in der While-Schleife, oder auch einfach mit einer Max-Life-Time des jeweiligen Keys verhindern. Dabei muss man die maximale Laufzeit des Skripts zwischen dem Akquirieren und dem Freigeben des Locks berücksichtigen – sonst hat man genau die gleichen Probleme wie ohne Locks. Zudem können beispielsweise Verbindungsprobleme zum Memcache-Server auftreten. Dann kann das Hinzufügen eines Keys länger dauern als erwartet und somit die Ausführungsdauer steigern. Mit vernünftigen Max-Lifetimes hat man aber ein gutes Steuerungsinstrument in der Hand. Benötigt ein einfaches DB-Update beispielsweise mehr als 5 Minuten, hat man eh ganz andere Probleme.

Weiterhin kann man sich überlegen, die Werte der Keys mit etwas sinnvollem zu füllen: Welcher/Welches Webserver/Prozess/Skript hat das Log geschrieben? Darf es nur derjenige Prozess freigeben, der es auch erstellt hat? Es gibt sicherlich noch viele Optimierungsmöglichkeiten bei der Memcache-Version… Ideen als Kommentar sind daher sehr willkommen.

Zend-Framework

Auch im Zend-Framework kann man das Memcache-Lock nutzen. Allerdings ist der Einbau nicht ganz trivial. Denn Zend_Cache_Backend_Memcached benutzt leider statt der add()-  die save()-Methode. Das hat zur Folge, dass kein Fehler auftritt, wenn der zu speichernde Key bereits vorhanden ist – das ist unbrauchbar für Locks.

Abhilfe schafft nur das Hinzufügen einer add()-Methode in einer von Zend_Cache_Backend_Memcached abgeleiteten Klasse. Je nachdem, wo man sein Backend initialisiert muss die Factory-Methode von Zend_Cache dann neue Parameter erhalten (nachfolgend der Prototyp der factory-Methode):

factory(
  mixed $frontend,
  mixed $backend,
  array $frontendOptions = array,
  array $backendOptions = array,
  boolean $customFrontendNaming = false,
  boolean $customBackendNaming = false,
  boolean $autoload = false
)

$backend muss dann auf den neuen Backendnamen verweisen und $customBackendNaming muss auf true gesetzt werden. Damit nicht genug. Auch das Frontend will angepasst werden – denn hier existiert die neue add()-Methode auch nicht. Die Anpassung kann aber analog zum Backend erfolgen. Wer sich die Tortur angetan hat, kann sich immerhin über exklusive Locks per Memcached freuen, die Zend-Framework-kompatibel sind 😉

Fazit:

Auch in PHP können exklusive, verlässliche Locks mit ein wenig Handarbeit nachgerüstet werden. Ein wenig Gehirnschmalz muss man in die Implementierung aber stecken, um für Deadlocks und Co. gewappnet zu sein. Ich würde mir wünschen, dass solch grundlegende Funktionen zumindest ins Zend-Framework einzug halten und man keine komplizierten Implementierungen dafür benötigt.

2 Gedanken zu „Das fehlende Synchronize

  1. Vorerst einmal: Guter Beitrag!

    Es gibt keine mir bekannte Lösung für systemübergreifende Locks, die cool ist. Ich habe das Problem an einigen Stellen bei mir aber auch. Dafür habe ich mir vor einiger Zeit mal einen einfachen java-basierten Socket-Server geschrieben.

    Idee dabei war: Solange eine Connection aktiv ist, ist der Lock aktiv. Neue Connections werden sofort wieder abgewiesen. Wenn ein Script abstürzt, dann fällt wenig später auch der Lock automatisch weg. Für das Deadlock-Problem habe ich einen Server, den ich bei Start und Ende eines Scripts an-pinge. Beim Startping teile ich mit, in welcher Zeit das Script gelaufen sein muss. Beim Endping, wann das nächste Start-Ping erwartet wird. Failed ein Vorgang, bekomme ich eine Mitteilung (z.B. via SMS oder eMail).

    1. Interessante Idee. Klingt aber auch nicht ganz unaufwendig. Auf das Deadlock-Problem bin ich ja nicht wirklich eingegangen. Die Ping-Lösung klingt aber ähnlich wie die MaxLifeTime eines Keys.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *