最近、Windowsで子プロセスを監視して、落ちたら蘇生してくれる Resurrector というのを作っていた。
基本的な要件はこれだけ:
- 子プロセスを起動して死活監視する
- 死んだら再起動する
- 終了時には、子プロセスを お行儀よく 殺す
だが、最後の「お行儀よく殺す」のところを、自分は完全にナメていた。
UNIX的な発想で、SIGTERM を投げて少し待って、ダメなら SIGKILL。それでいいんでしょ? ── そう思っていた時期が、私にもありました。
結論から先に言う。
WindowsにはUNIXのシグナルのように、外部から任意のプロセスに「ねえ、そろそろ終わってよ」と信号を送る統一的な手段は、ない。
ググっても「TerminateProcess を呼ぶ」みたいな雑な回答ばっかり出てきて、まとまった解説が驚くほど見つからない。だからこの機会に、学んだことをまとめておこうと思ったのがこの記事である。
「いやいや、signal.h で SIGTERM 受け取れるじゃん」
それな! 自分も最初はそう思ってた。
では、試しにこんなCコードを書いてみよう。
#include <stdio.h>
#include <signal.h>
void handler(int sig) {
printf("caught signal: %d\n", sig);
fflush(stdout);
}
int main(void) {
signal(SIGTERM, handler);
signal(SIGINT, handler);
printf("waiting...\n");
fflush(stdout);
while (1) Sleep(1000);
return 0;
}
ビルドして走らせて Ctrl+C を叩くと、ちゃんと caught signal: 2 と表示される。
「ほら、シグナルあるじゃん。なに騒いでるの?」 と思うよね。
では、ここで質問です:
Windowsで SIGTERM を送る方法、知ってますか?
Linuxなら kill -TERM <pid> するだけ。
これに相当する「Windowsの作法」を答えてみてほしい。
Windowsで kill コマンドに相当するのは taskkill コマンドだ。
でも、 taskkill にはシグナルを指定するオプションはない:
❯ taskkill /?
TASKKILL [/S system [/U username [/P [password]]]]
{ [/FI filter] [/PID processid | /IM imagename] } [/T] [/F]
シグナルを指定できないんだとしてもプロセスを終了させることはできるんだから、さっきのプログラムのPIDを taskkill に渡してみよう。
> taskkill /PID 53884
ERROR: The process with PID 53884 could not be terminated.
Reason: This process can only be terminated forcefully (with /F option).
・・・・・・は?
「/F を付けないとコイツは殺せませんよ」……だと……?
じゃあ言われたとおり付けてみる:
> taskkill /F /PID 53884
SUCCESS: The process with PID 53884 has been terminated.
殺せた。ただし、caught signal: のメッセージは1行も出ていない。
そう。taskkill /F の正体は TerminateProcess というWin32 APIで、これは 「アプリへの通知」ではなく「OSへの命令」 だ。カーネルが直接スレッドの実行をぶった切り、ユーザーモードのコードは1行も動かない。atexit も finally も defer もデストラクタも呼ばれず、開いていたファイルもソケットもDB接続も全部投げっぱなしで即死する1。
実はWindowsでは、外部から SIGTERM を送る手段は存在しない。
でもさっき Ctrl+C で caught signal: 2 って出力されてたってことは、SIGINT は送れるっていうこと?じゃあ、コマンドで SIGINT を送るにはどうしたらいいんだ?
signal.h はエミュレーション
種を明かすと、Windowsの signal.h は Cランタイム(CRT)が ISO C 仕様に準拠するために用意したユーザーモードのエミュレート層 にすぎない。2
実態は、CRTが内部でWin32APIの SetConsoleCtrlHandler を呼んで コンソール制御イベント(Console Control Event) を受け取って、それをシグナルに読み替えて signal() ハンドラを呼んでいる。つまりこういうことだ:
signal.h という看板を出しているけれど、中身は完全に Windows固有のConsole Control Event機構の上に乗っかったエミュレーション なのである。3
なお、Console Control Eventには CTRL_C_EVENT、CTRL_BREAK_EVENT、CTRL_CLOSE_EVENT、CTRL_LOGOFF_EVENT、CTRL_SHUTDOWN_EVENT の5種類があるのだが、ユーザーモードから能動的に発火させられるのは CTRL_C_EVENT と CTRL_BREAK_EVENT の2種類だけ 4 であり、それぞれ SIGINT と SIGBREAK に対応している。そして、残りの3つは signal.h では捕捉できない。
つまり、SIGTERM を外部から送る方法は Windows公式には、存在しない。
一応、raise(SIGTERM) で自分自身には送れる。(なんやそれ)5
ちなみに、CTRL_C_EVENT はプロセスグループ全体には送れない ので、プロセスグループ全体に終了通知を送りたい場合に使えるのは CTRL_BREAK_EVENT 一択だ。
結局、Windowsではシグナルの代わりがConsole Control Eventってこと?
そう思うでしょう?ここからが Windows 沼の本番である。
Console Control Eventを外部から送るAPIは GenerateConsoleCtrlEvent なのだが、こいつには重大な前提がある。
Console Control Eventが送れるのはコンソールがあるアプリだけである。コンソールがないアプリには送れない。
「コンソールがないアプリ」って何??メモ帳(notepad.exe)?
そう、まさにそういうのだ。
サブシステムという概念
ここで Windows の サブシステム という、UNIX民にはあまり馴染みのない概念が出てくる。
Windowsの実行ファイル(PEバイナリ)には、ビルド時にリンカが「俺はコンソールアプリだ/GUIアプリだ」というフラグをPEヘッダに書き込む という仕組みがある。
このフラグを見て、Windows ローダーは挙動を変える。
- コンソール(cmd.exe とか Windows Terminal とか)内でコンソールアプリを起動すると、そのコンソールがアプリに割り当てられる
- GUI(例: explorer.exe)からコンソールアプリを起動すると、Windowsが
conhost.exe経由で新しいコンソールウィンドウ(あの黒い画面)を割り当てる - GUIアプリを起動する場合、 コンソールは割り当てられない ──
AllocConsoleやAttachConsoleで自分で明示的に作ったり拾いに行ったりしない限り、Console Control Eventを受け取る土壌すらない
つまり、メモ帳に対して GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid) を叩いても 失敗する。メモ帳はコンソールイベントを受け付ける窓口を持っていないからだ。
GUIアプリに信号を送るには:ウィンドウメッセージ
じゃあGUIアプリにはどうやって「そろそろ終わって」を伝えればいいの?
答えは、ウィンドウメッセージで伝える。
WindowsのGUIアプリの WinMain の中身は、教科書的にはだいたいこんな形をしている。
MSG msg;
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
メッセージループ、というやつだ。再描画命令、マウス入力、キーボード入力、ウィンドウサイズ変更……ありとあらゆる「外部からの出来事」が、このループを通じて配送される。
そして、外部のプロセスからそのウィンドウに対してメッセージを Post すると、このループに飛び込んでくる。
「お行儀よく終わって」を伝えたいなら WM_CLOSE を PostMessage で投げつける、これがWindows流の作法だ。
ただし当然ながら、コンソールアプリはウィンドウメッセージを受け取れない。
なぜならウィンドウを持ってもいなければ、メッセージループも実装していないからだ。
従って「終わって」を伝える方法を整理するとこうなる:
| プロセスの種類 | 「終わって」を伝える方法 |
|---|---|
| GUIアプリ(ウィンドウを持っているプロセス) | PostMessage(hwnd, WM_CLOSE, ...) |
| コンソールアプリ(コンソールにアタッチしているプロセス) | GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, ...) |
| コンソールもウィンドウも持たない/応答しないアプリ | なし(TerminateProcess のみ) |
ひとつのAPIで全部済む、UNIXの kill(2) のような美しい世界は、Windowsには存在しない。6
結局どうすればいいのか:「3段構え」
ここまでの整理を踏まえて、Resurrectorで自分が採用した方針はこうだ。
- 対象プロセスのウィンドウを
EnumWindowsで探し、見つかったらWM_CLOSEを送る - 見つからなければ、対象のコンソールに
AttachConsoleしてCTRL_BREAK_EVENTを送る -
どちらも効かなかったら、最後は
TerminateProcessで問答無用 に殺す
それぞれ 5秒 だけ待ってから次に進む。Goで書くと、こういう雰囲気になる(エラー処理は簡略化): 7
// 1. 対象PIDが所有するトップレベルウィンドウに WM_CLOSE
func sendCloseToWindows(pid uint32) (sent bool) {
cb := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr {
var wpid uint32
procGetWindowThreadProcessID.Call(hwnd, uintptr(unsafe.Pointer(&wpid)))
if wpid == pid {
// WM_CLOSE = 0x0010
procPostMessage.Call(hwnd, 0x0010, 0, 0)
sent = true
}
return 1 // continue enumeration
})
procEnumWindows.Call(cb, 0)
return sent
}
// 2. 対象のコンソールに乗り込んで CTRL_BREAK_EVENT
func sendCtrlBreak(pid uint32) error {
// 自分が既にコンソールを持っているとAttachConsoleは失敗するので、
// 一度FreeConsoleしてから対象pidのコンソールにくっつく
procFreeConsole.Call()
if r, _, err := procAttachConsole.Call(uintptr(pid)); r == 0 {
return err
}
defer procFreeConsole.Call()
// 自分自身もイベントを食わないように、いったんハンドラを無効化
procSetConsoleCtrlHandler.Call(0, 1)
defer procSetConsoleCtrlHandler.Call(0, 0)
// CTRL_BREAK_EVENT = 1, グループ0 = 同コンソール全部
r, _, err := procGenerateConsoleCtrlEvent.Call(1, 0)
if r == 0 {
return err
}
return nil
}
// 3. 最後の手段
func forceKill(h windows.Handle) error {
return windows.TerminateProcess(h, 1)
}
ちなみに各段階のタイムアウトを 5秒 にしているのは、Windowsの公式パラメータ HungAppTimeout(既定値5000ms)に揃えているからだ。OSが「このアプリ応答なしだな」と判定する公式の閾値であり、これより長く待つくらいなら諦めて殺すほうが妥当、という理屈である。
まとめ
- Windowsには、UNIXのシグナルに相当する 「外部から任意のプロセスに通知する汎用の仕組み」が存在しない
-
signal.hは ISO C準拠のためのCRTのエミュレーション層 で、実体はConsole Control Eventをシグナル番号に翻訳しているだけ - Console Control Eventは コンソールにアタッチされているプロセスにしか送れず、しかも送れるのは
CTRL_C/CTRL_BREAKの2種類のみ - GUIアプリには ウィンドウメッセージ(
WM_CLOSE等) で伝えるしかなく、コンソールアプリとGUIアプリで信号の受け付け方が完全に別世界 - 結局、外から「お行儀よく終わらせたい」場合は
WM_CLOSE→CTRL_BREAK_EVENT→TerminateProcessの3段構えで殴る のが現実解
UNIXの世界に慣れていると、「シグナルなんて当然あるでしょ」と思ってしまう。
でも、「これだからWindowsは……」と言いたいわけじゃない。
WindowsはOSレベルでGUIをサポートするための設計が組み込まれているからこそ、GUIアプリとコンソールアプリで全然違う「シグナル」の受け取り方をサポートしているとも言える。
逆に言うとUNIXでは、その複雑さをX-WindowやWaylandに押し出して、責務を分離したわけだ。
というわけで、Windowsでプロセスを終了させたかっただけなのに、ずいぶんと長旅だった気がする。
まぁ、あらためてWindowsの勉強になったというお話でした。
え?こんなこともう知ってた?そうですか、自分が知らなかっただけでしたか……
参考文献
- Microsoft Learn —
GenerateConsoleCtrlEvent - Microsoft Learn —
signal - Microsoft Learn —
taskkill - Microsoft Learn —
TerminateProcess - StackOverflow - How to gracefully terminate a process?
-
UNIXで言えば
SIGKILL相当。 ↩ -
ちなみに、ISO Cでは
SIGABRT、SIGFPE、SIGILL、SIGINT、SIGSEGV、SIGTERMの6種類のシグナルをサポートしないといけないことになっている。だからWindowsはSIGTERM定数を定義している。送ってくれる奴がいないのにな! ↩ -
ちなみに、シグナルハンドラが別スレッドで実行される点も、UNIXのシグナルハンドラとは大きく異なる。UNIXの場合、シグナルを受け取れるスレッドのどれかの処理が中断されてそのスレッドで実行される。つまりシングルスレッドの場合は受け取れるスレッドが1つしかないので混乱しようがない。一方でWindowsの場合は、Console Control Eventを受け取るハンドラは新しく作られたスレッドの上で実行される。つまり受け取った瞬間、突如としてマルチスレッドプログラムになってしまう。 ↩
-
CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENTはOSが「死の宣告」として一方的に発行するイベントであって、ユーザーランドからは送信できない。 ↩ -
ちなみに、Goランタイムは
CTRL_CLOSE_EVENT,CTRL_LOGOFF_EVENT,CTRL_SHUTDOWN_EVENTをSIGTERMとして読み替える親切設計になっている。だからGoで書いたプログラムは、WindowsのシャットダウンやログオフのときにSIGTERMとして通知される。だがこれはGoの仕組みであって、Windowsの仕組みではない。 ↩ -
なお
taskkill(/Fなし)はGUIアプリに対しては内部でWM_CLOSEを投げる、ちょっと気の利いた実装になっている。ただしコンソールアプリ相手だと刺さらないことが多い。だから冒頭のtaskkill /PID 53884は「/F付けろ」と突き返してきた。あいつは半分しか仕事してない。 ↩ -
Console Control Eventは「自分がアタッチしているコンソール」にしか送れない。つまり同じコンソールを共有しているプロセス群にしか届かない。だから上のコードでは、わざわざ
FreeConsoleで自分のコンソールを離脱して、相手のコンソールにAttachConsoleで乗り換えている。他人のコンソールには干渉できないのだ。なお、対象のウィンドウをEnumWindowsで探そうとしても コンソールウィンドウはconhost.exeが所有しているのでPIDが一致せず、引っかからない。「コンソールアプリ=自分のウィンドウを持っている」と思っていると、ここで盛大に転ぶ。 ↩