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()
3 commentaires
Avis sur Lobste.rs
Je ne comprends pas très bien pourquoi fork+exec existe à l’origine
Sans bien connaître le développement noyau, si je devais concevoir des appels système de gestion des processus, j’aurais sans doute pensé que le cas général était de « créer un nouveau processus fils propre », et que « copier le processus en cours d’exécution » relevait plutôt du cas particulier.
Ce dernier semble surtout utile pour des cas comme des serveurs web qui lancent de nombreuses copies d’un même processus, donc je suis curieux du contexte historique qui explique pourquoi fork a été conçu ainsi.
fork()+exec()est détaillé dans un fil sur le Site OrangeSur beaucoup de systèmes, il peut y avoir plus de créations de sous-processus et de threads que d’appels à
execve().Je ne connais pas l’histoire ancienne, mais la sémantique elle-même est bien antérieure à Linux et, d’un point de vue logique, puisqu’il fallait de toute façon un
fork(), construire par-dessus semblait plus simple et plus composable qu’une API distincte du typeexec_in_a_new_process()qui dupliquerait une partie de la logique de création de processus.De plus, cette séparation permet d’utiliser
execve()sansfork()pour remplacer le processus courant par un autre programme, ce qui constitue parfois une optimisation acceptable.Autrement dit, si le travail est terminé et qu’on veut passer le relais à un autre programme, on peut le faire sans continuer à occuper une entrée de table des processus ni de la mémoire.
Dans les applications sensibles aux performances, même un simple
fork()ouexecve()peut déjà être trop lent, donc une API plus concise ne résoudrait pas tous les problèmes.C’est pour cela qu’on voit apparaître des optimisations comme le processus Zygote pré-initialisé d’Android, ou des workers de serveurs web pré-forkés, et dans ce genre d’approche un seul changement de contexte suffit.
exec, des éléments comme l’environnement, le groupe, l’utilisateur, les descripteurs de fichiers ou le masque de signaux.Quand on regarde
posix_spawn, l’API moderne pour « créer un nouveau processus fils propre », on voit des dizaines de drapeaux et d’options, et c’est beaucoup plus difficile à faire évoluer.forkseul, c’est le sous-shellQuand un sous-shell est créé depuis le shell parent, il a exactement le même environnement, donc c’est en pratique un
fork().Quand
asha été porté vers Windows, il a fallu sérialiser toutes les données vers le nouveau processus de sous-shell.Pour les contenus LWN, il vaudrait peut-être mieux attendre une ou deux semaines avant de les publier
Ils méritent d’avoir des abonnés, et les abonnés méritent eux aussi de profiter de bonnes choses.
Un avantage de
fork()est que les capacités des processus peuvent s’étendre sans que tous les programmes aient à modifier leur code de création de processusPar exemple, un processus peut recevoir une notion de répertoire racine avec
chroot, un terminal de contrôle avec le contrôle des jobs, ou encore des espaces de noms réseau.Avec
fork, le nouveau processus hérite naturellement des nouvelles capacités de son parent, donc les nouvelles fonctionnalités continuent de fonctionner de manière cohérente même dans les anciens programmes.Seuls les programmes qui manipulent explicitement ces nouvelles fonctions ont besoin de les connaître.
À l’inverse, si un nouveau processus était réellement vide et que tout devait être configuré via quelque chose comme
pidfd_config(), alors tous les programmes qui créent d’autres processus devraient être mis à jour pour régler correctement chaque nouvelle fonctionnalité.Dans un système non-Unix de ce type, il faudrait peut-être un objet concret, séparé du processus, pour porter au sens large l’environnement de processus, y compris des choses comme le répertoire de travail, les espaces de noms, le propriétaire et le groupe.
Mais dans ce cas, il deviendrait difficile de tracer la frontière entre ce qui relève de l’état de l’environnement et ce qui relève de l’état du processus.
On pourrait même se demander si l’environnement ne devrait pas être, non pas une partie héritée de l’état du processus comme sous Unix, mais un conteneur partagé entre plusieurs processus.
On pourrait sans doute faire mieux que tout cela avec
clone/execLe cœur du problème avec
fork, c’est la rupture du partage mémoire, et ce modèle s’adapte mal quand on a des threads et beaucoup d’entrées de table de pages.Faire un exec depuis un thread à mémoire partagée résout une bonne partie du problème.
Il faudrait probablement faire quelques expérimentations.
C’est une optimisation au niveau du noyau qui permet apparemment de créer rapidement des processus terminaux comme
child_process.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