Excel-Checkliste mit japanisch-englischem Arbeitsblatt herunterladen
Konvertierungstools, Updater, Analyse-Worker, externe CLIs, PowerShell, ffmpeg, interne Utilities. Windows-Apps geraten leichter als gedacht in eine Abhängigkeit von Kindprozessen.
Der Unfall passiert aber nicht bei der Frage „konnte gestartet werden“.
- Der Elternprozess stirbt, doch das Kind bleibt zurück
- Nur ein Enkelprozess überlebt
stdout/stderrstockt, undWaitForExitkehrt nicht zurück- Der Watchdog stirbt zusammen mit seinem Überwachungsziel
- Man glaubte, mit
Kill(entireProcessTree: true)sei alles erledigt, doch nur die Beobachtung endete zuerst
Der Trick beim sicheren Umgang mit Kindprozessen unter Windows ist nicht, eine Start-API auszuwählen, sondern den Besitzer des Prozessbaums zu bestimmen und das Beendigungsverfahren sowie die I/O zu entwerfen.
Dieser Artikel ordnet Job Object, Exit-Propagation, Standard-I/O und Watchdog als ein zusammenhängendes Design ein.
In diesem Artikel verwendete Begriffe
Begriffe, die im Englischen belassen werden, ordnen wir vorab jeweils in einer Zeile ein.
| Begriff | Bedeutung in einem Satz |
|---|---|
| process tree | Prozessbaum. Die vom Elternprozess gestarteten Kinder bis hin zu den von diesen gestarteten Enkeln — die gesamte Familie |
| graceful shutdown | Kooperative Beendigung. Man bittet „bitte beenden“, das Gegenüber räumt selbst auf und beendet sich dann. Das Gegenteil von Zwangsbeendigung |
| I/O completion port | Der Mechanismus für asynchrone I/O-Abschlussbenachrichtigungen unter Windows. An ein Job Object gebunden, empfängt man darüber Benachrichtigungen über Start und Ende von Prozessen |
| message pump | Die Nachrichtenschleife. Der Mechanismus, mit dem ein Thread mit einem Fenster fortlaufend Nachrichten vom Betriebssystem entnimmt und verarbeitet. Bleibt sie stehen, friert der Bildschirm ein |
| heartbeat | Ein Signal, das ein Kindprozess regelmäßig zur Lebendprüfung aussendet. Wird verwendet, um den Zustand „lebt, kommt aber nicht voran“ zu erkennen |
| restart budget | Das Neustart-Budget. Die Obergrenze, wie oft innerhalb einer bestimmten Zeit neu gestartet werden darf. Dient dazu, eine Crash Loop zu stoppen |
| drain | Abschöpfen. Die in einer Pipe angesammelte Ausgabe vollständig lesen, damit die schreibende Seite nicht blockiert |
Das Gesamtbild
Zuerst halten wir die Beziehungen der Akteure in einem Bild fest.
flowchart TB
W["watchdog<br/>außerhalb des Jobs platziert"]
subgraph JOB["Job Object mit JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
P["Eltern-App / Worker-Hauptteil<br/>letzter Besitzer des Job-Handles"]
C["Kind helper.exe"]
G1["Enkel converter.exe"]
G2["Enkel ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"erkennt Beendigung über Exit-Handle"| P
W -.->|"erkennt Hänger über Heartbeat"| P
W -.->|"baut innerhalb des restart budget neu auf"| JOB
Zwei Punkte sind entscheidend.
- Die Grenze des Jobs ist die Grenze des Prozessbaums. Gebündelt wird nach Job-Zugehörigkeit, nicht nach Leben oder Tod des Elternprozesses, sodass auch bei zunehmenden Enkeln nichts beim Einsammeln übersehen wird
- Nur der Watchdog steht außerhalb des Jobs. Steckt man ihn hinein, wird er zusammen mit dem Überwachungsziel entsorgt
1. Das Fazit zuerst
Zuerst nur das, was in der Praxis am meisten bringt.
- Wenn Sie die Lebensdauer des Kindprozessbaums an Leben und Tod des Elternprozesses koppeln wollen, ist das Job Object der Bezugspunkt
- Eine Beendigungsanfrage an die Konsole und das Einsammeln des Prozessbaums sind zwei verschiedene Dinge
- Ersteres sind Process Group und
GenerateConsoleCtrlEvent - Letzteres ist das Job Object
- Ersteres sind Process Group und
- Wenn Sie Prozesse schon ab dem Start in den Job aufnehmen wollen, ist ein Design mit
STARTUPINFOEXundPROC_THREAD_ATTRIBUTE_JOB_LISTdie naheliegende Wahl - Standardausgabe / Standardfehler grundsätzlich parallel abschöpfen
- Verwenden Sie
stdin, entwerfen Sie es so weit, dass Sie nach dem Schreiben schließen und so EOF übermitteln - Den Watchdog außerhalb des Jobs des Überwachungsziels zu platzieren, ist die sicherere Anordnung
.NETsKill(entireProcessTree: true)ist als API zum expliziten Stoppen praktisch, ersetzt aber kein Design, das die automatische Bereinigung beim Absturz des Elternprozesses und ein Graceful Shutdown einschließt
2. Was tatsächlich gefährlich ist
Die Implementierung des Kindprozessstarts lässt sich anfangs meist mit rund 10 Zeilen schreiben. Der Unfall passiert aber außerhalb dieser 10 Zeilen.
- Nach dem Absturz des Elternprozesses laufen Kind und Enkel weiter
- Ein Helper startet einen weiteren Helper, und man wartet nur auf das direkte Kind und ist zufrieden
- Eine Seite von
stdout/stderrstockt, und Eltern- und Kindprozess warten aufeinander - Man wartet im UI-Thread, und Bildschirm wie COM frieren ein
- Der Watchdog teilt das Schicksal seines Überwachungsziels und stürzt im Fehlerfall mit ab
Wichtig ist hier: „Kindprozessverwaltung“ ist nicht die Geschichte einer einzelnen API.
Mindestens diese vier Punkte getrennt zu betrachten, verschafft Übersicht.
- Wer besitzt den Prozessbaum
- Wie wird die kooperative Beendigung angefordert
- Wie fließt die Standard-I/O
- Wie werden abnormales Beenden und Hänger überwacht
3. Die Rollen der Mechanismen nicht vermischen
Process Handle, Process Group und Job Object sehen ähnlich aus, spielen aber unterschiedliche Rollen.
| Mechanismus | Hauptrolle | Geeignet für | Was er allein nicht abdeckt |
|---|---|---|---|
| Process Handle | Warten auf das Ende eines Prozesses, Abrufen des Exit Code | Warten auf den Abschluss eines Einmal-Tools | Einsammeln von Enkelprozessen |
| Process Group | Weitergabe von Ctrl+Break an die Konsole | Kooperative Beendigung eines Konsolen-Kindes | Cleanup beim Absturz des Elternprozesses, GUI-Kindprozesse |
| Job Object | Bündelung des Prozessbaums, Limits, gemeinsame Beendigung | Worker-Bäume, Updater, Helper-Ketten | App-spezifisches „erst speichern, dann schließen“ |
Eine Process Group ist ein Mechanismus, der entscheidet, wohin ein Konsolensignal gesendet wird, nicht ein Mechanismus, um den Baum beim Tod des Elternprozesses aufzuräumen. Das Job Object dagegen ist der eigene Windows-Mechanismus, um eine Gruppe von Prozessen als eine Einheit zu verwalten.
3.1 Zuordnungstabelle nach Sprache
In diesem Artikel vermischen sich Themen aus Win32 und .NET. Damit Sie nur die Spalte Ihrer Sprache herausgreifen können, stellen wir die Zuordnung vorab zusammen.
| Was Sie erreichen wollen | Win32 / C++ | .NET / C# |
|---|---|---|
| Einen Prozess starten | CreateProcessW |
Process.Start |
| Einen Job erzeugen und Limits setzen | CreateJobObjectW + SetInformationJobObject |
Dieselben APIs per P/Invoke. Die Standardbibliothek besitzt keinen Wrapper für das Job Object |
| Schon ab dem Start in den Job aufnehmen | STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST |
Dasselbe. Über ProcessStartInfo lässt sich das nicht angeben |
| Nachträglich in den Job aufnehmen | AssignProcessToJobObject |
Dieselbe API per P/Invoke, Process.Handle übergeben |
| Auf das Ende warten | WaitForSingleObject |
Process.WaitForExit, asynchron WaitForExitAsync (ab .NET 5) |
| Den Exit Code abrufen | GetExitCodeProcess |
Process.ExitCode |
| stdout / stderr lesen | Anonyme Pipe erzeugen, in einem separaten Thread lesen | RedirectStandardOutput und BeginOutputReadLine |
| Ein GUI-Kind zum Schließen bewegen | WM_CLOSE senden |
Process.CloseMainWindow |
| Einem Konsolen-Kind Ctrl+Break senden | CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent |
Keine entsprechende API vorhanden, daher P/Invoke |
| Den gesamten Baum zwangsweise beenden | TerminateJobObject, oder das letzte Job-Handle schließen |
Process.Kill(entireProcessTree: true) (ab .NET Core 3.0), oder dasselbe P/Invoke wie oben |
| Auf das Ende zahlreicher Kinder warten | RegisterWaitForSingleObject / SetThreadpoolWait |
Process.Exited-Ereignis, oder WaitForExitAsync |
Hier zeigt sich: Rund um das Job Object ruft auch .NET unverändert die Win32-API auf. Was .NET-seitig bereitgestellt wird, beschränkt sich auf Operationen je einzelnem Prozess.
4. Das Job Object als Bezugspunkt nehmen
Die stärkste Eigenschaft des Job Object ist, dass es den Prozessbaum nicht nach „wessen Kind es ist“, sondern nach „zu welchem Job es gehört“ bündeln kann. Ein Prozess, der zu einem Job gehört, erzeugt mit CreateProcess Kinder, die standardmäßig demselben Job beitreten.
Mit JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE beenden sich außerdem alle mit dem Job verknüpften Prozesse, sobald das letzte Job-Handle geschlossen wird.
4.1 Vier Dinge, die zuerst festzulegen sind
1. Soll der Baum bei Elternbeendigung mit aufgeräumt werden, dann KILL_ON_JOB_CLOSE
Das ist das Fundament für den Umgang mit Helpern / Workern in einer Windows-App. Ein Design, das explizit TerminateJobObject aufruft, ist ebenfalls möglich, aber wenn Sie das Cleanup, einschließlich des abnormalen Beendens des Elternprozesses, an dessen Lebensdauer koppeln wollen, ist KILL_ON_JOB_CLOSE die klare Wahl.
2. BREAKAWAY nicht leichtfertig setzen
JOB_OBJECT_LIMIT_BREAKAWAY_OK und JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK sehen praktisch aus, sind aber auch eine Ursache dafür, dass ein Teil aus einem Baum entweicht, den man eigentlich aufräumen wollte. Ohne bewusste Absicht senkt der Verzicht auf Breakaway die Unfallquote.
3. Soll die Job-Zugehörigkeit schon ab dem Start gelten, dann PROC_THREAD_ATTRIBUTE_JOB_LIST
Mit AssignProcessToJobObject lässt sich die Verknüpfung auch nachträglich herstellen.
Wenn Sie aber in einer Situation, in der die Job-Zugehörigkeit direkt nach dem Start vorausgesetzt werden soll, ist es sauberer, den Job bereits bei der Erzeugung über STARTUPINFOEX und PROC_THREAD_ATTRIBUTE_JOB_LIST anzugeben.
4. Den Besitzer des Job-Handles nicht offenlassen
KILL_ON_JOB_CLOSE greift, wenn das letzte Handle geschlossen wird.
Anders gesagt: Wird das Job-Handle in einen anderen Prozess dupliziert oder unbeabsichtigt vererbt, wird das Cleanup beim Tod des Elternprozesses nicht wie vorgesehen ausgelöst. Wer der letzte Besitzer des Job-Handles ist, sollte vorab festgelegt werden.
4.2 Das Job Object eignet sich auch für Observability, doch die Benachrichtigung ist nicht allmächtig
Ein Job Object lässt sich mit einem I/O Completion Port verknüpfen, um Benachrichtigungen zu empfangen. Es ist jedoch sicherer, die Benachrichtigung des Completion Port nicht in jedem Fall als vollständig garantiert zu betrachten.
Der Completion Port ist daher praktisch für
- Überwachung
- Aggregation
- Logging
- Metriken
aber man sollte darauf allein keine Correctness aufbauen.
4.3 Am minimalen Code betrachtet
Kürzer als in Worten — hier der minimale Code in beiden Sprachen.
Auf der C++-Seite sind es drei Schritte: den Job erzeugen → KILL_ON_JOB_CLOSE setzen → den Job schon beim Start angeben.
// Windows 10 oder neuer / C++17. Startet helper.exe innerhalb eines Jobs und räumt den Baum beim Ende des Elternprozesses mit auf
#include <windows.h>
#include <memory>
#include <string>
int wmain()
{
// 0. Die zu startende Datei über einen absoluten Pfad festlegen.
// Übergibt man lpApplicationName als nullptr und lässt vom ersten Wort der
// Befehlszeile suchen, gehören zu den Suchzielen "das aktuelle Verzeichnis
// des Elternprozesses" und "PATH".
// Befindet sich helper.exe nicht im eigenen Ordner, läuft eine gleichnamige
// ausführbare Datei, die an einem beschreibbaren Ort platziert wurde,
// mit den Rechten des Elternprozesses
wchar_t modulePath[MAX_PATH]{};
DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
if (moduleLen == 0 || moduleLen >= MAX_PATH) // gilt auch als Fehler, wenn bei MAX_PATH abgeschnitten
{
return 1;
}
std::wstring application(modulePath, moduleLen);
application.resize(application.find_last_of(L'\\') + 1); // der Ordner, in dem die eigene ausführbare Datei liegt
application += L"helper.exe";
// 1. Job erzeugen, damit beim Schließen des letzten Handles alles darin beendet wird
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (job == nullptr)
{
return 1;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
{
CloseHandle(job);
return 1;
}
// 2. Attributliste erzeugen, um die Job-Zugehörigkeit schon ab dem Start festzulegen
SIZE_T attributeSize = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize); // Leerlauf, um die benötigte Größe zu ermitteln
auto storage = std::make_unique<BYTE[]>(attributeSize);
auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());
if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
{
CloseHandle(job);
return 1;
}
// der Wert von job muss am Leben bleiben, bis DeleteProcThreadAttributeList aufgerufen wird
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
&job, sizeof(job), nullptr, nullptr))
{
DeleteProcThreadAttributeList(attributes);
CloseHandle(job);
return 1;
}
// 3. Starten
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.lpAttributeList = attributes;
PROCESS_INFORMATION info{};
// CreateProcessW verlangt einen beschreibbaren Puffer.
// Denselben Pfad auch in argv[0] setzen. Wegen des Leerzeichens unbedingt in Anführungszeichen setzen
std::wstring commandLine = L"\"" + application + L"\" --input data.bin";
BOOL created = CreateProcessW(
application.c_str(), commandLine.data(), nullptr, nullptr,
FALSE, // zu vererbende Handles begrenzen
EXTENDED_STARTUPINFO_PRESENT,
nullptr, nullptr,
&startup.StartupInfo, &info);
DeleteProcThreadAttributeList(attributes);
if (!created)
{
CloseHandle(job);
return 1;
}
WaitForSingleObject(info.hProcess, INFINITE);
DWORD exitCode = 0;
GetExitCodeProcess(info.hProcess, &exitCode);
CloseHandle(info.hThread);
CloseHandle(info.hProcess);
CloseHandle(job); // das letzte Job-Handle. Was hier noch übrig ist, wird gesammelt beendet
return static_cast<int>(exitCode);
}
In diesem Code werden zwei Einschränkungen aus der Dokumentation zu UpdateProcThreadAttribute beachtet. Beide werden leicht übersehen.
PROC_THREAD_ATTRIBUTE_JOB_LISTlässt sich erst ab Windows 10 / Windows Server 2016 verwenden. Für ältere Systeme muss man aufAssignProcessToJobObjectausweichen- Der an
UpdateProcThreadAttributeübergebene Wert muss bis zum Aufruf vonDeleteProcThreadAttributeListam Leben bleiben. Eine lokale Variable zu übergeben und den Gültigkeitsbereich sofort zu verlassen, ist fehlerhaft
Die zu startende Datei stets über einen absoluten Pfad angeben
Dass in der obigen „0.“ der Pfad aus GetModuleFileNameW zusammengesetzt wird, ist keine Stilfrage, sondern soll festlegen, welche ausführbare Datei tatsächlich läuft.
Übergibt man lpApplicationName als nullptr, wird das erste Wort der Befehlszeile zum Modulnamen. Enthält dieses keinen Pfad, sucht Windows in dieser Reihenfolge:
- Das Verzeichnis, aus dem die Anwendung geladen wurde
- Das aktuelle Verzeichnis des Elternprozesses
- Das 32-Bit-Systemverzeichnis
- Das 16-Bit-Systemverzeichnis
- Das Windows-Verzeichnis
- Die in der Umgebungsvariable
PATHaufgeführten Verzeichnisse
Das Problem sind 2 und 6. Befindet sich helper.exe nicht unter 1 — etwa wegen einer fehlenden Bereitstellung, eines anderen Build-Konfigurationssatzes oder eines Deinstallationsrests — schreitet die Suche zu 2 fort. Ist das aktuelle Verzeichnis ein beschreibbarer Ort (etwa weil direkt aus dem Download-Ordner des Benutzers gestartet wurde oder ein Freigabeordner als Arbeitsverzeichnis dient), läuft ein dort platziertes helper.exe mit denselben Rechten wie der Elternprozess. Lässt sich PATH verändern, gilt für 6 dasselbe.
Auch die Microsoft-Dokumentation widmet diesem Punkt einen eigenen Abschnitt „Sicherheitshinweise“ und schreibt ausdrücklich: „Um dieses Problem zu vermeiden, übergeben Sie NULL nicht als lpApplicationName.“ Auch das bekannte Beispiel, dass ohne Anführungszeichen um einen Pfad mit Leerzeichen C:\Program.exe gestartet werden könnte, steht im selben Abschnitt. Deshalb wird auch die Befehlszeile in "..." eingeschlossen.
Für C#s ProcessStartInfo gilt dasselbe. Bei UseShellExecute = false setzt .NET FileName und Argumente zu einer einzigen Befehlszeile zusammen und übergibt null als lpApplicationName, sodass die Übergabe nur des Dateinamens exakt dieselbe Suche auslöst. Übergeben Sie einen aus AppContext.BaseDirectory zusammengesetzten absoluten Pfad.
Auch in einer Umgebung, in der man denkt „so ein Platzierungsfehler passiert nicht“, ist der Schreibaufwand praktisch null. Es gibt grundsätzlich keinen Grund, in Code, der einen Kindprozess startet, die ausführbare Datei relativ zu benennen.
Da .NET keinen Wrapper für das Job Object besitzt, führt kein Weg an P/Invoke vorbei. Die Struktur-Definitionen wirken lang, tatsächlich aufgerufen werden aber nur zwei Funktionen.
// .NET 8 / C# 12. Erzeugt einen Job, setzt KILL_ON_JOB_CLOSE und nimmt einen bereits gestarteten Prozess auf
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
internal static class KillOnCloseJob
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public nuint MinimumWorkingSetSize;
public nuint MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public nuint Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
public nuint PeakJobMemoryUsed;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);
/// <summary>Erzeugt den Job. Das zurückgegebene Handle bleibt für die Lebensdauer der App geöffnet.</summary>
public static SafeJobHandle Create()
{
var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
if (job.IsInvalid)
{
throw new InvalidOperationException($"CreateJobObject ist fehlgeschlagen. code={Marshal.GetLastWin32Error()}");
}
var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
{
// Einen erzeugten, aber nicht konfigurierten Job nicht einfach so verwerfen.
// Fängt der Aufrufer den Initialisierungsfehler ab und wiederholt den Versuch,
// leckt bei jedem Versuch ein Kernel-Handle (die obige C++-Version ruft
// auf diesem Pfad CloseHandle auf)
var error = Marshal.GetLastWin32Error();
job.Dispose();
throw new InvalidOperationException($"SetInformationJobObject ist fehlgeschlagen. code={error}");
}
return job;
}
public static void Add(SafeJobHandle job, Process process)
{
if (!AssignProcessToJobObject(job, process.Handle))
{
throw new InvalidOperationException($"AssignProcessToJobObject ist fehlgeschlagen. code={Marshal.GetLastWin32Error()}");
}
}
}
// Ein rohes IntPtr lässt sich auf dem Pfad eines fehlgeschlagenen Init von niemandem schließen.
// Als SafeHandle genügt auf dem Fehlerpfad ein einziger Dispose-Aufruf
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// Muss ohne Argumente erzeugbar sein, da der Marshaller sie als P/Invoke-Rückgabewert erzeugt
private SafeJobHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle() => CloseHandle(handle);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
}
Beim Aufrufer sieht es so aus. Ruft man nur Create auf und vergisst Add, entsteht der am schwersten zu bemerkende Zustand: Der Job existiert, enthält aber kein Kind.
// Das Job-Handle in einem Feld oder Ähnlichem halten und erst schließen, wenn die App endet.
// Wegen KILL_ON_JOB_CLOSE beenden sich in dem Moment, in dem es geschlossen wird,
// alle Kinder im Job. Hier darf kein using stehen (sonst sterben die Kinder,
// sobald der Gültigkeitsbereich verlassen wird)
SafeJobHandle job = KillOnCloseJob.Create();
try
{
// Die zu startende Datei mit absolutem Pfad übergeben. Übergibt man nur den
// Dateinamen, geraten das aktuelle Verzeichnis und PATH in die Suche von CreateProcess
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false,
CreateNoWindow = true,
};
using var child = Process.Start(startInfo)
?? throw new InvalidOperationException("helper.exe konnte nicht gestartet werden.");
try
{
KillOnCloseJob.Add(job, child); // vergisst man das, bleibt der Job leer
}
catch (Exception assignFailed)
{
// Add schlägt zum Beispiel fehl, wenn auf der Elternseite ein unvereinbares
// Job-Limit gesetzt ist. In diesem Moment läuft helper.exe bereits. Das Dispose
// von `using` verwirft nur den Process-Wrapper, der OS-Prozess endet dadurch
// nicht, und da der Job leer ist, räumt auch job.Dispose() nichts auf. Hier
// wird selbst gestoppt und auf das Ende gewartet
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
// Kill fordert die Beendigung an und kehrt sofort zurück. Wirft man ohne
// zu warten, kann sich das mit einem zweiten, im Rahmen eines erneuten
// Init gestarteten helper überschneiden
child.WaitForExit();
}
catch (Exception killFailed)
{
// Nicht stoppen zu können, wiegt schwerer als der Fehlschlag von Add.
// Wird das geschluckt, geht es weiter, während "weder im Job noch
// gestoppt" als Zustand zurückbleibt
throw new AggregateException(
"Die Zuweisung zum Job ist fehlgeschlagen, und auch helper.exe ließ sich nicht stoppen.",
assignFailed, killFailed);
}
throw;
}
}
catch
{
// Ist sowohl der Start als auch die Aufnahme in den Job fehlgeschlagen, wird
// dieser Job nicht mehr verwendet. Verlässt man den Block ohne zu schließen,
// bleibt bei jeder Wiederholung der Initialisierung ein Kernel-Handle übrig.
// An dieser Stelle ist der Job leer (oder das Kind wurde im obigen catch bereits
// gestoppt), sodass beim Schließen nichts verloren geht, was noch gebraucht wird
job.Dispose();
throw;
}
Allerdings besitzt diese .NET-Version eine Lücke zwischen dem Start und der Aufnahme in den Job. Erzeugt das Kind in dieser Zeit bereits einen Enkel, entsteht dieser außerhalb des Jobs. Genau um diese Lücke zu schließen, verwendet die C++-Version PROC_THREAD_ATTRIBUTE_JOB_LIST. Hat man es mit einem Helper zu tun, der Enkel erzeugt, lohnt es sich, auch in .NET bis zum P/Invoke mit STARTUPINFOEX vorzudringen.
5. Exit-Propagation über Protocol und Timeout entwerfen
Das Beenden eines Kindprozesses ist keine Sache, die mit einem einzigen Kill-API-Aufruf erledigt ist. Am unfallsichersten ist die Vorgehensweise in diesen drei Stufen.
- Kooperative Beendigung anfordern
- Mit kurzem Timeout warten
- Zuletzt den gesamten Job zwangsweise beenden
In dieser Reihenfolge bleibt der normale Beendigungsweg erhalten, während bei einem Hänger dennoch eingesammelt werden kann.
5.1 GUI-Kind
Bei einem Kindprozess mit GUI entspricht CloseMainWindow in .NET dem Senden der Close-Nachricht.
Das ist jedoch eine Beendigungsanfrage, keine Zwangsbeendigung. Daher ist dieser Ablauf naheliegend:
CloseMainWindow- eine bestimmte Zeit warten
- schlägt es fehl, den gesamten Job beenden
5.2 Konsolen-Kind
Bei einem Konsolen-Kind steht die GUI-Close-Nachricht nicht zur Verfügung. Hier verwendet man Process Group und Konsolensignal.
Man startet mit CREATE_NEW_PROCESS_GROUP und sendet mit GenerateConsoleCtrlEvent ein CTRL_BREAK_EVENT.
Wichtig dabei ist:
CTRL_C_EVENTeignet sich nicht zum Adressieren einer bestimmten Gruppe- Nur Prozesse, die die Konsole gemeinsam nutzen, können das Signal empfangen
- Mit
CREATE_NEW_PROCESS_GROUPändert sich auch die Bedeutung vonCTRL+C
5.3 Worker- / Headless-Kind
Worker und Headless-Kinder sind oft weder GUI noch Konsole. In diesem Fall ist ein eigenes Beendigungsprotokoll für den Kindprozess die sicherere Wahl.
quitüberstdinsenden- Einen Shutdown-Befehl über Named Pipe / Socket / RPC senden
- Die Stoppanfrage über ein Event-Objekt übermitteln
Auf Windows-Ebene übernimmt das Job Object das Cleanup des Baums, auf App-Ebene übernehmen Pipe oder stdin das Graceful Shutdown — diese Trennung senkt die Unfallquote.
6. Standard-I/O nicht verstopfen lassen
6.1 stdout / stderr parallel abschöpfen
Die erste Grundregel lautet:
stdout und stderr parallel abschöpfen. Erst eine Seite vollständig zu lesen und dann die andere, verstopft leicht.
Windows-Pipes sind keine unbegrenzten Puffer. Schreibt das Kind massenhaft auf stderr, während der Elternprozess nur stdout liest, blockiert das Kind beim Schreiben und der Elternprozess beim Warten auf das Ende — ein ganz gewöhnlicher Vorgang.
Als Bild sieht das so aus.
sequenceDiagram
participant P as Elternprozess
participant SO as stdout-Pipe
participant SE as stderr-Pipe
participant C as Kindprozess
P->>SO: liest fortlaufend nur stdout
C->>SO: schreibt ein wenig
SO-->>P: gelesen
C->>SE: schreibt massenhaft Warnungen
Note over SE: der Puffer der Pipe wird voll
C->>SE: versucht weiter zu schreiben
Note over C: write kehrt nicht zurück. Das Kind hält hier an
P->>SO: versucht weiterzulesen
Note over P: da das Kind angehalten hat, kommt nichts an
Note over P,C: der Elternprozess wartet auf Lesen, das Kind auf Schreiben. Auch WaitForExit kehrt nicht zurück
Da der Stillstand weder beim Elternprozess noch beim Kind, sondern in der Pipe liegt, zeigt sich die Ursache in keinem der beiden Logs. Die eine fehlende Zeile „stderr wird nicht gelesen“ führt direkt zum Hänger.
Nimmt man stdout und stderr mit separaten Handlern entgegen und liest jede Seite unabhängig weiter, kann sich dieser Kreis nicht schließen. In .NET sieht das so aus:
// .NET 8 / C# 12. Schöpft stdout und stderr parallel ab und wartet, bis die Ausgabe vollständig gelesen ist
using System;
using System.ComponentModel; // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;
// Die zu startende Datei mit absolutem Pfad übergeben (Begründung siehe Abschnitt zum Job Object)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false, // bei Verwendung von Redirection erforderlich
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = startInfo };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
// nicht erst die eine Seite vollständig lesen und dann die andere. Beide über Ereignisse entgegennehmen
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stderr.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine(); // durch bloßes Registrieren beginnt das Lesen noch nicht. Beide unbedingt aufrufen
process.BeginErrorReadLine();
if (!process.WaitForExit(30_000))
{
// hier wird entschieden, das Warten zu beenden, das ersetzt nicht das Cleanup
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
{
// unmittelbar nach Ablauf der 30 Sekunden gibt es ein Wettrennen, bei dem
// das Kind sich selbst beendet. In .NET wird ein Kill während der laufenden
// Beendigung zur Win32Exception ("The process is terminating."), in .NET
// Framework ein Kill gegen einen bereits beendeten Prozess zur
// InvalidOperationException.
// Ist der Prozess bereits beendet, ist das kein Fehlschlag, also wird
// geschluckt und mit der TimeoutException unten fortgefahren. Lebt er noch,
// konnte er tatsächlich nicht gestoppt werden, also wird erneut geworfen
if (!process.HasExited)
{
throw;
}
}
// eine AggregateException (ein Teil der Nachkommen ließ sich nicht stoppen) wird
// nicht geschluckt. Das ist genau der Zustand "der Baum ist nicht aufgeräumt"
// und wird nach außen gereicht
// Kill fordert die Beendigung an und kehrt sofort zurück. Wirft man hier ohne zu
// warten, kann das Kind noch leben, wenn das using-Dispose läuft — dann gilt
// "die Timeout-Exception kam = der Baum ist aufgeräumt" nicht mehr
process.WaitForExit();
throw new TimeoutException("helper.exe hat sich nicht innerhalb von 30 Sekunden beendet.");
}
// selbst wenn das WaitForExit mit Timeout true zurückgibt, kann die asynchrone
// Ausgabeverarbeitung noch nicht abgeschlossen sein. Das parameterlose WaitForExit
// erneut aufrufen und bis zum vollständigen Lesen der Ausgabe warten.
process.WaitForExit();
Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout : {stdout.Length} Zeichen");
Console.WriteLine($"stderr : {stderr.Length} Zeichen");
An der Timeout-Grenze gibt es immer ein Wettrennen. In der kurzen Zeitspanne zwischen dem Zeitpunkt, an dem WaitForExit(30_000) false zurückgibt, und dem Aufruf von Kill, kann sich das Kind selbst beenden. In diesem Fall gelingt Kill nicht — in .NET als Win32Exception („The process is terminating.“) während der laufenden Beendigung, in .NET Framework als InvalidOperationException gegen einen bereits beendeten Prozess. Lässt man das ungefiltert durch, fliegt statt der eigentlich zu werfenden TimeoutException ein Fehlschlag beim Cleanup. Der Aufrufer erhält nicht „es kam zum Timeout“, sondern „irgendein unklarer Fehler ist aufgetreten“, und auch das vollständige Lesen der Ausgabe über das abschließende WaitForExit() entfällt. Wie oben gezeigt, prüft man daher mit HasExited, ob wirklich beendet wurde, bevor man den Fehler schluckt. Lebt der Prozess noch, konnte er tatsächlich nicht gestoppt werden, also wird erneut geworfen. Die von Kill(entireProcessTree: true) geworfene AggregateException (ein Teil der Nachkommen ließ sich nicht stoppen) wird dabei nicht geschluckt. Das ist genau der Zustand „der Baum ist nicht aufgeräumt“, den dieser Abschnitt zu verhindern versucht.
Das abschließende WaitForExit() ist kein vergessener Rest, sondern notwendig.
In der Dokumentation zu WaitForExit(int) steht, dass bei Umleitung der Standardausgabe auf einen asynchronen Ereignishandler die Ausgabeverarbeitung zu dem Zeitpunkt, an dem diese Überladung zurückkehrt, noch nicht abgeschlossen sein kann, und dass nach Erhalt von true das parameterlose WaitForExit() aufgerufen werden soll. Lässt man das weg, bricht es auf schwer reproduzierbare Weise: nur das Ende der Ausgabe fehlt.
6.2 Verwenden Sie stdin, entwerfen Sie es bis zum EOF
Dass man auf stdin schreiben kann, und dass das Kind sich beenden kann, sind nicht dasselbe.
- Man schreibt die Eingabe, schließt aber nicht
- Der Elternprozess denkt „ich habe es bereits übergeben“
- Das Kind denkt „es kommt noch mehr“ und wartet weiter
Ein solcher Zustand kann entstehen. Verwendet man stdin, muss das Design bis dahin reichen, dass man nach dem Schreiben schließt und so EOF übermittelt.
6.3 Unnötige Pipe-Enden unbedingt schließen
Schließt man das ungenutzte Ende auf Eltern- oder Kindseite nicht, wird kein EOF übermittelt und die Beendigungsbedingung bricht zusammen. So einfach das ist, in der Praxis ist es ein ziemlich häufiger Unfall.
6.4 UseShellExecute=false und den Umgang mit Handle-Vererbung nicht offenlassen
Verwenden Sie Standard-I/O-Redirection, ist .NET-seitig UseShellExecute=false Voraussetzung.
Auch unter Win32 ist es sicherer, was vererbt wird so weit wie möglich einzugrenzen. Belässt man bInheritHandles=TRUE und vererbt alles, ist das eine Quelle unerwarteter Handle-Lecks.
7. Den Watchdog „außerhalb“ platzieren
Beim Einbau eines Watchdogs ist das Wichtigste, ihn nicht in denselben Job wie das Überwachungsziel zu stecken. Soll ein Worker beim Absturz neu gestartet werden, ist es sinnlos, wenn der Neustarter gleich mitstirbt.
7.1 Exit-Überwachung auf Wait Handles gründen
Ein Prozess wird beim Beenden signalisiert.
Die Exit-Überwachung braucht daher grundsätzlich keine Polling-Schleife, die alle 100 ms HasExited prüft.
Unter Win32 sind die geeigneten Mittel:
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
Behandelt man mehrere Kinder, ist eine auf Wait Handles basierende Überwachung natürlicher als Timer-Polling.
7.2 Nicht endlos im UI-Thread warten
WaitForSingleObject(INFINITE) ist praktisch, blockiert aber leicht die Message Pump, wenn es in einem Thread mit Fenster verwendet wird.
In UI-Threads, COM-Apartment-Threads und Threads mit Message Pump sollte man zuerst überlegen, wo das Warten platziert wird.
7.3 Ein Hang-Watchdog braucht einen Heartbeat
Für einen Exit-Watchdog genügt das Process Handle. Ein Hang-Watchdog ist anders.
- Bei 100 % CPU eingefroren
- Deadlock
- Die Event-Loop lebt, macht aber keinen Fortschritt
- Wartet auf Eingabe und steht still
Solche Zustände lassen sich nicht allein daran erkennen, „ob der Prozess lebt“. Will man auch Hänger erkennen, braucht man eine anwendungsseitige Lebendprüfung wie:
- Heartbeat
- Fortschrittssequenz
- Zeitstempel der letzten erfolgreichen Arbeit
- Health Probe
7.4 Den Neustarter außerhalb des Überwachungsziels platzieren
In der Praxis kommen diese beiden Muster häufig vor.
- Die Eltern-App startet nur vorübergehend einen Helper
- Der Elternprozess besitzt den Job; beim Beenden des Elternprozesses wird der Helper-Baum eingesammelt
- Ein langfristiger Worker läuft als Hintergrundprozess und soll bei Absturz neu gestartet werden
- Ein externer Watchdog-Prozess / -Dienst erzeugt für jede Worker-Generation einen Job
Im letzteren Fall macht die Trennung von Worker-Baum und Neustart-Autorität das Design stabiler.
7.5 Die Neustart-Policy als Budget führen
Baut man einen Watchdog ein, beginnt als Nächstes die Crash Loop.
- Sofortiger Neustart
- Sofort wieder abgestürzt
- Nur die Logs häufen sich
Um das zu vermeiden, sollte man ein restart budget führen:
- Backoff
- Eine Obergrenze der Neustarts innerhalb einer bestimmten Zeit
- Bei wiederholtem Fehlschlag stoppen und benachrichtigen
8. Empfohlene Konfiguration nach typischem Muster
| Szenario | Empfohlene Konfiguration |
|---|---|
| Eine Desktop-App startet einen einmaligen CLI-Helper | 1 Start = 1 Job. KILL_ON_JOB_CLOSE setzen, stdout / stderr parallel abschöpfen. Bei Abbruch: kooperative Beendigung → Timeout → Job kill |
| Der Helper startet weitere Enkelprozesse | Job Object voraussetzen, kein Breakaway erlauben. Soll die Zugehörigkeit ab dem Start fixiert sein, PROC_THREAD_ATTRIBUTE_JOB_LIST verwenden |
| Ein Dienst / Watchdog überwacht einen langfristigen Worker-Baum | Der Watchdog ist ein externer Prozess / Dienst. Für jede Worker-Generation einen Job erzeugen, über Exit-Handle + Heartbeat überwachen |
| Ein Konsolentool soll sauber gestoppt werden | Mit CREATE_NEW_PROCESS_GROUP starten, mit CTRL_BREAK_EVENT kooperativ beenden. Danach nach Timeout Job kill |
| Ein GUI-Helper soll geschlossen werden | CloseMainWindow / Äquivalent zu WM_CLOSE → Timeout → Job kill |
| Zahlreiche Kindprozesse sollen überwacht werden | Statt blockierende Threads zu vermehren, RegisterWaitForSingleObject / SetThreadpoolWait verwenden |
Am wichtigsten ist hier, den Mechanismus für Graceful Shutdown und den Mechanismus für Cleanup getrennt zu halten.
9. Was man nicht tun sollte
Die in jedem Kapitel angesprochenen Punkte werden hier noch einmal in einer für Reviews direkt nutzbaren Form zusammengefasst. „Was passiert“ steht neben „wo es steht“, damit man von der Zeile, an der man hängen bleibt, zurück in den Text springen kann.
| Was man nicht tun sollte | Was passiert | Text |
|---|---|---|
Glauben, mit Kill(entireProcessTree: true) allein seien Graceful Shutdown und die Bereinigung beim Absturz des Elternprozesses gelöst |
Wirkt nur beim expliziten Stoppen. Die Bereinigung beim Tod des Elternprozesses und der Weg, das Kind selbst aufräumen zu lassen, fehlen | Kapitel 5 |
Alles mit bInheritHandles=TRUE vererben |
Unbeabsichtigte Handles gelangen an das Kind, Ursache für Handle-Lecks und nicht zugestelltes EOF | 6.4 |
Erst stdout vollständig lesen, dann stderr |
Die andere Pipe wird voll, der Elternprozess wartet auf Lesen, das Kind auf Schreiben, beide bleiben stehen | 6.1 |
| Das ungenutzte Ende einer Pipe nicht schließen | Kein EOF wird übermittelt, die Beendigungsbedingung der lesenden Seite tritt nicht ein | 6.3 |
WaitForSingleObject(INFINITE) im UI-Thread aufrufen |
Die Message Pump bleibt stehen, Bildschirm und COM frieren ein | 7.2 |
| Den Watchdog in denselben Job wie das Überwachungsziel stecken | Beim Aufräumen des Überwachungsziels verschwindet auch der Neustarter | Kapitel 7 |
| 259 als gewöhnlichen Exit Code verwenden | GetExitCodeProcess liefert während der Ausführung STILL_ACTIVE, also 259. Beendet sich das Kind regulär mit 259, wird ein bereits beendeter Prozess fälschlich als laufend eingestuft |
7.1 |
| Die Benachrichtigung des Job Completion Port als einzige Wahrheit behandeln | Die Benachrichtigung ist für Überwachung und Aggregation gedacht; baut man allein darauf Correctness auf, geht etwas verloren | 4.2 |
10. Zusammenfassung
Beim sicheren Umgang mit Kindprozessen in einer Windows-App hilft vor allem diese Einordnung:
Wer besitzt den Prozessbaum? Wie wird die Beendigungsanfrage übermittelt? Wie wird die Standard-I/O bis zum Ende ausgelesen? Wo wird der Watchdog platziert?
Legen Sie diese vier Punkte zuerst fest.
Darauf aufbauend, grob gesagt:
- Der Bezugspunkt für das Cleanup des Baums ist das Job Object
- Graceful Shutdown wird nach GUI / Konsole / Worker unterschieden
- Stdio wird bis zum parallelen Abschöpfen und zum EOF durchentworfen
- Der Watchdog steht außerhalb des Überwachungsziels und wird nicht per Polling, sondern über Wait Handles und Heartbeat beobachtet
CreateProcess und Process.Start selbst sind nur der Einstieg.
Was die Unfallquote wirklich beeinflusst, sind die Zuständigkeit für die Beendigung und das vollständige Auslesen der I/O.
11. Referenzen
- Microsoft Learn, Job Objects
- Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION
- Microsoft Learn, UpdateProcThreadAttribute
- Microsoft Learn, InitializeProcThreadAttributeList
- Microsoft Learn, Inheritance (Processes and Threads)
- Microsoft Learn, CreateProcessW
- Microsoft Learn, Creating a Child Process with Redirected Input and Output
- Microsoft Learn, Pipe Handle Inheritance
- Microsoft Learn, Process.Kill
- Microsoft Learn, Process.CloseMainWindow
- Microsoft Learn, GenerateConsoleCtrlEvent
- Microsoft Learn, WaitForSingleObject
- Microsoft Learn, RegisterWaitForSingleObject
- Microsoft Learn, GetExitCodeProcess
- Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Praktische Best Practices für Multithreading: .NET-Edition — Was Sie entscheiden sollten, bevor Sie weitere Threads hinzufügen
Eine praxisnahe Übersicht der Entwurfsregeln, die verhindern, dass Multithreading-Code in .NET/C# gelegentlich abstürzt oder hängen bleib...
WMI/CIM aus C# und PowerShell verwenden — Praxisleitfaden für Hardwareinformationen, Prozessüberwachung und Remoteabfragen
WMI/CIM ist die Standardlösung, um die Seriennummer eines PCs auszulesen, freien Festplattenspeicher zu überwachen und den Start von Proz...
Wie lange lässt sich MSMQ noch nutzen? ── Die Migrationsentscheidung für eine Legacy-Queue, die nicht einmal „deprecated“ ist
MSMQ steht nicht auf Microsofts offiziellen Deprecated-Listen, doch System.Messaging existiert nur im .NET Framework und blockiert die Mi...
Abwärtskompatibilität von DLL- und COM-Schnittstellen — Eine Entscheidungstabelle dafür, welche Änderungen Aufrufer brechen
Welche Änderungen an einer DLL oder COM-Komponente brechen tatsächlich deren Aufrufer? Wir legen die drei Ebenen der Kompatibilität dar —...
Wie man die Windows-Sitzungsisolierung versteht — Session 0, RDP und die gleichzeitige Nutzung durch mehrere Benutzer
Dieser Artikel erklärt das Konzept der Windows-„Sitzung“ (Session), das Windows-App-Entwickler immer wieder verwirrt. Er behandelt, warum...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
In Windows-Apps, die externe CLIs, Konvertierungstools, Worker und Updater ansteuern, entscheidet weniger die Art des Starts als das Management des Prozessbaums und das Design der Beendigung über die Stabilität.
Fehleruntersuchung und Ursachenanalyse
Schwer reproduzierbare Betriebsstörungen — Kindprozesse, die nach dem Absturz des Elternprozesses zurückbleiben, ein stockender stdout oder ein Watchdog, der mit seinem Überwachungsziel abstürzt — lassen sich meist durch eine Überarbeitung des Prozessmanagement-Designs beheben.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Warum bleibt der Kindprozess bestehen, obwohl der Elternprozess abgestürzt ist?
- Weder Process Handle noch Process Group besitzen einen Mechanismus, um den Prozessbaum beim Absturz des Elternprozesses einzusammeln. Wenn Sie die Lebensdauer des Kindprozessbaums an Leben und Tod des Elternprozesses koppeln wollen, ist das Job Object der Bezugspunkt. Mit JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE beenden sich alle zum Job gehörenden Prozesse, sobald das letzte Job-Handle geschlossen wird — dadurch lässt sich das Cleanup, einschließlich des abnormalen Beendens des Elternprozesses, an dessen Lebensdauer koppeln.
- Warum kehrt WaitForExit nicht zurück?
- Sehr wahrscheinlich, weil die Pipe von Standardausgabe oder Standardfehler verstopft ist. Windows-Pipes sind keine unbegrenzten Puffer: Schreibt der Kindprozess massenhaft auf stderr, während der Elternprozess nur stdout liest, blockiert der Kindprozess beim Schreiben und der Elternprozess beim Warten auf das Ende. Grundsätzlich sollten stdout und stderr parallel abgeschöpft werden; eine Implementierung, die erst die eine Seite vollständig liest und dann die andere, verstopft leicht. Zudem muss das nicht genutzte Ende der Pipe geschlossen werden, sonst wird kein EOF übermittelt und die Beendigungsbedingung bricht zusammen.
- Reicht .NETs Kill(entireProcessTree: true) allein nicht aus?
- Nein, das reicht nicht. Als API zum expliziten Stoppen ist es praktisch, ersetzt aber kein Design, das die automatische Bereinigung beim Absturz des Elternprozesses und ein Graceful Shutdown einschließt. Am unfallsichersten ist ein dreistufiges Vorgehen: kooperative Beendigung anfordern, mit kurzem Timeout warten und zuletzt den gesamten Job zwangsweise beenden. Das Mittel für die kooperative Beendigung richtet sich nach der Art des Kindprozesses: CloseMainWindow bei einem GUI-Kind, CREATE_NEW_PROCESS_GROUP und CTRL_BREAK_EVENT bei einem Konsolen-Kind, und bei einem Worker ein Beendigungsprotokoll über stdin oder eine Pipe.
- Wo sollte der Watchdog-Prozess platziert werden?
- Am wichtigsten ist, ihn nicht in denselben Job wie das Überwachungsziel zu stecken. Wenn ein Worker beim Absturz neu gestartet werden soll, es aber sinnlos ist, wenn der Neustarter gleich mitstirbt. Läuft ein Worker langfristig als Hintergrundprozess, ist eine Konstruktion stabiler, bei der ein externer Watchdog-Prozess oder -Dienst für jede Worker-Generation einen eigenen Job erzeugt. Die Exit-Überwachung sollte auf Wait Handles statt auf Polling basieren; ist zusätzlich Hang-Erkennung nötig, kombiniert man das mit einer anwendungsseitigen Lebendprüfung wie einem Heartbeat.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.