Windowsアプリで子プロセスを安全に扱うチェックリスト

· 更新日: · · Windows, Process, Job Object, IPC, C++, .NET, C#

更新履歴(10件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
タイムアウト時の`Kill`が、境界の取り合いで別の例外に化けるのを直しました。`WaitForExit(30_000)`が`false`を返してから`Kill`を呼ぶまでのわずかな間に子が自分で終わることがあり、そのとき`Kill`は成功しません(.NETでは終了処理中に`Win32Exception`、.NET Frameworkでは終了済みに対して`InvalidOperationException`)。素通りさせると、投げるはずだった`TimeoutException`の代わりに後始末の失敗が飛び、最後の`WaitForExit()`による出力の読み切りも飛ばされます。`HasExited`で本当に終わっているかを確かめてから握りつぶし、まだ生きているなら投げ直す形にしました。`AggregateException`(子孫の一部を止められなかった)は握りません。
子プロセスの起動を、実行ファイル名ではなく絶対パスで指すように直しました。`CreateProcessW`の`lpApplicationName`に`nullptr`を渡すと、コマンドラインの先頭の語がモジュール名になり、パスを含まない場合の探索対象に「親プロセスのカレントディレクトリ」と「`PATH`」が入ります。`helper.exe`が自分のフォルダーに無い状況で、書き込み可能な場所に同名の実行ファイルを置かれると、それが親と同じ権限で動きます。Microsoftのリファレンスも独立した「セキュリティに関する注意事項」で`NULL`を渡さないよう明記しています。C++側は`GetModuleFileNameW`から自分のフォルダーを求めて絶対パスを組み立て、コマンドラインも引用符で囲む形に、C#側は`AppContext.BaseDirectory`からの絶対パスを`ProcessStartInfo`へ渡す形にしました(`UseShellExecute = false`のとき.NETは`lpApplicationName`に`null`を渡すため、同じ探索が起きます)。探索順の説明も本文に追加しています。
`AssignProcessToJobObject`が失敗したときの後始末を直しました。`Kill`の失敗をすべて握りつぶしたうえ、`Kill`は非同期なので待たずに抜けており、Jobは空なので`job.Dispose()`でも片付きません。リトライした2本目のhelperと重なって動くか、止まらないまま残ります。`HasExited`を見てから`Kill`し、`WaitForExit`で終了を待ち、止められなかった場合は元の例外と合わせて`AggregateException`で投げるようにしました。
Jobのハンドルを生の`IntPtr`ではなく`SafeHandle`で持つようにしました。呼ぶ側で`Process.Start`が失敗したときや`Add`が失敗したときにJobを閉じる手段が無く、初期化をやり直すたびにカーネルハンドルが1個ずつ残っていました。あわせて、`KILL_ON_JOB_CLOSE`付きのハンドルに`using`を付けてはいけない(スコープを抜けた時点で子が全部死ぬ)ことも明記しています。
C#のJob生成で、`CreateJobObject`は成功したが`SetInformationJobObject`が失敗した経路がハンドルを閉じずに例外を投げていたのを直しました。呼び出し側が初期化エラーを捕まえてリトライすると、試行のたびにカーネルハンドルが1個ずつ漏れます(C++版はこの経路で`CloseHandle`しています)。
タイムアウト時の`Kill`のあとに`WaitForExit`を足しました。`Kill`は終了を要求して即座に返るため、待たずに例外を投げると`using`の破棄が走った時点でまだ子が生きていることがあり、「タイムアウト例外が出た=プロセスの木は片付いた」が成り立ちません。
C#の呼び出し側で、`AssignProcessToJobObject`が失敗したときに子プロセスが残る問題を直しました。`using`の`Dispose`は`Process`のラッパーを捨てるだけでOSのプロセスは終わらないため、Jobの外に取り残された子を明示的に停止するようにしました。
冒頭にJobの内側と外側を示す全体像の図、6.1にstdout/stderrのデッドロックが起きる流れの図を追加しました。Job Objectの最小コードをC++とC#で示し(起動からAssignまでの隙間に孫が生まれる点も明記)、標準出力と標準エラーを並行して読み切る例、言語別の対応表、終了コード259の理由を追加しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589712)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「Windowsアプリで子プロセスを安全に扱うチェックリスト」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589712 https://staging.comcomponent.com/blog/2026/03/20/001-windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/

DOI(最新版)
10.5281/zenodo.21589712
DOI(この版)
10.5281/zenodo.21732732

日英シート付きの Excel チェックリストをダウンロード

変換ツール、アップデータ、解析ワーカー、外部 CLI、PowerShell、ffmpeg、社内ユーティリティ。 Windows アプリは、思っている以上に簡単に子プロセスへ依存します。

ただ、事故るのは「起動できたかどうか」ではありません。

  • 親が落ちたのに子だけ残る
  • 孫プロセスだけが生き残る
  • stdout / stderr が詰まって WaitForExit が返らない
  • watchdog が監視対象と一緒に死ぬ
  • Kill(entireProcessTree: true) で終わったつもりが、観測だけ先に終わる

Windows で子プロセスを安全に扱うコツは、起動 API を選ぶこと ではなく、プロセス木の所有者を決め、終了手順と I/O を設計すること です。

この記事では、Job Object、終了伝播、標準入出力、watchdog を一枚の設計として整理します。

この記事で使う用語

英語のまま出てくる語を、先に一行ずつ整理しておきます。

用語 一行での意味
process tree プロセス木。親から起動した子、その子が起動した孫までを含めた一族のことです
graceful shutdown 協調終了。「終わってください」と依頼し、相手が後始末をしてから自分で終わるやり方。強制終了の対義語です
I/O completion port Windows の非同期 I/O 完了通知のしくみ。Job Object に結び付けると、プロセスの起動や終了の通知を受け取れます
message pump メッセージループ。ウィンドウを持つスレッドが、OS からのメッセージを取り出して処理し続ける仕組みです。ここが止まると画面が固まります
heartbeat 生存確認のために、子プロセスから定期的に出す信号。プロセスが「生きているのに進んでいない」状態を検出するために使います
restart budget 再起動の予算。一定時間内に何回まで再起動してよいかの上限。crash loop を止めるために持ちます
drain 吸い上げること。pipe に溜まった出力を読み切って、書き手側が詰まらないようにすることです

全体像

先に、登場人物の関係を 1 枚にしておきます。

JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 付きの Job Objectexit handle で終了を検知heartbeat でハングを検知restart budget の範囲で作り直す親アプリ / worker 本体job handle の最終所有者子 helper.exe孫 converter.exe孫 ffmpeg.exewatchdogJob の外に置く

見どころは 2 つです。

  • Job の境界がプロセス木の境界 です。親の生死ではなく Job 所属で束ねるので、孫が増えても回収漏れが起きません
  • watchdog だけが Job の外にいます。中に入れてしまうと、監視対象と一緒に片づけられます

1. まず結論

先に、実務でいちばん効くところだけ並べます。

  • 親の生死と子プロセス木の寿命を結びつけたいなら、基準点は Job Object です
  • console への終了依頼プロセス木の回収 は別です
    • 前者は process group と GenerateConsoleCtrlEvent
    • 後者は Job Object
  • 起動時点から Job に入れたい なら、STARTUPINFOEXPROC_THREAD_ATTRIBUTE_JOB_LIST を使う設計が素直です
  • 標準出力 / 標準エラーは並列に吸い上げる のが基本です
  • stdin を使うなら、書き終えたら close して EOF を伝える ところまで設計します
  • watchdog は監視対象の Job の外に置く ほうが安全です
  • .NETKill(entireProcessTree: true) は明示的に止める API として便利ですが、親クラッシュ時の自動回収や graceful shutdown まで含む設計の代替ではありません

この記事の知識マップ

Windowsアプリで子プロセスを安全に扱う設計の基準点はJob Objectで、KILL_ON_JOB_CLOSEを付けることで親クラッシュ時にもプロセス木ごと自動回収でき、Process.Kill(entireProcessTree: true)だけでは代替できません。終了は協調終了(graceful shutdown)を依頼してから短いtimeoutで待ち、最後にJobごと強制終了する3段階が事故りにくく、console子への協調終了はprocess groupとconsole制御イベントで行います。stdoutとstderrを並列にdrainしないと、Windowsのpipeが有限バッファのために親子が読み待ち・書き待ちで固まるstdioデッドロックが起きます。watchdogは監視対象と同じJobに入れず外側に置き、heartbeatでハングを検知し、restart budgetでcrash loopを防ぐ設計が安定します。

Windows子プロセス安全管理の知識マップJob Objectによるプロセス木の束ねと親クラッシュ時の自動回収、協調終了から強制終了までの終了手順、stdout/stderrの並列drainによるデッドロック回避、watchdogをJobの外に置きheartbeatとrestart budgetで監視する設計の関係を示す図。で構成できる防止する推奨される対応用いるのは非推奨前提とする利用する前提とするより先に行うべき利用する用いるのは非推奨利用する自動化する防止する防止する利用する実装を担う原因になり得るJob Object標準入出力のデッドロック監視プロセス(watchdog)JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE親を失った子プロセスの残存親クラッシュ時の自動回収Process.Kill(entireProcessTree: true)PROC_THREAD_ATTRIBUTE_JOB_LISTプロセスグループ(コンソール)コンソール制御イベントgraceful shutdownheartbeatによる生存確認再起動予算(restart budget)crash loopstdout/stderrの並列drainI/O完了ポート(IOCP)プロセス木(process tree)JOB_OBJECT_LIMIT_BREAKAWAY_OK

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

2. 何が危ないのか

子プロセス起動の実装は、最初はだいたい 10 行前後で書けます。 でも事故るのは、その 10 行の外側です。

  • 親が落ちたあと、子や孫が残り続ける
  • helper がさらに helper を起動して、直下の子だけ待って満足してしまう
  • stdout / stderr の片側が詰まって、親も子も待ち合う
  • UI thread で待機して、画面も COM も固まる
  • watchdog が監視対象と同じ運命共同体になっていて、異常時に一緒に落ちる

ここで大事なのは、「子プロセス管理」は 1 つの API の話ではない という点です。

少なくともこの 4 つは分けて考えたほうが見通しが立ちます。

  1. プロセス木を誰が所有するか
  2. どうやって協調終了を依頼するか
  3. 標準入出力をどう流すか
  4. 異常終了とハングをどう監視するか

3. 仕組みの役割を混ぜない

process handle / process group / Job Object は、似て見えて役割が違います。

仕組み 主な役割 向いている場面 それだけでは足りないこと
process handle 1 プロセスの終了待ち、exit code 取得 単発ツールの完了待ち 孫プロセスの回収
process group console への Ctrl+Break 伝播 console child の協調終了 親クラッシュ時の cleanup、GUI 子プロセス
Job Object プロセス木の束ね、制限、まとめて終了 worker tree、updater、helper chain アプリ固有の「保存してから閉じる」

process group は console signal をどこへ送るか を決める仕組みであって、親が死んだら木ごと片づける ための仕組みではありません。 一方で Job Object は、プロセス群を 1 単位で管理する 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 を作り、別スレッドで読む RedirectStandardOutputBeginOutputReadLine
GUI 子に閉じてもらう WM_CLOSE を送る Process.CloseMainWindow
console 子に 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 側に用意されているのは、あくまで 1 プロセス単位の操作までです。

4. Job Object を基準にする

Job Object のいちばん強い点は、「誰の子か」ではなく「どの Job に属するか」 で process tree を束ねられることです。Job に入ったプロセスが CreateProcess で作る子は、デフォルトでその Job に入ります。

さらに、JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE を付けると、最後の job handle が閉じられたとき に Job に関連付けられた全プロセスが終了します。

4.1 まず押さえたい 4 つ

1. 親終了で木ごと片づけたいなら KILL_ON_JOB_CLOSE

これは Windows アプリで helper / worker を扱うときの土台です。TerminateJobObject を明示的に呼ぶ設計でもよいですが、親の異常終了まで含めて cleanup を親の寿命へ寄せたい なら KILL_ON_JOB_CLOSE が分かりやすいです。

2. BREAKAWAY を軽く付けない

JOB_OBJECT_LIMIT_BREAKAWAY_OKJOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK は便利そうに見えますが、cleanup できるつもりだった木から一部が抜ける 原因にもなります。意図がない限り、breakaway は付けないほうが事故率が下がります。

3. 起動時点から Job に入れたいなら PROC_THREAD_ATTRIBUTE_JOB_LIST

AssignProcessToJobObject で後から結びつけることもできます。 ただし、起動直後から Job 所属を前提にしたい 場面では、STARTUPINFOEXPROC_THREAD_ATTRIBUTE_JOB_LIST を使って作成時に Job を指定するほうが筋がよいです。

4. job handle の所有者を曖昧にしない

KILL_ON_JOB_CLOSE最後の handle が閉じたとき に効きます。 つまり逆に言うと、job handle を別プロセスへ複製したり、意図せず継承させたりすると、親が死んでも想定どおり cleanup されません。誰が job handle の最終所有者か は先に決めるべきです。

4.2 Job Object は observability にも使えるが、通知は万能ではない

Job Object には I/O completion port を関連付けて通知を受ける仕組みがあります。ただし completion port の通知は、すべてのケースで完全保証される通知だと見なさないほうが安全です。

なので completion port は、

  • 監視
  • 集計
  • ログ
  • メトリクス

には便利ですが、それだけで correctness を組まない ほうがよいです。

4.3 最小コードで見る

言葉より短いので、両方の言語で最小形を置いておきます。

C++ 側は、Job を作る → KILL_ON_JOB_CLOSE を付ける → 起動時点で Job を指定する の 3 手です。

// Windows 10 以降 / C++17。helper.exe を Job に入れて起動し、親の終了で木ごと片づける
#include <windows.h>
#include <memory>
#include <string>

int wmain()
{
    // 0. 起動するファイルを絶対パスで確定させる。
    // lpApplicationName を nullptr にしてコマンドラインの先頭から探させると、
    // 探索対象に「親プロセスのカレントディレクトリ」と「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 のドキュメントに書かれている制約を 2 つ踏んでいます。どちらも読み落としやすいところです。

  • PROC_THREAD_ATTRIBUTE_JOB_LIST が使えるのは Windows 10 / Windows Server 2016 以降 です。それ以前を相手にするなら、AssignProcessToJobObject に落とす必要があります
  • UpdateProcThreadAttribute に渡した値は、DeleteProcThreadAttributeList を呼ぶまで生存していなければなりません。ローカル変数を渡してすぐスコープを抜ける書き方は壊れます

起動するファイルは、必ず絶対パスで指す

冒頭の「0.」で GetModuleFileNameW からパスを組み立てているのは、書き方の好みではなくどの実行ファイルが動くかを確定させるためです。

lpApplicationNamenullptr を渡すと、コマンドラインの先頭の語がモジュール名になります。そこにパスが含まれていない場合、Windows は次の順で探します。

  1. アプリケーションが読み込まれたディレクトリ
  2. 親プロセスのカレントディレクトリ
  3. 32bit のシステムディレクトリ
  4. 16bit のシステムディレクトリ
  5. Windows ディレクトリ
  6. PATH 環境変数に並んだディレクトリ

問題は 2 と 6 です。helper.exe が 1 に無いとき ── 配置漏れ、別構成でのビルド、アンインストールの取り残し ── 探索は 2 へ進みます。カレントディレクトリが書き込み可能な場所(ユーザーのダウンロードフォルダーからそのまま起動した、共有フォルダーを作業ディレクトリにしている)なら、そこに置かれた helper.exe親と同じ権限で動きます。PATH を書き換えられる環境なら 6 も同じことです。

Microsoft のドキュメントも、この点は「セキュリティに関する注意事項」として独立した節を設けており、「この問題を避けるには、lpApplicationNameNULL を渡さないでください」と明記しています。空白を含むパスを引用符で囲まないと C:\Program.exe が起動され得る、という有名な例も同じ節にあります。だからコマンドライン側も "..." で囲んでいます。

C# の ProcessStartInfo も同じです。UseShellExecute = false のとき、.NET は FileName と引数を 1 本のコマンドラインに組み立てて lpApplicationName には null を渡すため、ファイル名だけを渡すと上の探索がそのまま起きますAppContext.BaseDirectory から組み立てた絶対パスを渡してください。

「そんな配置ミスは起きない」と思える環境でも、書くコストはほぼゼロです。子プロセスを起動するコードで、実行ファイルを相対名で書く理由は基本的にありません。

.NET には Job Object のラッパーがないので、P/Invoke になります。構造体の定義が長く見えますが、実際に呼ぶのは 2 関数だけです。

// .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 をそのまま捨てない。
            // 呼び出し側が初期化エラーを捕まえてリトライする作りだと、
            // 試行のたびにカーネルハンドルが1個ずつ漏れる(上の 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 で持つと、初期化に失敗した経路で誰も閉じられない。
// SafeHandle にしておけば、失敗経路は Dispose を1回呼ぶだけで済む
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 は既に走っている。`using` の Dispose は Process の
        // ラッパーを捨てるだけで OS のプロセスは終わらず、Job は空なので
        // job.Dispose() でも片付かない。ここで自分で止め、終わるまで待つ
        try
        {
            if (!child.HasExited)
            {
                child.Kill(entireProcessTree: true);
            }

            // Kill は終了を要求して即座に返る。待たずに throw すると、
            // 初期化をやり直した2本目の helper と重なって動くことがある
            child.WaitForExit();
        }
        catch (Exception killFailed)
        {
            // 止められなかったことは、Add の失敗より重い。握りつぶすと
            // 「Job にも入らず、止まってもいない子」を残したまま先へ進む
            throw new AggregateException(
                "Job への割り当てに失敗し、helper.exe も停止できませんでした。",
                assignFailed, killFailed);
        }

        throw;
    }
}
catch
{
    // 起動にも Job への投入にも失敗したら、この Job はもう使わない。
    // 閉じずに抜けると、初期化をやり直すたびにカーネルハンドルが1個ずつ残る。
    // この時点では Job は空(または上の catch で子を止めた後)なので、
    // 閉じても止まって困るものはない
    job.Dispose();
    throw;
}

