Чек-лист безопасной работы с дочерними процессами в Windows-приложении

· Обновлено: · · Windows, Процессы, Job Object, IPC, C++, .NET, C#

История изменений (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 собраны в одну архитектуру.

Сбои случаются за пределами запускаСбои дочерних процессов — это не вопрос, удалось ли запустить процесс, а ситуации вроде «родитель упал, а дочерний остался» или «stdout забился». Помогает не выбор API запуска, а решение, кто владеет деревом процессов, и проектирование завершения и I/O.Выбрать API запускаЭто не ядро сбояОпределить владельца дерева процессовБезопасная работа с дочерними процессамиСпроектировать процедуру завершенияСпроектировать I/O

Рис. 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 вывод до конца, чтобы пишущая сторона не застряла

Общая картина

Сначала — одна схема отношений между участниками.

Job Object с JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEловит завершение через exit handleловит зависание через heartbeatпересоздаёт в пределах restart budgetродительское приложение / сам workerконечный владелец job handleдочерний helper.exeвнук converter.exeвнук ffmpeg.exewatchdogставить снаружи 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).

Карта знаний безопасного управления дочерними процессами WindowsСхема показывает, как Job Object связывает дерево процессов и автоматически завершает его при сбое родителя, процедуру завершения от graceful shutdown до принудительного завершения, как параллельный drain stdout/stderr предотвращает взаимную блокировку, и как watchdog размещают вне Job и наблюдают через heartbeat и restart budgetнастраиваетсяпредотвращаетрекомендуется дляне рекомендуетсятребуетиспользуеттребуетдолжен предшествоватьиспользуетне рекомендуетсяиспользуетавтоматизируетпредотвращаетпредотвращаетиспользуетреализуетможет вызватьJob Objectвзаимная блокировка стандартного ввода-выводапроцесс-watchdogJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEосиротевший дочерний процессавтоочистка при падении родителяProcess.Kill(entireProcessTree: true)PROC_THREAD_ATTRIBUTE_JOB_LISTгруппа процессов (консоль)событие управления консольюgraceful shutdownпроверка живости по heartbeatбюджет перезапусковцикл падений (crash loop)параллельный drain stdout/stderrI/O Completion Port (IOCP)дерево процессов (process tree)JOB_OBJECT_LIMIT_BREAKAWAY_OK

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 17, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. В чём именно опасность

Реализацию запуска дочернего процесса поначалу обычно укладывают примерно в 10 строк. Но ломается то, что за пределами этих 10 строк.

  • После падения родителя дочерние процессы и внуки продолжают жить
  • helper запускает ещё один helper, а ждут только непосредственного потомка — и на этом успокаиваются
  • Одна сторона stdout / stderr забивается, и родитель с дочерним процессом ждут друг друга
  • Ожидание идёт в UI-потоке, и зависают и окно, и COM
  • watchdog делит судьбу с объектом наблюдения и при сбое падает вместе с ним

Здесь важно вот что: «управление дочерними процессами» — это не разговор про один API.

Как минимум эти четыре вопроса лучше держать раздельно — тогда картина складывается.

  1. Кто владеет деревом процессов
  2. Как запрашивается согласованное завершение
  3. Как пропускается стандартный ввод-вывод
  4. Как наблюдают аварийное завершение и зависания
Четыре вопроса, которые стоит разделитьУправление дочерними процессами — это не разговор про один API. Картина складывается, если раздельно держать, кто владеет деревом процессов, как запрашивается согласованное завершение, как пропускается стандартный ввод-вывод и как наблюдают аварийное завершение и зависания.1. Владелец дерева процессов2. Как просить согласованное завершение3. Как пропускать стандартный ввод-вывод4. Наблюдение за сбоем и зависаниемЭто не разговор про один 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 из коробки есть только операции в масштабе одного процесса.

Граница ответственности .NET и граница JobВ стандартной библиотеке .NET есть запуск, ожидание завершения и другие операции в масштабе одного процесса. Вокруг Job Object обёртки нет, поэтому Win32 API вызывают через P/Invoke как есть.Операции в масштабе одного процессаХватает стандартного Process в .NETОперации вокруг Job ObjectОбёртки нетВызвать 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.

Связка через Job убирает пропуски в зачисткеJob Object связывает дерево процессов не по тому, чей это потомок, а по принадлежности к Job. Потомки процесса внутри Job по умолчанию входят в тот же Job, а с KILL_ON_JOB_CLOSE все процессы завершаются, когда закрывается последний job handle.Следить по «чей потомок»Появляются внуки — и кто-то утекаетСвязывать по «к какому Job принадлежит»Потомки потомка тоже в тот же JobKILL_ON_JOB_CLOSEЗакрылся последний 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 — это нужно решить заранее.

