17 points par GN⁺ 2026-06-07 | 3 commentaires | Partager sur WhatsApp
  • 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 execfd ou via le chemin absolu filename, 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 de posix_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, et exec() 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 par exec(), et exec() jette alors toute la mémoire copiée pour l’enfant
  • Des tentatives d’optimisation comme vfork() ont existé, mais le schéma fork() puis exec() 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() et exec() 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);
  • 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 absolu filename, 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_args
    • argv est un pointeur vers la liste des arguments transmis au programme
    • envp est un pointeur vers l’environnement du programme
    • actions est un pointeur vers un tableau de spawn_template_action servant à transmettre les modifications de descripteurs de fichiers et de gestion des signaux
  • spawn_template_action se compose des champs type, flags, fd, newfd et arg
    • S’il faut fermer le descripteur de fichier 4 dans l’enfant, type est défini sur SPAWN_TEMPLATE_ACTION_CLOSE et fd sur 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
  • 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);
  • 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 exec n’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éma fork()/exec()
  • L’implémentation actuelle masque en interne fork() et exec(), 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

 
GN⁺ 2026-06-08
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.

    • L’historique de fork()+exec() est détaillé dans un fil sur le Site Orange
    • Tout dépend sans doute de la façon dont on définit « général »
      Sur 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 type exec_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() sans fork() 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() ou execve() 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.
    • Je ne sais pas si c’était la raison d’origine ou un avantage découvert après coup, mais partir d’une copie permet de modifier librement, avant l’appel à 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.
    • Un exemple d’usage de fork seul, c’est le sous-shell
      Quand un sous-shell est créé depuis le shell parent, il a exactement le même environnement, donc c’est en pratique un fork().
      Quand ash a é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.

    • Quand un article LWN est lié sur Lobsters, est-il plus probable qu’une personne déjà abonnée, ou qui envisageait de s’abonner, décide finalement de ne pas payer, ou qu’au contraire davantage de gens trouvent le contenu intéressant et envisagent un abonnement à LWN ?
  • 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 processus
    Par 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/exec
    Le 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.

 
shakespeares 2026-06-07

C’est une optimisation au niveau du noyau qui permet apparemment de créer rapidement des processus terminaux comme child_process.

 
GN⁺ 2026-06-07
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’exploitation
    Au 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

    • Si 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 parent
      L’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 appeler exec()
      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 .so qui le démarre, le chargeur de programmes tournant dans l’espace utilisateur non privilégié. C’est peut-être plus proche de la bonne approche
    • Il est intéressant de noter que la création de processus sous Windows, le système d’exploitation « grand public » le plus utilisé qui n’utilise pas fork(), est très lente
      Je suis d’accord qu’il faut une primitive autre que fork(), mais je ne suis pas sûr que la performance soit l’argument principal
    • Cet article est bon, et la référence [29] l’est particulièrement aussi, car elle traite des aspects subtils des interfaces extensibles incluant fork() : The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf
    • La discussion de l’époque est ici : https://news.ycombinator.com/item?id=19621799 - A fork() in the road (2019-04-10, 178 comments)
    • fork() est excellent pour le pattern zygote
      Il 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

    • En général, on veut communiquer avec ce processus, donc il faut par exemple configurer des choses comme des descripteurs de fichiers et lui transmettre des informations du processus parent
    • Est-ce que O_CLOEXEC ne règle pas ce problème ?
    • Si par « une façon d’exprimer directement le second » on entend cela, n’est-ce pas justement le rôle de posix_spawn ?
    • Que signifie exactement « un processus entièrement nouveau » ?
  • 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 de exec(), 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 étrange
    C’est justement l’optimisation qui évite de copier toute la mémoire réelle

    • L’article le traite implicitement, mais ici la copie de l’état du processus désigne les structures de gestion mémoire. Principalement les tables de pages et les VMA
      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
    • Redis est un type de processus pour lequel ce coût est particulièrement important. fork() ne copie pas la mémoire elle-même, mais il faut tout de même copier les tables de pages
      Pour 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 .rdb ou réécrit le journal binaire AOF
      Il 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.xlarge utilisant 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 pages
      Il 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
    • Pour le lectorat visé par ce type d’article, le copy-on-write est probablement considéré comme une connaissance de base, d’où l’omission
    • Même avec le copy-on-write, 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 que exec() ne s’exécute
    • Le texte parlait d’« état ». Même avec le copy-on-write, on ne copie pas le contenu, mais il reste un coût proportionnel au nombre d’entrées de table de pages
      Le 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ès fork(), on peut utiliser les API habituelles telles quelles pour effectuer toutes sortes de configurations
    Jusqu’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

    • Je ne suis pas tout à fait d’accord, mais j’y vois un intérêt. Même si fork()/exec() peut être utile dans certains cas, les API pourraient être plutôt correctes si elles acceptaient un argument pidfd. 0 pourrait signifier le processus courant
      Le problème concernerait surtout les binaires setuid/setgid, mais dans ce cas il vaudrait peut-être mieux traiter cela spécialement dans exec
      Par exemple, on pourrait créer un processus arrêté avec pidfd_t ps = spawn();, puis le configurer avec setuid(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 utilisateur
    • Je pense exactement l’inverse. La grande erreur du modèle à la UNIX, c’est qu’une trop grande quantité d’état est conservée lors de la création d’un processus
      Par 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 bonne
    • Appeler cela élégant relève de la dépendance au chemin historique de fork()+exec()
      Dans un autre monde où fork()+exec() n’aurait jamais existé, beaucoup de ces « API générales » auraient eu un argument pid explicite permettant de modifier la configuration d’un autre processus. Fuchsia fonctionne à peu près comme ça
      Ce 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
    • La bonne manière de supprimer fork() consiste à faire en sorte que les API générales qui modifient l’état d’un processus prennent un handle de processus explicite
      On 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
    • L’ordre devrait être spawn, configure, exec
      Si le processus démarrait avec une connexion ptrace et 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 factice
  • L’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() et exec() en interne comme l’implémentation actuelle »

    • Il semble y avoir de l’intérêt non pas pour une implémentation spécifique, mais pour le concept lui-même
  • 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 rapport
    Comme 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émarrer git encore 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ême
      Sinon, 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
    • En venant de Windows, le modèle 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 si fork()+exec() était réellement une bonne idée
    • Il y a libgit2. On peut imaginer un fonctionnement où l’on communiquerait avec une sorte de gitd via des pipes ou des sockets, mais je ne vois pas pourquoi ce serait une bonne idée. Sinon, il faut lancer un processus
  • S’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, configurer seccomp, ajuster les privilèges
    Or 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 spawn pourrait 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 via exec()
    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

    • Heureusement, quelqu’un semble avoir pris une machine à remonter le temps pour lire cet article et ajouter ça à POSIX.1-2001 :)
      Désolé si ce n’était pas une blague, mais posix_spawn() existe déjà et, dans glibc, fork n’est qu’un alias de clone()
      Même si ce n’est pas exactement la même chose que la proposition initiale, fork()/exec() relève vraiment presque du legacy
  • Si fork et exec pouvaient 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 paresseuse

  • Il y a eu beaucoup de discussions sur cette ancienne API sur Hacker News, par exemple https://news.ycombinator.com/item?id=31739794