ただし、この .NET 版には 起動してから Job に入れるまでの隙間 があります。その間に子がさらに孫を作ると、孫は Job の外で生まれます。C++ 版が PROC_THREAD_ATTRIBUTE_JOB_LIST を使っているのは、まさにこの隙間をなくすためです。孫を作る helper を相手にするなら、.NET でも STARTUPINFOEX を使う P/Invoke まで踏み込む価値があります。

5. 終了伝播を protocol と timeout で設計する

子プロセスの終了は、1 発の kill API で終わる話ではありません。 いちばん事故りにくいのは、この 3 段階を踏む形です。

  1. 協調終了を依頼する
  2. 短い timeout で待つ
  3. 最後に Job ごと強制終了する

この順番にしておくと、正常な終了経路は保ちつつ、ハング時は回収できます。

5.1 GUI child

GUI を持つ子プロセスなら、.NET では CloseMainWindow が close message 送信になります。 ただしこれは 終了要求 であって強制終了ではありません。なので、

  • CloseMainWindow
  • 一定時間待つ
  • だめなら Job ごと kill

という流れにしたほうが素直です。

5.2 Console child

Console child では、GUI の close message は使えません。 このときは process group と console signal を使います。

CREATE_NEW_PROCESS_GROUP で起動し、GenerateConsoleCtrlEventCTRL_BREAK_EVENT を送る、という流れです。 ここで大事なのは、

  • CTRL_C_EVENT は特定 group への限定に向かない
  • signal を受け取れるのは console を共有しているプロセスだけ
  • CREATE_NEW_PROCESS_GROUP を使うと CTRL+C の意味も変わる

