2 points par GN⁺ 3 시간 전 | 1 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
    Publicité
  • 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()

1 commentaires

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