親が落ちたあとに何が残るか ── Job Objectで子プロセスを飼う

· 更新日: · · Windows, Windows開発, C#, C++, Win32 API, 計測制御

「UIをTask Managerで落としたら、カメラが再オープンできなくなった」「監視アプリはもう落ちているのに、メーカーSDKのヘルパープロセスが残ってCOMポートを握っている」「親を再起動したら二重起動になって、共有メモリと名前付きパイプが壊れた」── カメラSDK・計測器・シリアル/USB装置を別プロセスに逃がした構成で、この種の事故は繰り返し起きています。

先に宣言しておきます。Process.Kill()CloseMainWindow() も、孫プロセスと、その孫が握っている装置ハンドルには届きません(殺したプロセス自身のハンドルは終了とともに解放されます。問題は、生き残った子孫が握っているほうです)。Windowsのプロセスの親子関係は起動時の系譜にすぎず、親の終了は子に伝播しない ── この空白を埋める道具が Job Object です。本記事は、8月に出した「応答なし」シャットダウンスリープ復帰名前付きパイプの各記事の延長として、プロセスの外側の寿命を扱います。対象読者は、装置SDKを別プロセスに分離しているWinForms / WPF / サービスの開発者です。前提環境はWindows 10/11(入れ子ジョブとPROC_THREAD_ATTRIBUTE_JOB_LISTを使う箇所)で、コードはC++(Win32 API)とC#(.NET 6以降)で示します。難易度は中級です。

1. まず結論

読後に残してほしい一文はこれです。

子プロセスの寿命は Process.WaitForExit() では扱えない。Job Objectはプロセスツリーを一つの単位にする仕組みであり、計測・装置連携では「親が死んだら子も消す」と「死ぬ直前の状態を残す」が両立しない。どちらを取るかを先に決める。

  • Job Objectは、プロセスのグループに制限と通知と一括終了を付けるカーネルオブジェクトです。1
  • 一度入れたら、そのプロセス自身はJobから外れられません。入れ子(1プロセスが複数Jobに所属)はWindows 8以降です。2
  • 孫まで追うなら、生成前に入れます。走り始めてからでは、最初の数ミリ秒に生まれた孫を取りこぼします。
  • JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEは「最後のJobハンドルが閉じたらツリーを殺す」です。親のクラッシュには強く、事後解析には弱い。3
  • 計測アプリが欲しいのは殺すこと自体ではありません。装置を占有したまま残さないことと、異常終了を観測できること ── この2つです。

この記事の知識マップ

Job Objectは、親子の系譜ではなくどのJobに属するかで子・孫のプロセス木を一つの単位として束ね、親の生死と子プロセス木の寿命を結びつける基準点になる。JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEを設定すると最後のJobハンドルが閉じたときに配下の全プロセスが終了する構成になり、親を失った子プロセスの残留や、カメラ・COMポートなどの装置ハンドルの掴みっぱなしを防げるが、強制終了される側の子・孫のクラッシュダンプや最終フレームを残すこととは両立しない(親自身のクラッシュダンプはWERが親の存命中に書ける)。Jobハンドルを子に継承させると親の死後もハンドルが閉じずKILL_ON_JOB_CLOSEが発火しなくなり、子が走り始めてからAssignするまでの競合窓やbreakawayの許可も、監視から漏れた残留プロセスの原因になる。CREATE_SUSPENDEDで生成してから所属させる手順は競合窓を狭め、PROC_THREAD_ATTRIBUTE_JOB_LIST(要Job Object)は生成時点から所属させて競合窓をなくす。Jobの通知メッセージはあらかじめ関連付けたI/O完了ポートに届き、GetQueuedCompletionStatusで取り出して確認する。通知上限(JobObjectNotificationLimitInformation)の超過通知もこの完了ポート通知を利用し、この通知だけは到着が保証される。会計情報は終了したプロセスの分も含めてJobに保持される。UI制限を設定したジョブは入れ子にできない。監視プロセスが装置を安全化してから畳む構成ではTerminateJobObjectを使う。.NETのKill(entireProcessTree: true)は親クラッシュ時の自動回収には推奨されない。プロセスの所属はIsProcessInJobとProcess ExplorerのJobタブで確認できる。

Job Objectと子プロセスの寿命の知識マップJob Objectがプロセス木を一つの単位として束ね、KILL_ON_JOB_CLOSEで親クラッシュ時の残留と装置ハンドルの掴みっぱなしを防ぐ一方でクラッシュダンプの確保と両立しないこと、Jobハンドルの継承や生成と所属の競合窓・breakawayが残留の原因になり、CREATE_SUSPENDEDとPROC_THREAD_ATTRIBUTE_JOB_LISTが競合窓を閉じること、完了ポート通知がIOCPとGetQueuedCompletionStatusで受け取られ通知上限だけ到着保証があること、会計情報・入れ子とUI制限・TerminateJobObject・IsProcessInJobやProcess Explorerによる確認までを含む関係図実装を担うで構成できる推奨される対応で確認できるで確認できる防止する防止する両立しない原因になり得る原因になり得る軽減する防止する前提とする前提とするで確認できる利用するに保存される両立しない原因になり得る利用する用いるのは非推奨Job ObjectJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEJobの完了ポート通知装置ハンドルの残留プロセス木(process tree)親クラッシュ時の自動回収IsProcessInJobProcess Explorer親を失った子プロセスの残存クラッシュダンプJobハンドルの継承生成と所属の競合窓CREATE_SUSPENDEDPROC_THREAD_ATTRIBUTE_JOB_LISTI/O完了ポート(IOCP)GetQueuedCompletionStatusJobの通知上限Jobの会計情報入れ子ジョブJobのUI制限JOB_OBJECT_LIMIT_BREAKAWAY_OK監視プロセス(watchdog)TerminateJobObjectProcess.Kill(entireProcessTree: true)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. なぜ WaitForExit では足りないのか