という点です。

5.3 Worker / headless child

Worker や headless child は、GUI でも console でもないことが多いです。 この場合は、子プロセス専用の終了 protocol を持ったほうが安全です。

  • stdinquit を送る
  • named pipe / socket / RPC で shutdown command を送る
  • event object で停止要求を伝える

Windows 的には Job Object が tree cleanup を担い、アプリ的には pipe や stdin が graceful shutdown を担う、という分離が事故りにくいです。

6. 標準入出力を詰まらせない

6.1 stdout / stderr は並列 drain

最初の基本はこれです。 stdoutstderr は並列に吸い上げる。片方を全部読んでからもう片方、は詰まりやすいです。

Windows の pipe は無限バッファではありません。子が stderr を大量出力し、親が stdout しか読んでいなければ、子は write で止まり、親は終了待ちで止まる、という形が普通に起きます。

図にすると、この形です。

子プロセスstderr の pipestdout の pipe親プロセス子プロセスstderr の pipestdout の pipe親プロセスpipe のバッファが満杯になるwrite が返らない。子はここで停止子が止まっているので何も来ない親は読み待ち、子は書き待ち。WaitForExit も返らないstdout だけを読み続ける少しだけ書く読めた警告を大量に書くさらに書こうとする続きを読もうとする

止まっている場所が 親でも子でもなく pipe なので、どちらのログを見ても原因が写りません。「stderr を読んでいない」という一行の抜けが、そのままハングになります。

