2 Punkte von GN⁺ 3 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • 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 execfd oder den absoluten Pfad filename angegeben 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 einer posix_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ährend exec() 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 unmittelbar exec(), und exec() verwirft dann den gesamten für das Kind kopierten Speicher
  • Es gab Optimierungsversuche wie vfork(), doch das Muster fork() gefolgt von exec() 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() und exec() 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);
  • 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 execfd oder über den absoluten Pfad filename angegeben 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_args abgelegt
    • argv ist ein Zeiger auf die Liste der an das Programm zu übergebenden Argumente
    • envp ist ein Zeiger auf die Programmumgebung
    • actions ist ein Zeiger auf ein Array von spawn_template_action, das Änderungen an Dateideskriptoren und der Signalbehandlung übermittelt
    Anzeige
  • spawn_template_action besteht aus den Feldern type, flags, fd, newfd und arg
    • Wenn im Kindprozess der Dateideskriptor 4 geschlossen werden soll, wird type auf SPAWN_TEMPLATE_ACTION_CLOSE und fd auf 4 gesetzt
    • Weitere Aktionen unterstützen das Duplizieren von Dateideskriptoren, das Öffnen von Dateien, das Wechseln des Arbeitsverzeichnisses und Änderungen an der Signalbehandlung
  • 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);
  • 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 Muster fork()/exec()
  • Die aktuelle Implementierung verbirgt intern fork() und exec(), 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

 
GN⁺ 3 시간 전
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.

    • Der Grund, warum 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 konnte exec() 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.
    • Es ist interessant, dass die Prozesserzeugung im Windows-Betriebssystem, dem am weitesten verbreiteten „großen“ System ohne 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.
    • Dieses Paper ist gut, und Referenz [29] ist ebenfalls besonders gut, weil sie subtile Aspekte skalierbarer Interfaces einschließlich fork() behandelt: The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf
    • Die damalige Diskussion dazu findet sich hier: https://news.ycombinator.com/item?id=19621799 - A fork() in the road (2019-04-10, 178 Kommentare)
    • Für das Zygote-Muster ist fork() 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.

    • Normalerweise möchte man mit diesem Prozess kommunizieren, also muss man z. B. Dinge wie Dateideskriptoren einrichten und Informationen aus dem Elternprozess übergeben.
    • Wird das nicht durch O_CLOEXEC gelöst?
    • Wenn mit „eine Möglichkeit, Letzteres direkt auszudrücken“ gemeint ist: Ist das nicht genau der Zweck von posix_spawn?
    • Was bedeutet „vollständig neuer Prozess“ hier eigentlich genau?
  • 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 von fork() oft unmittelbar ein exec() 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.

    • Im Artikel ist das implizit enthalten, aber mit der Kopie des Prozesszustands sind hier die Speicherverwaltungsstrukturen gemeint, vor allem Seitentabellen und VMAs.
      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.
    • Bei Redis ist das ein Prozesstyp, bei dem diese Kosten stark ins Gewicht fallen. 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, dauerte fork() 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.
    • Für die intendierte Leserschaft solcher Paper ist Copy-on-Write vermutlich Grundwissen und wurde deshalb weggelassen.
    • Auch mit Copy-on-Write muss fork() die Kosten für dieses Setup tragen. Wenn es im Elternprozess viele aktive Threads gibt, kann es vor der Ausführung von exec() zu viel unnötigem Copy-on-Write kommen, etwa bei Java.
    • Im Text steht „Zustand“. Auch bei Copy-on-Write werden die Inhalte zwar nicht kopiert, aber die Kosten proportional zur Anzahl der Seitentabelleneinträge bleiben bestehen.
      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 nach fork() die normalen APIs unverändert verwenden kann, um jede Art von Konfiguration vorzunehmen
    Die 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

    • Ich stimme nur teilweise zu, sehe aber den Nutzen. Auch wenn fork()/exec() in manchen Fällen nützlich sein kann, wäre es ziemlich gut, wenn APIs ein pidfd-Argument annehmen würden. 0 könnte dann den aktuellen Prozess bedeuten
      Das Problem wären vor allem setuid-/setgid-Binärdateien; in diesem Fall wäre eine Sonderbehandlung bei exec vielleicht besser
      Man könnte zum Beispiel mit pidfd_t ps = spawn(); einen angehaltenen Prozess erzeugen und ihn dann etwa mit setuid(ps, 33);, capset(ps, ...);, socket(ps, ...);, mmap(ps, ...);, process_vm_writev(ps, ...);, exec(ps, ...);, signal(ps, SIGCONT); konfigurieren
      Das 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 erreichen
      Ich stimme allerdings zu, dass ein Ansatz wie CreateProcess, der Unmengen an Parametern annimmt, keine großartige User-Space-API ist
    • Ich sehe das völlig anders. Der große Fehler des UNIX-Modells ist, dass bei der Prozesserzeugung zu viel Zustand erhalten bleibt
      Es 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 stimmte
    • Das elegant zu nennen, ist Pfadabhängigkeit in der Geschichte von fork()+exec()
      In einer anderen Welt ohne fork()+exec() hätten viele dieser „allgemeinen APIs“ wahrscheinlich explizite pid-Argumente, mit denen sich die Konfiguration eines anderen Prozesses ändern ließe. Fuchsia macht es ungefähr so
      Diese 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
    • Der richtige Weg, fork() loszuwerden, wäre, dass allgemeine APIs zum Ändern des Prozesszustands explizite Prozess-Handles annehmen
      Dann könnte man mit denselben APIs einen leeren Prozess konfigurieren und sie auch auf andere Weise mit IPC oder Debugging kombinieren
    • Die Reihenfolge sollte spawn, configure, exec sein
      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-Thread
  • Das 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 immer
    Ja, 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() und exec() nicht wie die aktuelle Implementierung intern versteckt“

    • Es scheint Interesse nicht an einer bestimmten Implementierung zu geben, sondern an dem Konzept selbst
  • 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 tun
    Wie 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, git immer 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 selbst
      Sonst 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
    • Für jemanden, der von Windows kommt, war das 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 sei fork()+exec() tatsächlich gut
    • Es gibt libgit2. Man könnte sich eine Kommunikation mit irgendeinem gitd per Pipe oder Socket vorstellen, aber ich weiß nicht, warum das eine gute Idee sein sollte. Ansonsten muss man eben Prozesse starten
  • exec/fork lassen 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, seccomp einrichten oder Berechtigungen anpassen
    Die 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 spawn einen 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 per exec() starten
    So könnte man das Forken des Speichers vermeiden und die bestehenden APIs beibehalten, aber Dateideskriptoren und andere Dinge müssten weiterhin dupliziert werden

    • Zum Glück ist offenbar jemand mit einer Zeitmaschine zurückgereist, hat diesen Artikel gelesen und das schon in POSIX.1-2001 ergänzt :)
      Falls das kein Scherz war: posix_spawn() existiert bereits, und in glibc ist fork nur ein Alias für clone(). Auch wenn es nicht exakt derselbe Vorschlag ist, sind fork()/exec() wirklich fast schon Legacy
  • Wenn fork und exec ü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