Process.WaitForExit() は「親が子の終了を待つ」APIです。この記事が扱うのは逆方向、親が先に死んだとき子に何が起きるかで、答えは「何も起きない」です。親が死ぬ主な理由ごとに、現場に何が残るかを並べます。

親が死ぬ理由 子に起きること 現場に残るもの
UIを×で閉じ、終了処理が不完全 何も起きない(Process オブジェクトが破棄されるだけ) ヘルパープロセス、カメラのロック
Task Managerで親だけKill 子は生存し続ける COMポート、USB、共有メモリ
未処理例外でクラッシュ 親の finally が走る保証はない 一時ファイル、排他ロック
サービス停止のタイムアウト SCMが面倒を見るのは親だけ セッション0に子が残留
親の寿命と装置の占有寿命のずれ親プロセスの終了で親の待機とProcessオブジェクトは消えるが、子と孫のプロセスは生き続け、装置ハンドル・名前付きパイプ・ロックファイルの占有も残り続ける親プロセスの終了消える:親の待機・Processオブジェクト残る:子・孫プロセス装置ハンドルの占有名前付きパイプのサーバ側ロックファイル・共有メモリ

図1: 親の寿命と装置の占有寿命はずれている。親側の後始末コードは、親が異常な死に方をしたときほど走らない。

ここで切り分けをひとつ。「プロセスが死ぬ話」と「OSが落とす話」は別物です。シャットダウンやスリープでOSが全体を止める流れはシャットダウン記事スリープ復帰記事の領分で、本記事は「OSは元気なまま、親だけが死ぬ」ケースを扱います。計測の現場では後者のほうが頻度が高く、しかも誰も気づきません。

3. Job Objectとは何か

仕組みは薄く押さえます。Job Objectはプロセスのグループを一つの単位として管理するカーネルオブジェクトで、CreateJobObject で空の容器を作り、AssignProcessToJobObject でプロセスを所属させます。所属は不可逆で、プロセスが終了するまで外れられません。制限は SetInformationJobObject で設定し、会計情報(CPU時間・ページフォールト・プロセス数など)は終了したプロセスの分も含めて QueryInformationJobObject で読めます。14

所属したプロセスが CreateProcess で作る子は、既定で同じJobに所属します。つまり孫も曾孫も自動で入る。これがJobの価値の中心です。1

Job Objectの基本構造親プロセスが作ったJob Objectに子と孫が所属し、Jobはプロセス木単位で制限の強制・完了ポートへの通知・一括終了を行う親プロセスJob Object子(装置SDKホスト)孫(メーカーヘルパー)制限(メモリ・CPU)通知(完了ポート)一括終了

図2: Jobは「制限」「通知」「一括終了」の3つをプロセス木単位で提供する容器である。

バージョンの境目はひとつだけ覚えれば足ります。Windows 7以前は1プロセス1ジョブ、Windows 8から入れ子(複数所属)が可能になりました。5 工場PCでもWindows 10/11が普通になった今、本文は入れ子前提で書き、7以前の差分は9章の注記に落とします。

先に釘を刺しておきます。「Jobに入れる」はサンドボックスの万能薬ではありません。ネットワークアクセスは制限できず、アクセストークン(権限)は別の仕組みで、UI制限はセキュリティ境界と呼べるものではない。本記事でのJobの役割はあくまで「プロセスツリーの寿命と資源を一つの単位にする」ことです。

4. 正しい入れ方 ── 生成と所属の競合

実務の本丸です。子プロセスは走り始めた最初の数ミリ秒で孫を生むことがあります(SDKのヘルパー起動は典型)。Process.Start() してからPIDを取ってAssignする方式では、Assignより先に生まれた孫がJobの外に出ます

走り始めてから入れると孫を取りこぼすProcess.Startの直後から子は走っており、AssignProcessToJobObjectを呼ぶまでの競合窓の間に子が生んだ孫はJobの外に出てしまうProcess.Startで子が走り出すAssignまでの競合窓この間に生まれた孫Jobの外で走り続けるAssignProcessToJobObject以後に生まれる孫だけ入る

図3: 競合窓は数ミリ秒でも、SDKヘルパーの起動はまさにそこで起きる。

手順A(古典・互換が広い)は、CREATE_SUSPENDED でこの窓を閉じます。67

  1. CreateJobObject でJobを作る
  2. SetInformationJobObject で制限を先にセットする
  3. CREATE_SUSPENDED を付けて CreateProcess する(初期スレッドは走らない)
  4. AssignProcessToJobObject で入れる
  5. 失敗したらResumeせず、その場で TerminateProcess(Jobの外で1命令も走らせない)
  6. ResumeThread で走らせる

ただし手順Aが閉じるのは孫の競合窓だけです。3と4の間で親自身がクラッシュすると、まだJobに入っていないサスペンド状態の子が残ります(走りはしませんが、消えもしません)。親のクラッシュ耐性まで含めて窓を閉じたいなら、次の手順Bを使います。

SUSPENDEDで起動してからJobへ入れる手順Jobを作って制限を設定し、CREATE_SUSPENDEDで子を起動してAssignProcessToJobObjectで所属させ、失敗したらResumeせずTerminateProcessで止め、成功したらResumeThreadで走らせる成功失敗CreateJobObjectでJobを作るSetInformationJobObjectで制限CREATE_SUSPENDEDで子を起動AssignProcessToJobObjectResumeThreadで走らせる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 の所有権は呼び出し元の寿命管理オブジェクト
// (4章のC#ラッパー相当)へ移す。jobを閉じる=KillOnJobCloseの発火であり、
// pi.hProcess は6章で「誰が死んだか」の確定に使う