stdoutstderr を別々のハンドラで受け、それぞれ独立に読み進めれば、この輪は成立しません。.NET なら次の形です。

// .NET 8 / C# 12。stdout と stderr を並列に drain し、出力の読み切りまで待つ
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 は終了を要求して即座に返る。ここで待たずに throw すると、
    // using の Dispose が走った時点でまだ子が生きていることがあり、
    // 「タイムアウト例外が出た=木は片付いた」が成り立たない
    process.WaitForExit();

    throw new TimeoutException("helper.exe が 30 秒以内に終了しませんでした。");
}

// timeout 付きの WaitForExit が true を返しても、非同期の出力処理はまだ終わっていないことがある。
// 引数なしの WaitForExit をもう一度呼び、出力の読み切りまで待つ。
process.WaitForExit();

Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout    : {stdout.Length} 文字");
Console.WriteLine($"stderr    : {stderr.Length} 文字");

タイムアウトの境界には、必ず取り合いがあります。WaitForExit(30_000)false を返してから Kill を呼ぶまでのわずかな間に、子が自分で終わることがあります。そのとき Kill は成功しません ── .NET では終了処理中に Win32Exception(「The process is terminating.」)、.NET Framework では終了済みに対して InvalidOperationException になります。ここを素通りさせると、投げるはずだった TimeoutException の代わりに、後始末の失敗が飛びます。呼び出し側は「タイムアウトした」ではなく「よく分からないエラーが出た」を受け取り、最後の WaitForExit() による出力の読み切りも飛ばされます。上のように、HasExited で「本当に終わっているか」を確かめてから握りつぶします。まだ生きているなら止められなかったということなので、そのまま投げ直します。なお Kill(entireProcessTree: true) が投げる AggregateException(子孫の一部を止められなかった)は握りません。それは「木が片付いていない」ことそのもので、この節が防ごうとしている状態だからです。

