История изменений (2 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Карта знаний: ребро mermaid перегенерировано (--> на -.->). Утверждения статьи не менялись.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619762)
Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.
Го Комура (2026). Чек-лист безопасной работы с дочерними процессами в Windows-приложении. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619762 https://comcomponent.com/ru/blog/2026/03/20/001-windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/
- DOI (последняя версия)
- 10.5281/zenodo.21619762
- DOI (эта версия)
- 10.5281/zenodo.21619763
Скачать Excel-чек-лист (с японским и английским листами)
Средства конвертации, updater, worker для разбора данных, внешние CLI, PowerShell, ffmpeg, внутренние утилиты. Windows-приложения начинают зависеть от дочерних процессов гораздо легче, чем кажется.
Но ломается не «удалось ли запустить».
- Родитель упал, а дочерний процесс остался
- В живых остаётся только процесс-внук
stdout/stderrзабиваются, иWaitForExitне возвращает управление- watchdog гибнет вместе с объектом наблюдения
- Казалось, что
Kill(entireProcessTree: true)всё закончил, а на деле раньше закончилось только наблюдение
Безопасно работать с дочерними процессами в Windows — это не выбор API запуска, а решение, кто владеет деревом процессов, и проектирование процедуры завершения и I/O.
В этой статье Job Object, распространение завершения, стандартный ввод-вывод и watchdog собраны в одну архитектуру.
flowchart TB
accTitle: Сбои случаются за пределами запуска
accDescr: Сбои дочерних процессов — это не вопрос, удалось ли запустить процесс, а ситуации вроде «родитель упал, а дочерний остался» или «stdout забился». Помогает не выбор API запуска, а решение, кто владеет деревом процессов, и проектирование завершения и I/O.
a1["Выбрать API запуска"] -.-> a2["Это не ядро сбоя"]
a3["Определить владельца дерева процессов"] --> a6["Безопасная работа с дочерними процессами"]
a4["Спроектировать процедуру завершения"] --> a6
a5["Спроектировать I/O"] --> a6
Рис. 1: Безопасность дочерних процессов задаётся владением, завершением и I/O, а не API запуска.
Термины, которые встречаются в статье
Слова, которые дальше идут по-английски, сначала сводим в одну строку.
| Термин | Смысл в одну строку |
|---|---|
| process tree | Дерево процессов. Семейство, включающее дочерние процессы, которые запустил родитель, и «внуков», которых запустили эти дочерние процессы |
| graceful shutdown | Согласованное завершение. Запрос «завершитесь, пожалуйста»; вторая сторона делает cleanup и выходит сама. Антоним принудительного завершения |
| I/O completion port | Механизм уведомления о завершении асинхронного I/O в Windows. Если связать его с Job Object, можно получать уведомления о запуске и завершении процессов |
| message pump | Цикл сообщений. Поток, владеющий окном, непрерывно извлекает и обрабатывает сообщения от ОС. Если он останавливается, экран зависает |
| heartbeat | Сигнал, который дочерний процесс периодически отправляет для проверки живости. Нужен, чтобы поймать состояние «жив, но не продвигается» |
| restart budget | Бюджет перезапусков. Верхняя граница, сколько раз можно перезапустить за заданный интервал. Держат, чтобы остановить crash loop |
| drain | Вычитать накопленный в pipe вывод до конца, чтобы пишущая сторона не застряла |
Общая картина
Сначала — одна схема отношений между участниками.
flowchart TB
W["watchdog<br/>ставить снаружи Job"]
subgraph JOB["Job Object с JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
P["родительское приложение / сам worker<br/>конечный владелец job handle"]
C["дочерний helper.exe"]
G1["внук converter.exe"]
G2["внук ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"ловит завершение через exit handle"| P
W -.->|"ловит зависание через heartbeat"| P
W -.->|"пересоздаёт в пределах restart budget"| JOB
Рис. 2: Общая картина. Граница Job — это граница дерева процессов, снаружи стоит только watchdog.
Смотреть стоит на два места.
- Граница Job — это граница дерева процессов. Связываем не по жизни родителя, а по принадлежности к Job, поэтому даже если появляются внуки, зачистка их не пропускает
- Снаружи Job стоит только watchdog. Если засунуть его внутрь, он будет убран вместе с объектом наблюдения
1. Сначала вывод
Сначала — только то, что сильнее всего работает на практике.
- Если нужно связать время жизни дерева дочерних процессов с тем, жив родитель или нет, точка отсчёта — Job Object
- Запрос завершения консоли и зачистка дерева процессов — разные вещи
- первое — группа процессов и
GenerateConsoleCtrlEvent - второе — Job Object
- первое — группа процессов и
- Если процессы должны попадать в Job уже в момент запуска, естественная схема —
STARTUPINFOEXиPROC_THREAD_ATTRIBUTE_JOB_LIST - Стандартный вывод и стандартную ошибку вычитывать параллельно — это базовое правило
- Если используется
stdin, проектировать нужно вплоть до close после записи, чтобы передать EOF - watchdog безопаснее ставить снаружи Job, за которым он наблюдает
Kill(entireProcessTree: true)в.NETудобен как API явной остановки, но не заменяет архитектуру, которая включает автоматическую зачистку при падении родителя и graceful shutdown
Карта знаний этой статьи
Опорная точка безопасной работы с дочерними процессами в Windows-приложении — Job Object: флаг KILL_ON_JOB_CLOSE позволяет автоматически завершить всё дерево процессов даже при сбое родителя, и одного Process.Kill(entireProcessTree: true) недостаточно, чтобы это заменить. Завершение реже даёт сбои в три этапа: запросить graceful shutdown, подождать с коротким timeout и в конце принудительно завершить весь Job. Для консольного потомка graceful shutdown делают через process group и событие управления консолью. Если не дренировать stdout и stderr параллельно, возникает взаимная блокировка stdio: у pipe в Windows буфер конечен, и родитель с потомком зависают в ожидании чтения и записи. Устойчивая схема не кладёт watchdog в тот же Job, что и наблюдаемая цель, а держит его снаружи, обнаруживает зависание по heartbeat и предотвращает crash loop бюджетом перезапусков (restart budget).
flowchart LR
accTitle: Карта знаний безопасного управления дочерними процессами Windows
accDescr: Схема показывает, как Job Object связывает дерево процессов и автоматически завершает его при сбое родителя, процедуру завершения от graceful shutdown до принудительного завершения, как параллельный drain stdout/stderr предотвращает взаимную блокировку, и как watchdog размещают вне Job и наблюдают через heartbeat и restart budget
job_object["Job Object"]
stdio_deadlock["взаимная блокировка стандартного ввода-вывода"]
watchdog_process["процесс-watchdog"]
kill_on_job_close["JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
orphaned_process["осиротевший дочерний процесс"]
parent_crash_recovery["автоочистка при падении родителя"]
entire_process_tree_kill["Process.Kill(entireProcessTree: true)"]
proc_thread_attribute_job_list["PROC_THREAD_ATTRIBUTE_JOB_LIST"]
process_group["группа процессов (консоль)"]
console_ctrl_event["событие управления консолью"]
graceful_shutdown["graceful shutdown"]
heartbeat_monitoring["проверка живости по heartbeat"]
restart_budget["бюджет перезапусков"]
crash_loop["цикл падений (crash loop)"]
parallel_stdio_drain["параллельный drain stdout/stderr"]
iocp["I/O Completion Port (IOCP)"]
process_tree["дерево процессов (process tree)"]
breakaway_option["JOB_OBJECT_LIMIT_BREAKAWAY_OK"]
job_object -->|"настраивается"| kill_on_job_close
kill_on_job_close -.->|"предотвращает"| orphaned_process
job_object -->|"рекомендуется для"| parent_crash_recovery
entire_process_tree_kill -->|"не рекомендуется"| parent_crash_recovery
proc_thread_attribute_job_list -->|"требует"| job_object
process_group -->|"использует"| console_ctrl_event
console_ctrl_event -->|"требует"| process_group
graceful_shutdown -->|"должен предшествовать"| entire_process_tree_kill
graceful_shutdown -.->|"использует"| console_ctrl_event
watchdog_process -->|"не рекомендуется"| job_object
watchdog_process -.->|"использует"| heartbeat_monitoring
watchdog_process -->|"автоматизирует"| restart_budget
restart_budget -->|"предотвращает"| crash_loop
parallel_stdio_drain -->|"предотвращает"| stdio_deadlock
job_object -.->|"использует"| iocp
job_object -.->|"реализует"| process_tree
breakaway_option -.->|"может вызвать"| orphaned_process
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 17, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. В чём именно опасность
Реализацию запуска дочернего процесса поначалу обычно укладывают примерно в 10 строк. Но ломается то, что за пределами этих 10 строк.
- После падения родителя дочерние процессы и внуки продолжают жить
- helper запускает ещё один helper, а ждут только непосредственного потомка — и на этом успокаиваются
- Одна сторона
stdout/stderrзабивается, и родитель с дочерним процессом ждут друг друга - Ожидание идёт в UI-потоке, и зависают и окно, и COM
- watchdog делит судьбу с объектом наблюдения и при сбое падает вместе с ним
Здесь важно вот что: «управление дочерними процессами» — это не разговор про один API.
Как минимум эти четыре вопроса лучше держать раздельно — тогда картина складывается.
- Кто владеет деревом процессов
- Как запрашивается согласованное завершение
- Как пропускается стандартный ввод-вывод
- Как наблюдают аварийное завершение и зависания
flowchart TB
accTitle: Четыре вопроса, которые стоит разделить
accDescr: Управление дочерними процессами — это не разговор про один API. Картина складывается, если раздельно держать, кто владеет деревом процессов, как запрашивается согласованное завершение, как пропускается стандартный ввод-вывод и как наблюдают аварийное завершение и зависания.
b1["1. Владелец дерева процессов"] --> b2["2. Как просить согласованное завершение"]
b2 --> b3["3. Как пропускать стандартный ввод-вывод"]
b3 --> b4["4. Наблюдение за сбоем и зависанием"]
b1 -.-> b5["Это не разговор про один API"]
Рис. 3: Управление дочерними процессами проектируют, разложив его на эти четыре вопроса.
3. Не смешивать роли механизмов
Дескриптор процесса, группа процессов и Job Object внешне похожи, но роли у них разные.
| Механизм | Основная роль | К чему подходит | Чего одного его недостаточно |
|---|---|---|---|
| Дескриптор процесса | Ожидание завершения одного процесса, получение exit code | Ожидание завершения разового инструмента | Зачистка процессов-внуков |
| Группа процессов | Распространение Ctrl+Break на консоль | Согласованное завершение консольного дочернего процесса | Cleanup при падении родителя, GUI-потомки |
| Job Object | Связать дерево процессов, ограничения, завершить как целое | Дерево worker, updater, цепочки helper | Прикладное «сначала сохранить, потом закрыть» |
Группа процессов — это механизм, который решает, куда доставить сигнал консоли, а не механизм, который при гибели родителя убирает всё дерево. Job Object, напротив, — штатный механизм Windows, чтобы управлять группой процессов как одной единицей.
3.1 Соответствие по языкам
В статье смешаны Win32 и .NET. Чтобы можно было смотреть только свой столбец, соответствие лучше выписать заранее.
| Что нужно сделать | Win32 / C++ | .NET / C# |
|---|---|---|
| Запустить процесс | CreateProcessW |
Process.Start |
| Создать Job и задать ограничения | CreateJobObjectW + SetInformationJobObject |
Те же API через P/Invoke. В стандартной библиотеке обёртки над Job Object нет |
| Попасть в Job уже в момент запуска | STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST |
То же самое. Через ProcessStartInfo это не задать |
| Попасть в Job позже | AssignProcessToJobObject |
Тот же API через P/Invoke, передавая Process.Handle |
| Дождаться завершения | WaitForSingleObject |
Process.WaitForExit, асинхронно — WaitForExitAsync (.NET 5 и новее) |
| Взять exit code | GetExitCodeProcess |
Process.ExitCode |
| Читать stdout / stderr | Создать анонимный pipe и читать в отдельном потоке | RedirectStandardOutput и BeginOutputReadLine |
| Попросить GUI-потомка закрыться | Отправить WM_CLOSE |
Process.CloseMainWindow |
| Отправить консольному потомку Ctrl+Break | CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent |
Эквивалентного API нет, поэтому P/Invoke |
| Принудительно завершить всё дерево | TerminateJobObject или закрыть последний job handle |
Process.Kill(entireProcessTree: true) (.NET Core 3.0 и новее) либо тот же P/Invoke |
| Дождаться завершения многих потомков | RegisterWaitForSingleObject / SetThreadpoolWait |
Событие Process.Exited или WaitForExitAsync |
Отсюда видно: вокруг Job Object даже в .NET вызывают Win32 API как есть. На стороне .NET из коробки есть только операции в масштабе одного процесса.
flowchart TB
accTitle: Граница ответственности .NET и граница Job
accDescr: В стандартной библиотеке .NET есть запуск, ожидание завершения и другие операции в масштабе одного процесса. Вокруг Job Object обёртки нет, поэтому Win32 API вызывают через P/Invoke как есть.
c1["Операции в масштабе одного процесса"] --> c2["Хватает стандартного Process в .NET"]
c3["Операции вокруг Job Object"] --> c4["Обёртки нет"]
c4 --> c5["Вызвать Win32 API через P/Invoke"]
Рис. 4: Даже в .NET ту часть, которая связывает дерево процессов, вызывают напрямую через Win32 API.
4. Точка отсчёта — Job Object
Самая сильная сторона Job Object в том, что дерево процессов связывается не по принципу «чей это потомок», а по принципу «к какому Job он принадлежит». Дочерний процесс, который процесс внутри Job создаёт через CreateProcess, по умолчанию входит в тот же Job.
Если к этому добавить JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, все процессы, связанные с Job, завершаются в момент закрытия последнего job handle.
flowchart TB
accTitle: Связка через Job убирает пропуски в зачистке
accDescr: Job Object связывает дерево процессов не по тому, чей это потомок, а по принадлежности к Job. Потомки процесса внутри Job по умолчанию входят в тот же Job, а с KILL_ON_JOB_CLOSE все процессы завершаются, когда закрывается последний job handle.
d1["Следить по «чей потомок»"] -.-> d2["Появляются внуки — и кто-то утекает"]
d3["Связывать по «к какому Job принадлежит»"] --> d4["Потомки потомка тоже в тот же Job"]
d4 --> d5["KILL_ON_JOB_CLOSE"]
d5 --> d6["Закрылся последний handle — завершаются все"]
Рис. 5: Связываем не по отношению родитель–потомок, а по принадлежности к Job — поэтому зачистка доходит до внуков.
4.1 Четыре вещи, которые стоит закрепить сначала
1. Если при завершении родителя нужно убрать всё дерево — KILL_ON_JOB_CLOSE
Это основание для работы с helper и worker в Windows-приложении. Архитектура с явным вызовом TerminateJobObject тоже допустима, но если нужно привязать cleanup к времени жизни родителя, включая его аварийное завершение, понятнее KILL_ON_JOB_CLOSE.
2. Не вешать BREAKAWAY на автомате
JOB_OBJECT_LIMIT_BREAKAWAY_OK и JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK выглядят удобно, но именно они часто становятся причиной того, что из дерева, которое собирались зачистить целиком, кто-то выскальзывает. Если нет намеренной причины, без breakaway сбоев меньше.
3. Если в Job нужно попасть уже в момент запуска — PROC_THREAD_ATTRIBUTE_JOB_LIST
Связать процесс с Job можно и позже, через AssignProcessToJobObject.
Но там, где принадлежность к Job нужна уже сразу после запуска, правильнее указать Job при создании через STARTUPINFOEX и PROC_THREAD_ATTRIBUTE_JOB_LIST.
4. Не оставлять владельца job handle неопределённым
KILL_ON_JOB_CLOSE срабатывает когда закрывается последний handle.
Обратная сторона: если job handle продублировать в другой процесс или нечаянно отдать по наследованию, после гибели родителя cleanup не произойдёт так, как ожидалось. Кто конечный владелец job handle — это нужно решить заранее.
flowchart TB
accTitle: Владельца job handle нельзя оставлять неопределённым
accDescr: KILL_ON_JOB_CLOSE срабатывает, когда закрывается последний handle. Если job handle продублировать в другой процесс или нечаянно отдать по наследованию, после гибели родителя cleanup не произойдёт как ожидалось, поэтому конечного владельца нужно решить заранее.
e1["Дублировать или наследовать job handle"] --> e2["Последний handle не закрывается"]
e2 --> e3["Родитель погиб, а cleanup нет"]
e3 -.->|"поэтому"| e4["Сначала решить, кто конечный владелец"]
Рис. 6: KILL_ON_JOB_CLOSE начинает работать только когда закрыт «последний handle».
4.2 Job Object годится и для наблюдаемости, но уведомления не всесильны
К Job Object можно привязать I/O completion port и получать уведомления. Но уведомления через completion port безопаснее не считать полностью гарантированными во всех случаях.
Поэтому completion port удобен для
- мониторинга;
- агрегации;
- журналирования;
- метрик,
но корректность системы на одних только этих уведомлениях лучше не строить.
flowchart TB
accTitle: Где уместны уведомления completion port
accDescr: К Job Object можно привязать I/O completion port и получать уведомления, но их не стоит считать полностью гарантированными во всех случаях. Их удобно использовать для мониторинга, агрегации, журналирования и метрик и не строить на них одних корректность.
f1["Уведомления completion port у Job"] --> f2["Удобны для мониторинга, агрегации, журналов"]
f1 -.-> f3["Не считать полностью гарантированными"]
f3 -.-> f4["Не строить на них одних корректность"]
Рис. 7: Уведомления — для наблюдения, а не как основание правильности.
4.3 Смотреть на минимальный код
Так короче, чем словами, поэтому минимальный вариант есть на обоих языках.
На стороне C++ это три шага: создать Job → задать KILL_ON_JOB_CLOSE → указать Job уже в момент запуска.
// Windows 10 и новее / C++17. Запускаем helper.exe внутри Job и при завершении
// родителя убираем всё дерево
#include <windows.h>
#include <memory>
#include <string>
int wmain()
{
// 0. Файл запуска фиксируем абсолютным путём.
// Если передать nullptr в lpApplicationName и искать по первому слову
// командной строки, в поиск попадают «текущий каталог родительского
// процесса» и «PATH». Если helper.exe нет в собственной папке, а в
// доступное на запись место положили одноимённый исполняемый файл,
// он запустится с правами родителя
wchar_t modulePath[MAX_PATH]{};
DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
if (moduleLen == 0 || moduleLen >= MAX_PATH) // усечение по MAX_PATH тоже считаем ошибкой
{
return 1;
}
std::wstring application(modulePath, moduleLen);
application.resize(application.find_last_of(L'\\') + 1); // папка собственного исполняемого файла
application += L"helper.exe";
// 1. Создаём Job и при закрытии последнего handle завершаем всё содержимое
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. Список атрибутов, чтобы принадлежность к Job была с момента запуска
SIZE_T attributeSize = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize); // холостой вызов, чтобы узнать нужный размер
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;
}
// значение job должно жить до вызова DeleteProcThreadAttributeList
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
&job, sizeof(job), nullptr, nullptr))
{
DeleteProcThreadAttributeList(attributes);
CloseHandle(job);
return 1;
}
// 3. Запуск
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.lpAttributeList = attributes;
PROCESS_INFORMATION info{};
// CreateProcessW требует изменяемый буфер.
// Тот же путь кладём и в argv[0]. В пути есть пробелы — обязательно в кавычках
std::wstring commandLine = L"\"" + application + L"\" --input data.bin";
BOOL created = CreateProcessW(
application.c_str(), commandLine.data(), nullptr, nullptr,
FALSE, // наследуемые handle сужаем
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); // последний job handle. Оставшиеся потомки здесь завершаются вместе
return static_cast<int>(exitCode);
}
В этом коде учтены два ограничения из документации UpdateProcThreadAttribute. Оба легко пропустить при чтении.
PROC_THREAD_ATTRIBUTE_JOB_LISTдоступен начиная с Windows 10 / Windows Server 2016. Если нужно поддерживать более старые системы, придётся опуститься кAssignProcessToJobObject- Значение, переданное в
UpdateProcThreadAttribute, должно жить до вызоваDeleteProcThreadAttributeList. Запись, которая передаёт локальную переменную и сразу выходит из области видимости, ломается
Файл запуска всегда указывать абсолютным путём
То, что в пункте «0.» путь собирается из GetModuleFileNameW, — не вкус к стилю, а способ зафиксировать, какой именно исполняемый файл будет запущен.
Если в lpApplicationName передать nullptr, модулем становится первое слово командной строки. Если в нём нет пути, Windows ищет в таком порядке.
- Каталог, из которого загружено приложение
- Текущий каталог родительского процесса
- Системный каталог 32-bit
- Системный каталог 16-bit
- Каталог Windows
- Каталоги из переменной среды
PATH
Проблема — пункты 2 и 6. Когда helper.exe нет в пункте 1 — забыли положить при развёртывании, собрали другую конфигурацию, остаток после удаления — поиск идёт дальше, в пункт 2. Если текущий каталог доступен на запись (запустили прямо из папки загрузок пользователя, рабочий каталог — общая папка), лежащий там helper.exe запустится с теми же правами, что и родитель. Если среду можно переписать через PATH, то же самое верно и для пункта 6.
Документация Microsoft выделяет это отдельным разделом «Security Remarks» и прямо говорит: «Чтобы избежать этой проблемы, не передавайте NULL в lpApplicationName». В том же разделе знаменитый пример: если путь с пробелами не взять в кавычки, может запуститься C:\Program.exe. Поэтому и командную строку мы оборачиваем в "...".
То же самое у ProcessStartInfo в C#. Когда UseShellExecute = false, .NET собирает FileName и аргументы в одну командную строку и передаёт null в lpApplicationName, поэтому если передать только имя файла, сработает тот же поиск. Передавайте абсолютный путь, собранный из AppContext.BaseDirectory.
Даже в среде, где «такой ошибки размещения не бывает», стоимость записи почти нулевая. В коде запуска дочернего процесса по сути нет причин указывать исполняемый файл относительным именем.
flowchart TB
accTitle: Какой сбой даёт запуск по относительному имени
accDescr: Если передать NULL в lpApplicationName и запускать только по имени файла, в поиск попадают текущий каталог родителя и PATH. Когда настоящего helper.exe нет, одноимённый исполняемый файл из доступного на запись места запускается с правами родителя. Поэтому путь всегда фиксируют абсолютным.
g1["Запуск только по имени файла"] --> g2["В поиск входят текущий каталог и PATH"]
g2 --> g3["Когда helper.exe нет на своём месте"]
g3 --> g4["Положенный одноимённый EXE идёт с правами родителя"]
g4 -.->|"чтобы предотвратить"| g5["Указать абсолютный путь и взять в кавычки"]
Рис. 8: Файл запуска не оставляют поиску — фиксируют абсолютным путём.
В .NET обёртки над Job Object нет, поэтому будет P/Invoke. Определения структур выглядят длинно, но вызываются всего две функции.
// .NET 8 / C# 12. Создаём Job, задаём KILL_ON_JOB_CLOSE и кладём уже запущенный процесс
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>Создаёт Job. Возвращённый handle нужно держать открытым на всё время жизни приложения.</summary>
public static SafeJobHandle Create()
{
var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
if (job.IsInvalid)
{
throw new InvalidOperationException($"Не удалось выполнить CreateJobObject. 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))
{
// Не бросать наполовину созданный Job, который удалось открыть,
// но не удалось настроить. Если вызывающая сторона ловит ошибку
// инициализации и повторяет попытку, на каждую попытку утекает
// по одному kernel handle (C++-вариант на этом пути вызывает
// CloseHandle)
var error = Marshal.GetLastWin32Error();
job.Dispose();
throw new InvalidOperationException($"Не удалось выполнить SetInformationJobObject. code={error}");
}
return job;
}
public static void Add(SafeJobHandle job, Process process)
{
if (!AssignProcessToJobObject(job, process.Handle))
{
throw new InvalidOperationException($"Не удалось выполнить AssignProcessToJobObject. code={Marshal.GetLastWin32Error()}");
}
}
}
// Если держать сырой IntPtr, на пути с ошибкой инициализации закрыть handle
// уже некому. Со SafeHandle достаточно одного Dispose на аварийном пути
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// Маршалер создаёт объект как возвращаемое значение P/Invoke, поэтому
// нужен конструктор без аргументов
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);
}
Вызывающая сторона выглядит так. Если вызвать только Create и забыть Add, получится самое незаметное состояние: Job есть, а дочернего процесса в нём нет.
// job handle держим в поле и не закрываем, пока живёт приложение.
// Стоит KILL_ON_JOB_CLOSE, поэтому в момент закрытия все потомки внутри Job
// завершаются. using здесь ставить нельзя (выйдя из области видимости,
// убьёте всех потомков)
SafeJobHandle job = KillOnCloseJob.Create();
try
{
// Файл запуска передаём абсолютным путём. Если передать только имя файла,
// в поиск CreateProcess попадут текущий каталог и PATH
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.");
try
{
KillOnCloseJob.Add(job, child); // если забыть эту строку, Job останется пустым
}
catch (Exception assignFailed)
{
// Add падает, например, когда на стороне родителя уже висят
// несовместимые ограничения Job. helper.exe к этому моменту уже
// запущен. Dispose у using выбрасывает только обёртку Process,
// процесс ОС не завершает, а Job пуст, поэтому job.Dispose() тоже
// ничего не уберёт. Останавливаем сами и ждём завершения
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
// Kill запрашивает завершение и сразу возвращается. Если
// выбросить исключение, не дождавшись, второй helper после
// повторной инициализации может пойти параллельно с первым
child.WaitForExit();
}
catch (Exception killFailed)
{
// Невозможность остановить тяжелее, чем сбой Add. Если
// проглотить это, дальше пойдёте с потомком, который не в Job
// и при этом ещё жив
throw new AggregateException(
"Не удалось назначить процесс в Job, и helper.exe тоже не удалось остановить.",
assignFailed, killFailed);
}
throw;
}
}
catch
{
// Если не удалось ни запустить, ни положить в Job, этот Job больше
// не используем. Выйти, не закрыв, — значит оставлять по одному
// kernel handle на каждую повторную инициализацию. На этот момент
// Job пуст (или потомка уже остановили в catch выше), так что
// закрытие никого нужного не убьёт
job.Dispose();
throw;
}
У этого варианта на .NET, однако, есть щель между запуском и попаданием в Job. Если за это время потомок успеет создать внука, внук родится уже снаружи Job. Именно поэтому C++-вариант использует PROC_THREAD_ATTRIBUTE_JOB_LIST: чтобы этой щели не было. Если helper сам плодит внуков, в .NET тоже стоит дойти до P/Invoke с STARTUPINFOEX.
flowchart TB
accTitle: Щель между запуском и Assign
accDescr: Если после запуска класть процесс в Job через AssignProcessToJobObject, в промежутке потомок может создать внука, и внук родится снаружи Job. Если helper плодит внуков, щель стоит закрыть, указав Job уже при создании через PROC_THREAD_ATTRIBUTE_JOB_LIST.
h1["Сначала запустить, потом положить в Job"] --> h2["Между запуском и Assign есть щель"]
h2 --> h3["Внук, родившийся в этой щели, снаружи Job"]
h3 -.->|"чтобы закрыть щель"| h4["Указать Job уже при создании и запустить"]
Рис. 9: У схемы «положить в Job потом» есть щель, через которую утекают внуки.
5. Распространение завершения проектировать как протокол и timeout
Завершение дочернего процесса — не история, которую закрывает один вызов kill. Реже всего ломается схема из трёх этапов.
- Запросить согласованное завершение
- Подождать с коротким timeout
- В конце принудительно завершить весь Job
В таком порядке нормальный путь завершения сохраняется, а при зависании дерево всё равно можно зачистить.
flowchart TB
accTitle: Завершение в три этапа
accDescr: Завершение дочернего процесса не закрывается одним вызовом kill. Если запросить согласованное завершение, подождать с коротким timeout и в конце принудительно завершить весь Job, нормальный путь завершения сохраняется, а при зависании дерево всё равно можно зачистить.
i1["1. Запросить согласованное завершение"] --> i2["2. Подождать с коротким timeout"]
i2 --> i3["3. В конце принудительно завершить весь Job"]
i2 -.-> i4["Нормальный путь сохранить, зависание зачистить"]
Рис. 10: Завершение проектируют тремя этапами: запрос, ожидание, принудительная остановка.
5.1 GUI-потомок
Если у дочернего процесса есть графический интерфейс, в .NET CloseMainWindow отправляет сообщение закрытия.
Но это запрос на завершение, а не принудительная остановка. Поэтому естественнее такой порядок:
CloseMainWindow- подождать некоторое время
- если не помогло — убить весь Job
5.2 Консольный потомок
У консольного потомка сообщения закрытия GUI нет. Здесь используют группу процессов и сигнал консоли.
Порядок такой: запуск с CREATE_NEW_PROCESS_GROUP, затем CTRL_BREAK_EVENT через GenerateConsoleCtrlEvent.
Важно вот что:
CTRL_C_EVENTплохо подходит, чтобы адресовать конкретную группу;- сигнал могут получить только процессы, которые разделяют одну консоль;
CREATE_NEW_PROCESS_GROUPменяет и смыслCTRL+C.
5.3 Worker / headless-потомок
Worker и headless-потомки часто не являются ни GUI, ни консолью. В этом случае безопаснее иметь отдельный протокол завершения для дочернего процесса.
- отправить
quitвstdin; - отправить команду shutdown через именованный канал / сокет / RPC;
- передать запрос остановки через объект события.
Со стороны Windows зачистку дерева берёт на себя Job Object, со стороны приложения graceful shutdown берут на себя канал или stdin — такое разделение реже ломается.
flowchart TB
accTitle: Согласованное завершение разделяют по типу потомка
accDescr: Для GUI-потомка — close message вроде CloseMainWindow, для консольного — CREATE_NEW_PROCESS_GROUP и CTRL_BREAK_EVENT, для worker — протокол завершения через stdin или pipe. Средство запроса согласованного завершения выбирают по типу потомка.
j0["Запрос согласованного завершения"] --> j1["GUI-потомок: close message"]
j0 --> j2["Консольный потомок: CTRL_BREAK_EVENT"]
j0 --> j3["worker: протокол завершения через stdin или pipe"]
j3 -.-> j4["Зачистку дерева берёт на себя Job Object"]
Рис. 11: Средство согласованного завершения выбирают по типу потомка, зачистку оставляют Job.
6. Не забивать стандартный ввод-вывод
6.1 stdout / stderr — параллельный drain
Первое базовое правило такое.
stdout и stderr вычитывать параллельно. Схема «сначала полностью одну сторону, потом другую» легко забивается.
Pipe в Windows — не бесконечный буфер. Если потомок пишет много в stderr, а родитель читает только stdout, обычная картина такая: потомок останавливается на write, родитель — в ожидании завершения.
На схеме это выглядит так.
sequenceDiagram
participant P as Родительский процесс
participant SO as pipe stdout
participant SE as pipe stderr
participant C as Дочерний процесс
P->>SO: продолжает читать только stdout
C->>SO: пишет немного
SO-->>P: прочитано
C->>SE: пишет много предупреждений
Note over SE: буфер pipe заполняется
C->>SE: пытается писать ещё
Note over C: write не возвращается. Потомок здесь останавливается
P->>SO: пытается читать дальше
Note over P: потомок стоит, ничего не приходит
Note over P,C: родитель ждёт чтения, потомок — записи. WaitForExit тоже не возвращается
Рис. 12: Если читать только stdout, pipe stderr заполняется, и родитель с потомком ждут друг друга.
Место, где всё стоит, — не родитель и не потомок, а pipe, поэтому ни в одном из журналов причина не видна. Одна пропущенная строка «не читаем stderr» превращается в зависание как есть.
Если принимать stdout и stderr разными обработчиками и читать каждый независимо, это кольцо не складывается. В .NET это выглядит так.
// .NET 8 / C# 12. Параллельный drain stdout и stderr, ждать до вычитывания вывода
using System;
using System.ComponentModel; // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;
// Файл запуска передаём абсолютным путём (почему — в разделе про Job Object)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false, // обязательно, если используете перенаправление
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = startInfo };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
// Не читать одну сторону до конца, а потом другую. Обе принимаем событиями
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(); // сама регистрация чтение не начинает. Вызвать нужно обе
process.BeginErrorReadLine();
if (!process.WaitForExit(30_000))
{
// Это решение «перестать ждать», а не замена cleanup
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
{
// Есть гонка: «сразу после» того, как истекли 30 секунд, потомок
// может завершиться сам. В .NET Kill во время завершения даёт
// Win32Exception("The process is
// terminating."), в .NET Framework Kill уже завершённого процесса —
// InvalidOperationException.
// Если процесс уже закончился, это не отказ, поэтому глотаем и
// идём к TimeoutException ниже. Если ещё жив — остановить
// действительно не удалось, и исключение пробрасываем
if (!process.HasExited)
{
throw;
}
}
// AggregateException (не удалось остановить часть потомков) не глотаем.
// Это и есть «дерево не зачищено», поэтому выпускаем наружу
// Kill запрашивает завершение и сразу возвращается. Если здесь
// выбросить исключение, не подождав, к моменту Dispose у using
// потомок ещё может быть жив, и «выброшен TimeoutException =
// дерево зачищено» уже не выполняется
process.WaitForExit();
throw new TimeoutException("helper.exe не завершился за 30 секунд.");
}
// Даже если WaitForExit с timeout вернул true, асинхронная обработка
// вывода к этому моменту может ещё не закончиться.
// Ещё раз вызываем WaitForExit без аргументов и ждём, пока вывод
// будет вычитан до конца.
process.WaitForExit();
Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout : {stdout.Length} символов");
Console.WriteLine($"stderr : {stderr.Length} символов");
На границе timeout всегда есть гонка. Между тем, как WaitForExit(30_000) вернул false, и вызовом Kill потомок иногда успевает завершиться сам. Тогда Kill не проходит успешно — в .NET это Win32Exception во время завершения («The process is terminating.»), в .NET Framework — InvalidOperationException на уже завершённый процесс. Если это пропустить как есть, вместо TimeoutException, который нужно было выбросить, улетает сбой cleanup. Вызывающая сторона получает не «истёк timeout», а «вылезла какая-то непонятная ошибка», и вычитывание вывода финальным WaitForExit() тоже пропускается. Как в примере выше, сначала через HasExited проверяем, действительно ли процесс уже закончился, и только тогда глотаем. Если ещё жив — остановить не удалось, и исключение пробрасываем. AggregateException от Kill(entireProcessTree: true) (не удалось остановить часть потомков) не глотаем. Это и есть состояние «дерево не зачищено», которое этот раздел как раз и пытается предотвратить.
flowchart TB
accTitle: Гонка на границе timeout
accDescr: В короткий промежуток после того, как WaitForExit вернул false, и до вызова Kill потомок может завершиться сам. Тогда сбой cleanup перекрывает настоящий TimeoutException. Поэтому сначала через HasExited проверяем, действительно ли процесс закончился, и только тогда глотаем; если ещё жив — пробрасываем.
k1["Сразу после истечения ожидания потомок завершается сам"] --> k2["Kill завершается ошибкой"]
k2 --> k3{"Проверить через HasExited"}
k3 -->|"уже закончился"| k4["Это не отказ, глотаем"]
k3 -->|"ещё жив"| k5["Остановить не удалось, пробрасываем"]
k4 --> k6["Выбросить настоящий TimeoutException"]
Рис. 13: В гонке на границе через HasExited отличаем, отказ Kill — настоящий отказ или нет.
Последний WaitForExit() — не забытый вызов, а обязательный.
В документации WaitForExit(int) сказано: если стандартный вывод перенаправлен в асинхронный обработчик событий, к моменту возврата этой перегрузки обработка вывода может быть ещё не завершена, и после получения true рекомендуют вызвать WaitForExit() без аргументов. Если это опустить, ломается трудновоспроизводимо: отрезается только хвост вывода.
6.2 Если используется stdin, проектировать до EOF
То, что в stdin можно писать, и то, что потомок может завершиться, — не одно и то же.
- ввод записали, но close не сделали;
- родитель считает, что «уже всё передал»;
- потомок считает, что «ещё придёт продолжение», и продолжает ждать.
Такое состояние возникает. Если используется stdin, в архитектуру нужно включить и close после записи, чтобы передать EOF.
flowchart TB
accTitle: stdin проектируют до EOF
accDescr: Если после записи во stdin не сделать close, родитель думает, что уже всё передал, а потомок ждёт продолжения. Поэтому проектируют вплоть до close после записи, чтобы передать EOF.
m1["Записали ввод и не сделали close"] --> m2["Родитель думает: «уже передал»"]
m1 --> m3["Потомок ждёт: «ещё придёт продолжение»"]
m2 --> m4["После записи close, чтобы передать EOF"]
m3 --> m4
Рис. 14: У stdin в проектирование входит не «можно писать», а то, что EOF доходит.
6.3 Неиспользуемые концы pipe закрывать обязательно
Если на стороне родителя или потомка не закрыть неиспользуемые концы, EOF не доходит, и условие завершения рассыпается. Это просто, но на практике один из самых частых сбоев.
6.4 Не оставлять размытыми UseShellExecute=false и наследование handle
Если используется перенаправление стандартного ввода-вывода, в .NET предпосылка — UseShellExecute=false.
В Win32 тоже безопаснее максимально сузить, что именно наследуется. Если оставить bInheritHandles=TRUE и отдать всё подряд, это источник неожиданных утечек handle.
7. Watchdog ставить «снаружи»
Самое важное при добавлении watchdog — не помещать его в тот же Job, что и объект наблюдения. Если worker упал и его нужно перезапустить, нет смысла, чтобы вместе с ним погиб и тот, кто должен перезапускать.
flowchart TB
accTitle: Watchdog ставят снаружи объекта наблюдения
accDescr: Если watchdog стоит в том же Job, что и объект наблюдения, при зачистке упавшего worker вместе с ним гибнет и тот, кто должен перезапускать. Поэтому watchdog ставят снаружи Job наблюдения.
n1["Поместить watchdog в тот же Job"] --> n2["При зачистке гибнет и тот, кто перезапускает"]
n2 -.->|"поэтому"| n3["Watchdog ставить снаружи Job"]
n3 --> n4["Worker упал — перезапуск всё ещё возможен"]
Рис. 15: Первое условие размещения watchdog — не делать перезапускающего участником общей судьбы.
7.1 Наблюдение за завершением строить на wait handle
Когда процесс завершается, он переходит в сигнальное состояние.
Поэтому наблюдение за завершением в принципе не требует цикла опроса, который каждые 100 мс смотрит на HasExited.
В Win32 правильный путь такой:
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
Если потомков несколько, естественнее наблюдение на wait handle, а не опрос по таймеру.
7.2 Не ждать бесконечно в UI-потоке
WaitForSingleObject(INFINITE) удобен, но в потоке, который владеет окном, легко останавливает message pump.
Для UI-потока, потока COM-апартамента и потока с message pump безопаснее заранее решить, где именно стоит ожидание.
flowchart TB
accTitle: Наблюдение за завершением — на wait handle
accDescr: Процесс при завершении переходит в сигнальное состояние, поэтому наблюдение за завершением строят не на периодическом опросе HasExited, а на wait handle. Бесконечное ожидание в UI-потоке останавливает message pump, его избегают.
p1["Периодический polling HasExited"] -.-> p2["По сути не нужен"]
p3["Ждать handle, который становится signaled"] --> p4["Наблюдение на wait handle"]
p4 -.-> p5["Бесконечное ожидание в UI-потоке зависает экран"]
Рис. 16: Завершение ловят не опросом, а wait handle.
7.3 Watchdog зависаний требует heartbeat
Для watchdog завершения хватает дескриптора процесса. Для watchdog зависаний — нет.
- процесс завис при загрузке CPU 100%;
- deadlock;
- цикл обработки событий жив, но прогресса нет;
- процесс стоит в ожидании ввода.
Такие состояния по одному факту «процесс жив» не отличить. Поэтому если нужно видеть и зависания, нужна проверка живости на уровне приложения, например:
- heartbeat;
- последовательность прогресса;
- метка времени последней успешной работы;
- health probe.
flowchart TB
accTitle: Чтобы ловить зависания, нужен heartbeat
accDescr: Состояния вроде зависания при CPU 100%, deadlock или отсутствия прогресса по одному факту «процесс жив» не отличить. Если нужно видеть и зависания, нужна проверка живости на уровне приложения — heartbeat, прогресс и тому подобное.
q1["Процесс жив"] --> q2["Но, возможно, не продвигается"]
q2 --> q3["Одним наблюдением за завершением не отличить"]
q3 -.->|"поэтому"| q4["Проверка на уровне приложения: heartbeat, прогресс"]
Рис. 17: «Жив ли» и «продвигается ли» — это разное наблюдение.
7.4 Того, кто перезапускает, ставить снаружи объекта наблюдения
На практике часто встречаются два таких шаблона.
- Родительское приложение лишь временно запускает helper
- Job держит родитель; при завершении родителя зачищается всё дерево helper
- Нужно держать worker долго и при падении перезапускать
- внешний процесс или служба watchdog создаёт Job на каждое поколение worker
Во втором случае архитектура устойчивее, если отделить дерево worker от полномочий на перезапуск.
7.5 Политику перезапуска держать как бюджет
Как только появляется watchdog, следом начинается crash loop.
- немедленный перезапуск;
- снова немедленное падение;
- в журналах копится только шум.
Чтобы этого избежать, лучше держать бюджет перезапусков:
- backoff;
- верхняя граница числа перезапусков за заданный интервал;
- при подряд идущих сбоях — остановиться и уведомить.
flowchart TB
accTitle: Crash loop останавливают restart budget
accDescr: Чтобы не получить crash loop «сразу перезапустить — сразу снова упасть», держат restart budget: backoff, верхняя граница числа перезапусков за интервал и при подряд идущих сбоях остановка с уведомлением.
r1["Сразу перезапуск → сразу снова падение"] --> r2["Crash loop и потоп в журналах"]
r2 -.->|"чтобы предотвратить"| r3["Ввести backoff"]
r3 --> r4["Верхняя граница числа за интервал"]
r4 --> r5["Подряд неудачи — остановиться и уведомить"]
Рис. 18: Перезапуски ведут бюджетом: исчерпали — останавливаются и сообщают человеку.
8. Рекомендуемая схема по типичным сценариям
| Сценарий | Рекомендуемая схема |
|---|---|
| Настольное приложение запускает разовый CLI-helper | Один запуск = один Job. Задать KILL_ON_JOB_CLOSE, параллельный drain stdout / stderr. При отмене: согласованное завершение → timeout → завершить Job |
| Helper сам запускает процессы-внуки | Опираться на Job Object и не разрешать breakaway. Если принадлежность нужно зафиксировать с запуска — PROC_THREAD_ATTRIBUTE_JOB_LIST |
| Служба / watchdog долго наблюдает за деревом worker | watchdog — внешний процесс или служба. Job на каждое поколение worker, наблюдение через дескриптор завершения и heartbeat |
| Нужно аккуратно остановить консольный инструмент | Запуск с CREATE_NEW_PROCESS_GROUP, согласованное завершение через CTRL_BREAK_EVENT, затем по timeout завершить Job |
| Нужно закрыть GUI-helper | CloseMainWindow / эквивалент WM_CLOSE → timeout → завершить Job |
| Нужно наблюдать за множеством дочерних процессов | Вместо роста числа блокирующих потоков — RegisterWaitForSingleObject / SetThreadpoolWait |
Здесь важнее всего разделить механизм graceful shutdown и механизм cleanup.
flowchart TB
accTitle: Механизм запроса и механизм зачистки
accDescr: Во всех типичных сценариях важнее всего держать раздельно механизм graceful shutdown — close message или протокол завершения — и механизм cleanup через Job Object.
s1["Механизм graceful shutdown"] --> s3["Держать оба по отдельности"]
s2["Механизм cleanup (Job)"] --> s3
s3 -.-> s4["В норме работает первый, при сбое — второй"]
Рис. 19: В любом сценарии путь запроса и путь зачистки готовят отдельно.
9. Чего делать не стоит
Замечания из предыдущих глав собраны в виде, который можно сразу нести на ревью. Рядом — «что произойдёт» и «где это в тексте», поэтому от зацепившей строки можно вернуться в статью.
| Чего делать не стоит | Что произойдёт | Текст |
|---|---|---|
Считать, что одного Kill(entireProcessTree: true) хватает и на graceful shutdown, и на зачистку при падении родителя |
Работает только при явной остановке. Выпадают зачистка, когда упал родитель, и путь, по которому потомок сам делает cleanup | глава 5 |
Оставить bInheritHandles=TRUE и отдать всё подряд |
В потомка уходят не те handle, появляются утечки handle и EOF, который не доходит | 6.4 |
Сначала полностью прочитать stdout, потом stderr |
Другой pipe заполняется: родитель ждёт чтения, потомок — записи | 6.1 |
| Не закрывать неиспользуемые концы pipe | EOF не доходит, условие завершения на читающей стороне не выполняется | 6.3 |
Вызывать WaitForSingleObject(INFINITE) в UI-потоке |
Останавливается message pump, зависают экран и COM | 7.2 |
| Помещать watchdog в тот же Job, что и объект наблюдения | Когда зачищают объект наблюдения, вместе с ним исчезает и тот, кто должен перезапускать | глава 7 |
| Использовать 259 как обычный exit code | GetExitCodeProcess во время выполнения возвращает STILL_ACTIVE, то есть 259. Если потомок штатно вышел с кодом 259, его ошибочно считают ещё выполняющимся |
7.1 |
| Считать уведомления completion port у Job единственным источником истины | Уведомления — для мониторинга и агрегации; если на них одних строить корректность, будут пропуски | 4.2 |
10. Итог
Когда в Windows-приложении нужно безопасно работать с дочерними процессами, сильнее всего помогает такая раскладка.
Кто владеет process tree? Как передаётся запрос на завершение? Как стандартный ввод-вывод пропускается до конца? Куда ставить watchdog?
Эти четыре пункта решают заранее.
Если сказать грубо, вывод такой.
- Точка отсчёта для зачистки дерева — Job Object
- Graceful shutdown разделяют по типу: GUI / консоль / worker
- stdio проектируют с параллельным drain и вплоть до EOF
- watchdog ставят снаружи объекта наблюдения и смотрят через wait handle и heartbeat, а не через опрос
Сами CreateProcess и Process.Start — только вход.
На частоту сбоев по-настоящему влияют то, где закреплена ответственность за завершение, и то, доведено ли I/O до конца.
flowchart TB
accTitle: Четыре вещи, которые решают заранее
accDescr: На частоту сбоев дочерних процессов сильнее всего влияет то, что заранее решают, кто владеет process tree, как передаётся запрос на завершение, как стандартный ввод-вывод пропускается до конца и куда ставить watchdog.
t1["Владелец дерева"] --> t5["Решить заранее"]
t2["Как передавать завершение"] --> t5
t3["Как обращаться со stdio"] --> t5
t4["Куда ставить наблюдение"] --> t5
t5 --> t6["API запуска — только вход"]
Рис. 20: На частоту сбоев влияет то, что эти четыре пункта решают раньше, чем запуск.
11. Источники
- 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
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
wait на условной переменной может вернуться и без уведомления (ложное пробуждение). Разбираем по реализации Windows, почему спецификация ...
Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
Проверенные приёмы проектирования на .NET/C#, чтобы код не «иногда падал или зависал»: не создавать потоки вручную и опираться на Task, с...
WMI/CIM из C# и PowerShell — практическое руководство по сведениям об оборудовании, мониторингу процессов и удалённым запросам
WMI/CIM — стандартный способ получить серийный номер ПК, следить за свободным местом на диске и ловить запуск процессов. Разбираем команд...
Глубины ввода-вывода Windows (часть 4) — диспетчер кэша: когда WriteFile оказывается на диске
Четвёртая часть серии со схемами диспетчера кэша Windows. Разбираем кэш как проекцию файла, упреждающее чтение и отложенную запись, когда...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
В Windows-приложениях, которые запускают внешние CLI, средства конвертации, worker и updater, на стабильность сильнее влияет не способ запуска, а управление деревом процессов и проектирование завершения.
Расследование ошибок и причин
Трудновоспроизводимые эксплуатационные сбои — после падения родителя остаётся только дочерний процесс, забивается stdout, watchdog падает вместе с объектом наблюдения — часто снимаются пересмотром архитектуры управления процессами.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Почему после падения родителя дочерний процесс остаётся жить?
- Потому что ни дескриптор процесса, ни группа процессов сами по себе не содержат механизма, который зачищает дерево процессов при падении родителя. Если нужно связать время жизни дерева дочерних процессов с тем, жив родитель или нет, точка отсчёта — Job Object. Если задать JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, все процессы, принадлежащие Job, завершаются в момент закрытия последнего job handle. Так cleanup, включая аварийное завершение родителя, можно привязать к времени жизни родителя.
- Почему WaitForExit не возвращает управление?
- С большой вероятностью забиты pipe стандартного вывода и стандартной ошибки. Pipe в Windows — не бесконечный буфер, поэтому если дочерний процесс пишет много в stderr, а родитель читает только stdout, дочерний процесс останавливается на write, а родитель — в ожидании завершения. Базовое правило — вычитывать stdout и stderr параллельно; реализация, которая сначала полностью читает одну сторону и только потом другую, легко забивается. Кроме того, если не закрыть неиспользуемый конец pipe, EOF не доходит и условие завершения рассыпается.
- Разве недостаточно одного Kill(entireProcessTree: true) в .NET?
- Недостаточно. Как API явной остановки он удобен, но не заменяет архитектуру, которая включает автоматическую зачистку при падении родителя и graceful shutdown. Реже ломается схема из трёх этапов: запросить согласованное завершение, подождать с коротким timeout и в конце принудительно завершить весь Job. Способ согласованного завершения зависит от типа дочернего процесса: для GUI — CloseMainWindow, для консольного — CREATE_NEW_PROCESS_GROUP и CTRL_BREAK_EVENT, для worker — протокол завершения через stdin или pipe.
- Где должен стоять процесс watchdog?
- Главное — не помещать его в тот же Job, что и объект наблюдения. Если worker упал и его нужно перезапустить, нет смысла, чтобы вместе с ним погиб и тот, кто должен перезапускать. Когда worker работает долго, устойчивее схема, в которой внешний процесс или служба watchdog создаёт отдельный Job на каждое поколение worker. Наблюдение за завершением лучше строить на wait handle, а не на polling; если нужно ещё и ловить зависания, к этому добавляют проверку живости на уровне приложения — например, heartbeat.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.