Владельца job handle нельзя оставлять неопределённымKILL_ON_JOB_CLOSE срабатывает, когда закрывается последний handle. Если job handle продублировать в другой процесс или нечаянно отдать по наследованию, после гибели родителя cleanup не произойдёт как ожидалось, поэтому конечного владельца нужно решить заранее.поэтомуДублировать или наследовать job handleПоследний handle не закрываетсяРодитель погиб, а cleanup нетСначала решить, кто конечный владелец

Рис. 6: KILL_ON_JOB_CLOSE начинает работать только когда закрыт «последний handle».

4.2 Job Object годится и для наблюдаемости, но уведомления не всесильны

К Job Object можно привязать I/O completion port и получать уведомления. Но уведомления через completion port безопаснее не считать полностью гарантированными во всех случаях.

Поэтому completion port удобен для

  • мониторинга;
  • агрегации;
  • журналирования;
  • метрик,

но корректность системы на одних только этих уведомлениях лучше не строить.

Где уместны уведомления completion portК Job Object можно привязать I/O completion port и получать уведомления, но их не стоит считать полностью гарантированными во всех случаях. Их удобно использовать для мониторинга, агрегации, журналирования и метрик и не строить на них одних корректность.Уведомления completion port у JobУдобны для мониторинга, агрегации, журналовНе считать полностью гарантированнымиНе строить на них одних корректность

Рис. 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 ищет в таком порядке.

  1. Каталог, из которого загружено приложение
  2. Текущий каталог родительского процесса
  3. Системный каталог 32-bit
  4. Системный каталог 16-bit
  5. Каталог Windows
  6. Каталоги из переменной среды 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.

Даже в среде, где «такой ошибки размещения не бывает», стоимость записи почти нулевая. В коде запуска дочернего процесса по сути нет причин указывать исполняемый файл относительным именем.

Какой сбой даёт запуск по относительному имениЕсли передать NULL в lpApplicationName и запускать только по имени файла, в поиск попадают текущий каталог родителя и PATH. Когда настоящего helper.exe нет, одноимённый исполняемый файл из доступного на запись места запускается с правами родителя. Поэтому путь всегда фиксируют абсолютным.чтобы предотвратитьЗапуск только по имени файлаВ поиск входят текущий каталог и PATHКогда helper.exe нет на своём местеПоложенный одноимённый EXE идёт с правами родителяУказать абсолютный путь и взять в кавычки

Рис. 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.

Щель между запуском и AssignЕсли после запуска класть процесс в Job через AssignProcessToJobObject, в промежутке потомок может создать внука, и внук родится снаружи Job. Если helper плодит внуков, щель стоит закрыть, указав Job уже при создании через PROC_THREAD_ATTRIBUTE_JOB_LIST.чтобы закрыть щельСначала запустить, потом положить в JobМежду запуском и Assign есть щельВнук, родившийся в этой щели, снаружи JobУказать Job уже при создании и запустить

Рис. 9: У схемы «положить в Job потом» есть щель, через которую утекают внуки.

5. Распространение завершения проектировать как протокол и timeout

Завершение дочернего процесса — не история, которую закрывает один вызов kill. Реже всего ломается схема из трёх этапов.

  1. Запросить согласованное завершение
  2. Подождать с коротким timeout
  3. В конце принудительно завершить весь Job

В таком порядке нормальный путь завершения сохраняется, а при зависании дерево всё равно можно зачистить.

Завершение в три этапаЗавершение дочернего процесса не закрывается одним вызовом kill. Если запросить согласованное завершение, подождать с коротким timeout и в конце принудительно завершить весь Job, нормальный путь завершения сохраняется, а при зависании дерево всё равно можно зачистить.1. Запросить согласованное завершение2. Подождать с коротким timeout3. В конце принудительно завершить весь JobНормальный путь сохранить, зависание зачистить

Рис. 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 — такое разделение реже ломается.

Согласованное завершение разделяют по типу потомкаДля GUI-потомка — close message вроде CloseMainWindow, для консольного — CREATE_NEW_PROCESS_GROUP и CTRL_BREAK_EVENT, для worker — протокол завершения через stdin или pipe. Средство запроса согласованного завершения выбирают по типу потомка.Запрос согласованного завершенияGUI-потомок: close messageКонсольный потомок: CTRL_BREAK_EVENTworker: протокол завершения через stdin или pipeЗачистку дерева берёт на себя Job Object