手順B(Windows 10以降)は、生成時点で所属させます。STARTUPINFOEX の属性リストに PROC_THREAD_ATTRIBUTE_JOB_LIST でJobハンドルを載せ、EXTENDED_STARTUPINFO_PRESENT フラグ付きCreateProcess に渡します(このフラグがないと拡張構造体として解釈されず、属性は無視されます)。初期スレッドが走る前にプロセスがJobに入るため、競合窓そのものがありません。SUSPENDEDもAssignの失敗分岐も不要になります。89

手順Aと手順Bの競合窓の違い手順Aは停止状態で生成してからAssignとResumeを行うため失敗時に終了させる分岐が必要だが、手順Bは属性リストにJobを載せて生成するため生まれた時点で所属済みで、競合窓も失敗分岐も存在しない手順A:停止状態で生成Assignで所属Resumeで始動失敗分岐が必須手順B:属性リストで生成生まれた時点で所属済み競合窓も失敗分岐もない

図5: 手順Aは「入れてから走らせる」、手順Bは「入った状態で生まれる」。Windows 10以降を前提にできるなら、選ぶ理由は競合窓と失敗分岐の有無そのもの。

やってはいけないことは3つです。

  • Process.Start() したあとでPIDを取って入れる(孫が先に出る)
  • Jobハンドルを子に継承させる(親が死んでも子がハンドルを握り続け、KillOnJobCloseが発火しなくなる ── 5章)
  • Assignの失敗を握りつぶして運転を続ける(Jobの外の装置プロセスは、次の事故の主役になる)

.NET側の事情も短く。System.Diagnostics.Process にJobの概念はなく、公式ラッパーもありません。P/InvokeかCsWin32で薄いラッパーを書きます。ポイントはJobハンドルをSafeHandleで包み、IDisposable にすることです。Dispose() でハンドルを閉じる=KillOnJobCloseの発火、つまり「ラッパーの寿命が子ツリーの寿命」という設計意図がコードにそのまま現れます。

// C#: Jobハンドルの所有だけを担う薄いラッパー(生成は手順A/BのP/Invoke経由で)
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();              // KillOnJobCloseのないJobを渡さない
            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

KillOnJobCloseのタイムライン親が正常終了でもクラッシュでも強制終了でも、カーネルが親の全ハンドルを閉じ、それが最後のJobハンドルならJobが閉じて配下のプロセス木が一括終了するはいいいえ(継承あり)親の消滅(クラッシュ含む)カーネルが全ハンドルを閉じる最後のJobハンドル?配下のツリーを一括終了子は生き続ける

図6: 発火条件は「親の死」ではなく「最後のハンドルが閉じたこと」。だからハンドルを子に継承させてはいけない。

ただし、消えるのは装置の占有だけではありません。強制終了される側の子・孫からクラッシュダンプを取る機会も、最後の有効フレームも、書きかけの計測ファイルのフラッシュも消えます。親自身がクラッシュした場合のダンプは別です ── WERは親の存命中に未処理例外を処理するので、親のダンプはハンドル閉鎖より前に書けます。失うのは、道連れで殺される側の事後解析材料です。ここがこの記事の分岐点です。

方針 向く現場 失うもの
KillOnJobCloseあり 装置の二重オープンが最悪の現場 事後解析の材料、最後のサンプル
なし(監視だけ) ダンプとログが資産の現場 放置すると孤児プロセス、ポート占有
なし+監視プロセスが TerminateJobObject 制御用サービスが別にあるとき 実装が二重になる

判断の材料として、「残してはいけないもの」と「残したいもの」を先に列挙してください。

残してはいけないもの: カメラ/デジタイザのオープン、シリアル・USBの排他、名前付きパイプのサーバ側、ライセンスドングルのセッション、共有メモリとロックファイル。

残したいもの: クラッシュダンプ、最後の有効フレーム/カウンタ、装置を安全側に戻すコマンドの送信機会(可能なら殺す前に送る)。

殺すか、監視だけにするか装置の二重オープンが最悪ならKillOnJobCloseを付け、クラッシュダンプや最終フレームが資産なら付けずに監視し、別の制御サービスがあるならそちらからTerminateJobObjectを呼ぶ装置の解放が最優先ダンプと最終状態が資産制御サービスが別にある落ちた瞬間に何を守る?KillOnJobCloseあり監視のみ(殺さない)監視側がTerminateJobObject

図7: 「殺すか」ではなく「落ちた瞬間に何を守るか」で選ぶ。両方欲しい場合は、監視側が先にダンプを採ってから畳む3行目の構成になる。

3行目の構成には前提が1つあります。監視側は、親が死ぬ前にJobハンドルを手に入れておかなければなりません。この記事の手順で作るJobは無名なので、親が消えたあとに外から辿る方法はありません。親が生きているうちに DuplicateHandle で監視プロセスへハンドルを複製して渡すか、最初から名前付きでJobを作り、監視側が OpenJobObject で開いて保持しておきます(名前はグローバルに衝突し得るので、固有のGUIDなどを含めます)。この受け渡しを忘れた監視サービスは、親が落ちた瞬間に「畳む手段を持たない見物人」になります。

ここで1つ誤解を潰しておきます。KillOnJobCloseで消される子は TerminateProcess と同様に前触れなく終了させられるため、未処理例外は発生せず、子側にWER(LocalDumps)を仕込んでいてもその瞬間のダンプは残りません。WERが拾えるのは「子が自分のクラッシュで死ぬ」ケースだけです。「自動回収もダンプも」が要件なら、監視プロセスが生きているうちにダンプを採取してから TerminateJobObject で畳む ── つまり殺す主体を監視側に移すしかありません。

自動回収とダンプを両立させる畳み方監視プロセスが異常を検知したら、先にダンプを採取し、必要なら装置を安全側に戻すコマンドを送り、最後にTerminateJobObjectで木を畳むことで、自動回収と事後解析を両立させる監視側が異常を検知先にダンプを採取装置を安全側へ戻すTerminateJobObjectで畳む

図8: 「自動回収もダンプも」の唯一の解。順序が逆だと、採る対象がもう存在しない。

親を殺された側の子は前触れなく TerminateProcess されるため、この構成で「装置を安全側に戻すコマンド」を送る機会はありません。それが必要な装置では、監視側が先に安全化コマンドを送り、それから TerminateJobObject で畳む ── 3行目の構成が正解になります。11

6. 完了ポートで「空になった」を待つ

意外な事実から始めます。Jobハンドルは、配下のプロセスが全部終了してもシグナル状態になりません。Jobオブジェクトがシグナルされるのは、ジョブ時間の上限超過で全プロセスが終了させられたときだけです。12 「子ツリーが空になったら次へ進む」を待ちたければ、I/O完了ポート(IOCP)をJobに関連付けます。1314

監視するメッセージは4つで足ります。

  • JOB_OBJECT_MSG_NEW_PROCESS ── Jobにプロセスが加わった(孫の発生の検知。パケットには新しいPIDだけが入り、誰が生んだかの親子関係までは分からない。系譜が要るならETWなど別の手段を足す)
  • JOB_OBJECT_MSG_EXIT_PROCESS ── 終了
  • JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS ── 計測アプリの本命。アクセス違反などの異常終了コードで死んだプロセスの検知13
  • JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO ── アクティブプロセス数が0になった
完了ポート通知の流れJobがプロセスの発生・終了・異常終了・ゼロ化のメッセージを完了ポートへ投げ、専用の監視スレッドがGetQueuedCompletionStatusで受け取り、UIスレッドには結果だけを渡すUIスレッド監視スレッド完了ポートJob ObjectUIスレッド監視スレッド完了ポートJob ObjectNEW / EXIT_PROCESSABNORMAL_EXIT / ZERO完了パケットを待つメッセージとPID結果の通知だけ渡す

図9: GetQueuedCompletionStatusは専用スレッドで回す。UIスレッドで待つと、子の異常のたびにUIが「応答なし」になる。

// 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;
    }                                    // switchのbreakだけでは待機は終わらない
}

1つ前提を明示しておきます。この監視ループはJobの通知専用に作った完了ポートで回してください。既存のI/Oと同じポートに相乗りさせると、GetQueuedCompletionStatus が返った時点でパケットはポートから取り出されているため、キーが合わないからと continue で捨てた瞬間に、そのI/Oの持ち主は完了を永久に待ち続けます(冒頭の失敗パケット分岐も同じです)。相乗りさせるなら、キーごとに持ち主へ配送する仕組みが別途必要になります ── この記事の範囲では、ポートを分けるのがいちばん安い解です。

注意点を4つ、本文で強調しておきます。

  1. 通知は原則として到着保証がありません。保証されるのは JobObjectNotificationLimitInformation で設定した上限の通知だけです。「通知が来ない=起きていない」とは読めないため、「空になったか」のような集計状態の確認には会計情報のポーリングを併用します。ただし会計は集計カウンタなので、落ちたEXIT/ABNORMAL_EXITのPID・終了コード・異常かどうかまでは復元できません。そこはハンドル保持(3項)とETWの領分です。15
  2. ACTIVE_PROCESS_ZEROを「全部きれいに終われた」と読んではいけません。殺されてゼロになったのかもしれず、メッセージ自体は区別しません。終了の質はEXIT/ABNORMAL_EXITと終了コードで判定します。
  3. 完了パケットのPIDは再利用されます。プロセスハンドルを自分で保持していない限り、そのPIDがまだ同じプロセスを指している保証はありません。13 ただし4章で保持した pi.hProcess が固定できるのは直接起動した子のPIDだけです。SDKが生んだ孫のPIDを確定させたいなら、NEW_PROCESSを受けた時点で OpenProcess してハンドルを取り、以後の突き合わせはそのハンドル基準で行います。それでも通知からOpenまでの短い窓は残り、その間にPIDが再利用されていれば別プロセスを掴みます。個体の同定まで厳密に要る現場では、ETWのプロセス開始イベントのように生成時刻を持つテレメトリで裏を取ってください。
  4. 完了ポートの関連付けは、Jobが空のうち(プロセスを入れる前)に済ませます。関連付けの最中に状態が変わったプロセスの通知を取りこぼす可能性があるためで、公式ドキュメントもJobが非アクティブなうちの関連付けを推奨しています。15
通知だけに頼らない監視完了ポートのメッセージは通知目的で到着保証がないため、会計情報のポーリングとプロセスハンドルの保持を併用して、取りこぼしとPID再利用に備える完了ポートの通知到着保証なし(通知目的)会計情報のポーリング併用PIDは再利用されるプロセスハンドルを保持

図10: 通知は主経路、会計とハンドルは保険。両方そろって初めて「観測できている」と言える。

なお、かつては「Jobが空になるのを待つスレッドをTerminateThreadで殺す」ような実装が出回りましたが、完了ポートがあれば待ちスレッドの強制終了は不要です。Raymond Chenも、この古いパターンを完了ポート方式へ書き換える記事を出しています。16

7. 計測・装置連携で実際に起きる事故

