WindowsでPopenしたプロセスに対してCTRL_C_EVENTを送りたかったが、実現が結構厳しい話。
厳しい理由とちょっと強引な解決方法についてまとめておく。開発環境は以下の通り。
開発環境
実現方法の検討
1. Popenインスタンスの.send_signal()
でSIGINT送信
signalライブラリの公式リファレンスには、下記の通りSIGINT
の説明がある。
signal --- Set handlers for asynchronous events — Python 3.12.3 ドキュメント signal.SIGINT
Interrupt from keyboard (CTRL + C).Default action is to raise KeyboardInterrupt.
であるならば、単純に下記のようなコードで良さそうである。
import signal import subprocess import time cmd = ["python", "sub.py"] proc = subprocess.Popen(cmd) time.sleep(2) # SIGINTの送信 proc.send_signal(signal.SIGINT)
しかしながら、上記コードを実行するとValueError: Unsupported signal: 2
、つまりはSIGINT(この値は2)のシグナル送信は対応されていないということ。
Unix/Linuxでは問題なく動作するようであるが、Windowsではそうもいかないらしい。
2. os.kill
でsignal.CTRL_C_EVENT
を送信する
同じくsignalライブラリにはCTRL_C_EVENTのシグナルが存在し、公式リファレンスには下記説明がある。
https://docs.python.org/ja/3/library/signal.html#signal.CTRL_C_EVENT signal.CTRL_C_EVENT
CTRL+C キーストロークに該当するシグナル。このシグナルは os.kill() でだけ利用できます。利用可能な環境: Windows 。
今回実現したいCTRL+Cの送信そのものである。実際にメインプログラムとサブプロセスのプログラムを2つ用意して、os.kill()
を試してみる。
【main.py】
import os import signal import subprocess import time cmd = ["python", "sub.py"] proc = subprocess.Popen(cmd) # 少し待ってからKILL time.sleep(2) os.kill(proc.pid, signal.CTRL_C_EVENT) # プロセスのKILL後に別の処理 while True: time.sleep(1) print("main処理")
【sub.py】
import time try: # 30秒で終了するプログラム for i in range(60): print(f"loop{i+1}") time.sleep(1) except KeyboardInterrupt: print("KeyboardInterrupt発火")
【実行結果】
$ python main.py loop1 loop2 loop3 Traceback (most recent call last): KeyboardInterrupt発火 File "D:\workspace\main.py", line 14, in <module> time.sleep(1) KeyboardInterrupt
ちゃんとサブプロセス側のKeyboardInterrupt
例外が発生していることは確認できるが、メインプロセス側までKeyboardInterrupt
が波及し、メイン処理を実行できずに終了してしまっていることがわかる。
このあたりの挙動について非常に詳しく回答されているページがある。
python - How to handle a signal.SIGINT on a Windows OS machine? - Stack Overflow
上記によれば、「Pythonのos.kill()
はWindowsAPIのGenerateConsoleCtrlEvent()
を呼んで、CTRL_C_EVENT
を送信」しているとのこと。
そしてGenerateConsoleCtrlEvent
のリファレンスによれば、「呼び出し元のプロセスとコンソールを共有するコンソール・プロセス・グループに、指定されたシグナルを送る。」と最初に明記されている。したがって、今回の動作は元から想定されていた通りということだ。
となると、Popenのcreationflagsにsubprocess.CREATE_NEW_PROCESS_GROUP
を渡してプロセスグループを分けてみようと思いつく。
3. Popen(..., creationflags=CREATE_NEW_PROCESS_GROUP)
してからos.kill()
?
実はPopenのsend_signal()
のリファレンスには下記言及がある。
Note: On Windows, SIGTERM is an alias for terminate(). CTRL_C_EVENT and CTRL_BREAK_EVENT can be sent to processes started with a creationflags parameter which includes CREATE_NEW_PROCESS_GROUP.
おー!プロセスグループ作ってから、CTRL_C_EVENT
送っても良さそうな感じ!と思えるだろう。しかし、MSDNのリファレンスには下記のように書かれているのだ。
if a process in the group creates a new console, that process does not receive the signal, nor do its descendants.
(DeepL翻訳) グループ内のプロセスが新しいコンソールを作成した場合、そのプロセスはシグナルを受け取らず、その子孫もシグナルを受け取らない。
つまり、CREATE_NEW_PROCESS_GROUP
でPopenしたプロセスにはCTRL_C_EVENT
は届かないよ、ということだ。
実際に、Popen部分のみ下記のように編集して実行したところ、何も起きずにサブプロセスは実行され続けた。
proc = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
※なお、このAPIで遅れるもう一つのイベントCTRL_BREAK_EVENT
については、CREATE_NEW_PROCESS_GROUP
状況下においても送ることができる。ただし、当然だがKeyboardInterrupt
の発火はない。
4. じゃあどうするか?(解決策)
こちらの回答の方策を取る。 stackoverflow.com
別のプログラム(ここでは、ctrl_c.py
とする)を用意し、メインプログラムでPopenしたプロセスのコンソールにアタッチしてCTRL_C_EVENT
を送信する。
3つのソースコードは下記の通り。
【main.py】
import subprocess import sys import time cmd = ["python", "sub.py"] # CREATE_NEW_CONSOLEフラグは必須 proc = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE) time.sleep(2) # プロセスのPIDをコマンドライン引数として引き渡し subprocess.check_call([sys.executable, "ctrl_c.py", str(proc.pid)]) # プロセスのKILL後に別の処理 while True: time.sleep(1) print("main処理")
【sub.py】
import time try: # 30秒で終了するプログラム for i in range(60): print(f"loop{i+1}") time.sleep(1) except KeyboardInterrupt: print("KeyboardInterrupt発火") # 別コンソールになるため、結果を確認できるように標準入力でブロッキング input()
【ctrl_c.py】
import ctypes import sys kernel = ctypes.windll.kernel32 pid = int(sys.argv[1]) kernel.FreeConsole() # コンソールのアタッチ kernel.AttachConsole(pid) kernel.SetConsoleCtrlHandler(None, 1) # CTRL_C_EVENTの送信 kernel.GenerateConsoleCtrlEvent(0, 0) sys.exit(0)
main.py
の実行により、PopenしたプロセスでKeyboardInterrupt
の発火を確認し、かつメイン処理も走り続けることが確認できた。
なお、ctrl_c.py
のプログラムについては、multiprocessing.Process()
等で動作させることも可能なようだ。(回答へのコメント)
扱っていないSetConsoleCtrlHandler()
等の話を含めた解説は、下記回答が非常に参考になる。
むすび
Linuxと比べて実装が非常にややこしくなるが、実現可能であることが確認できた。カーネル周りの話は本当に入り組んでるね。。。