Рис. 11: Средство согласованного завершения выбирают по типу потомка, зачистку оставляют Job.

6. Не забивать стандартный ввод-вывод

6.1 stdout / stderr — параллельный drain

Первое базовое правило такое. stdout и stderr вычитывать параллельно. Схема «сначала полностью одну сторону, потом другую» легко забивается.

Pipe в Windows — не бесконечный буфер. Если потомок пишет много в stderr, а родитель читает только stdout, обычная картина такая: потомок останавливается на write, родитель — в ожидании завершения.

На схеме это выглядит так.

Дочерний процессpipe stderrpipe stdoutРодительский процессДочерний процессpipe stderrpipe stdoutРодительский процессбуфер pipe заполняетсяwrite не возвращается. Потомок здесь останавливаетсяпотомок стоит, ничего не приходитродитель ждёт чтения, потомок — записи. WaitForExit тоже не возвращаетсяпродолжает читать только stdoutпишет немногопрочитанопишет много предупрежденийпытается писать ещёпытается читать дальше

Рис. 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) (не удалось остановить часть потомков) не глотаем. Это и есть состояние «дерево не зачищено», которое этот раздел как раз и пытается предотвратить.

Гонка на границе timeoutВ короткий промежуток после того, как WaitForExit вернул false, и до вызова Kill потомок может завершиться сам. Тогда сбой cleanup перекрывает настоящий TimeoutException. Поэтому сначала через HasExited проверяем, действительно ли процесс закончился, и только тогда глотаем; если ещё жив — пробрасываем.уже закончилсяещё живСразу после истечения ожидания потомок завершается самKill завершается ошибкойПроверить через HasExitedЭто не отказ, глотаемОстановить не удалось, пробрасываемВыбросить настоящий TimeoutException

Рис. 13: В гонке на границе через HasExited отличаем, отказ Kill — настоящий отказ или нет.

Последний WaitForExit() — не забытый вызов, а обязательный. В документации WaitForExit(int) сказано: если стандартный вывод перенаправлен в асинхронный обработчик событий, к моменту возврата этой перегрузки обработка вывода может быть ещё не завершена, и после получения true рекомендуют вызвать WaitForExit() без аргументов. Если это опустить, ломается трудновоспроизводимо: отрезается только хвост вывода.

6.2 Если используется stdin, проектировать до EOF

То, что в stdin можно писать, и то, что потомок может завершиться, — не одно и то же.

  • ввод записали, но close не сделали;
  • родитель считает, что «уже всё передал»;
  • потомок считает, что «ещё придёт продолжение», и продолжает ждать.

Такое состояние возникает. Если используется stdin, в архитектуру нужно включить и close после записи, чтобы передать EOF.

stdin проектируют до EOFЕсли после записи во stdin не сделать close, родитель думает, что уже всё передал, а потомок ждёт продолжения. Поэтому проектируют вплоть до close после записи, чтобы передать EOF.Записали ввод и не сделали closeРодитель думает: «уже передал»Потомок ждёт: «ещё придёт продолжение»После записи close, чтобы передать EOF

Рис. 14: У stdin в проектирование входит не «можно писать», а то, что EOF доходит.

6.3 Неиспользуемые концы pipe закрывать обязательно

Если на стороне родителя или потомка не закрыть неиспользуемые концы, EOF не доходит, и условие завершения рассыпается. Это просто, но на практике один из самых частых сбоев.

6.4 Не оставлять размытыми UseShellExecute=false и наследование handle

Если используется перенаправление стандартного ввода-вывода, в .NET предпосылка — UseShellExecute=false. В Win32 тоже безопаснее максимально сузить, что именно наследуется. Если оставить bInheritHandles=TRUE и отдать всё подряд, это источник неожиданных утечек handle.

7. Watchdog ставить «снаружи»

Самое важное при добавлении watchdog — не помещать его в тот же Job, что и объект наблюдения. Если worker упал и его нужно перезапустить, нет смысла, чтобы вместе с ним погиб и тот, кто должен перезапускать.

Watchdog ставят снаружи объекта наблюденияЕсли watchdog стоит в том же Job, что и объект наблюдения, при зачистке упавшего worker вместе с ним гибнет и тот, кто должен перезапускать. Поэтому watchdog ставят снаружи Job наблюдения.поэтомуПоместить watchdog в тот же JobПри зачистке гибнет и тот, кто перезапускаетWatchdog ставить снаружи JobWorker упал — перезапуск всё ещё возможен

Рис. 15: Первое условие размещения watchdog — не делать перезапускающего участником общей судьбы.

