Au-delà de `fork()` + `exec()`
(lwn.net)- Les spawn templates sont une proposition de création de processus pour le noyau Linux visant à permettre au noyau de mettre en cache les informations sur un exécutable afin d’accélérer les démarrages suivants dans les applications qui lancent de façon répétée le même binaire
- fork() doit copier l’état complet du processus, y compris la mémoire, pour le processus enfant, et lorsque
exec()qui suit immédiatement jette ensuite cette mémoire, cela crée l’inefficacité du schéma traditionnel - spawn_template_create() spécifie un exécutable via
execfdou via le chemin absolufilename, renvoie un descripteur de fichier de modèle, et le noyau ouvre ce fichier puis met en cache les informations nécessaires à une exécution rapide - spawn_template_spawn() fonctionne d’une manière proche du chemin habituel
fork()/exec(), conserve les vérifications appliquées lors de l’exécution d’un nouveau fichier, et les benchmarks de la lettre d’accompagnement montrent une amélioration d’environ 2 % {p:2} - La création d’un processus vide sur la base de pidfd et sa configuration via
pidfd_config()sont jugées comme une meilleure approche, avec pour objectif de prendre en charge une implémentation deposix_spawn()en espace utilisateur
Les limites du modèle de création de processus Unix
- Depuis les débuts d’Unix,
fork()est l’appel système fondamental orienté processus qui crée un processus enfant comme copie du parent, etexec()exécute un nouveau programme à la place du processus courant - Dans le noyau Linux, les mêmes fonctions fondamentales sont davantage connues via clone() et execve()
- Ce modèle de création de processus présente à la fois une certaine élégance et des inconvénients, et la proposition de spawn templates de Li Chen, bien qu’elle ne soit pas destinée à être acceptée telle quelle dans le noyau Linux, pourrait déboucher à l’avenir sur de nouvelles primitives de création de processus
fork()est un appel système relativement coûteux, car il doit copier l’état complet du processus, y compris la mémoire, pour créer le processus enfant- De nombreuses optimisations ont été ajoutées au fil des ans, mais
fork()reste fondamentalement une opération coûteuse - Dans bien des cas, un appel à
fork()est immédiatement suivi parexec(), etexec()jette alors toute la mémoire copiée pour l’enfant - Des tentatives d’optimisation comme vfork() ont existé, mais le schéma
fork()puisexec()reste encore plus coûteux qu’il ne pourrait l’être
Spawn templates
- La série de patches de Li Chen vise à optimiser le schéma
fork()etexec()en se concentrant sur les applications qui exécutent de façon répétée le même binaire - Un exemple typique est un programme qui doit lancer Git à répétition pour obtenir des informations sur le contenu d’un dépôt
- Dans ce cas, le programme peut créer un modèle afin d’amortir le coût de préparation sur plusieurs exécutions et accélérer les invocations grâce à ce modèle
- La création du modèle passe par l’appel système
spawn_template_create()- avec la signature
int spawn_template_create(struct spawn_template_create_args *args, size_t args_size);
- avec la signature
- Cet appel renvoie un descripteur de fichier représentant un modèle d’exécutable
- L’exécutable doit être spécifié soit par le descripteur de fichier
execfd, soit par le chemin absolufilename, sans pouvoir utiliser les deux à la fois - Le noyau ouvre le fichier indiqué et met ensuite en cache diverses informations nécessaires pour exécuter ce fichier plus rapidement par la suite
- Chaque exécution peut avoir des arguments, un environnement, des modifications de descripteurs de fichiers et des changements de gestion des signaux différents
- Les informations d’exécution concrètes sont placées dans la structure
spawn_template_spawn_argsargvest un pointeur vers la liste des arguments transmis au programmeenvpest un pointeur vers l’environnement du programmeactionsest un pointeur vers un tableau despawn_template_actionservant à transmettre les modifications de descripteurs de fichiers et de gestion des signaux
spawn_template_actionse compose des champstype,flags,fd,newfdetarg- S’il faut fermer le descripteur de fichier 4 dans l’enfant,
typeest défini surSPAWN_TEMPLATE_ACTION_CLOSEetfdsur 4 - D’autres actions prennent en charge la duplication de descripteurs de fichiers, l’ouverture de fichiers, le changement de répertoire de travail et la modification de la gestion des signaux
- S’il faut fermer le descripteur de fichier 4 dans l’enfant,
- Une fois les informations d’exécution remplies,
spawn_template_spawn()lance le nouveau processus- avec la signature
int spawn_template_spawn(int template_fd, struct spawn_template_spawn_args *args, int args_size);
- avec la signature
- Son fonctionnement interne reste proche du chemin habituel
fork()/exec() - Toutes les vérifications normales appliquées lors de l’exécution d’un nouveau fichier sont conservées
- Les informations mises en cache dans le modèle permettent d’accélérer l’ensemble du flux de création
- Les benchmarks de la lettre d’accompagnement montrent un gain d’environ 2 %, un chiffre qui peut compter pour les applications correspondant au schéma visé {p:2}
Vers posix_spawn()
- Mateusz Guzik estime que « l’ensemble de l’idiome fork + exec est horrible et devrait disparaître »
- Le point étrange de cette série de patches est qu’elle conserve la partie
fork(), alors que c’est là que se situe l’essentiel du coût - L’optimisation devrait supprimer la copie du processus courant et créer à la place un « processus pristine »
- Christian Brauner considère qu’une API de type builder pour
execn’est « pas si étrange » - Il préfère toutefois une approche où la nouvelle API serait construite au-dessus de l’abstraction existante pidfd
- Même si les détails concrets ne sont pas arrêtés, ajouter à pidfd_open() une option permettant de créer un processus vide semble être la bonne approche
- On appellerait ensuite à plusieurs reprises un nouvel appel système
pidfd_config()pour appliquer au nouveau processus la configuration souhaitée, comme l’environnement ou l’image à exécuter pidfd_config()jouerait un rôle similaire à fsconfig()- Un objectif important de cette nouvelle interface est de permettre en espace utilisateur une implémentation de posix_spawn()
posix_spawn()constitue une bonne alternative au schémafork()/exec()- L’implémentation actuelle masque en interne
fork()etexec(), tandis qu’une implémentation native reposerait sur une structure différente - Li Chen a reconnu que l’API esquissée à grands traits par Brauner semblait meilleure et prévoit d’orienter les travaux futurs dans cette direction
- Les spawn templates n’entreront pas dans le noyau Linux, mais si les travaux futurs aboutissent, Linux pourrait disposer d’une implémentation correcte de
posix_spawn()
1 commentaires
Avis sur Hacker News
Il y a comme discussion connexe l’article A fork() in the road : https://www.microsoft.com/en-us/research/wp-content/uploads/...
Le résumé soutient que, contrairement à l’idée reçue selon laquelle la combinaison Unix
fork()+exec()serait une conception inspirée, il s’agissait d’un hack astucieux pour les machines et programmes des années 1970, mais qu’il s’agit désormais d’une mauvaise abstraction pour les programmeurs modernes et qu’elle contraint aussi l’implémentation des systèmes d’exploitationAu lieu de la conserver comme primitive de premier ordre du système d’exploitation, il faudrait l’enseigner comme un artefact historique et éviter qu’elle soit le premier mécanisme de création de processus appris par les étudiants
fork()+exec()en est arrivé là, c’était pour permettre d’exécuter des programmes trop gros pour tenir en mémoire avec le programme parentL’implémentation d’origine, lors de l’appel à
fork(), swapait sur disque le programme en cours de fork, puis dupliquait et ajustait l’entrée de la table des processus avant de rendre le contrôle, de sorte qu’on obtenait un processus en mémoire et un processus swapé sur disque, et que celui resté en mémoire pouvait prendre le contrôle et appelerexec()Cette méthode permettait d’exécuter de gros programmes même sur de petites machines PDP-11, et c’était nécessaire à une époque où la mémoire coûtait très cher
Fait intéressant, dans QNX, le chargement des programmes ne se fait pas dans le système d’exploitation mais dans une bibliothèque. Elle lit l’en-tête de l’exécutable, alloue la mémoire, charge le programme et le prépare à l’exécution, puis se lie à un
.soqui le démarre, le chargeur de programmes tournant dans l’espace utilisateur non privilégié. C’est peut-être plus proche de la bonne approchefork(), est très lenteJe suis d’accord qu’il faut une primitive autre que
fork(), mais je ne suis pas sûr que la performance soit l’argument principalfork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdffork()est excellent pour le pattern zygoteIl est difficile d’imaginer une optimisation à la fois aussi efficace et élégante
J’ai récemment eu un bug obscur dû au fait qu’il fallait fermer davantage de descripteurs de fichiers dans le processus forké
Dans mon expérience, le cas « je veux une copie du processus actuel » est bien moins fréquent que « je veux un processus entièrement nouveau », et il est étrange qu’on ne puisse pas exprimer directement le second, seulement l’approcher en dupliquant puis en corrigeant après coup
O_CLOEXECne règle pas ce problème ?posix_spawn?Dire que «
fork()est un appel système relativement coûteux, qui doit copier tout l’état du processus enfant, y compris la mémoire. De nombreuses optimisations ont été apportées au fil des ans, mais il reste fondamentalement coûteux. Pire encore,fork()est souvent immédiatement suivi deexec(), ce qui fait que toute la mémoire soigneusement copiée pour l’enfant est finalement jetée » sans mentionner le copy-on-write paraît étrangeC’est justement l’optimisation qui évite de copier toute la mémoire réelle
Même si la mémoire pointée par les pages réelles reste partagée, il faut quand même allouer de nouvelles pages pour contenir des copies de ces structures. Et le simple fait de parcourir et copier toutes ces structures reste coûteux
fork()ne copie pas la mémoire elle-même, mais il faut tout de même copier les tables de pagesPour un processus occupant des dizaines de Go de RAM,
fork()peut prendre beaucoup de temps, et cela se produit à chaque fois que Redis dump un fichier.rdbou réécrit le journal binaire AOFIl y avait déjà en 2012 un billet montrant le coût élevé de cette opération : https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
Sur une
m2.xlargeutilisant environ 25 Go de RAM,fork()prenait 5,67 secondes. Sachant que les clients Redis subissent en général des latences de l’ordre de quelques millisecondes sur la plupart des opérations, c’est une longue pause. Et cela ne couvre que le temps de copie des tables de pagesIl est surprenant de ne pas voir mention des huge pages, qui semblent ici être un facteur clé. Le matériel est sans doute plus rapide 14 ans plus tard, mais les instances Redis utilisent probablement aussi plus de RAM, donc il serait intéressant de refaire ce benchmark
fork()doit en payer le coût de mise en place. Si le processus parent a beaucoup de threads occupés, par exemple en Java, il peut y avoir beaucoup de copy-on-write inutiles avant queexec()ne s’exécuteLe fait que le fork de programmes avec un grand espace mémoire virtuel soit lent est un problème bien connu
L’élégance du modèle
fork()+exec()tient au fait qu’aprèsfork(), on peut utiliser les API habituelles telles quelles pour effectuer toutes sortes de configurationsJusqu’ici, les alternatives à l’appel combiné vues ici m’ont semblé fondamentalement pauvres, parce qu’il faut ajouter toutes les options de configuration comme paramètres d’appel et faire en sorte que cela reste extensible plus tard sans devenir un chaos
fork()/exec()peut être utile dans certains cas, les API pourraient être plutôt correctes si elles acceptaient un argumentpidfd.0pourrait signifier le processus courantLe problème concernerait surtout les binaires
setuid/setgid, mais dans ce cas il vaudrait peut-être mieux traiter cela spécialement dansexecPar exemple, on pourrait créer un processus arrêté avec
pidfd_t ps = spawn();, puis le configurer avecsetuid(ps, 33);,capset(ps, ...);,socket(ps, ...);,mmap(ps, ...);,process_vm_writev(ps, ...);,exec(ps, ...);,signal(ps, SIGCONT);C’est aussi une critique du fait que les API habituelles des appels système prennent insuffisamment en compte la question « et si je veux faire cela sur un autre processus auquel j’ai accès ? ». De cette manière, on pourrait aussi en partie résoudre les questions de sûreté vis-à-vis des threads autour de
fork()En revanche, je suis d’accord pour dire qu’une approche à la
CreateProcess, avec une foule de paramètres, n’est pas une excellente API en espace utilisateurPar exemple, certaines API permettent de faire en sorte qu’un objet devienne le descripteur de fichier numéro 4, puis d’exécuter un programme pour qu’il retrouve cet objet sur le descripteur 4. C’est étrange
Malgré tous ses défauts, Windows n’utilise pas
fork()+exec()et propose surtout des options sur la manière de créer les processus. Ce n’était pas élégant, mais la direction était la bonnefork()+exec()Dans un autre monde où
fork()+exec()n’aurait jamais existé, beaucoup de ces « API générales » auraient eu un argumentpidexplicite permettant de modifier la configuration d’un autre processus. Fuchsia fonctionne à peu près comme çaCe monde a beaucoup d’avantages. Le plus évident, c’est qu’il n’y a pas besoin d’inventer comme par magie un mécanisme IPC séparé pour signaler les erreurs de configuration, et il peut aussi être très utile d’avoir un processus gestionnaire chargé d’ajuster les propriétés de l’enfant. Les débogueurs apprécieraient particulièrement
fork()consiste à faire en sorte que les API générales qui modifient l’état d’un processus prennent un handle de processus expliciteOn pourrait alors configurer un processus vide avec les mêmes API, et les combiner avec d’autres mécanismes comme l’IPC ou le débogage
Si le processus démarrait avec une connexion
ptraceet sans thread, on pourrait forcer l’exécution d’appels système à l’étape de configuration. Linux n’a même pas la notion de « processus sans thread », donc il faudrait sans doute un thread facticeL’idée fausse selon laquelle
fork()serait peu coûteux est étonnamment répandue, alors que c’est en O(N) par rapport à la taille du processus, et cela l’a toujours étéOui, c’est du copy-on-write. Mais il existe une relation linéaire entre la taille du processus et le nombre d’entrées de table de pages nécessaires pour la représenter
Il n’est pas surprenant que le patch de Chen ait été rejeté. Le cas d’usage est trop spécialisé pour justifier sa prise en charge
Du point de vue d’un développeur de shell, je suis d’accord avec la conclusion selon laquelle « il est probable que les développeurs accueillent favorablement une implémentation native qui ne cache pas
fork()etexec()en interne comme l’implémentation actuelle »fork()m’a toujours semblé conceptuellement affreux, dès la première fois que je l’ai appris. Si l’on veut accomplir une seule tâche, à savoir démarrer un processus, on ne devrait pas avoir à passer par une incantation énigmatique consistant à forker le processus courant, ce qui est une autre opération sans rapportComme dans l’exemple de l’article, je me demande quelle est la meilleure manière de gérer le cas où un processus lance de nombreux sous-processus
git. Redémarrergitencore et encore depuis zéro au cours d’un travail parent de longue durée ne semble pas avoir de sens ; quelle abstraction peu coûteuse permettrait d’obtenir le même résultat ?fork()est conceptuellement simple. Si l’on n’introduit pas d’autre couche, on démarre un processus à partir de la seule chose dont on soit certain qu’elle existe : soi-mêmeSinon, il faut plusieurs étapes pour créer un processus, le remplir avec quelque chose à exécuter, puis le faire démarrer. Ou bien il faut, comme sous Win32, fusionner de façon permanente d’autres couches comme le système de fichiers, le chargeur d’objets et l’éditeur de liens
fork()+exec()ne m’a jamais semblé avoir de sens. Maintenant je sais que c’est juste une bizarrerie historique, mais il y a encore des gens qui font comme sifork()+exec()était réellement une bonne idéelibgit2. On peut imaginer un fonctionnement où l’on communiquerait avec une sorte degitdvia des pipes ou des sockets, mais je ne vois pas pourquoi ce serait une bonne idée. Sinon, il faut lancer un processusS’il est difficile de remplacer
exec/fork, c’est parce qu’il faut généralement configurer le nouveau processus. Par exemple, il faut régler les gestionnaires de signaux, fermer ou ouvrir des descripteurs de fichier, changer de namespace, configurerseccomp, ajuster les privilègesOr les appels système destinés à cela ne s’appliquent actuellement qu’au processus courant, donc il faut un moyen de remplacement. La proposition de l’article consistait à créer une nouvelle API pour cela
À mon avis, un nouvel appel système comme
spawnpourrait créer un processus vide, y charger un loader léger, puis lui transmettre des données de configuration arbitraires. Le loader configurerait le processus puis lancerait le programme principal viaexec()Cela permettrait de conserver les API existantes sans forker la mémoire, mais il faudrait tout de même dupliquer les descripteurs de fichier et d’autres éléments
Désolé si ce n’était pas une blague, mais
posix_spawn()existe déjà et, dans glibc,forkn’est qu’un alias declone()Même si ce n’est pas exactement la même chose que la proposition initiale,
fork()/exec()relève vraiment presque du legacySi
forketexecpouvaient avoir un comportement persistant et algébrique au-delà de leur nature copy-on-write, ce serait non seulement plus utile, mais aussi plus intéressant à utiliser. Par exemple, cela pourrait servir à l’évaluation paresseuseIl y a eu beaucoup de discussions sur cette ancienne API sur Hacker News, par exemple https://news.ycombinator.com/item?id=31739794