PID-Dateien nicht löschen!
Wie löscht man PID-Dateien bzw. Lock-Dateien am besten? Gar nicht! Das Löschen ist fast immer mit Race-Conditions verbunden ist, die den Schutz gegen parallele Ausführung des Programmes aushebeln.
Der normale Locking-Ablauf
Die normale Prozedur sieht so aus:
- Prozess A öffnet die PID-Datei read/write und erzeugt sie dabei gegebenenfalls.
- Prozess A holt sich (nicht-blockierend), einen exklusiven Lock auf die Datei.
- Prozess A beginnt mit der Arbeit.
- Prozess B öffnet die PID-Datei read/write.
- Prozess B versucht, nicht-blockierend einen exklusiven Lock auf die Datei zu bekommen, was aber fehlschlägt, weil A ja bereits einen Lock hat.
- Prozess B terminiert mit einer Fehlermeldung.
- Prozess A ist irgendwann mit der Arbeit fertig, und terminiert.
Meistens schreiben die Prozesse auch noch ihre PID in die Datei, nachdem sie den exklusiven Lock erhalten haben, aber das ist für den Ablauf nicht relevant. Darüberhinaus ist es ein zweifelhaftes Konzept. Sinn der Sache ist es, einem Benutzer zu erleichtern, den Prozess abzuschießen, indem die PID aus der Datei ausgelesen werden kann. Es kann aber leicht passieren, dass der Prozess schon lange nicht mehr läuft, und die PID längst für einen anderen Prozess in Verwendung ist. Statt cat process.pid
sollte man also ohnehin lieber lsof process.pid
verwenden.
Aber weshalb sollte der Prozess die PID-Datei nicht löschen, wenn er seine Arbeit beendet hat? Würde das nicht das Problem lösen? Bevor die Frage beantwortet werden kann, muss man sich klar machen, was beim Löschen einer Datei tatsächlich passiert.
PID-Dateien Löschen? Unlinken!
Weshalb heißt die Funktion zum Löschen einer Datei auf POSIX-Systemen unlink()
und nicht delete()
?
Normalerweise versteht man unter einer Datei etwas, das einen Namen hat, was aber nicht zwangsläufig so sein muss. Eine Datei kann auch ohne Namen existieren. Der Name ist eine Eigenschaft des die Datei enthaltenden Verzeichnisses, ein Verzeichniseintrag.
Das Löschen einer Datei mit unlink()
hat zwei Konsequenzen. Der sichtbare Effekt ist das Verschwinden des Verzeichniseintrages. Aber daneben wird auch noch der Link-Count --- als der Link-Zähler --- für den Inode (Dateisystems-Knoten) um eins heruntergesetzt.
Was ist der Link-Zähler? Erzeugt man eine Datei a
und dann einen harten Link auf sie mit ln a b
wird einfach nur der Link-Count des Inodes um eins hochgezählt:
$ >a
$ ln a b
$ ls -li
total 0
8597082957 -rw-r--r-- 2 user wheel 0 Jan 23 23:58 a
8597082957 -rw-r--r-- 2 user wheel 0 Jan 23 23:58 b
Die Ganzzahl 8597082957 in der ersten Spalte ist die Inode-Nummer, die für beide Dateien identisch ist. Die beiden Dateien a
und b
repräsentieren also die gleiche Datei bzw. den gleichen Inode.
Damit der Kernel alle mit einem Inode assoziierten Ressourcen freigibt, müssen zwei Dinge passieren:
- Der Link-Count muss auf Null fallen, und
- Kein Prozess darf einen offenen Dateideskriptor haben, der auf den Inode zeigt.
Dieses Feature wird gemeinhin ausgenutzt, um das automatische Löschen temporärer Dateien zu realisieren. Dazu erzeugt ein Prozess eine temporäre Datei, nur um sie umgehend mit unlink()
wieder zu "löschen". Ein ls
im enthaltenden Verzeichnis würde die Datei nicht mehr anzeigen, weil der Verzeichniseintrag von unlink()
sofort entfernt wird. Das funktioniert mit praktisch allen bekannten Betriebssystemen, aber vermutlich nicht mit MS-DOS/Windows.
Sobald der Prozess terminiert, gibt der Kernel die vom Inode verwendeten Ressourcen frei, weil der letzte offene Dateideskriptor darauf geschlossen wurde.
Verschiedene Gesichter der Race-Condition
Will man PID-Datei vor dem Terminieren aufräumen, hat man zwei Optionen. Man kann zuerst den Lock freigeben, dann löschen (unlink()
), oder man löscht erst, und gibt dann den Lock frei. Beide Varianten sind fehlerhaft. Richtig ist es, die PID-Datei in Ruhe zu lassen und einfach zu terminieren.
Erst Unlock, Dann Unlink
Die Race-Condition, die hier entsteht, ist leicht zu verstehen:
- Prozess A öffnet die PID-Datei read/write und erzeugt sie dabei gegebenenfalls.
- Prozess A holt sich (nicht-blockierend), einen exklusiven Lock auf die Datei.
- Prozess A beginnt mit der Arbeit.
- Prozess A ist irgendwann mit der Arbeit fertig.
- Prozess A gibt den Lock auf die Datei frei.
- Prozess B öffnet die PID-Datei read/write.
- Prozess A "löscht" (
unlink()
) die Datei. - Prozess C erzeugt die PID-Datei read/write.
- Prozess B und C erhalten jetzt problemlos einen exklusiven Lock auf ihren Dateideskriptor und laufen gleichzeitig.
Aber wie kann B im letzten Schritt einen exklusiven Lock bekommen? Einfach nochmal oben nachlesen! B bekommt den Lock auf den Deskriptor auf As Lock-Datei, und C bekommt seinen Lock auf die Datei, die C neu erzeugt hat, nachdem A die Datei gelöscht hatte.
Erst Unlink, Dann Unlock
In umgekehrter Reihenfolge ergibt sich eine ganz änhliche Race-Condition:
- Prozess A öffnet die PID-Datei read/write und erzeugt sie dabei gegebenenfalls.
- Prozess A holt sich (nicht-blockierend), einen exklusiven Lock auf die Datei.
- Prozess A beginnt mit der Arbeit.
- Prozess B öffnet die PID-Datei read/write.
- Prozess A ist mit der Arbeit fertig.
- Prozess A "löscht" (
unlink()
) die PID-Datei. - Prozess C erzeugt die PID-Datei read/write.
- Prozess A gibt den Lock auf die PID-Datei frei.
- Prozess B und C erhalten jetzt problemlos einen exklusiven Lock auf ihren Dateideskriptor und laufen gleichzeitig.
Schon wieder laufen B und C gleichzeitig und zerschießen sich hemmungslos alles, was die Lock-Datei schützen sollte.
Schon 'mal von OPEN_EX und OPEN_SH gehört?
Ja.
Schaut man sich den Grund für das Race etwas genauer an, stellt man schnell fest, dass der kritische Zeitpunkt zwischen dem Öffnen und Locken der Datei liegt, weil es sich dabei um keine atomare Operation handelt. *BSD-Systeme (das schließt Mac OS X ein), haben die zusätzlichen Flags OPEN_EX
und OPEN_SH
für open(2)
, die genau diese Atomizität sicherstellen.
Problem gelöst? Mitnichten. Linux unterstützt diese Flags nicht, und deine Software wird eventuell etwas Akzeptanzprobleme haben, wenn sie auf dem weit verbreitetsten Betriebssystem der Welt nicht läuft.
Eventuell kann man die Race-Condition mit viel Gefrickel doch irgendwie vermeiden, aber wofür? Was, wenn man dann doch noch einen Denkfehler in seinem genialen Konstrukt findet? Und --- Spoiler-Alarm! --- das wird vermutlich erst nach dem Desaster passieren.
Die Race-Condition reproduzieren.
Das ist einfach. Speichere die zusammengehackte C-Implementierung am Ende dieser Seite als delete-pid.c
, und kompiliere sie mit make delete-pid
.
Öffne drei Terminalfenster, starte das Programm in allen drei Fenstern mit ./delete-pid daemon.pid
(daemon.pid
ist der Name der verwendeten PID-Datei), und jetzt kannst du durch Bestätigung mit RETURN
interaktiv die Schritte "Lock-Datei öffnen", "Lock-Datei locken", "arbeiten", "löschen/unlink()" nacheinander abarbeiten.
Wenn man den Instruktionen weiter oben für die drei Prozesse A, B und C genau folgt, werden am Ende die beiden Prozesse B und C fröhlich nebeneinander laufen. Durch Vertauschen der Aufrufe von unlock_file()
und unlink_file
am Ende, kann man zwischen den beiden unterschiedlichen Reihenfolgen für Löschen und Freigeben des Links hin- und herschalten.
Zu viel Theater? Dann glaube es einfach! PID-Dateien zu löschen, ist ein Rezept für Ärger.
Voilà, der Quelltext (Download-Link) für die Heimwerker-Fraktion:
#include <stdio.h>
#include <sys/file.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
static void
prompt(char *message)
{
char buffer[1];
setvbuf(stdout, NULL, _IONBF, 0);
printf("PID %u: %s ", getpid(), message);
read(0, buffer, sizeof buffer);
}
static int
open_lock_file(char *path)
{
int fd;
prompt("hit return to open lock file!");
fd = open(path, O_CREAT | O_RDWR, 0644);
if (fd < 0) {
fprintf(stderr, "error opening '%s': %s\n", path, strerror(errno));
exit(1);
}
return fd;
}
static void
lock_file(int fd)
{
prompt("hit return to lock file!");
if (flock(fd, LOCK_EX | LOCK_NB) < 0) {
fprintf(stderr, "cannot get exclusive lock: %s\n", strerror(errno));
exit(1);
}
}
static void
work(void)
{
prompt("working, hit return to finish!");
printf("PID %u: finished work.\n", getpid());
}
static void
unlock_file(int fd)
{
prompt("hit return to unlock file!");
if (flock(fd, LOCK_UN) < 0) {
fprintf(stderr, "cannot unlock: %s\n", strerror(errno));
exit(1);
}
}
static void
unlink_file(char *path)
{
prompt("hit return to unlink file!");
if (unlink(path) < 0) {
fprintf(stderr, "warning: unlinking '%s' failed: %s\n",
path, strerror(errno));
}
}
int
main (int argc, char *argv[])
{
char *pidfile;
int fd;
if (argc < 2) {
fprintf(stderr, "usage: %s PIDFILE\n", argv[0]);
return 1;
}
pidfile = argv[1];
fd = open_lock_file(pidfile);
lock_file(fd);
work();
/* Feel free to swap the next two steps. */
unlock_file(fd);
unlink_file(pidfile);
return 0;
}
Leave a comment