7.1 Наблюдение за завершением строить на wait handle

Когда процесс завершается, он переходит в сигнальное состояние. Поэтому наблюдение за завершением в принципе не требует цикла опроса, который каждые 100 мс смотрит на HasExited.

В Win32 правильный путь такой:

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

Если потомков несколько, естественнее наблюдение на wait handle, а не опрос по таймеру.

7.2 Не ждать бесконечно в UI-потоке

WaitForSingleObject(INFINITE) удобен, но в потоке, который владеет окном, легко останавливает message pump. Для UI-потока, потока COM-апартамента и потока с message pump безопаснее заранее решить, где именно стоит ожидание.

Наблюдение за завершением — на wait handleПроцесс при завершении переходит в сигнальное состояние, поэтому наблюдение за завершением строят не на периодическом опросе HasExited, а на wait handle. Бесконечное ожидание в UI-потоке останавливает message pump, его избегают.Периодический polling HasExitedПо сути не нуженЖдать handle, который становится signaledНаблюдение на wait handleБесконечное ожидание в UI-потоке зависает экран

Рис. 16: Завершение ловят не опросом, а wait handle.

7.3 Watchdog зависаний требует heartbeat

Для watchdog завершения хватает дескриптора процесса. Для watchdog зависаний — нет.

  • процесс завис при загрузке CPU 100%;
  • deadlock;
  • цикл обработки событий жив, но прогресса нет;
  • процесс стоит в ожидании ввода.

Такие состояния по одному факту «процесс жив» не отличить. Поэтому если нужно видеть и зависания, нужна проверка живости на уровне приложения, например:

  • heartbeat;
  • последовательность прогресса;
  • метка времени последней успешной работы;
  • health probe.
Чтобы ловить зависания, нужен heartbeatСостояния вроде зависания при CPU 100%, deadlock или отсутствия прогресса по одному факту «процесс жив» не отличить. Если нужно видеть и зависания, нужна проверка живости на уровне приложения — heartbeat, прогресс и тому подобное.поэтомуПроцесс живНо, возможно, не продвигаетсяОдним наблюдением за завершением не отличитьПроверка на уровне приложения: heartbeat, прогресс

Рис. 17: «Жив ли» и «продвигается ли» — это разное наблюдение.

7.4 Того, кто перезапускает, ставить снаружи объекта наблюдения

На практике часто встречаются два таких шаблона.

  • Родительское приложение лишь временно запускает helper
    • Job держит родитель; при завершении родителя зачищается всё дерево helper
  • Нужно держать worker долго и при падении перезапускать
    • внешний процесс или служба watchdog создаёт Job на каждое поколение worker

Во втором случае архитектура устойчивее, если отделить дерево worker от полномочий на перезапуск.

7.5 Политику перезапуска держать как бюджет

Как только появляется watchdog, следом начинается crash loop.

  • немедленный перезапуск;
  • снова немедленное падение;
  • в журналах копится только шум.

Чтобы этого избежать, лучше держать бюджет перезапусков:

  • backoff;
  • верхняя граница числа перезапусков за заданный интервал;
  • при подряд идущих сбоях — остановиться и уведомить.
Crash loop останавливают restart budgetЧтобы не получить crash loop «сразу перезапустить — сразу снова упасть», держат restart budget: backoff, верхняя граница числа перезапусков за интервал и при подряд идущих сбоях остановка с уведомлением.чтобы предотвратитьСразу перезапуск → сразу снова падениеCrash loop и потоп в журналахВвести backoffВерхняя граница числа за интервалПодряд неудачи — остановиться и уведомить

Рис. 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.

Механизм запроса и механизм зачисткиВо всех типичных сценариях важнее всего держать раздельно механизм graceful shutdown — close message или протокол завершения — и механизм cleanup через Job Object.Механизм graceful shutdownДержать оба по отдельностиМеханизм cleanup (Job)В норме работает первый, при сбое — второй

Рис. 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 до конца.

Четыре вещи, которые решают заранееНа частоту сбоев дочерних процессов сильнее всего влияет то, что заранее решают, кто владеет process tree, как передаётся запрос на завершение, как стандартный ввод-вывод пропускается до конца и куда ставить watchdog.Владелец дереваРешить заранееКак передавать завершениеКак обращаться со stdioКуда ставить наблюдениеAPI запуска — только вход

Рис. 20: На частоту сбоев влияет то, что эти четыре пункта решают раньше, чем запуск.

11. Источники

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Расследование ошибок и причин

Трудновоспроизводимые эксплуатационные сбои — после падения родителя остаётся только дочерний процесс, забивается 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог