Checklist pour gérer les processus enfants en toute sécurité dans une application Windows

· Mis à jour le: · · Windows, Processus, Job Object, IPC, C++, .NET, C#

Télécharger la checklist Excel avec feuilles en japonais et en anglais

Outils de conversion, updaters, workers d’analyse, CLI externes, PowerShell, ffmpeg, utilitaires internes. Une application Windows en vient à dépendre de processus enfants bien plus facilement qu’on ne le pense.

Mais les accidents ne portent pas sur « est-ce que le lancement a réussi ».

  • Le parent tombe, mais l’enfant reste
  • Seul un processus petit-enfant survit
  • stdout / stderr se bloque et WaitForExit ne revient jamais
  • Le watchdog meurt avec ce qu’il surveille
  • Vous pensiez que Kill(entireProcessTree: true) avait terminé le travail, mais seule l’observation s’est terminée en premier

La clé pour gérer les processus enfants en toute sécurité sous Windows n’est pas de choisir l’API de lancement, mais de décider qui possède l’arbre des processus et de concevoir la procédure de fin ainsi que les E/S.

Dans cet article, nous organisons le Job Object, la propagation de fin, les entrées-sorties standard et le watchdog en une seule conception cohérente.

1. D’abord la conclusion

Voici d’abord, listés directement, les points qui comptent le plus en pratique.

  • Si vous voulez lier la durée de vie de l’arbre des processus enfants à celle du parent, le point de référence est le Job Object
  • Demander la fin à la console et récupérer l’arbre des processus sont deux choses différentes
    • La première relève du process group et de GenerateConsoleCtrlEvent
    • La seconde relève du Job Object
  • Si vous voulez intégrer les processus au Job dès le lancement, la conception naturelle consiste à utiliser STARTUPINFOEX et PROC_THREAD_ATTRIBUTE_JOB_LIST
  • La règle de base est de drainer la sortie standard et l’erreur standard en parallèle
  • Si vous utilisez stdin, concevez jusqu’à fermer le flux après l’écriture pour transmettre l’EOF
  • Il est plus sûr de placer le watchdog en dehors du Job qu’il surveille
  • Le Kill(entireProcessTree: true) de .NET est pratique comme API d’arrêt explicite, mais il ne remplace pas une conception incluant la récupération automatique en cas de crash du parent et un arrêt gracieux (graceful shutdown)

2. Ce qui pose vraiment problème

L’implémentation du lancement d’un processus enfant tient généralement en une dizaine de lignes au départ. Mais les accidents se produisent en dehors de ces dix lignes.

  • Après la mort du parent, les enfants et petits-enfants continuent de tourner
  • Un helper lance un autre helper, et l’on se contente d’attendre seulement l’enfant direct en pensant que c’est terminé
  • Un côté de stdout / stderr se bloque, et le parent comme l’enfant finissent par s’attendre mutuellement
  • On attend sur le thread UI, et à la fois l’écran et COM se figent
  • Le watchdog partage le même destin que ce qu’il surveille, et tombe avec lui en cas d’anomalie

Ce qu’il faut retenir ici, c’est que « la gestion des processus enfants » n’est pas l’affaire d’une seule API.

Il est plus clair de distinguer au minimum ces quatre aspects.

  1. Qui possède l’arbre des processus
  2. Comment demander l’arrêt coopératif
  3. Comment faire circuler les entrées-sorties standard
  4. Comment surveiller les fins anormales et les blocages

3. Ne pas confondre le rôle de chaque mécanisme

Le process handle, le process group et le Job Object se ressemblent mais jouent des rôles différents.

Mécanisme Rôle principal Adapté à Ce qu’il ne couvre pas à lui seul
Process handle Attendre la fin d’un processus, récupérer l’exit code Attendre la fin d’un outil ponctuel La récupération des processus petits-enfants
Process group Propager Ctrl+Break vers une console L’arrêt coopératif d’un enfant console Le nettoyage lors d’un crash du parent, les processus enfants GUI
Job Object Regrouper l’arbre de processus, appliquer des limites, tout terminer d’un coup Les arbres de workers, les updaters, les chaînes de helpers Le comportement spécifique à l’appli « sauvegarder avant de fermer »