ここからが本編です。抽象API解説ではなく、装置の現場でJobが刺さる形を6つ並べます。各事故の末尾に「何が残ったか」を1行で書きます。

事故1: 親だけ死んでカメラが開いたまま。メーカーSDKがフレーム転送用のヘルパープロセスを持つ構成で、親UIをTask Managerで落とすとヘルパーだけが残ります。再起動した親はSDKの初期化で device busy。装置の電源を入れ直すまで復旧しない現場もあります。── 残ったもの: ヘルパープロセスとカメラの排他オープン

親だけ死んでカメラが開いたままUIの強制終了で親は消えるがSDKのヘルパープロセスは残り、カメラのハンドルを握ったままになるため、再起動した親はdevice busyで再オープンに失敗するUIをTask Managerで終了親は消えるSDKヘルパーは残るカメラを握ったまま再起動後にdevice busy

図11: 「プロセスは消えたのに装置が開けない」の正体。犯人はTask Managerに表示された名前のプロセスではないことが多い。

事故2: 孫がJobの外に出る。経路は2つあり、対処が違います。(a) SDKが自分のJobを使っている ── 相手がすでに別のJobに入っているケースです。Windows 7ではこちらのAssignが失敗し(1プロセス1ジョブ)、Windows 8以降なら入れ子で拾えますが、こちらのJobにUI制限を付けていると入れ子自体ができません(9章)。(b) SDKが CREATE_BREAKAWAY_FROM_JOB で孫を生む ── これはこちらのJobがBREAKAWAY_OKを許可している場合にだけ成立し、生まれた孫は最初から木の外です。入れ子でも拾えません。許可していなければSDK側の生成が失敗するので、監視を優先するなら許可せず、失敗として検知するのが基本です。── 残ったもの: 監視の外で走る孫と、失敗を握りつぶしたAssign

孫がJobの外に出る2経路SDKが自分のJobを使っている経路はWindows 8以降なら入れ子で拾える余地があるがUI制限を付けていると失敗し、SDKがブレイクアウェイで孫を生む経路はこちらが許可した場合にだけ成立して孫は最初から木の外になる孫がJobの外に出る(a)SDKが自分のJob(b)breakawayで生成Win8以降は入れ子で拾うUI制限があると失敗許可した場合のみ成立孫は最初から木の外

図12: 同じ「外に出る」でも、(a)は入れ子で拾える余地が残り、(b)は許可した時点で拾えないことが確定する。対処は経路の特定から始まる。

事故3: サービスから起動した装置プロセス。サービス停止時にSCMが待つのは親だけで、セッション0で生んだ子・孫は停止処理の管轄外です。Job+KillOnJobCloseはこの管轄外まで面倒を見ます。サービスと対話セッションをまたぐ構成自体の設計はユーザー境界の記事に委ねます。── 残ったもの: セッション0の残留プロセスと、次回起動時の二重起動判定の誤爆

事故4: 1か月後に落ちる子。Jobの会計(PeakJobMemoryUsed、I/Oカウンタ、総プロセス数)を定期記録していないと、「どの世代のヘルパーがいつから膨らんだか」が事後に追えません。17 ハンドルリークで1か月後に落ちる構造は産業用カメラの長期障害記事で解剖したとおりで、Jobの会計はその調査の入口になります。── 残ったもの: 原因確定に足りないログ

事故5: 親の終了処理が子の終了を待つ。UIスレッドで WaitForExit やツリーの終了待ちをすると、子が固まった日に親が「応答なし」になります。終了待ちはIOCPスレッドに任せ、UIには進捗と打ち切りボタンだけを置きます。仕組みは「応答なし」記事のとおりです。── 残ったもの: 道連れでハングした親

終了待ちを親のUIスレッドでやらないUIスレッドで子の終了を待つと子のハングが親の応答なしに伝播するため、終了待ちはIOCPの監視スレッドに任せ、UIスレッドには進捗表示と打ち切りボタンだけを置くUIスレッドで子の終了を待つ子がハングした日親も応答なし(道連れ)IOCPスレッドで待つUIは進捗と打ち切りだけ

図13: 子の異常を観測する側が、子の異常で固まってはいけない。待つ場所を分けるだけで道連れは消える。

事故6: デバッガ配下でだけ失敗する。開発ツールやランチャーが、あなたの親プロセスをすでにどこかのJobに入れていることがあります。Windows 8以降は入れ子で大抵は救われますが、7以前の装置PCではAssignが ERROR_ACCESS_DENIED になり、「開発機でだけ動かない」「本番でだけ動かない」の差が生まれます。まず IsProcessInJob で自分の所属を確認するのが定石です。18── 残ったもの: 環境差の原因を特定できない検証時間

8. 制限は何を付けるか

フラグを全部は列挙しません。計測アプリで意味があるものだけです。319

制限 使う理由 やりすぎると
KILL_ON_JOB_CLOSE 親消失で装置を解放 事後解析の材料が消える
ACTIVE_PROCESS SDKの暴走的な子の増殖を止める 正常なヘルパーまで生成拒否
JOB_MEMORY / PROCESS_MEMORY 長期稼働リークの上限 巨大な画像バッファの確保が失敗し始める
DIE_ON_UNHANDLED_EXCEPTION 無人機でエラーダイアログを出さない 対話デバッグが辛くなる
CPUレート制御 画像処理の子がUIを餓死させない フレーム期限に間に合わない
BREAKAWAY_OK 別ジョブが必要なSDKに脱出路を残す 監視対象から消える
UI制限 サンドボックス的な締め付け 入れ子が壊れる(9章)

大事なのは、「通知用の緩い上限」と「超えたら止める上限」を分けることです。JobObjectNotificationLimitInformation の上限は超過を通知するだけでプロセスは走り続けます(監視用)。Extended Limitの上限は強制ですが、「強制」の形は上限ごとに違います3 ── メモリ上限は超過するコミット操作を失敗させるだけでプロセスは生きています。ACTIVE_PROCESSは超過する生成・所属を失敗させ、所属で超過したそのプロセスは終了させられます。時間上限は範囲が分かれます ── プロセス時間(PROCESS_TIME)の上限は超過したそのプロセスだけを終了させますが、ジョブ時間(JOB_TIME)の上限は集計値なので、既定ではJob配下の全プロセスが終了させられます。どの形で効くかを知らずに付けると、「生成された直後に消えるヘルパー」を別の障害と誤診することになります。長期稼働の現場ではまず通知上限で観測し、傾向がわかってから強制上限を決める順番が安全です。15

通知用の上限と強制の上限JobObjectNotificationLimitInformationの上限は超過を通知するだけでプロセスは走り続け、Extended Limitの上限は強制で、メモリ上限は操作の失敗、ACTIVE_PROCESSは生成・所属の失敗、時間上限はプロセスの終了として効く観測したい止めたい上限の目的は?通知上限:超えても走る強制上限:拒否または終了会計ログで世代を特定コミット失敗・生成拒否・終了

図14: 同じ「上限」でも通知と強制は別物で、強制の効き方も上限ごとに違う。観測なしにいきなり強制上限を張ると、正常動作の山で誤爆する。

CPUレート制御と周期処理の関係はソフトリアルタイム記事に委ね、ここでは「装置プロセスがUIを食う」対策程度に留めます。

9. 入れ子、Breakaway、すでにJobに入っている相手

Windows 8以降の入れ子のルールは4つで整理できます。5

  • 親ジョブが広い集合、子ジョブはその部分集合(この包含が成立しない順序でAssignすると失敗する)
  • 主要な資源制限は、チェーン上で最も厳しいものが実効になる
  • UI制限が付いたジョブは入れ子にできない
  • 通知はチェーン上のすべての親ジョブの完了ポートにも届く(子ジョブ側にポートがなくてもよい)
入れ子ジョブの階層と実効制限親ジョブは広い集合で子ジョブはその部分集合になり、主要な資源制限はチェーン上で最も厳しい値が実効になる。UI制限が付いたジョブは入れ子にできない親ジョブ(広い集合)子ジョブ(部分集合)所属プロセス実効制限=最も厳しい値UI制限付きジョブ入れ子にできない

図15: 入れ子は「集合の包含」で考える。UI制限は入れ子を壊すので、寿命管理のJobには付けないのが無難。

breakawayは、CreateProcess で生まれる子孫が木から抜ける正規の経路です。Jobに JOB_OBJECT_LIMIT_BREAKAWAY_OK が付いていると、CREATE_BREAKAWAY_FROM_JOB 指定で生成された子はJobの外で生まれます。SILENT_BREAKAWAY_OK ならフラグ指定すら不要で、すべての子が外に生まれます。3 相手のSDKが自分でJobを使うために必要なこともありますが、付けた瞬間、その経路で生まれたプロセスは一括終了からも監視からも消えることは覚悟してください。もうひとつ、breakawayを禁止しても塞げない穴があります。WMIの Win32_Process.Create のように第三者のプロセスが代理で起動する経路では、実際の親はWMIプロバイダーなので、生まれたプロセスは最初からJobの外です。1 SDKがこの種の起動をしていないかは、NEW_PROCESSのログではなくProcess Explorerの親子関係で確認します。

Breakawayで木から抜ける経路JobにBREAKAWAY_OKが付いていると、CREATE_BREAKAWAY_FROM_JOBで生成された孫はJobの外に生まれ、一括終了と監視の対象から消える通常の生成BREAKAWAY指定の生成Job(BREAKAWAY_OKあり)子プロセス孫もJobの中孫はJobの外へ監視と一括終了の対象外

図16: breakawayは「必要なSDKへの脱出路」と「監視の穴」の両方の顔を持つ。付けるなら、抜けた先を誰が看取るかまで決める。

相手がすでにJobに入っているとき(事故6)の手順は、IsProcessInJob で確認 → 入れ子が組めるならそのままAssign → 組めない(Windows 7、またはUI制限)なら設計を変える、の順です。18

最後に「自分のプロセスを自分のJobに入れる」設計にも触れておきます。親自身も配下に入れると、親のクラッシュ時にKillOnJobCloseの対象へ自分も含まれ、木全体の寿命が完全に一致します。ただしJobハンドルの持ち方を間違えると意図しない一斉終了になる諸刃の剣で、まずは「親は外、子ツリーだけ中」から始めるのが安全です。

10. 調査の仕方

書いたあと現場で使うための節です。

  • Process Explorer: プロセスのプロパティにJobタブがあり、所属Jobと制限が見えます。「このヘルパーはどのJobにいるのか」を最速で確認できます
  • IsProcessInJob: 自分や相手の所属をコードから確認する入口18
  • QueryInformationJobObject: Basic Accounting(総プロセス数・CPU時間)とExtended Limit(PeakJobMemoryUsed など)を定期記録する417
  • 完了ポートのログをファイルに残す: NEW_PROCESS / EXIT / ABNORMAL_EXITの時系列は、1か月後の調査で唯一の証拠になる
  • 残留確認はPIDでやらない: 見るべきは装置ハンドルとパイプ名とロックファイル。「Task Managerにプロセスが見えない」は「装置が解放された」を意味しません
残留調査の手順まずIsProcessInJobとProcess ExplorerのJobタブで所属を確認し、QueryInformationJobObjectで会計を読み、最後にプロセスの有無ではなく装置ハンドル・パイプ名・ロックファイルで残留を判定するIsProcessInJobで所属確認Process ExplorerのJobタブQueryInformationJobObjectで会計装置ハンドル・パイプ名で残留判定

図17: 調査は「所属→会計→占有」の順。プロセス名の目視だけで「残っていない」と結論しない。

11. ざっくり使い分け(判断表)

状況 推奨
UI本体+装置SDKを別プロセスに出した Jobに入れ、完了ポートで監視する
親消失で装置が掴みっぱなしになるのが最悪 KillOnJobCloseを付ける
落ちた瞬間のフレームやダンプが資産 KillOnJobCloseは付けず、監視側が安全化のうえ TerminateJobObject
メーカーSDKがヘルパーを生む 生成をSUSPENDEDかJOB_LISTにし、NEW_PROCESSをログする
Assignが ERROR_ACCESS_DENIED 既存ジョブと入れ子可否を先に見る。UI制限を疑う
サービスから対話セッションの子を出す UI制限を付けない。ユーザー境界記事の設計に戻る
孫の終了をUIスレッドで待っている やめる。IOCPスレッドに出す

12. まとめ

  • 子の寿命は、親の Process オブジェクトの寿命ではない。親の死は子に伝播しない
  • Job Objectはプロセスツリーを飼う器。制限・通知・一括終了をツリー単位で提供する
  • 孫まで追うなら生成前に入れる。SUSPENDED+Assignか、Windows 10以降ならJOB_LIST
  • KillOnJobCloseは親のどんな死に方にも効くが、事後解析の材料も一緒に消す
  • 「落ちたあとに残るもの」を先に列挙してから、殺すか監視するかを決める。これがこの記事の設計手順のすべて

次に書くならセッション0と対話セッションをまたぐプロセス起動の各論か、overlapped I/Oで装置を待つ話です。プロセスの「外側の寿命」を押さえたら、次はI/Oの寿命が待っています。

関連記事

関連する相談領域

合同会社小村ソフトでは、カメラ・計測器・シリアル/USB装置と連携するWindowsアプリのプロセス分離設計、SDKヘルパーの残留や device busy といった装置占有トラブルの調査、長期稼働アプリの監視・自動回復の仕組みづくりを扱っています。「親を再起動すると装置が開けない」の一件からでもご相談ください。

参考リンク

  1. Microsoft Learn, Job Objects. Job Objectがプロセスのグループを一つの単位として管理するカーネルオブジェクトであること、所属プロセスが作る子プロセスが既定で同じJobに関連付くこと(Win32_Process.Create経由は除く)、breakaway用の2つの制限フラグ、TerminateJobObjectによる一括終了、入れ子が使えない環境でのプロセス木の管理方法について。  2 3 4

  2. Microsoft Learn, AssignProcessToJobObject function (jobapi2.h). プロセスとJobの関連付けが解除できないこと、Windows 7以前は1プロセス1ジョブでWindows 8から複数所属(入れ子)が可能になったこと、入れ子での実効制限とbreakawayの伝播について。 

  3. 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

  4. Microsoft Learn, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h). Jobが終了したプロセスの分も含めて総プロセス数・CPU時間・ページフォールト数などの会計情報を保持し、QueryInformationJobObjectで取得できることについて。  2

  5. Microsoft Learn, Nested Jobs. 入れ子ジョブが親子の階層(子ジョブは親ジョブのプロセスの部分集合)を作ること、UI制限を設定したジョブは入れ子にできないこと、実効制限がチェーン上の最も厳しい値になること、通知が親ジョブチェーンの全完了ポートへ送られること、階層の終了が最下層から行われることについて。  2

  6. Microsoft Learn, Process Creation Flags. CREATE_SUSPENDED(初期スレッドをサスペンド状態で生成しResumeThreadまで実行しない)と、CREATE_BREAKAWAY_FROM_JOB(呼び出し元のJobにJOB_OBJECT_LIMIT_BREAKAWAY_OKが必要)について。 

  7. Raymond Chen, Closing the race window between creating a suspended process and putting it in a job (The Old New Thing). CREATE_SUSPENDEDで生成してからJobへ入れる古典手順と、その競合窓の閉じ方について。 

  8. Microsoft Learn, UpdateProcThreadAttribute function (processthreadsapi.h). PROC_THREAD_ATTRIBUTE_JOB_LISTにより生成される子プロセスへ指定順にJobハンドルを割り当てられること、Windows 10 / Windows Server 2016以降でサポートされることについて。 

  9. 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に所属させる方法について。 

  10. Raymond Chen, Destroying all child processes (and grandchildren) when the parent exits (The Old New Thing). KILL_ON_JOB_CLOSE付きのJobで親の消滅時に子孫をまとめて終了させる構成と、Jobハンドルを継承させないことの重要性について。 

  11. Microsoft Learn, TerminateJobObject function (jobapi2.h). Jobに関連付けられた全プロセスを、TerminateProcessを個々に呼んだのと同様に強制終了させることについて。 

  12. Microsoft Learn, Job Objects - Managing Job Objects. Jobオブジェクトがシグナル状態になるのはジョブ時間上限の超過で全プロセスが終了したときであること、最後のハンドルが閉じたときのJobの破棄と、KILL_ON_JOB_CLOSE指定時は閉鎖が全所属プロセスの終了を引き起こすことについて。 

  13. 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

  14. 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で待つ必要があることについて。 

  15. Microsoft Learn, Job Objects - Job Limits and Notifications. 完了ポートの関連付けはJobが非アクティブなうちに行うのがよいこと(関連付け中に状態が変わったプロセスの通知を取りこぼす可能性の低減)、JobObjectNotificationLimitInformationで設定した上限を除きメッセージ配送が保証されないこと、通知上限では超過後もプロセスが走り続けることについて。  2 3

  16. Raymond Chen, Removing the TerminateThread from code that waits for a job object to empty (The Old New Thing). 待ちスレッドをTerminateThreadで殺す古いパターンを、完了ポートベースの待機に書き換える方法について。 

  17. Microsoft Learn, JOBOBJECT_EXTENDED_LIMIT_INFORMATION structure (winnt.h). プロセス単位・ジョブ単位のメモリ上限の設定と、PeakProcessMemoryUsed / PeakJobMemoryUsedによるピークメモリの取得について。  2

  18. Microsoft Learn, IsProcessInJob function (jobapi.h). プロセスが指定したJob(またはいずれかのJob)で実行されているかを判定できることについて。  2 3

  19. Microsoft Learn, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION structure (winnt.h). Job単位でCPUレート(サイクルの割合や重み)を制御できることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

Job Objectはコンテナやサンドボックスですか?
違います。Job Objectは、プロセスのグループに「制限」「通知」「一括終了」を付けるカーネルオブジェクトです。メモリ・CPU・プロセス数の上限は課せられますが、ネットワークアクセスの制限はできず、アクセストークン(権限)も変わりません。UI制限はありますが、それだけでセキュリティ境界になるものではありません。分離やセキュリティが目的ならAppContainerやコンテナなど別の仕組みと組み合わせる必要があります。本記事で扱うのは「プロセスツリーの寿命と資源を一つの単位として扱う」用途です。
Process.Kill()ではだめですか?
Process.Kill()が終了させるのはそのプロセス1つだけで、孫プロセスには届きません。.NET Core 3.0以降のKill(entireProcessTree: true)は子孫をたどって終了させますが、プロセスツリーをその時点の親子関係から列挙する方式なので、列挙中に生まれたプロセスや、親が先に死んで系譜が切れたプロセスを取りこぼす余地があります。また、どちらの方法も「親自身がクラッシュしたとき」には呼ばれません。親の生死に関係なく子孫を回収したいなら、カーネルに寿命を預けるJob Object+KillOnJobCloseが確実です。
親がJobに入っていたら、子も自動でJobに入りますか?
既定では入ります。Jobに所属するプロセスがCreateProcessで作った子プロセスは、自動的に同じJobに所属します。例外はbreakawayです。JobにJOB_OBJECT_LIMIT_BREAKAWAY_OKが設定されていて子がCREATE_BREAKAWAY_FROM_JOBフラグ付きで生成された場合や、JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OKが設定されている場合、子はJobの外で生まれます。なお、WMIのWin32_Process.Create経由で作られたプロセスはJobに関連付きません。
一度入れたプロセスをJobから外せますか?
外せません。AssignProcessToJobObjectによる関連付けは不可逆で、プロセスが終了するまで所属は続きます。したがって設計の選択肢は「入れない」「breakawayで最初から外で生む」「別のJobを入れ子にする」の3つで、「あとから外す」はありません。この不可逆性が、生成前にJobを準備しておくべき理由でもあります。
KillOnJobCloseは親のクラッシュでも効きますか?
効きます。JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEは「最後のJobハンドルが閉じたとき」に配下を終了させる仕組みで、親が正常終了しても、未処理例外で落ちても、Task Managerで殺されても、カーネルがプロセスの後始末としてハンドルを閉じるため発火します。ただしJobハンドルを子プロセスに継承させていると、親が死んでも子の持つハンドルが残るため「最後のハンドル」にならず、発火しません。Jobハンドルは継承させないでください。
完了ポートの通知は必ず届きますか?
届かないことがあります。公式ドキュメントは、JobObjectNotificationLimitInformationで設定した上限の通知を除き、完了ポートへのメッセージ配送は保証されないと明記しています。通知が来なかったことは事象が起きなかったことを意味しません。確実性が要る監視では、QueryInformationJobObjectによる会計情報のポーリングを併用し、プロセスハンドルを自分で保持して生死を確定させてください。
.NETにJob Objectの公式APIはありますか?
ありません。System.Diagnostics.ProcessにはJobの概念がなく、BCLにもラッパーはありません。P/Invokeで CreateJobObject / SetInformationJobObject / AssignProcessToJobObject を呼ぶか、Microsoft製のソースジェネレーターCsWin32で署名を生成して薄いラッパーを書くのが実務解です。JobハンドルをSafeHandleで包み、IDisposableのDisposeで閉じる設計にすると、「ラッパーの寿命=子プロセスツリーの寿命」というKillOnJobCloseの意味がコードにそのまま現れます。
Windows 7の装置PCではどう設計すればよいですか?
Windows 7以前は1プロセスが1つのJobにしか入れず、入れ子もできません。相手のSDKが自分でJobを使っていると、こちらのAssignProcessToJobObjectは失敗します。JOB_OBJECT_LIMIT_BREAKAWAY_OKは「すでに自分のJobに入っているプロセスが、CREATE_BREAKAWAY_FROM_JOB指定で子をJobの外に作ること」を許すフラグであり、入ってしまった後の二重Assignを通す魔法ではありません。つまり脱出路はSDK側が生成時にbreakawayを要求してくれる場合にだけ成立します。それが望めないなら、起動前に「自分のJobは1つだけ」という前提で設計を変えるしかありません。Microsoftのドキュメントも、入れ子が使えない環境ではbreakawayの2つの制限フラグで木を管理する方法を示しています。とはいえサポート切れのOSなので、可能なら移行が先です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る