История изменений (первая версия, опубликована 29 Aug 2026)
- Первая публикация
UI закрыли из Task Manager, а камеру снова открыть нельзя. Наблюдающее приложение упало, а помощник SDK по-прежнему держит COM-порт. Родителя перезапустили — двойной запуск, проблемы с общей памятью и именованным каналом. Стоит вынести SDK устройства в отдельный процесс — и встречаешь такие аварии «после падения родителя».
Отправная точка: в Windows завершение родительского процесса само по себе не завершает дочерние и внучатые. Родство при запуске и управление жизнью при завершении — разные вещи. Инструмент, который заполняет этот пробел, — Job Object.
В статье сначала подтверждаем, почему одной завершающей обработки родителя мало, затем разбираем, как вкладывать в Job, какую политику завершения выбрать и как наблюдать. В конце связываем это с авариями при работе с устройствами и порядком расследования.
Кому статья. Разработчики WinForms / WPF / служб, которые вынесли SDK устройства в отдельный процесс. Исходная среда — Windows 10/11 (места с вложенными заданиями и PROC_THREAD_ATTRIBUTE_JOB_LIST); код — C++ (Win32 API) и C# (.NET 6 и новее). Сложность — средняя.
Статья продолжает цикл «Не отвечает», «завершение работы», «выход из сна», «именованные каналы» и берёт жизнь снаружи процесса.
1. Сначала выводы
Отправная точка дизайна — не «как завершать», а сначала решить, «что нельзя оставлять и что хочется оставить».
Job Object делает дерево процессов одной единицей. Но принудительно завершить потомков в тот же момент, когда исчез родитель, и сохранить дамп или последнее состояние этих потомков как есть не совмещаются. Решают, забирать занятие устройства или сначала оставить материал для диагностики.
- Единица управления жизнью — не родословная родитель–потомок, а Job. На группу процессов вешают ограничение, уведомление и пакетное завершение. Процесс, который раз вложили, до завершения не вынуть; с Windows 8 можно вкладывать.12
- Принадлежность к Job фиксируют до того, как дочерний побежит. Assign после запуска упускает внуков, родившихся в этом окне. У
CREATE_SUSPENDEDиJOB_LISTпри порождении закрываемое окно гонки разное (глава 4). - Автоподбор и сохранение диагностических сведений выбирают как политику завершения. KillOnJobClose срабатывает, «когда закрылся последний дескриптор Job». Против крушения родителя это сильно, против последующего разбора потомков, которых утащили принудительным завершением, — слабо; если нужно оба, наблюдающая сторона сначала снимает материал, потом завершает (глава 5).3
Измерительному приложению нужно не само убийство. Нужно не оставлять устройство занятым и уметь наблюдать аварийное завершение.
| Что нужно понять | Главы |
|---|---|
| Почему одной завершающей обработки родителя мало | 2–3: родство и роль Job |
| Как вести потомков, не упуская | 4–6: порождение, политика завершения, наблюдение |
| Что ломается в SDK и службах | 7–9: аварии, ограничение ресурсов, вложенность и breakaway |
| Что смотреть на месте и как выбирать | 10–11: порядок расследования и таблица решений |
Карта знаний ниже — чтобы возвращаться к связям элементов. Если читать от устройства — переходите к главе 2.
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Почему WaitForExit недостаточно
«Ждать завершения» и связать жизни — разные вещи
Process.WaitForExit() — API «родитель ждёт завершения дочернего». В статье направление обратное: что делать с дочерним, когда родитель умер первым. Одного родства Windows мало: завершение родителя дочернему не передаётся.
Process.Kill() и CloseMainWindow() сами по себе не доводят до внучатых процессов и дескрипторов устройства, которые внуки держат. Дескрипторы завершённого процесса освобождаются; проблема — дескрипторы, которые держат выжившие потомки.
Kill(entireProcessTree: true) в .NET обходит потомков и завершает их, но есть упущения при порождении во время перечисления и когда родитель умер первым, и после крушения самого родителя его не вызывают. Нужен механизм, который не зависит только от кода уборки родителя.
По основным причинам завершения родителя — что остаётся на месте.
| Почему умер родитель | Что с дочерним | Что остаётся на месте |
|---|---|---|
| Закрыли UI крестиком, завершающая обработка неполная | Ничего (Process только уничтожается) |
Процесс-помощник, блокировка камеры |
| Из Task Manager убили только родителя | Дочерний продолжает жить | COM-порт, USB, общая память |
| Крушение на необработанном исключении | finally родителя не гарантирован |
Временные файлы, исключающая блокировка |
| Таймаут остановки службы | SCM ведёт только родителя | В сеансе 0 остаются дочерние |
flowchart TB
accTitle: Расхождение жизни родителя и занятия устройства
accDescr: Завершение родительского процесса убирает ожидание родителя и объект Process, но дочерние и внучатые процессы живут дальше, и занятие дескрипторов устройства, именованного канала и файла блокировки остаётся
parent["Завершение родительского процесса"] --> gone["Пропадает: ожидание родителя, объект Process"]
parent --> live["Остаётся: дочерние и внучатые процессы"]
live --> dev["Занятие дескрипторов устройства"]
live --> pipe["Серверная сторона именованного канала"]
live --> lock["Файл блокировки, общая память"]
Рис. 1: Жизнь родителя и жизнь занятия устройства расходятся. Код уборки на стороне родителя тем реже бежит, чем аномальнее смерть родителя.
Объект — случай «ОС жива, умер только родитель»
Поток, в котором ОС останавливает всё при завершении работы или сне, — область статей «завершение работы» и «выход из сна». Здесь — ОС в порядке, завершается только родитель. На измерительном месте этот случай чаще, и остаток труднее заметить.
3. Что такое Job Object
Базовые операции: создать, вложить, настроить, запросить
Job Object — объект ядра, который ведёт группу процессов как одну единицу. Базовые операции по роли — четыре.14
| API | Роль |
|---|---|
CreateJobObject |
Создать Job, которому ещё не принадлежит ни один процесс |
AssignProcessToJobObject |
Вложить процесс в Job |
SetInformationJobObject |
Задать ограничения и прочее |
QueryInformationJobObject |
Читать учётные сведения: время ЦП, ошибки страниц, число процессов |
Принадлежность необратима: до завершения процесса не вынуть. В учётные сведения входит и доля уже завершившихся процессов.
Дочерний, которого принадлежащий процесс создаёт через CreateProcess, по умолчанию принадлежит тому же Job. То есть внуки и правнуки входят сами — в этом центр ценности Job.1 Пути, которыми из принадлежности выпадают (breakaway, порождение через WMI), отдельно смотрят в главе 9.
flowchart TB
accTitle: Базовая структура Job Object
accDescr: Job Object, созданный родительским процессом, держит дочернего и внука; Job принуждает ограничения, уведомляет порт завершения и пакетно завершает на единицу дерева процессов
parent["Родительский процесс"] --> job["Job Object"]
job --> child["Дочерний (хост SDK устройства)"]
child --> gc1["Внук (помощник производителя)"]
job -.-> lim["Ограничение (память, ЦП)"]
job -.-> note["Уведомление (порт завершения)"]
job -.-> kill["Пакетное завершение"]
Рис. 2: Job — сосуд, который на единицу дерева процессов даёт «ограничение», «уведомление» и «пакетное завершение».
Разница поколений ОС и отличие от песочницы
До Windows 7 — один процесс, одно задание; с Windows 8 возможна вложенность (несколько принадлежностей).5 Текст исходит из Windows 10/11; на что смотреть до Windows 7 — глава 9 и FAQ.
Одного вложения в Job мало, чтобы стать контейнером или песочницей. Сетевой доступ не ограничить, маркер доступа (права) — другой механизм. Одними ограничениями UI границу безопасности не построить. Роль в этой статье — сделать жизнь и ресурсы дерева процессов одной единицей.
4. Как вкладывать правильно ── гонка порождения и принадлежности
Assign после запуска: за это время рождаются внуки
Дочерний процесс в первые миллисекунды после старта может породить внука. Типичный случай — запуск помощника SDK. Если после Process.Start() взять PID и Assign, внук, родившийся раньше Assign, оказывается вне Job.
flowchart TB
accTitle: Вложить после того, как побежал, — упустить внука
accDescr: Сразу после Process.Start дочерний уже бежит; внук, которого он породил в окне гонки до AssignProcessToJobObject, выходит за Job
s["Process.Start: дочерний побежал"] --> w["Окно гонки до Assign"]
w --> g["Внук, родившийся за это время"]
g --> out["Продолжает бежать вне Job"]
s --> a2["AssignProcessToJobObject"]
a2 --> in2["Входят только внуки, рождённые после"]
Рис. 3: Окно гонки — миллисекунды, но запуск помощника SDK как раз там.
Приём A: породить остановленным, вложить, потом пустить
Классический способ с широкой совместимостью — CREATE_SUSPENDED. Окно, в котором дочерний успевает породить внука, закрывают таким порядком.67
CreateJobObject— создать JobSetInformationJobObject— сначала задать ограниченияCreateProcessсCREATE_SUSPENDED(начальный поток не бежит)AssignProcessToJobObject— вложить- При неудаче не Resume, сразу
TerminateProcess(вне Job не дать выполниться ни одной инструкции) ResumeThread— пустить
Приём A закрывает окно гонки с порождением внука. Окно против крушения самого родителя остаётся. Если родитель рухнет между шагами 3 и 4, останется остановленный дочерний, ещё не в Job. Он не бежит, но сам не исчезает.
Чтобы закрыть и это окно, включая устойчивость к крушению родителя, берут приём B.
flowchart TB
accTitle: Порядок: запуск SUSPENDED, затем вложение в Job
accDescr: Создают Job, задают ограничения, запускают дочернего с CREATE_SUSPENDED, вкладывают AssignProcessToJobObject; при неудаче не Resume, а TerminateProcess; при успехе пускают ResumeThread
a["CreateJobObject: создать Job"] --> b["SetInformationJobObject: ограничения"]
b --> c["Запуск дочернего с CREATE_SUSPENDED"]
c --> d["AssignProcessToJobObject"]
d -->|"Успех"| e["ResumeThread: пустить"]
d -->|"Неудача"| f["Не Resume, сразу Terminate"]
Рис. 4: Каркас приёма A. Реализация, которая опускает ветвь «при неудаче не пускать», как раз в аварии и плодит бесхозный процесс.
// C++: минимальное ядро приёма A (обработка ошибок только каркасом)
HANDLE job = CreateJobObjectW(nullptr, nullptr); // безымянный. не наследовать
if (!job) return HRESULT_FROM_WIN32(GetLastError());
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation,
&limits, sizeof(limits))) {
DWORD err = GetLastError(); // спасти до того, как CloseHandle перезапишет
CloseHandle(job); // не пускать дочернего в Job без ограничений
return HRESULT_FROM_WIN32(err);
}
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {};
if (!CreateProcessW(exePath, cmdline, nullptr, nullptr, FALSE,
CREATE_SUSPENDED, nullptr, nullptr, &si, &pi)) {
DWORD err = GetLastError();
CloseHandle(job); // не терять дескриптор Job при повторе запуска
return HRESULT_FROM_WIN32(err);
}
if (!AssignProcessToJobObject(job, pi.hProcess)) {
DWORD err = GetLastError(); // спасти до того, как Terminate перезапишет
TerminateProcess(pi.hProcess, 1); // вне Job не пускать
// закрыть дескрипторы и поднять err
} else if (ResumeThread(pi.hThread) == (DWORD)-1) {
DWORD err = GetLastError();
TerminateProcess(pi.hProcess, 1); // не оставлять навечно приостановленным
// закрыть дескрипторы и поднять err
}
CloseHandle(pi.hThread);
// при успехе владение job и pi.hProcess переходит объекту управления жизнью
// вызывающего (аналог обёртки C# в главе 4). закрыть job = срабатывание
// KillOnJobClose; pi.hProcess в главе 6 нужен, чтобы установить, кто умер
Приём B: с Windows 10 порождать уже внутри Job
В список атрибутов STARTUPINFOEX кладут дескриптор Job через PROC_THREAD_ATTRIBUTE_JOB_LIST. И передают в CreateProcess с флагом EXTENDED_STARTUPINFO_PRESENT. Без флага структура не читается как расширенная, атрибуты игнорируются.89
При этом способе процесс принадлежит Job ещё до того, как побежит начальный поток. Самого окна гонки «породили, ещё не вложили» нет, поэтому SUSPENDED и ветвь обработки неудачи Assign после порождения не нужны.
| Способ | Когда фиксируют принадлежность | Что остаётся |
|---|---|---|
| Приём A: SUSPENDED → Assign → Resume | После порождения дочернего, до запуска начального потока | Если родитель рухнет до Assign, останется остановленный дочерний |
| Приём B: порождение с JOB_LIST | В момент порождения процесса | Нужна Windows 10 и новее. Задают список атрибутов и флаг расширенного запуска |
flowchart TB
accTitle: Разница окна гонки приёмов A и B
accDescr: Приём A порождает остановленным, затем Assign и Resume, поэтому нужна ветвь завершения при неудаче; приём B порождает с Job в списке атрибутов, поэтому к моменту рождения уже внутри, окна гонки и ветви неудачи нет
a1["Приём A: породить остановленным"] --> a2["Assign: вложить"]
a2 --> a3["Resume: пустить"]
a2 -.-> a4["Ветвь неудачи обязательна"]
b1["Приём B: породить со списком атрибутов"] --> b2["К моменту рождения уже внутри"]
b2 -.-> b3["Нет ни окна гонки, ни ветви неудачи"]
Рис. 5: Приём A — «вложить, потом пустить»; приём B — «родиться уже внутри». Если можно исходить из Windows 10 и новее, причина выбора — само наличие окна гонки и ветви неудачи.
Три реализации, которых избегать в любом приёме
- После
Process.Start()взять PID и вложить (внук успеет выйти) - Наследовать дочернему дескриптор Job (после смерти родителя дочерний держит дескриптор, KillOnJobClose не срабатывает — глава 5)
- Проглотить неудачу Assign и продолжить работу (процесс устройства вне Job станет героем следующей аварии)
В .NET владение дескриптором Job выражают в коде
У System.Diagnostics.Process нет понятия Job, официальной обёртки тоже нет. Тонкую обёртку пишут через P/Invoke или CsWin32.
Суть — обернуть дескриптор Job в SafeHandle и сделать IDisposable. Закрытие последнего дескриптора Job в Dispose() срабатывает как KillOnJobClose. Замысел «жизнь обёртки — жизнь дочернего дерева» выражают как владение.
Ниже — каркас владения дескриптором. Порождение — на стороне P/Invoke приёмов A/B; в реальном проекте ещё нужны настройки CsWin32 и unsafe.
// C#: тонкая обёртка только владения дескриптором Job (порождение — P/Invoke приёмов A/B)
sealed class ChildProcessJob : IDisposable
{
private readonly SafeFileHandle _job; // держать полем
public ChildProcessJob()
{
_job = PInvoke.CreateJobObject(default, null);
if (_job.IsInvalid) throw new Win32Exception();
var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
limits.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!PInvoke.SetInformationJobObject(_job,
JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation,
&limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)))
{
int err = Marshal.GetLastWin32Error(); // спасти до Dispose
_job.Dispose(); // не отдавать Job без KillOnJobClose
throw new Win32Exception(err);
}
}
public void Dispose() => _job.Dispose(); // здесь завершается подчинённое дерево
}
Наоборот, закрыть дескриптор, не собираясь закрывать, — завершить дочернее дерево. Ссылку на обёртку держат всю жизнь родителя. Не держать — в момент, когда GC заберёт SafeHandle, дочернее дерево погибнет без причины.
5. KillOnJobClose ── держать и умирать вместе
Срабатывает не «смерть родителя», а «закрытие последнего дескриптора»
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — флаг ограничения, который завершает все подчинённые процессы, когда закрылся последний дескриптор Job.3
Родитель завершился исключением, его убили из Task Manager, службу принудительно остановили — ядро закрывает все дескрипторы этого процесса. Если это был последний дескриптор Job, завершаются и дочерние, и внуки. Сила механизма в том, что он не предполагает, будто побежит завершающая обработка родителя.10
Но если дескриптор Job унаследовать дочернему, после завершения родителя дескриптор останется. Тогда он не «последний», и дерево дочерних не завершится.
flowchart TB
accTitle: Временная линия KillOnJobClose
accDescr: Штатное завершение, крушение или принудительное завершение родителя — ядро закрывает все дескрипторы родителя; если это последний дескриптор Job, Job закрывается и дерево подчинённых процессов завершается пакетом
die["Исчезновение родителя (включая крушение)"] --> close["Ядро закрывает все дескрипторы"]
close --> last{"Последний дескриптор Job?"}
last -->|"Да"| killall["Пакетно завершить подчинённое дерево"]
last -->|"Нет (есть наследование)"| stay["Дочерние живут дальше"]
Рис. 6: Срабатывает не «смерть родителя», а «закрылся последний дескриптор». Поэтому дескриптор дочернему не наследуют.
Выбирают политику «сразу подобрать» или «оставить материал для диагностики»
Принудительное завершение забирает не только занятие устройства. Пропадает и возможность снять аварийный дамп дочерних и внуков, которых утащили, последний годный кадр, сбросить недописанный файл измерения.
Аварийный дамп самого родителя — другое. WER обрабатывает необработанное исключение, пока родитель ещё жив, поэтому дамп родителя можно записать до закрытия дескрипторов. Здесь речь о материале последующего разбора дочерних и внуков на стороне, которую принудительно завершают.
| Политика | Куда подходит | Что теряют |
|---|---|---|
| С KillOnJobClose | Место, где двойное открытие устройства — худшее | Материал последующего разбора, последний образец |
| Без (только наблюдение) | Место, где актив — дамп и журнал | Если бросить — процесс-сирота, занятие порта |
Без + наблюдающий процесс вызывает TerminateJobObject |
Когда отдельно есть управляющая служба | Реализация становится двойной |
Как материал решения сначала перечисляют «что нельзя оставлять» и «что хочется оставить».
Нельзя оставлять: открытие камеры/цифровизатора, исключение последовательного порта и USB, серверную сторону именованного канала, сеанс лицензионного ключа, общую память и файл блокировки.
Хочется оставить: аварийный дамп, последний годный кадр/счётчик, возможность отправить устройству команду «в безопасную сторону» (если можно — до убийства).
flowchart TB
accTitle: Убивать или только наблюдать
accDescr: Если худшее — двойное открытие устройства, ставят KillOnJobClose; если актив — аварийный дамп и последний кадр, не ставят и наблюдают; если есть отдельная управляющая служба, TerminateJobObject вызывает она
q{"Что беречь в момент падения?"} -->|"Сначала освободить устройство"| k["С KillOnJobClose"]
q -->|"Актив — дамп и конечное состояние"| m["Только наблюдение (не убивать)"]
q -->|"Есть отдельная управляющая служба"| t["Наблюдающая сторона: TerminateJobObject"]
Рис. 7: Выбирают не «убивать ли», а «что беречь в момент падения». Если нужны оба, получается третья строка: наблюдающая сторона сначала снимает дамп, потом сворачивает.
Наблюдающей стороне дескриптор Job передают, пока родитель жив
Третья строка таблицы — конфигурация, в которой наблюдающий процесс завершает через TerminateJobObject, — требует подготовки. Наблюдающая сторона должна получить дескриптор Job до смерти родителя.
Job в порядке этой статьи безымянный: после исчезновения родителя снаружи его не проследить. Способов передачи два.
| Способ | Что сделать, пока родитель жив |
|---|---|
| Дублировать дескриптор безымянного Job | DuplicateHandle — передать дескриптор наблюдающему процессу |
| Использовать именованный Job | С самого начала создать с именем; наблюдающая сторона открывает OpenJobObject и держит |
Имя может столкнуться глобально, поэтому в него включают уникальный GUID и подобное. Забыть эту подготовку — наблюдающая сторона увидит аномалию родителя и не будет чем завершить дерево.
Дамп и перевод устройства в безопасную сторону — до принудительного завершения
У дочернего, которого завершает KillOnJobClose, как у TerminateProcess, нет предупреждения. Необработанного исключения нет, поэтому даже если на дочернем настроен WER (LocalDumps), дампа этого принудительного завершения не останется. WER подхватывает случай, когда дочерний завершается собственным крушением.
Если требование — «и автоподбор, и дамп», субъект завершения переносят на наблюдающую сторону. Порядок: пока цель жива, снять дамп, при необходимости вернуть устройство в безопасную сторону, в конце завершить TerminateJobObject.
flowchart TB
accTitle: Как свернуть, совместив автоподбор и дамп
accDescr: Наблюдающий процесс, обнаружив аномалию, сначала снимает дамп, при необходимости шлёт устройству команду в безопасную сторону и в конце сворачивает дерево TerminateJobObject — так совмещают автоподбор и последующий разбор
det["Наблюдающая сторона обнаружила аномалию"] --> dmp["Сначала снять дамп"]
dmp --> safe["Вернуть устройство в безопасную сторону"]
safe --> term["Свернуть TerminateJobObject"]
Рис. 8: Единственное решение «и автоподбор, и дамп». Обратный порядок — снимать уже нечего.
В конфигурации, где KillOnJobClose принудительно завершает дочернего одновременно с исчезновением родителя, у дочернего нет и возможности отправить «команду в безопасную сторону». Для такого устройства берут третью строку таблицы: наблюдающая сторона сначала шлёт команду безопасного состояния, потом вызывает TerminateJobObject.11
6. Ждать «стало пусто» через порт завершения
Одного ожидания дескриптора Job мало, чтобы подтвердить завершение дерева
Дескриптор Job не переходит в сигнальное состояние, даже когда все подчинённые процессы завершились. Сигнал есть, только когда все процессы завершили из-за превышения предела времени задания.12
Чтобы «дерево дочерних опустело — идти дальше», с Job связывают порт завершения ввода-вывода (IOCP).1314 Связь делают, пока Job пуст, до вложения процессов. Связать по ходу — можно упустить уведомление о процессе, чьё состояние изменилось в этот момент.15
Четыре сообщения: появление, завершение, аномалия, обнуление
| Сообщение | Что из него видно |
|---|---|
JOB_OBJECT_MSG_NEW_PROCESS |
В Job добавился процесс. Ловят и появление внука |
JOB_OBJECT_MSG_EXIT_PROCESS |
Процесс завершился |
JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS |
Завершился с кодом аномального завершения, например нарушением доступа. Для измерительного приложения особенно важно13 |
JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO |
Число активных процессов стало 0 |
В пакете NEW_PROCESS — только новый PID. Кто породил, по родству не видно. Если нужна родословная, добавляют другое средство, например ETW.
sequenceDiagram
accTitle: Поток уведомлений порта завершения
accDescr: Job кладёт на порт завершения сообщения о появлении, завершении, аномальном завершении и обнулении процесса; выделенный поток наблюдения забирает их GetQueuedCompletionStatus и на поток UI передаёт только результат
participant J as Job Object
participant P as Порт завершения
participant W as Поток наблюдения
participant U as Поток UI
J->>P: NEW / EXIT_PROCESS
J->>P: ABNORMAL_EXIT / ZERO
W->>P: Ждать пакет завершения
P-->>W: Сообщение и PID
W-->>U: Передать только уведомление о результате
Рис. 9: GetQueuedCompletionStatus крутят на выделенном потоке. Ждать на потоке UI — при каждой аномалии дочернего UI станет «не отвечает».
Ждут на выделенном потоке и выделенном порте; при таймауте запрашивают учёт
Этот цикл наблюдения крутят на порте завершения, созданном только для уведомлений Job. Сесть на тот же порт, что существующий ввод-вывод, — к моменту возврата GetQueuedCompletionStatus пакет уже забран. Если из-за другого ключа его выбросить continue, владелец того ввода-вывода будет ждать завершения вечно. Та же ветвь у пакета неудачи.
Если делить, нужна отдельная доставка владельцу по ключу. В этой статье порт разделяют и на поток UI передают только результат.
И исходят из того, что один Job — одно поколение запуска, при каждом запуске создают заново. Повторно использовать при повторе — в TotalProcesses войдёт прошлое поколение, и защита, отличающая пустой Job до запуска, перестанет работать.
// C++: каркас потока наблюдения (на случай упущенного уведомления — страховка таймаут + учёт)
DWORD msg; ULONG_PTR key; LPOVERLAPPED info;
bool treeEmpty = false;
while (!treeEmpty) {
if (!GetQueuedCompletionStatus(iocp, &msg, &key, &info, 5000)) {
if (info != nullptr) continue; // пакет завершения неудачного I/O. наблюдение продолжать
if (GetLastError() != WAIT_TIMEOUT) break; // уничтожение порта и т. п. — выход
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION acct = {};
if (QueryInformationJobObject(job, JobObjectBasicAccountingInformation,
&acct, sizeof(acct), nullptr))
treeEmpty = (acct.TotalProcesses > 0 && // пустой Job до запуска не принять за завершение
acct.ActiveProcesses == 0); // страховка, если уведомление ZERO упало
// исходное: Job создают заново при каждом запуске (1 Job = 1 поколение).
// повторно использовать Job при повторе — TotalProcesses считает
// прошлое поколение, этой защитой поколения не различить
continue; // при неудаче запроса не утверждать, что пусто
}
if ((HANDLE)key != job) continue; // сверить с CompletionKey при связывании.
// этот порт создают только для наблюдения Job
// (см. текст ниже. такое отбрасывание на общем
// порте — владелец другого I/O ждёт вечно)
DWORD pid = (DWORD)(UINT_PTR)info; // в части сообщений лежит PID
switch (msg) {
case JOB_OBJECT_MSG_NEW_PROCESS: /* журнал появления внука */ break;
case JOB_OBJECT_MSG_EXIT_PROCESS: /* журнал завершения */ break;
case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS:/* аномальное завершение: к проверке дампа */ break;
case JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO: treeEmpty = true; break;
} // одного break в switch мало, чтобы выйти из ожидания
}
У уведомления, учёта и дескриптора разная достоверность
У уведомления в принципе нет гарантии доставки. Гарантировано только уведомление о пределе, заданном через JobObjectNotificationLimitInformation. «Уведомления нет = не произошло» судить нельзя.15
Агрегированное состояние вроде «стало пусто» подтверждают, сочетая опрос учётных сведений. Но учёт — счётчики агрегатов: PID, код завершения и то, аномалия ли это, из упавших EXIT/ABNORMAL_EXIT не восстановить. Это область удержания дескриптора процесса и ETW.
ACTIVE_PROCESS_ZERO — не доказательство штатного завершения. Ноль мог получиться и от принудительного завершения; само сообщение не различает. Качество завершения судят по EXIT/ABNORMAL_EXIT и коду завершения.
По одному PID не решают, что это тот же процесс
PID в пакете завершения переиспользуется. Пока не держат дескриптор процесса, нет гарантии, что этот PID всё ещё указывает на тот же процесс.13
pi.hProcess, который держат с главы 4, фиксирует только PID дочернего, запущенного напрямую. Для внука, которого породил SDK, в момент NEW_PROCESS делают OpenProcess, берут дескриптор и дальше сверяют по нему.
Даже тогда остаётся короткое окно от уведомления до Open. Если за это время PID переиспользуют, хватают другой процесс. Если нужна строгая идентичность особи, подкрепляют телеметрией с временем порождения, например событием старта процесса ETW.
flowchart TB
accTitle: Наблюдение, которое не опирается только на уведомления
accDescr: Сообщения порта завершения — для уведомления, гарантии доставки нет, поэтому опрос учётных сведений и удержание дескриптора процесса сочетают, готовясь к упущению и переиспользованию PID
n["Уведомление порта завершения"] --> miss["Нет гарантии доставки (цель — уведомить)"]
miss --> poll["Сочетать опрос учётных сведений"]
n --> pid["PID переиспользуется"]
pid --> hold["Держать дескриптор процесса"]
Рис. 10: Уведомление — основной путь, учёт и дескриптор — страховка. «Наблюдаем» можно сказать, только когда есть оба.
Когда-то ходили реализации «убить поток, который ждёт опустения Job, через TerminateThread»; при порте завершения принудительно завершать ждущий поток не нужно. Raymond Chen тоже писал статью, как переписать этот старый шаблон на порт завершения.16
7. Аварии, которые реально бывают при измерении и работе с устройством
Устройство из глав выше накладываем на шесть аварий на месте с аппаратурой. В каждом примере кроме причины и меры смотрят, «что осталось».
Авария 1: умер только родитель, камера осталась открытой
Конфигурация, где SDK производителя держит процесс-помощник для передачи кадров: родительский UI снимают из Task Manager — остаётся только помощник. Перезапущенный родитель на инициализации SDK получает device busy. На месте до восстановления иногда перезапускают питание устройства.
Что осталось: процесс-помощник и исключающее открытие камеры.
flowchart TB
accTitle: Умер только родитель, камера осталась открытой
accDescr: Принудительное завершение UI убирает родителя, процесс-помощник SDK остаётся и держит дескриптор камеры, поэтому перезапущенный родитель на повторном открытии получает device busy
kill9["Завершить UI из Task Manager"] --> dead["Родитель исчезает"]
dead --> helper["Помощник SDK остаётся"]
helper --> busy["По-прежнему держит камеру"]
busy --> fail["После перезапуска device busy"]
Рис. 11: Лицо «процесса уже нет, устройство не открыть». Виновник часто не тот процесс, чьё имя видели в Task Manager.
Авария 2: внук выходит за Job
Путей два, меры разные.
(a) SDK сам пользуется своим Job. Партнёр уже в другом Job. На Windows 7 один процесс — одно задание, ваш Assign падает. С Windows 8 внука можно подобрать вложенностью, но если на вашем Job есть ограничение UI, самой вложенности не будет (глава 9).
(b) SDK порождает внука с CREATE_BREAKAWAY_FROM_JOB. Срабатывает, только если ваш Job разрешает BREAKAWAY_OK; внук рождается сразу вне дерева. Вложенностью не подобрать. Не разрешать — порождение на стороне SDK падает, поэтому если приоритет наблюдению, базово не разрешать и ловить как неудачу.
Что осталось: внук, бегущий вне наблюдения, и проглоченный неудачный Assign.
flowchart TB
accTitle: Два пути, которыми внук выходит за Job
accDescr: Путь, где SDK пользуется своим Job, с Windows 8 можно подобрать вложенностью, но ограничение UI ломает; путь, где SDK порождает внука breakaway, срабатывает только если вы разрешили, и внук с самого начала вне дерева
g["Внук выходит за Job"] --> ja["(a) SDK со своим Job"]
g --> jb["(b) Порождение breakaway"]
ja --> nest["С Win8 подобрать вложенностью"]
nest -.-> ui["С ограничением UI — неудача"]
jb --> allow["Только если разрешили"]
allow -.-> out["Внук с самого начала вне дерева"]
Рис. 12: Одно «выходит», но у (a) остаётся шанс подобрать вложенностью, у (b) с момента разрешения уже ясно, что не подобрать. Мера начинается с определения пути.
Авария 3: процесс устройства, запущенный из службы
При остановке службы SCM ждёт только родителя; дочерние и внуки, рождённые в сеансе 0, вне юрисдикции остановки. Job+KillOnJobClose ведёт и эту внеюрисдикционную часть. Сам дизайн конфигурации, которая пересекает службу и интерактивный сеанс, отдан статье о границе пользователя.
Что осталось: оставшийся процесс сеанса 0 и ложное срабатывание проверки двойного запуска при следующем старте.
Авария 4: дочерний падает через месяц
Без периодической записи учёта Job (PeakJobMemoryUsed, счётчики ввода-вывода, общее число процессов) после факта не проследить, «какое поколение помощника с какого момента раздулось».17 Структуру «через месяц падает из-за утечки дескрипторов» разобрали в статье о длительном сбое промышленной камеры; учёт Job — вход в это расследование.
Что осталось: журнал, которого не хватает, чтобы установить причину.
Авария 5: завершающая обработка родителя ждёт завершения дочернего
WaitForExit или ожидание завершения дерева на потоке UI в день, когда дочерний завис, делает родителя «не отвечает». Ожидание завершения отдают потоку IOCP, на UI оставляют только ход и кнопку прервать. Устройство — как в статье «не отвечает».
Что осталось: родитель, которого утащило зависание.
flowchart TB
accTitle: Не ждать завершения на потоке UI родителя
accDescr: Ожидание завершения дочернего на потоке UI передаёт зависание дочернего в «не отвечает» родителя; ожидание отдают потоку наблюдения IOCP, на потоке UI оставляют только ход и кнопку прервать
w2["Ждать завершения дочернего на потоке UI"] --> h2["День, когда дочерний завис"]
h2 --> f2["Родитель тоже «не отвечает» (утащило)"]
ok2["Ждать на потоке IOCP"] --> u2["UI только ход и прерывание"]
Рис. 13: Сторона, которая наблюдает аномалию дочернего, не должна застывать от аномалии дочернего. Разнести место ожидания — и утаскивание пропадает.
Авария 6: неудача только под отладчиком
Средство разработки или запускатель иногда уже вложило ваш родительский процесс в какой-то Job. С Windows 8 вложенность чаще спасает; на аппаратном ПК до 7 Assign даёт ERROR_ACCESS_DENIED, и появляется разница «не работает только на машине разработки» / «не работает только в бою». База — сначала своим IsProcessInJob подтвердить принадлежность.18
Что осталось: время проверки, в котором не удаётся установить причину разницы сред.
8. Какие ограничения вешать
У ограничения есть цель и побочный эффект
Берём ограничения, которые имеют смысл для измерительного приложения, и рядом — зачем и что будет при перегибе.319
| Ограничение | Зачем | Если перегнуть |
|---|---|---|
| KILL_ON_JOB_CLOSE | Освободить устройство при исчезновении родителя | Пропадает материал последующего разбора |
| ACTIVE_PROCESS | Остановить безудержное размножение дочерних SDK | Отказ и нормальному помощнику |
| JOB_MEMORY / PROCESS_MEMORY | Верхняя граница утечки при долгой работе | Начинает падать выделение огромного буфера изображения |
| DIE_ON_UNHANDLED_EXCEPTION | На безлюдной машине не показывать диалог ошибки | Тяжелее интерактивная отладка |
| Управление долей ЦП | Чтобы дочерний обработки изображения не морил UI голодом | Не успевают к сроку кадра |
| BREAKAWAY_OK | Оставить выход SDK, которому нужен другой Job | Исчезает из объекта наблюдения |
| Ограничение UI | Затяжка в духе песочницы | Ломается вложенность (глава 9) |
Предел уведомления — чтобы наблюдать, принудительный предел — чтобы отказать или завершить
«Мягкий предел для уведомления» и «предел, после которого останавливают» разделяют. JobObjectNotificationLimitInformation только уведомляет о превышении, процесс продолжает бежать.15
Предел Extended Limit принуждается, но форма принуждения у каждого предела своя.3
| Предел | Что происходит при превышении |
|---|---|
| Предел памяти | Операция фиксации, которая превысит, падает. Сам процесс жив |
| ACTIVE_PROCESS | Порождение или вложение, которое превысит, падает. Процесс, превысивший вложением, завершают |
| Время процесса (PROCESS_TIME) | Завершают только этот превысивший процесс |
| Время задания (JOB_TIME) | Предел на агрегат; по умолчанию завершают все процессы под Job |
Не зная этой разницы, «помощник, который исчезает сразу после порождения» принимают за другую неисправность. При долгой работе безопасный порядок: сначала наблюдать пределом уведомления, принудительный предел решать, когда видна тенденция.
flowchart TB
accTitle: Предел для уведомления и принудительный предел
accDescr: Предел JobObjectNotificationLimitInformation только уведомляет о превышении, процесс продолжает бежать; предел Extended Limit принудительный: предел памяти — неудача операции, ACTIVE_PROCESS — неудача порождения или вложения, предел времени — завершение процесса
lim2{"Цель предела?"} -->|"Наблюдать"| ntf["Предел уведомления: превысил — всё равно бежит"]
lim2 -->|"Остановить"| enf["Принудительный предел: отказать или завершить"]
ntf --> log2["По журналу учёта найти поколение"]
enf --> die2["Неудача фиксации, отказ порождения, завершение"]
Рис. 14: Одно «предел», но уведомление и принуждение — разное, и принуждение у каждого предела своё. Повесить принудительный предел без наблюдения — ложное срабатывание на горе нормальной работы.
Связь управления долей ЦП и периодической обработки отдана статье о soft real-time; здесь оставляем лишь меру «процесс устройства не съедает UI».
9. Вложенность, Breakaway, партнёр, который уже в Job
Вложенность думают как «включение множеств процессов»
Правила вложенности с Windows 8 укладываются в четыре.5
- Родительский Job — широкое множество, дочерний Job — его подмножество (Assign в порядке, где это включение не выполняется, падает)
- Основное ограничение ресурса в цепи — самое строгое становится действующим
- Job с ограничением UI вкладывать нельзя
- Уведомление доходит и до портов завершения всех родительских Job в цепи (на стороне дочернего Job порта может не быть)
flowchart TB
accTitle: Иерархия вложенных заданий и действующее ограничение
accDescr: Родительский Job — широкое множество, дочерний — его подмножество; основное ограничение ресурса в цепи — самое строгое значение становится действующим. Job с ограничением UI вкладывать нельзя
pj["Родительский Job (широкое множество)"] --> cj["Дочерний Job (подмножество)"]
cj --> pr["Принадлежащий процесс"]
pj -.-> eff["Действующее ограничение = самое строгое"]
cj -.-> eff
ui["Job с ограничением UI"] -.-> no["Вкладывать нельзя"]
Рис. 15: Вложенность думают как «включение множеств». Ограничение UI ломает вложенность, поэтому на Job управления жизнью его лучше не вешать.
Breakaway — путь породить сразу вне Job
breakaway — штатный путь, которым потомок, рождающийся в CreateProcess, выходит из дерева.3
| Настройка на стороне Job | Условие, при котором дочерний рождается вне Job |
|---|---|
JOB_OBJECT_LIMIT_BREAKAWAY_OK |
Порождать с CREATE_BREAKAWAY_FROM_JOB |
SILENT_BREAKAWAY_OK |
Флаг не нужен. Все дочерние рождаются снаружи |
Иногда это нужно SDK, чтобы самому пользоваться Job. Но процесс, рождённый этим путём, выпадает и из пакетного завершения, и из наблюдения. Разрешать — решить и то, кто ведёт место, куда он вышел.
flowchart TB
accTitle: Путь выхода из дерева через Breakaway
accDescr: Если на Job стоит BREAKAWAY_OK, внук, порождённый с CREATE_BREAKAWAY_FROM_JOB, рождается вне Job и пропадает из объекта пакетного завершения и наблюдения
j2["Job (есть BREAKAWAY_OK)"] --> c2["Дочерний процесс"]
c2 -->|"Обычное порождение"| in3["Внук тоже внутри Job"]
c2 -->|"Порождение с BREAKAWAY"| out3["Внук вне Job"]
out3 --> lost["Вне объекта наблюдения и пакетного завершения"]
Рис. 16: У breakaway два лица: «выход для нужного SDK» и «дыра в наблюдении». Вешать — решить и то, кто присмотрит место выхода.
Порождение через WMI не закрыть даже запретом breakaway
Путь вроде Win32_Process.Create WMI, где запускает третья сторона, — отдельная задача. Фактический родитель — поставщик WMI, поэтому рождённый процесс с самого начала вне Job. Запрет breakaway эту дыру не затыкает.1
Пользуется ли SDK этим путём, смотрят не по журналу NEW_PROCESS, а по родству в Process Explorer.
Сначала подтверждают принадлежность к существующему Job и ограничение до Windows 7
Порядок, когда партнёр уже в Job (авария 6): подтвердить IsProcessInJob → если вложенность собирается, Assign как есть → если нет (Windows 7 или ограничение UI) — менять дизайн.18
До Windows 7 нельзя дважды Assign партнёру, который уже принадлежит другому Job. BREAKAWAY_OK — не флаг, которым принадлежащий процесс потом вынимают. Выход есть, только если сторона SDK при порождении дочернего сама запросит breakaway. Если на это нельзя рассчитывать, до запуска меняют дизайн исходя из «один процесс — одно задание».12
Вкладывать ли самого родителя в Job, решают в конце
В конце — дизайн «свой процесс в свой Job». Если и родитель в подчинённых, при крушении родителя он сам входит в объект KillOnJobClose, и жизнь всего дерева совпадает полностью. Но ошибка в том, как держат дескриптор Job, даёт незадуманное одновременное завершение — обоюдоострый меч; безопаснее начать с «родитель снаружи, только дерево дочерних внутри».
10. Как расследовать
Расследование ведут в порядке принадлежность → учёт и журнал уведомлений → занятие устройства. Чтобы не заключать «остатка нет» по одному взгляду на имя процесса.
- Process Explorer: во свойствах процесса есть вкладка Job, видны принадлежащий Job и ограничения. Быстрее всего подтвердить, «в каком Job этот помощник»
IsProcessInJob: вход, чтобы из кода подтвердить принадлежность свою и партнёра18QueryInformationJobObject: периодически записывать Basic Accounting (общее число процессов, время ЦП) и Extended Limit (PeakJobMemoryUsedи т. п.)417- Журнал порта завершения в файл: временной ряд NEW_PROCESS / EXIT / ABNORMAL_EXIT через месяц станет единственным доказательством
- Остаток не подтверждают по PID: смотреть дескриптор устройства, имя канала и файл блокировки. «В Task Manager процесса не видно» не значит «устройство освобождено»
flowchart TB
accTitle: Порядок расследования остатка
accDescr: Сначала принадлежность подтверждают IsProcessInJob и вкладкой Job в Process Explorer, читают учёт QueryInformationJobObject, в конце остаток судят не по наличию процесса, а по дескриптору устройства, имени канала и файлу блокировки
s1["IsProcessInJob: подтвердить принадлежность"] --> s2["Вкладка Job в Process Explorer"]
s2 --> s3["QueryInformationJobObject: учёт"]
s3 --> s4["Остаток по дескриптору устройства и имени канала"]
Рис. 17: Расследование — «принадлежность → учёт → занятие». Не заключать «не осталось» по одному взгляду на имя процесса.
11. Грубая таблица выбора
Выборы выше сводим по ситуациям. Политику завершения сверяют с главой 5, предпосылки наблюдения — с главой 6, отношение к существующему Job — с главой 9.
| Ситуация | Рекомендация |
|---|---|
| Тело UI + SDK устройства вынесли в отдельный процесс | Вложить в Job, наблюдать портом завершения |
| Худшее — устройство остаётся занятым после исчезновения родителя | Поставить KillOnJobClose |
| Актив — кадр и дамп момента падения | KillOnJobClose не ставить; наблюдающая сторона после безопасного состояния вызывает TerminateJobObject |
| SDK производителя порождает помощника | Порождать SUSPENDED или JOB_LIST, журналировать NEW_PROCESS |
Assign даёт ERROR_ACCESS_DENIED |
Сначала существующее задание и возможность вложенности. Подозревать ограничение UI |
| Из службы порождают дочернего интерактивного сеанса | Ограничение UI не вешать. Вернуться к дизайну статьи о границе пользователя |
| Завершения внука ждут на потоке UI | Прекратить. Вынести на поток IOCP |
12. Итог
Жизнь дочернего — не жизнь объекта Process родителя. Завершение родителя дочернему само не передаётся. Job Object делает это дерево процессов одной единицей и вешает ограничение, уведомление и пакетное завершение.
Чтобы вести и внуков, принадлежность фиксируют до того, как дочерний побежит. Приём A — SUSPENDED+Assign, приём B с Windows 10 — JOB_LIST. KillOnJobClose работает при любом причине завершения родителя, когда закрывается последний дескриптор Job, но забирает и материал последующего разбора потомков, которых утащили.
Поэтому сначала перечисляют «что после падения нельзя оставлять» и «что хочется оставить» и выбирают: сразу подобрать или завершать после съёма и безопасного состояния на наблюдающей стороне. Это порядок проектирования, который хочет передать статья.
Дальше писать — частный случай запуска процесса через сеанс 0 и интерактивный сеанс или ожидание устройства overlapped I/O. Когда «жизнь снаружи процесса» зафиксирована, дальше ждёт жизнь ввода-вывода.
Похожие статьи
- Чек-лист безопасной работы с дочерними процессами в Windows-приложении
- «Не отвечает»: как Windows определяет зависание и как проектировать так, чтобы оно не возникало
- Именованные каналы на практике — от проектирования до безопасности IPC в Windows
- Утечка хендлов: почему приложение промышленной камеры падает после месяца работы
- Практическое руководство: soft real-time на обычной Windows
- «Тот же ПК» — ещё не та же среда выполнения ── граница пользователя: AppData, HKCU, DPAPI, учётные данные
Смежные области консультирования
Компания KomuraSoft LLC занимается проектированием разделения процессов Windows-приложений, связанных с камерами, измерительными приборами, последовательными и USB-устройствами; расследованием занятия устройства вроде остатка помощника SDK и device busy; построением наблюдения и автовосстановления приложений долгой работы. Можно начать с одного случая «после перезапуска родителя устройство не открыть».
- Разработка приложений для Windows
- Технические консультации и ревью дизайна
- Расследование неисправностей и анализ причин
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Job Objects. О том, что Job Object — объект ядра, который ведёт группу процессов как одну единицу; дочерний процесс принадлежащего процесса по умолчанию связывается с тем же Job (кроме пути Win32_Process.Create); два ограничивающих флага breakaway; пакетное завершение TerminateJobObject; как вести дерево процессов в среде без вложенности. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, AssignProcessToJobObject function (jobapi2.h). О том, что связь процесса и Job нельзя снять; до Windows 7 один процесс — одно задание, с Windows 8 возможны несколько принадлежностей (вложенность); действующее ограничение при вложенности и распространение breakaway. ↩ ↩2
-
Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION structure (winnt.h). О флагах ограничения JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (завершить все процессы, когда закрылся последний дескриптор Job), ACTIVE_PROCESS (верхняя граница одновременно активных процессов), JOB_MEMORY (верхняя граница фиксации всего задания), DIE_ON_UNHANDLED_EXCEPTION, BREAKAWAY_OK / SILENT_BREAKAWAY_OK. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h). О том, что Job держит учётные сведения, включая долю завершившихся процессов: общее число процессов, время ЦП, число ошибок страниц; получают через QueryInformationJobObject. ↩ ↩2
-
Microsoft Learn, Nested Jobs. О том, что вложенные задания строят иерархию родитель–потомок (дочерний Job — подмножество процессов родительского); Job с ограничением UI вкладывать нельзя; действующее ограничение — самое строгое значение в цепи; уведомление идёт на все порты завершения цепи родительских Job; завершение иерархии идёт с нижнего слоя. ↩ ↩2
-
Microsoft Learn, Process Creation Flags. О CREATE_SUSPENDED (начальный поток порождают приостановленным и не исполняют до ResumeThread) и CREATE_BREAKAWAY_FROM_JOB (у Job вызывающего нужен JOB_OBJECT_LIMIT_BREAKAWAY_OK). ↩
-
Raymond Chen, Closing the race window between creating a suspended process and putting it in a job (The Old New Thing). О классическом порядке породить с CREATE_SUSPENDED и затем вложить в Job и о том, как закрыть окно гонки. ↩
-
Microsoft Learn, UpdateProcThreadAttribute function (processthreadsapi.h). О том, что PROC_THREAD_ATTRIBUTE_JOB_LIST позволяет назначить порождаемому дочернему процессу дескрипторы Job в указанном порядке; поддерживается с Windows 10 / Windows Server 2016. ↩
-
Raymond Chen, A more direct and mistake-free way of creating a process in a job object (The Old New Thing). О способе через PROC_THREAD_ATTRIBUTE_JOB_LIST принадлежать Job с момента порождения. ↩
-
Raymond Chen, Destroying all child processes (and grandchildren) when the parent exits (The Old New Thing). О конфигурации, в которой Job с KILL_ON_JOB_CLOSE при исчезновении родителя завершает потомков вместе, и о важности не наследовать дескриптор Job. ↩
-
Microsoft Learn, TerminateJobObject function (jobapi2.h). О том, что все процессы, связанные с Job, принудительно завершают так же, как если бы TerminateProcess вызвали по одному. ↩
-
Microsoft Learn, Job Objects - Managing Job Objects. О том, что объект Job становится сигнальным, когда все процессы завершились из-за превышения предела времени задания; уничтожение Job при закрытии последнего дескриптора и то, что при KILL_ON_JOB_CLOSE закрытие вызывает завершение всех принадлежащих процессов. ↩
-
Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT structure (winnt.h). Перечень сообщений на порт завершения JOB_OBJECT_MSG_NEW_PROCESS / EXIT_PROCESS / ABNORMAL_EXIT_PROCESS / ACTIVE_PROCESS_ZERO; коды завершения, которые считают аномальными; у сообщений, возвращающих PID, нельзя отрицать переиспользование PID, пока не держат дескриптор процесса; доставка уведомлений не гарантируется. ↩ ↩2 ↩3
-
Raymond Chen, How do I wait until all processes in a job have exited? (The Old New Thing). О том, что ожиданием дескриптора Job «стало пусто» не обнаружить и нужно ждать JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO порта завершения. ↩
-
Microsoft Learn, Job Objects - Job Limits and Notifications. О том, что связь порта завершения лучше делать, пока Job неактивен (меньше шанс упустить уведомление о процессе, чьё состояние изменилось во время связи); кроме предела, заданного JobObjectNotificationLimitInformation, доставка сообщений не гарантируется; при пределе уведомления после превышения процесс продолжает бежать. ↩ ↩2 ↩3
-
Raymond Chen, Removing the TerminateThread from code that waits for a job object to empty (The Old New Thing). О том, как переписать старый шаблон убийства ждущего потока TerminateThread на ожидание на порте завершения. ↩
-
Microsoft Learn, JOBOBJECT_EXTENDED_LIMIT_INFORMATION structure (winnt.h). О задании предела памяти на процесс и на задание и получении пиковой памяти через PeakProcessMemoryUsed / PeakJobMemoryUsed. ↩ ↩2
-
Microsoft Learn, IsProcessInJob function (jobapi.h). О том, можно ли определить, выполняется ли процесс в указанном Job (или в каком-либо Job). ↩ ↩2 ↩3
-
Microsoft Learn, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION structure (winnt.h). О том, что на единицу Job можно управлять долей ЦП (долей циклов или весом). ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Почему ломаются аргументы ── правила аргументов командной строки Windows
В Windows массива аргументов нет: в CreateProcess уходит одна строка, делит её принимающая сторона. Правила деления CommandLineToArgvW, C...
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
wait на условной переменной может вернуться и без уведомления (ложное пробуждение). Почему спецификация это допускает и как правильно жда...
Пул потоков Win32 — параллелизм через CreateThreadpoolWork без своих потоков
Не плодите ли вы CreateThread по всему нативному коду? Разбираем API пула потоков Win32, переработанный в Vista: четыре объекта work, tim...
DllMain и блокировка загрузчика — почему говорят «в инициализации DLL ничего не делайте»
Почему из DllMain нельзя вызывать LoadLibrary и синхронизироваться с потоками. По первоисточникам: как блокировка загрузчика сериализует ...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Job Object — это контейнер или песочница?
- Нет. Job Object — объект ядра, который на группу процессов вешает ограничение, уведомление и пакетное завершение. Верхние границы памяти, ЦП и числа процессов задать можно, сетевой доступ ограничить нельзя, маркер доступа (права) тоже не меняется. Ограничения UI есть, но сами по себе границей безопасности не становятся. Если цель — изоляция или безопасность, сочетают с другим механизмом вроде AppContainer или контейнера. В этой статье речь о том, чтобы «жизнь и ресурсы дерева процессов считать одной единицей».
- Разве недостаточно Process.Kill()?
- Process.Kill() завершает только этот один процесс и до внуков не достаёт. Kill(entireProcessTree: true) с .NET Core 3.0 обходит потомков и завершает их, но перечисляет дерево по текущим отношениям родитель–потомок, поэтому процесс, родившийся во время перечисления, или тот, у кого родитель умер раньше и родословная оборвалась, можно упустить. И ни тот ни другой способ не вызывается, «когда рухнул сам родитель». Если потомков нужно подобрать независимо от жизни родителя, надёжнее отдать жизнь ядру: Job Object + KillOnJobClose.
- Если родитель уже в Job, дочерний тоже попадает туда сам?
- По умолчанию да. Дочерний процесс, который процесс, принадлежащий Job, создал через CreateProcess, автоматически принадлежит тому же Job. Исключение — breakaway. Если на Job стоит JOB_OBJECT_LIMIT_BREAKAWAY_OK и дочерний создан с флагом CREATE_BREAKAWAY_FROM_JOB, или стоит JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, дочерний рождается вне Job. Процесс, созданный через Win32_Process.Create в WMI, с Job не связывается.
- Можно ли вынуть процесс из Job, раз его уже вложили?
- Нельзя. Связь через AssignProcessToJobObject необратима и длится до завершения процесса. Поэтому вариантов дизайна три: «не вкладывать», «родить сразу снаружи через breakaway», «вложить другой Job». «Вынуть потом» нет. Эта необратимость и есть причина готовить Job до порождения.
- KillOnJobClose срабатывает и при крушении родителя?
- Срабатывает. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE завершает подчинённых, «когда закрылся последний дескриптор Job»: родитель завершился штатно, упал на необработанном исключении или его убили из Task Manager — ядро при уборке процесса закрывает дескрипторы, и флаг срабатывает. Но если дескриптор Job унаследовали дочернему, после смерти родителя дескриптор у дочернего остаётся, «последним» он не становится и флаг не срабатывает. Дескриптор Job дочернему не наследуйте.
- Уведомление порта завершения всегда доходит?
- Не всегда. Официальная документация прямо пишет: доставка сообщений на порт завершения не гарантируется, кроме уведомления о пределе, заданном через JobObjectNotificationLimitInformation. То, что уведомление не пришло, не значит, что события не было. Для наблюдения, где нужна достоверность, сочетают опрос учётных сведений через QueryInformationJobObject и сами держат дескриптор процесса, чтобы установить, жив он или нет.
- Есть ли в .NET официальный API Job Object?
- Нет. У System.Diagnostics.Process нет понятия Job, в BCL обёртки тоже нет. Практическое решение — вызвать через P/Invoke CreateJobObject / SetInformationJobObject / AssignProcessToJobObject либо сгенерировать подписи источником CsWin32 от Microsoft и написать тонкую обёртку. Если дескриптор Job обернуть в SafeHandle и закрывать в Dispose у IDisposable, смысл KillOnJobClose — «жизнь обёртки = жизнь дерева дочерних» — прямо виден в коде.
- Как проектировать на аппаратном ПК с Windows 7?
- До Windows 7 включительно процесс входит только в один Job, вложенности нет. Если SDK партнёра сам пользуется Job, ваш AssignProcessToJobObject завершится неудачей. JOB_OBJECT_LIMIT_BREAKAWAY_OK — флаг, который разрешает процессу, уже входящему в свой Job, породить дочернего вне Job с CREATE_BREAKAWAY_FROM_JOB; это не магия, которая пропустит второй Assign после того, как процесс уже внутри. То есть выход есть, только если сторона SDK при порождении сама запросит breakaway. Если на это нельзя рассчитывать, до запуска меняют дизайн исходя из «свой Job только один». Документация Microsoft для сред без вложенности как раз показывает, как вести дерево двумя ограничивающими флагами breakaway. ОС без поддержки, поэтому если можно — сначала миграция.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.