Un process group est un mécanisme qui décide où envoyer un signal à la console, pas un mécanisme pour nettoyer tout l’arbre quand le parent meurt. Le Job Object, lui, est le mécanisme propre à Windows pour gérer un groupe de processus comme une seule unité.

4. Faire du Job Object le point de référence

Le plus grand atout du Job Object est de pouvoir regrouper le process tree selon « à quel Job il appartient », et non « de qui il est l’enfant ». Les enfants créés via CreateProcess par un processus appartenant à un Job rejoignent ce Job par défaut.

De plus, en ajoutant JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, tous les processus associés au Job se terminent lorsque le dernier job handle est fermé.

4.1 Les quatre points à retenir en premier

1. Si vous voulez nettoyer tout l’arbre à la fin du parent, utilisez KILL_ON_JOB_CLOSE

C’est la base pour gérer des helpers/workers dans une application Windows. Une conception qui appelle explicitement TerminateJobObject fonctionne aussi, mais si vous voulez rattacher le cleanup à la durée de vie du parent, y compris en cas de fin anormale, KILL_ON_JOB_CLOSE est la solution la plus claire.

2. Ne pas ajouter BREAKAWAY à la légère

JOB_OBJECT_LIMIT_BREAKAWAY_OK et JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK semblent pratiques, mais ils peuvent aussi faire qu’une partie de l’arbre que vous pensiez pouvoir nettoyer s’en échappe. Sauf intention délibérée, laisser breakaway désactivé réduit le taux d’incidents.

3. Si vous voulez intégrer le Job dès le lancement, utilisez PROC_THREAD_ATTRIBUTE_JOB_LIST

Vous pouvez aussi rattacher un processus après coup avec AssignProcessToJobObject. Mais dans les cas où vous voulez supposer l’appartenance au Job dès l’instant du lancement, il est plus judicieux de spécifier le Job à la création via STARTUPINFOEX et PROC_THREAD_ATTRIBUTE_JOB_LIST.

4. Ne pas laisser flou le propriétaire du job handle

KILL_ON_JOB_CLOSE s’active quand le dernier handle est fermé. Autrement dit, si le job handle est dupliqué vers un autre processus ou hérité sans intention, le cleanup ne se produira pas comme prévu même si le parent meurt. Qui est le propriétaire final du job handle doit être décidé à l’avance.

4.2 Le Job Object sert aussi à l’observabilité, mais les notifications ne sont pas infaillibles

Le Job Object peut être associé à un I/O completion port pour recevoir des notifications. Toutefois, il est plus sûr de ne pas considérer les notifications de completion port comme totalement garanties dans tous les cas.

Les completion ports sont donc pratiques pour :

  • la surveillance
  • l’agrégation
  • la journalisation
  • les métriques

mais il vaut mieux ne pas construire la correction (correctness) sur elles seules.

5. Concevoir la propagation de fin comme un protocole assorti d’un timeout

La fin d’un processus enfant n’est pas une affaire réglée par un seul appel à une API de kill. Le schéma le moins sujet aux incidents suit ces trois étapes.

  1. Demander l’arrêt coopératif
  2. Attendre avec un court timeout
  3. Enfin, terminer le Job entier de force

En respectant cet ordre, vous préservez le chemin de fin normal tout en récupérant l’arbre en cas de blocage.

5.1 Enfant GUI

Pour un processus enfant doté d’une GUI, en .NET, CloseMainWindow envoie le message de fermeture. Mais il s’agit d’une demande de fin, pas d’un arrêt forcé. Le déroulement naturel est donc :

  • CloseMainWindow
  • attendre un certain temps
  • si cela échoue, tuer tout le Job

5.2 Enfant console

Pour un enfant console, le close message de la GUI n’est pas disponible. Dans ce cas, on utilise le process group et les signaux de console.

Le déroulement consiste à lancer avec CREATE_NEW_PROCESS_GROUP, puis à envoyer CTRL_BREAK_EVENT via GenerateConsoleCtrlEvent. Les points importants ici sont :

  • CTRL_C_EVENT ne se prête pas bien au ciblage d’un groupe spécifique
  • seuls les processus qui partagent la console peuvent recevoir le signal
  • utiliser CREATE_NEW_PROCESS_GROUP change aussi la signification de CTRL+C

5.3 Worker / enfant headless

Les workers et les enfants headless ne sont souvent ni GUI ni console. Dans ce cas, il est plus sûr de disposer d’un protocole de fin dédié au processus enfant.

  • envoyer quit sur stdin
  • envoyer une commande d’arrêt via un named pipe / socket / RPC
  • transmettre la demande d’arrêt via un event object

Côté Windows, le Job Object prend en charge le nettoyage de l’arbre ; côté application, le pipe ou stdin prend en charge l’arrêt gracieux (graceful shutdown). Cette séparation limite les incidents.

6. Ne pas laisser les entrées-sorties standard se bloquer

6.1 Drainer stdout / stderr en parallèle

La première règle de base est celle-ci. Drainez stdout et stderr en parallèle. Lire entièrement un côté avant l’autre se bloque facilement.

Les pipes Windows n’ont pas de buffer infini. Si l’enfant produit une grosse quantité de sortie sur stderr alors que le parent ne lit que stdout, il est courant que l’enfant se bloque sur l’écriture et que le parent se bloque en attendant la fin du processus.

6.2 Si vous utilisez stdin, concevez jusqu’à l’EOF

Pouvoir écrire dans stdin et le fait que l’enfant puisse se terminer ne sont pas la même chose.

  • Vous écrivez l’entrée puis ne fermez pas le flux
  • Le parent pense « j’ai déjà tout transmis »
  • L’enfant pense « la suite va encore arriver » et continue d’attendre

C’est une situation qui se produit. Si vous utilisez stdin, la conception doit inclure la fermeture du flux après l’écriture, afin que l’EOF soit transmis.

6.3 Toujours fermer les extrémités de pipe inutilisées

Si les extrémités inutilisées, côté parent comme côté enfant, ne sont pas fermées, l’EOF ne se propage pas et les conditions de fin s’effondrent. C’est simple, mais c’est un incident étonnamment fréquent en pratique.

6.4 Ne pas rester flou sur UseShellExecute=false et l’héritage des handles

Si vous utilisez la redirection des entrées-sorties standard, .NET exige UseShellExecute=false. En Win32 aussi, il est plus sûr de limiter autant que possible ce qui est hérité. Laisser bInheritHandles=TRUE et tout hériter est une source de fuites de handles (handle leak) inattendues.

7. Placer le watchdog « à l’extérieur »

Le point le plus important lorsqu’on ajoute un watchdog est de ne pas le placer dans le même Job que ce qu’il surveille. Si vous voulez redémarrer un worker lorsqu’il tombe, cela n’a aucun sens que l’agent de redémarrage meure lui aussi avec lui.

7.1 Baser la surveillance de fin sur des wait handles

Un processus passe à l’état signalé (signaled) lorsqu’il se termine. La surveillance de la fin n’a donc en réalité pas besoin d’une boucle de polling vérifiant HasExited toutes les 100 ms.

En Win32, la bonne approche consiste à utiliser :

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

Si vous gérez plusieurs enfants, une surveillance basée sur des wait handles est plus naturelle qu’un polling par timer.

7.2 Ne pas attendre indéfiniment sur le thread UI

WaitForSingleObject(INFINITE) est pratique, mais utilisé sur un thread possédant une fenêtre, il bloque facilement le message pump. Sur les threads UI, les threads d’appartement COM (COM apartment thread) et les threads dotés d’un message pump, il est plus sûr de réfléchir d’abord à où placer l’attente.