最後の WaitForExit() は、消し忘れではなく必須です。 WaitForExit(int) のドキュメントには、標準出力を非同期イベントハンドラへリダイレクトしている場合、このオーバーロードが返った時点では出力処理が完了していない可能性がある と書かれており、true を受け取ったあとに引数なしの WaitForExit() を呼ぶよう案内されています。これを省くと、出力の末尾だけが欠ける という、再現しにくい形で壊れます。

6.2 stdin を使うなら EOF まで設計する

stdin へ書けることと、子が終われることは同じではありません。

  • 入力を書いたあと close しない
  • 親は「もう渡した」と思っている
  • 子は「まだ続きが来る」と思って待ち続ける

という状態が起きます。stdin を使うなら、書き終えたら close して EOF を伝える ところまで含めて設計する必要があります。

6.3 不要な pipe end を必ず閉じる

親側・子側の未使用 end を閉じないと、EOF が伝わらず、終了条件が崩れます。 これは単純ですが、実務ではかなり多い事故です。

6.4 UseShellExecute=false と handle 継承の扱いを曖昧にしない

標準入出力リダイレクトを使うなら、.NET では UseShellExecute=false が前提です。 Win32 でも、何を継承させるか をできるだけ絞ったほうが安全です。bInheritHandles=TRUE のまま全部継承させると、思わぬ handle leak の原因になります。

7. watchdog は「外」に置く

watchdog を入れるときに一番大事なのは、監視対象と同じ Job に入れない ことです。 worker が落ちたら再起動したいのに、その再起動役まで一緒に死んだら意味がありません。

7.1 exit 監視は wait handle ベースにする

プロセスは終了すると signaled 状態になります。 だから exit 監視は、本来 polling loop で HasExited を 100ms ごとに見る必要がありません。

Win32 なら、

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

が正攻法です。複数 child を扱うなら、timer polling より wait handle ベースのほうが自然です。

7.2 UI thread で無限待機しない

WaitForSingleObject(INFINITE) は便利ですが、window を持つ thread で使うと message pump を止めやすいです。 UI thread、COM apartment thread、message pump を持つ thread では、待機の置き場所 を先に考えたほうが安全です。

7.3 hang watchdog には heartbeat が要る

exit watchdog は process handle で十分です。 でも hang watchdog は違います。

  • CPU 100% で固まっている
  • deadlock している
  • event loop は生きているが進捗がない
  • 入力待ちで止まっている

こういう状態は、「プロセスが生きているか」だけでは判定できません。なので hang まで見たいなら、

  • heartbeat
  • progress sequence
  • last successful work timestamp
  • health probe

のような アプリ層の生存確認 が要ります。

7.4 再起動役は監視対象の外に置く

実務でよくあるのは、この 2 パターンです。

  • 親アプリが一時的に helper を起動するだけ
    • 親が Job を持ち、親終了で helper tree を回収
  • 長時間 worker を常駐させ、落ちたら再起動したい
    • 外部の watchdog process / service が worker generation ごとに Job を作る

後者では、worker tree と restart authority を分離する ほうが設計が安定します。

7.5 restart policy は budget で持つ

watchdog を入れると、次は crash loop が始まります。

  • 即再起動
  • また即落ちる
  • ログだけ大量に出る

これを避けるには、

  • backoff
  • 一定時間内の restart 回数上限
  • 連続失敗時は停止して通知

という restart budget を持ったほうがよいです。

8. 典型パターン別の推奨構成

場面 推奨構成
デスクトップアプリが単発の CLI helper を起動する 1 起動 = 1 Job。KILL_ON_JOB_CLOSE を付け、stdout / stderr を並列 drain。キャンセル時は協調終了 → timeout → Job kill
helper がさらに孫プロセスを起動する Job Object を前提にし、breakaway を許さない。起動時から固定したいなら PROC_THREAD_ATTRIBUTE_JOB_LIST
service / watchdog が長時間 worker tree を監視する watchdog は外部 process / service。worker generation ごとに Job を作り、exit handle + heartbeat で監視
console tool を丁寧に止めたい CREATE_NEW_PROCESS_GROUP で起動し、CTRL_BREAK_EVENT で協調終了。その後 timeout で Job kill
GUI helper を閉じたい CloseMainWindow / WM_CLOSE 相当 → timeout → Job kill
多数の子プロセスを監視したい blocking thread を増やすより RegisterWaitForSingleObject / SetThreadpoolWait を使う

ここで一番大事なのは、graceful shutdown の仕組みcleanup の仕組み を分けることです。

9. やってはいけないこと

各章で触れた注意点を、レビューでそのまま使える形にまとめ直します。 「何が起きるか」と「どこに書いてあるか」を並べているので、引っかかった行から本文へ戻れます。

やってはいけないこと 何が起きるか 本文
Kill(entireProcessTree: true) だけで graceful shutdown や親クラッシュ時の回収まで解けたと思う 明示的に止めるときしか効かない。親が落ちたときの回収と、子に後始末をさせる経路が抜ける 5 章
bInheritHandles=TRUE のまま全部継承する 意図しない handle が子へ渡り、handle leak と EOF 不達の原因になる 6.4
stdout を全部読んでから stderr を読む もう片方の pipe が満杯になり、親は読み待ち、子は書き待ちで止まる 6.1
pipe の未使用 end を閉じない EOF が伝わらず、読み側の終了条件が成立しない 6.3
UI thread で WaitForSingleObject(INFINITE) する message pump が止まり、画面と COM が固まる 7.2
watchdog を監視対象と同じ Job に入れる 監視対象を片づけるときに、再起動役まで一緒に消える 7 章
259 を普通の exit code に使う GetExitCodeProcess は実行中に STILL_ACTIVE、つまり 259 を返す。子が 259 で正常終了すると、終了したのに実行中だと誤判定する 7.1
Job completion port の通知を唯一の真実にする 通知は監視や集計向けであり、これだけで correctness を組むと取りこぼす 4.2

10. まとめ

Windows アプリで子プロセスを安全に扱うとき、いちばん効くのはこの整理です。

誰が process tree を所有するか どうやって終了要求を伝えるか 標準入出力をどう流し切るか watchdog をどこへ置くか

この 4 つを先に決める。

そのうえで、雑に言えばこうです。

  • tree cleanup の基準点は Job Object
  • graceful shutdown は GUI / console / worker で分ける
  • stdio は並列 drain と EOF まで含めて設計する
  • watchdog は監視対象の外に置き、polling ではなく wait handle と heartbeat で見る

CreateProcessProcess.Start 自体は入口にすぎません。 本当に事故率に効くのは、終了責任の所在I/O の流し切り です。

11. 参考資料

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

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

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

よくある質問

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

親プロセスが落ちたのに子プロセスが残るのはなぜですか?
process handleやprocess groupだけでは、親クラッシュ時にプロセス木を回収する仕組みがないためです。親の生死と子プロセス木の寿命を結びつけたいなら、基準点はJob Objectです。JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEを付けると、最後のjob handleが閉じられたときにJobに属する全プロセスが終了するため、親の異常終了まで含めてcleanupを親の寿命へ寄せられます。
WaitForExitが返ってこないのはなぜですか?
標準出力・標準エラーのpipeが詰まっている可能性が高いです。Windowsのpipeは無限バッファではないため、子がstderrへ大量出力しているのに親がstdoutしか読んでいないと、子はwriteで止まり親は終了待ちで止まります。stdoutとstderrは並列に吸い上げるのが基本で、片方を全部読んでからもう片方を読む実装は詰まりやすいです。また、pipeの未使用endを閉じないとEOFが伝わらず終了条件が崩れます。
.NETのKill(entireProcessTree: true)だけで十分ではないですか?
十分ではありません。明示的に止めるAPIとしては便利ですが、親クラッシュ時の自動回収やgraceful shutdownまで含む設計の代替にはなりません。事故りにくいのは、協調終了を依頼し、短いtimeoutで待ち、最後にJobごと強制終了する3段階です。協調終了の手段は、GUI子ならCloseMainWindow、console子ならCREATE_NEW_PROCESS_GROUPとCTRL_BREAK_EVENT、workerならstdinやpipe経由の終了protocolと、子の種類で分けます。
watchdogプロセスはどこに置くべきですか?
監視対象と同じJobに入れないことが一番大事です。workerが落ちたら再起動したいのに、その再起動役まで一緒に死んだら意味がないためです。長時間workerを常駐させる場合は、外部のwatchdogプロセスやサービスがworkerの世代ごとにJobを作る構成が安定します。exit監視はpollingではなくwait handleベースで行い、ハング検知まで必要ならheartbeatなどアプリ層の生存確認を組み合わせます。

著者プロフィール

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

小村 豪

合同会社小村ソフト 代表

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

ブログ一覧に戻る