>>159569>>159570>>159571Das Problem bei
fork
vs. Threads ist eher: Warum sollte man an Ressourcen alles und den Küchensiphon (Dateideskriptoren, Sockets, Signal-Handler) vererben, wenn man nur einen weiteren Ausführungsstrang für ein paar Berechnungen haben wollte?
Lustigerweise wird eine Sache bei
fork
gerade nur mit CoW-Semantik vererbt, und nicht geteilt: Der virtuelle Adressraum. Bereits beim einfachsten Fall (man startet n Berechnungsthreads, alle rechnen parallel, alle n Threads terminieren) ist das nicht das, was man will. Dort will man gerade den gleichen Adressraum haben, da dann jeder Thread am Ende sein Ergebnis ohne CoW-Semantik einfach in den Zielspeicher/ins Zielarray reinschreiben kann, wo der Hauptthread des Prozesses das dann nach Terminieren aller Threads rauslesen und weiterverarbeiten kann.
Bereits für den einfachen Fall von "wir brauchen ein paar Threads um etwas parallel zu berechnen" ist
fork()
damit eher nicht das, was man haben will. Wenn man hingegen den neuen Prozess/Thread tatsächlich für etwas Komplexes braucht, d.h. mit gegenseitiger Kommunikation, gegenseitigen Aufweck-Ereignissen und ggf. indefinit langer Laufzeit, fangen die Probleme erst so richtig an:
Das Vererben von Ressourcen mittels
fork()
ist bei komplexen Programmen gerade gefährlich, da das bedeutet, dass auch Locks/Mutexe vererbt werden (insbesondere Locks aus den Interna der glibc und anderen Bibliotheken, die eigene Threads erstellen), und es daher sehr schwer wird, anständig darüber zu urteilen. Das gilt insbesondere aufgrund des Umstandes, dass der ge
fork
te Prozess nur aus dem Hauptthread besteht, die anderen Threads existieren im ge
fork
ten Prozess nicht, die sind beim
fork()
einfach ersatzlos weggefallen. Wenn aber einer dieser Threads einen Lock/Mutex zum
fork
-Zeitpunkt hielt, und dann der Thread mit neuen Prozess einfach weg ist, wird dort der Lock/Mutex nie freigegeben. Wenn der ge
fork
te Prozess in dessen Hauptthread deswegen in einem Lock hängt, terminiert der nie, und auf das Terminieren dieses Kind-Prozesses wartet dann auch der ursprüngliche Hauptprozess indefinit lange (z.B. weil der Hauptprozess irgendeine Rückmeldung vom Kind-Prozess erwartet), womit der auch blockiert ist.
Da wollte dann der Spiele-Entwickler von Factorio super clever sein, und sich sagen, wenn man den Spiele-Prozess
fork
t, dann kann man das ausnutzen, um parallel zum laufenden Spiel den aktuellen Speicherstand auf die Platte zu schreiben: Man hat schließlich CoW-Semantik, wenn der Hauptprozess etwas verändert, hat man im ge
fork
ten Prozess immer noch den Speicherstand wie zum
fork()
-Zeitpunkt. Doch dieser Speicherprozess hing dann wie oben beschrieben in einem Lock, indirekt verursacht durchs
fork()
. Und dann wartete der Hauptprozess noch auf irgendeine Rückmeldung vom Speicherprozess. Und das Ergebnis war, dass das ganze Spiel hing.
>The core method (of using fork
to get a separate process with the same memory map and then doing the save in that process) is pretty risky. You need to make sure that no locks are held by any thread other than the one calling fork
at the time of the fork
. This include locks buried in library code; malloc()
might try to acquire locks.
https://forums.factorio.com/viewtopic.php?p=615920#p615920
>In the POSIX model, only the fork
ing thread is propagated. All the other threads are eliminated without any form of notice; no cancels are sent and no handlers are run. However, all the other portions of the address space are cloned, including all the mutex state. If the other thread has a mutex locked, the mutex will be locked in the child process, but the lock owner will not exist to unlock it. Therefore, the resource protected by the lock will be permanently unavailable.
>The fact that there may be mutexes outstanding only becomes a problem if your code attempts to lock a mutex that could be locked by another thread at the time of the fork
. This means that you cannot call outside of your own code between the call to fork
and the call to exec(). Note that a call to malloc(), for example, is a call outside of the currently executing application program and may have a mutex outstanding.
https://web.archive.org/web/20240215012854if_/http://www.doublersolutions.com/docs/dce/osfdocs/htmls/develop/appdev/Appde193.htm
Tangente: Felix hat auch mal die Zeit gemessen, die man für den Aufruf von
fork()
,
vfork()
,
clone()
etc. in verschiedenen Parameter-Varianten braucht. Auf Linux ist
clone()
mit neuem Stack beim Erstellen am schnellsten, und zwar auch schneller als die
clone()
-Variante mit CoW-Semantiken, weil nichts an Speicherseiten kopiert werden muss. Das mit dem "nichts vererben" wurde allerdings auch bei
clone()
nicht ganz durchgezogen und die Signal-Handler werden noch vererbt, was man ab Linux Version 5.5 via
CLONE_CLEAR_SIGHAND
auch noch abstellen kann.
Darüber hinaus ist das Erstellen von neuen Prozessen und Threads immer als schwergewichtig anzusehen, und man sollte bei indefinit lange laufenden Programmen auf persistente Fäden
persistent threads setzen (oder "persistente Prozesse" als Äquivalent, wenn es denn sein muss).
Nach Felixens letztem Stand waren für Webservierer persistente Threads + io_uring das aktuell schnellste auf Linux. Das gesagt, kann man auf heutiger Hardware bereits mit einem gammeligen Einzelfaden-Servierer mit
epoll()
eine 10-GBit/s-Leitung voll auslasten.
Für Felix ist damit die niedrigkomplexeste Variante *und* schnellste Variante auf Linux tatsächlich die Benutzung von Threads (via
clone()
+ eigenem Stack +
CLONE_CLEAR_SIGHAND
).
Davon abgesehen zeigt sich mal wieder, dass angesichts vom Komplexitätscheißeberg der einzig gewinnbringende Zug es ist, gar nicht erst mit Komplexität zu spielen.