7.3 Un watchdog de blocage a besoin d’un heartbeat

Pour un watchdog de fin de processus (exit watchdog), le process handle suffit. Mais un watchdog de blocage (hang watchdog) est différent.

  • Bloqué à 100 % de CPU
  • En deadlock
  • La event loop est vivante mais ne progresse pas
  • Arrêté en attente d’une entrée

Ces états ne peuvent pas être détectés par le seul critère « le processus est-il vivant ». Si vous voulez aussi détecter les blocages, il faut des vérifications de vivacité au niveau applicatif, comme :

  • un heartbeat
  • une séquence de progression
  • un horodatage du dernier travail réussi
  • une health probe

7.4 Placer l’agent de redémarrage en dehors de ce qu’il surveille

En pratique, on rencontre couramment ces deux schémas.

  • L’application parente lance un helper seulement de manière temporaire
    • Le parent possède le Job ; sa fin récupère l’arbre du helper
  • Un worker reste résident longtemps, et on veut le redémarrer s’il tombe
    • Un processus ou service watchdog externe crée un Job par génération de worker

Dans le second cas, séparer l’arbre du worker de l’autorité de redémarrage rend la conception plus stable.

7.5 Gérer la politique de redémarrage sous forme de budget

Une fois le watchdog en place, le risque suivant est le démarrage d’une crash loop.

  • Redémarrage immédiat
  • Nouveau crash immédiat
  • Seuls les logs s’accumulent en masse

Pour éviter cela, il vaut mieux disposer d’un budget de redémarrage (restart budget) comprenant :

  • un backoff
  • un plafond du nombre de redémarrages sur une fenêtre de temps donnée
  • un arrêt avec notification en cas d’échecs consécutifs

8. Configurations recommandées par scénario type

Scénario Configuration recommandée
Une application de bureau lance un helper CLI ponctuel 1 lancement = 1 Job. Ajouter KILL_ON_JOB_CLOSE et drainer stdout / stderr en parallèle. À l’annulation : arrêt coopératif → timeout → Job kill
Le helper lance à son tour des processus petits-enfants Partir du principe du Job Object et ne pas autoriser le breakaway. Pour fixer l’appartenance dès le lancement, utiliser PROC_THREAD_ATTRIBUTE_JOB_LIST
Un service / watchdog surveille un arbre de workers de longue durée Le watchdog est un processus / service externe. Créer un Job par génération de worker et surveiller via exit handle + heartbeat
Vous voulez arrêter un outil console proprement Lancer avec CREATE_NEW_PROCESS_GROUP, arrêt coopératif via CTRL_BREAK_EVENT, puis Job kill après timeout
Vous voulez fermer un helper GUI CloseMainWindow / l’équivalent de WM_CLOSE → timeout → Job kill
Vous voulez surveiller de nombreux processus enfants Plutôt que d’ajouter des blocking threads, utiliser RegisterWaitForSingleObject / SetThreadpoolWait

Le plus important ici est de séparer le mécanisme d’arrêt gracieux (graceful shutdown) du mécanisme de nettoyage (cleanup).

9. Ce qu’il ne faut pas faire

  • Penser que Kill(entireProcessTree: true) seul résout l’arrêt gracieux et la récupération lors d’un crash du parent
  • Hériter de tout en laissant bInheritHandles=TRUE
  • Lire tout stdout avant de lire stderr
  • Ne pas fermer les extrémités de pipe inutilisées
  • Appeler WaitForSingleObject(INFINITE) sur le thread UI
  • Placer le watchdog dans le même Job que ce qu’il surveille
  • Utiliser 259 comme exit code ordinaire
  • Faire des notifications du Job completion port l’unique source de vérité

10. Conclusion

Pour gérer les processus enfants en toute sécurité dans une application Windows, la structuration qui aide le plus est celle-ci :

Qui possède le process tree ? Comment la demande de fin est-elle transmise ? Comment les entrées-sorties standard sont-elles drainées jusqu’au bout ? Où le watchdog se trouve-t-il ?

Décidez d’abord ces quatre points.

Ensuite, pour le dire sans détour :

  • Le point de référence du nettoyage de l’arbre est le Job Object
  • L’arrêt gracieux se répartit selon GUI / console / worker
  • Les E/S standard se conçoivent en incluant le drainage parallèle et l’EOF
  • Le watchdog se place en dehors de ce qu’il surveille, et se surveille via des wait handles et des heartbeats plutôt que par polling

CreateProcess et Process.Start eux-mêmes ne sont qu’un point d’entrée. Ce qui influe réellement sur le taux d’incidents, c’est la localisation de la responsabilité de la fin et le drainage complet des E/S.

11. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Développement d'applications Windows

Dans les applications Windows qui pilotent des CLI externes, des outils de conversion, des workers et des updaters, la stabilité dépend davantage de la gestion de l'arbre des processus et de la conception de la fin de processus que de la méthode de lancement.

Analyse des bugs et des causes

Les incidents opérationnels difficiles à reproduire — un enfant qui survit après le crash du parent, un stdout qui se bloque, un watchdog qui tombe avec ce qu'il surveille — s'améliorent souvent en revoyant la conception de la gestion des processus.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Pourquoi le processus enfant reste-t-il actif après le crash du processus parent ?
Parce qu'un process handle ou un process group seul ne dispose d'aucun mécanisme pour récupérer l'arbre de processus lors d'un crash du parent. Si vous voulez lier la durée de vie de l'arbre des processus enfants à celle du parent, le point de référence est le Job Object. En ajoutant JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, tous les processus appartenant au Job se terminent lorsque le dernier job handle est fermé, ce qui permet de rattacher le nettoyage (cleanup) à la durée de vie du parent, y compris en cas de fin anormale de ce dernier.
Pourquoi WaitForExit ne retourne-t-il jamais ?
Il est très probable que le pipe de la sortie standard ou de l'erreur standard soit bloqué. Les pipes Windows n'ont pas de buffer infini : si l'enfant écrit massivement sur stderr alors que le parent ne lit que stdout, l'enfant se bloque sur l'écriture et le parent se bloque en attendant la fin du processus. La règle de base est de drainer stdout et stderr en parallèle ; une implémentation qui lit entièrement un côté avant l'autre se bloque facilement. Par ailleurs, si vous ne fermez pas les extrémités inutilisées des pipes, l'EOF ne se propage pas et les conditions de fin de processus s'effondrent.
Le Kill(entireProcessTree: true) de .NET ne suffit-il pas à lui seul ?
Non, ce n'est pas suffisant. C'est pratique comme API d'arrêt explicite, mais cela ne remplace pas une conception incluant la récupération automatique en cas de crash du parent et un arrêt gracieux (graceful shutdown). L'approche la moins sujette aux incidents comporte trois étapes : demander un arrêt coopératif, attendre avec un court timeout, puis terminer le Job entier de force en dernier recours. Le moyen de demander l'arrêt coopératif dépend du type d'enfant : CloseMainWindow pour un enfant GUI, CREATE_NEW_PROCESS_GROUP associé à CTRL_BREAK_EVENT pour un enfant console, et un protocole de fin dédié via stdin ou un pipe pour un worker.
Où faut-il placer le processus watchdog ?
Le plus important est de ne pas le placer dans le même Job que ce qu'il surveille. Si vous voulez redémarrer un worker lorsqu'il tombe, cela n'a aucun sens que l'agent de redémarrage meure en même temps que lui. Pour un worker qui reste résident longtemps, une configuration stable consiste à faire créer un Job par génération de worker par un processus ou service watchdog externe. La surveillance de la fin de processus doit reposer sur des wait handles plutôt que sur du polling, et si vous avez besoin de détecter les blocages (hang), combinez-la avec une vérification de vivacité au niveau applicatif, comme un heartbeat.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog