Über `fork()` + `exec()` hinaus
(lwn.net)- Spawn Templates sind ein Vorschlag zur Prozesserzeugung für den Linux-Kernel, bei dem der Kernel in Anwendungen, die dieselbe ausführbare Datei wiederholt starten, Informationen über die ausführbare Datei zwischenspeichert, um spätere Prozessstarts zu beschleunigen
- fork() muss den gesamten Prozesszustand einschließlich des Speichers für den Kindprozess kopieren, und wenn direkt danach
exec()folgt, wird dieser Speicher oft wieder verworfen, was zu Ineffizienzen im bestehenden Muster führt - spawn_template_create() gibt einen Template-Dateideskriptor zurück, wobei die ausführbare Datei entweder über
execfdoder den absoluten Pfadfilenameangegeben wird; der Kernel öffnet diese Datei und cached die für eine schnelle Ausführung nötigen Informationen - spawn_template_spawn() arbeitet in einer Weise, die dem gewöhnlichen Pfad
fork()/exec()nahekommt, behält die beim Start einer neuen Datei angewendeten Prüfungen bei, und die Benchmarks im Cover Letter verzeichnen eine Verbesserung von etwa 2% {p:2} - Die Erzeugung leerer Prozesse auf Basis von pidfd und die Konfiguration über
pidfd_config()gelten als der bessere Ansatz; das Ziel ist die Unterstützung einerposix_spawn()-Implementierung im Userspace
Grenzen des Unix-Modells zur Prozesserzeugung
- Seit den frühen Tagen von Unix ist
fork()der zentrale prozessorientierte Systemaufruf, der einen Kindprozess als Kopie des Elternprozesses erzeugt, währendexec()an Stelle des aktuellen Prozesses ein neues Programm ausführt - Im Linux-Kernel sind dieselben Kernfunktionen eher unter clone() und execve() bekannt
- Dieses Modell der Prozesserzeugung hat sowohl Eleganz als auch Nachteile, und Li Chens Vorschlag zu Spawn Templates wird in seiner jetzigen Form voraussichtlich nicht in den Linux-Kernel aufgenommen, könnte aber zu neuen primitiven Operationen für die Prozesserzeugung in der Zukunft führen
fork()ist ein relativ teurer Systemaufruf, weil zur Erzeugung des Kindprozesses der gesamte Prozesszustand einschließlich des Speichers kopiert werden muss- Über die Jahre gab es viele Optimierungen, doch
fork()bleibt grundsätzlich eine kostspielige Operation - Auf einen Aufruf von
fork()folgt oft unmittelbarexec(), undexec()verwirft dann den gesamten für das Kind kopierten Speicher - Es gab Optimierungsversuche wie vfork(), doch das Muster
fork()gefolgt vonexec()ist weiterhin teurer, als es eigentlich sein müsste
Spawn Templates
- Li Chens Patchset konzentriert sich auf Anwendungen, die dieselbe ausführbare Datei wiederholt starten, um das Muster
fork()undexec()zu optimieren - Als Beispiel wird ein Programm genannt, das Git wiederholt ausführen muss, um Informationen über Repository-Inhalte abzurufen
- In solchen Fällen kann das Programm ein Template anlegen, um die Einrichtungskosten über mehrere Ausführungen zu verteilen und die Aufrufe mit diesem Template zu beschleunigen
- Das Erzeugen eines Templates erfolgt über den Systemaufruf
spawn_template_create()- Signatur in der Form
int spawn_template_create(struct spawn_template_create_args *args, size_t args_size);
- Signatur in der Form
- Dieser Aufruf gibt einen Dateideskriptor zurück, der ein Template für die ausführbare Datei repräsentiert
- Die ausführbare Datei muss entweder über den Dateideskriptor
execfdoder über den absoluten Pfadfilenameangegeben werden; beides zugleich ist nicht möglich - Der Kernel öffnet die angegebene Datei und cached verschiedene Informationen, die benötigt werden, um diese Datei später schneller auszuführen
- Jede Ausführung kann unterschiedliche Argumente, Umgebungen, Änderungen an Dateideskriptoren und Änderungen an der Signalbehandlung haben
- Die konkreten Ausführungsinformationen werden in einer Struktur
spawn_template_spawn_argsabgelegtargvist ein Zeiger auf die Liste der an das Programm zu übergebenden Argumenteenvpist ein Zeiger auf die Programmumgebungactionsist ein Zeiger auf ein Array vonspawn_template_action, das Änderungen an Dateideskriptoren und der Signalbehandlung übermittelt
spawn_template_actionbesteht aus den Felderntype,flags,fd,newfdundarg- Wenn im Kindprozess der Dateideskriptor 4 geschlossen werden soll, wird
typeaufSPAWN_TEMPLATE_ACTION_CLOSEundfdauf 4 gesetzt - Weitere Aktionen unterstützen das Duplizieren von Dateideskriptoren, das Öffnen von Dateien, das Wechseln des Arbeitsverzeichnisses und Änderungen an der Signalbehandlung
- Wenn im Kindprozess der Dateideskriptor 4 geschlossen werden soll, wird
- Nachdem die Ausführungsinformationen gefüllt sind, wird der neue Prozess mit
spawn_template_spawn()gestartet- Signatur in der Form
int spawn_template_spawn(int template_fd, struct spawn_template_spawn_args *args, int args_size);
- Signatur in der Form
- Die interne Arbeitsweise liegt nahe am üblichen Pfad
fork()/exec() - Alle üblichen Prüfungen, die beim Start einer neuen Datei angewendet werden, bleiben unverändert bestehen
- Die im Template zwischengespeicherten Informationen beschleunigen den gesamten Erzeugungsablauf
- Die Benchmark-Ergebnisse im Cover Letter zeigen eine Verbesserung von etwa 2%, was für Anwendungen, die zu diesem Muster passen, einen Unterschied machen kann {p:2}
Auf dem Weg zu posix_spawn()
- Mateusz Guzik bewertet das „gesamte
fork-+exec-Idiom“ als schrecklich und meint, es sollte abgeschafft werden - Ein seltsamer Punkt des Patchsets sei, dass der
fork()-Teil unverändert bleibt, obwohl dort nach dieser Einschätzung der Großteil der Kosten entsteht - Eine Optimierung sollte die Kopie des aktuellen Prozesses beseitigen und stattdessen einen „sauberen (pristine) Prozess“ erzeugen
- Christian Brauner vertritt die Ansicht, dass die Idee einer Builder-API für
exec„nicht so seltsam“ sei - Er bevorzugt jedoch einen Ansatz, bei dem die neue API auf der bestehenden pidfd-Abstraktion aufbaut
- Konkrete Details gibt es noch nicht, aber das Hinzufügen einer Option zu pidfd_open(), die einen leeren Prozess erzeugt, gilt als der richtige Ansatz
- Anschließend würde man einen neuen Systemaufruf
pidfd_config()mehrfach aufrufen, um die gewünschte Konfiguration wie Umgebung oder das auszuführende Image auf den neuen Prozess anzuwenden pidfd_config()würde eine ähnliche Rolle wie fsconfig() spielen- Ein wichtiges Ziel der neuen Schnittstelle ist die Unterstützung einer posix_spawn()-Implementierung im Userspace
posix_spawn()eignet sich gut als Alternative zum Musterfork()/exec()- Die aktuelle Implementierung verbirgt intern
fork()undexec(), während eine native Implementierung eine andere Struktur hätte - Li Chen stimmte zu, dass die von Brauner grob umrissene API besser aussieht, und plant, künftige Arbeiten in diese Richtung voranzutreiben
- Spawn Templates werden nicht in den Linux-Kernel aufgenommen, aber wenn die künftige Arbeit Früchte trägt, könnte Linux eine angemessene
posix_spawn()-Implementierung bekommen
1 Kommentare
Hacker-News-Kommentare
Dazu gibt es die einschlägige Diskussion im Paper A fork() in the road: https://www.microsoft.com/en-us/research/wp-content/uploads/...
Im Abstract wird argumentiert, dass die Unix-Kombination aus
fork()+exec()entgegen der verbreiteten Auffassung einer inspirierten Konstruktion in den 1970er Jahren für damalige Maschinen und Programme zwar ein cleverer Hack war, heute aber für moderne Programmierer eine schlechte Abstraktion darstellt und auch die Implementierung von Betriebssystemen einschränkt.Die Position ist, dass man sie nicht als primäre Betriebssystem-Primitive beibehalten, sondern als historisches Relikt lehren sollte, damit sie nicht die erste Form der Prozesserzeugung ist, die Studierende lernen.
fork()+exec()so entstanden ist, war, dass man damit Programme ausführen konnte, die zu groß waren, um zusammen mit dem Elternprogramm in den Speicher zu passen.Die ursprüngliche Implementierung lagerte beim Aufruf von
fork()das forkerzeugende Programm auf die Festplatte aus und duplizierte bzw. passte vor der Rückgabe der Kontrolle den Eintrag in der Prozesstabelle an, sodass ein Prozess im Speicher und ein ausgelagerter Prozess entstanden. Die im Speicher befindliche Variante erhielt die Kontrolle und konnteexec()aufrufen.Dadurch konnten selbst kleine PDP-11-Maschinen große Programme ausführen, was in einer Zeit mit extrem teurem Speicher notwendig war.
Interessanterweise befindet sich bei QNX das Laden von Programmen nicht im Betriebssystem, sondern in einer Bibliothek. Diese liest den Header der ausführbaren Datei, allokiert Speicher, lädt das Programm, bereitet die Ausführung vor und linkt gegen die
.so, die den Start übernimmt; der Programmlader läuft als nicht privilegierter User-Space-Prozess. Das kommt vermutlich der richtigen Lösung näher.fork(), sehr langsam ist.Ich stimme zu, dass es eine Primitive jenseits von
fork()geben sollte, bin mir aber nicht sicher, ob Performance dafür das stärkste Argument ist.fork()behandelt: The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdffork()hervorragend geeignet.Es ist schwer, sich dafür eine ebenso effiziente und elegante Optimierung vorzustellen.
Ich hatte kürzlich einen obskuren Bug, der dadurch entstand, dass in einem geforkten Prozess mehr Dateideskriptoren geschlossen werden mussten.
Meiner Erfahrung nach ist „ich will einen Klon des aktuellen Prozesses“ viel seltener als „ich will einen vollständig neuen Prozess“, und es fühlt sich seltsam an, dass man Letzteres nicht direkt ausdrücken kann, sondern nur näherungsweise, indem man erst klont und dann im Nachhinein korrigiert.
O_CLOEXECgelöst?posix_spawn?Zu sagen, „
fork()ist ein relativ teurer Systemaufruf, weil der gesamte Prozesszustand einschließlich des Speichers für den Kindprozess kopiert werden muss. Über die Jahre gab es viele Optimierungen, aber im Kern bleibt das ein kostspieliger Vorgang. Noch schlimmer ist, dass auf den Aufruf vonfork()oft unmittelbar einexec()folgt, wodurch der für das Kind mühsam kopierte Speicher komplett verworfen wird“, ohne Copy-on-Write zu erwähnen, ist seltsam.Genau diese Optimierung verhindert ja, dass tatsächlich der gesamte Speicher kopiert werden muss.
Auch wenn der von den eigentlichen Seiten referenzierte Speicher geteilt wird, müssen neue Seiten allokiert werden, um Kopien dieser Strukturen unterzubringen. Und allein das vollständige Durchlaufen und Kopieren dieser Strukturen bleibt teuer.
fork()kopiert zwar nicht den Speicher selbst, aber die Seitentabellen müssen weiterhin kopiert werden.Bei einem Prozess mit mehreren Dutzend GB RAM kann
fork()lange dauern, und das passiert bei Redis jedes Mal, wenn eine.rdb-Datei gedumpt oder das binäre AOF-Protokoll neu geschrieben wird.Schon 2012 gab es einen Beitrag, der die hohen Kosten dieses Vorgangs zeigte: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
Auf einem
m2.xlarge, das etwa 25 GB RAM nutzte, dauertefork()5,67 Sekunden. Wenn man bedenkt, dass Redis-Clients bei den meisten Operationen normalerweise Latenzen im einstelligen Millisekundenbereich sehen, ist das eine lange Pause. Und das ist nur die Zeit zum Kopieren der Seitentabellen.Dass huge pages nicht erwähnt werden, ist überraschend; hier scheint das ein zentraler Aspekt zu sein. Die Hardware ist 14 Jahre später sicher schneller, aber Redis-Instanzen verwenden wahrscheinlich auch mehr RAM, daher wäre es interessant, diesen Benchmark noch einmal zu fahren.
fork()die Kosten für dieses Setup tragen. Wenn es im Elternprozess viele aktive Threads gibt, kann es vor der Ausführung vonexec()zu viel unnötigem Copy-on-Write kommen, etwa bei Java.Dass das Forken von Programmen mit großem virtuellem Speicher langsam ist, ist ein gut bekanntes Problem.
Die Eleganz des
fork()+exec()-Modells liegt darin, dass man nachfork()die normalen APIs unverändert verwenden kann, um jede Art von Konfiguration vorzunehmenDie bisher gesehenen Alternativen mit zusammengefassten Aufrufen wirkten grundsätzlich schwach, weil man alle Konfigurationsoptionen als Aufrufparameter ergänzen müsste und das später auch noch erweiterbar halten müsste, ohne dass es im Chaos endet
fork()/exec()in manchen Fällen nützlich sein kann, wäre es ziemlich gut, wenn APIs einpidfd-Argument annehmen würden. 0 könnte dann den aktuellen Prozess bedeutenDas Problem wären vor allem
setuid-/setgid-Binärdateien; in diesem Fall wäre eine Sonderbehandlung beiexecvielleicht besserMan könnte zum Beispiel mit
pidfd_t ps = spawn();einen angehaltenen Prozess erzeugen und ihn dann etwa mitsetuid(ps, 33);,capset(ps, ...);,socket(ps, ...);,mmap(ps, ...);,process_vm_writev(ps, ...);,exec(ps, ...);,signal(ps, SIGCONT);konfigurierenDas ist auch Kritik daran, dass gewöhnliche System-Call-APIs die Frage „Was, wenn ich das mit einem anderen Prozess machen will, auf den ich Zugriffsrechte habe?“ nicht ausreichend berücksichtigen. So ließe sich bei
fork()auch in gewissem Maß Thread-Sicherheit erreichenIch stimme allerdings zu, dass ein Ansatz wie
CreateProcess, der Unmengen an Parametern annimmt, keine großartige User-Space-API istEs gibt zum Beispiel APIs, mit denen sich ein Objekt gezielt auf Dateideskriptor Nummer 4 legen lässt, und dann kann man ein Programm starten, damit es dieses Objekt auf Deskriptor 4 findet. Das ist seltsam
Windows verwendet trotz seiner vielen Schwächen nicht
fork()+exec(), sondern bietet stattdessen vor allem Optionen dafür an, wie ein Prozess erzeugt wird. Elegant war das nicht, aber die Richtung stimmtefork()+exec()In einer anderen Welt ohne
fork()+exec()hätten viele dieser „allgemeinen APIs“ wahrscheinlich explizitepid-Argumente, mit denen sich die Konfiguration eines anderen Prozesses ändern ließe. Fuchsia macht es ungefähr soDiese Welt hat viele Vorteile. Am offensichtlichsten ist, dass man nicht extra auf magische Weise ein separates IPC-System zur Meldung von Konfigurationsfehlern schaffen muss; außerdem wäre es ziemlich nützlich, einen Verwaltungsprozess zu haben, der die Eigenschaften des Kindes anpasst. Debugger würden das besonders mögen
fork()loszuwerden, wäre, dass allgemeine APIs zum Ändern des Prozesszustands explizite Prozess-Handles annehmenDann könnte man mit denselben APIs einen leeren Prozess konfigurieren und sie auch auf andere Weise mit IPC oder Debugging kombinieren
Wenn der Prozess mit bestehender
ptrace-Verbindung und ohne Threads startet, könnte man ihm in der Konfigurationsphase System-Calls aufzwingen. Linux hat nicht einmal das Konzept eines „threadlosen Prozesses“, also bräuchte man vermutlich einen Dummy-ThreadDas Missverständnis, dass
fork()billig sei, ist erstaunlich weit verbreitet, dabei ist es in Bezug auf die Prozessgröße O(N) und war das schon immerJa, es ist Copy-on-Write. Aber zwischen der Prozessgröße und der Zahl der Seitentabelleneinträge, die zu ihrer Darstellung nötig sind, besteht eine lineare Beziehung
Es überrascht nicht, dass Chens Patch abgelehnt wurde. Der Anwendungsfall ist zu speziell, um die Unterstützung zu rechtfertigen
Aus Sicht eines Shell-Entwicklers stimme ich der Schlussfolgerung zu, dass „Entwickler vermutlich eine native Implementierung begrüßen würden, die
fork()undexec()nicht wie die aktuelle Implementierung intern versteckt“fork()wirkte auf mich schon beim ersten Lernen konzeptionell furchtbar. Wenn man eine einzige Aufgabe erledigen will, nämlich einen Prozess zu starten, sollte man dafür nicht erst durch den rätselhaften Zauberspruch gehen müssen, den aktuellen Prozess zu forken, also etwas ganz anderes und Unverwandtes zu tunWie im Beispiel des Artikels frage ich mich, wie man am besten den Fall behandelt, dass ein Prozess viele
git-Unterprozesse startet. Während eine lang laufende Elternaufgabe aktiv ist,gitimmer wieder komplett neu zu starten, scheint keinen Sinn zu ergeben. Welche kostengünstige Abstraktion würde dasselbe Ergebnis liefern?fork()ist konzeptionell einfach. Wenn man keine anderen Schichten hineinzieht, startet man einen Prozess aus dem einzigen Prozess, von dem man sicher weiß, dass er existiert: sich selbstSonst braucht man mehrere Schritte: einen Prozess erzeugen, ihn mit etwas füllen, das ausgeführt werden soll, und ihn zur Ausführung bringen. Oder man verschmilzt es wie bei Win32 dauerhaft mit anderen Schichten wie Dateisystem, Objekt-Loader und Linker
fork()+exec()-Modell überhaupt nicht nachvollziehbar. Inzwischen weiß ich, dass es einfach eine historische Kuriosität ist, aber es gibt immer noch Leute, die so tun, als seifork()+exec()tatsächlich gutlibgit2. Man könnte sich eine Kommunikation mit irgendeinemgitdper Pipe oder Socket vorstellen, aber ich weiß nicht, warum das eine gute Idee sein sollte. Ansonsten muss man eben Prozesse startenexec/forklassen sich so schwer ersetzen, weil ein neuer Prozess normalerweise konfiguriert werden muss. Man muss zum Beispiel Signal-Handler setzen, Dateideskriptoren schließen oder öffnen, Namespaces wechseln,seccompeinrichten oder Berechtigungen anpassenDie System-Calls dafür gelten aber derzeit nur für den aktuellen Prozess, daher braucht es einen Ersatzmechanismus. Der Vorschlag des Artikels war, dafür eine neue API zu schaffen
Meiner Ansicht nach könnte ein neuer System-Call wie
spawneinen leeren Prozess erzeugen, darin einen leichten Loader starten und dann beliebige Konfigurationsdaten übergeben. Der Loader würde den Prozess konfigurieren und anschließend das Hauptprogramm perexec()startenSo könnte man das Forken des Speichers vermeiden und die bestehenden APIs beibehalten, aber Dateideskriptoren und andere Dinge müssten weiterhin dupliziert werden
Falls das kein Scherz war:
posix_spawn()existiert bereits, und in glibc istforknur ein Alias fürclone(). Auch wenn es nicht exakt derselbe Vorschlag ist, sindfork()/exec()wirklich fast schon LegacyWenn
forkundexecüber ihre Copy-on-Write-Natur hinaus ein persistentes und algebraisches Verhalten zeigen könnten, wären sie nicht nur nützlicher, sondern auch interessanter in der Verwendung. Man könnte sie zum Beispiel für Lazy Evaluation einsetzenÜber diese alte API wurde auf Hacker News viel diskutiert; ein Beispiel ist https://news.ycombinator.com/item?id=31739794