为何 Windows 下无法用 Ctrl+C 终止 Python 进程
在 Windows 命令行中按下 Ctrl+C
或者 Ctrl+Break
可以结束当前正在执行的命令。通常情况下,这个方法同样适用于 Python 的控制台进程。特别地,Python 内置了一个 KeyboardInterrupt
异常专门用于捕获按下 Ctrl+C
而触发的程序退出:
pythontry:
while True:
print('running...')
except KeyboardInterrupt:
print('keyboard interrupt received')
# 退出前清理现场,释放资源
Python 触发 KeyboardInterrupt
异常的底层实现原理依赖于 signal 机制1。使用信号处理器也可以捕获按下 Ctrl+C
而触发的程序退出:
pythonimport signal
def signal_handler(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGINT, signal_handler)
try:
while True:
print('running...')
except KeyboardInterrupt:
print('keyboard interrupt received')
# 退出前清理现场,释放资源
但是在某些情况下,Windows 的 Python 命令行程序无法用 Ctrl+C
或者 Ctrl+Break
终止。比如在 issue 41437: SIGINT blocked by socket operations like recv on Windows 中提到:在 Windows 中,当 socket.recv()
操作阻塞时,无法响应 SIGINT
。类似的,当 thread.join()
操作阻塞时,也无法用 Ctrl+C
终止。例如下面代码在 Windows 下运行就无法使用 Ctrl+C
来退出:
pythonimport socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('', 8964))
s.recvfrom(1)
except KeyboardInterrupt:
pass
而上面的代码如果在 WSL 下运行,是可以用 Ctrl+C
终止的。要解释这个现象,先要搞清楚 Windows 的信号处理机制和 Python 的信号处理机制的区别。
在 Windows 命令行中,当用户按下 Ctrl+C
时,Console 会向所有关联到当前 Console 的命令行进程发送 SIGINT
信号,而命令行进程内会创建一个新线程来处理接收到的信号2。下面是一段 C++ 写的演示代码:
c++#include <iostream>
#include <signal.h>
#include <Windows.h>
void SignalHandler(int signal)
{
if (signal == SIGINT) {
std::cout << "Signal Handler Thread Id:" << GetCurrentThreadId() << "\n";
}
}
int main()
{
signal(SIGINT, SignalHandler);
std::cout << "Main Thread Id:" << GetCurrentThreadId() << "\n";
while (true) {
Sleep(1000);
}
}
上面的代码运行结果为:
Main Thread Id:28476
Signal Handler Thread Id:20392
观察结果可以发现,信号处理器运行的线程 ID 和主线程 ID 不一致,说明 SignalHandler
运行在新线程中。然而我们回过头观察本文第二段 Python 代码发现:在 signal_handler
中抛出的 KeyboardInterrupt
异常可以被主线程的代码捕获到。这是不是说明 Python 的信号处理机制和 Windows 底层的不一致?我们用下面的代码来验证猜想:
pythonimport signal
import time
import threading
def signal_handler(signum, frame):
print('Signal Handler Thread Id: {0}'.format(threading.current_thread().ident))
raise KeyboardInterrupt
print('Main Thread Id: {0}'.format(threading.current_thread().ident))
signal.signal(signal.SIGINT, signal_handler)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
上面的代码运行结果为:
Main Thread Id: 19644
Signal Handler Thread Id: 19644
信号处理器运行的线程 ID 和主线程 ID 一致,说明 signal_handler
运行在主线程中。Python 文档中对此的解释如下:
A Python signal handler does not get executed inside the low-level (C) signal handler. Instead, the low-level signal handler sets a flag which tells the virtual machine to execute the corresponding Python signal handler at a later point(for example at the next bytecode instruction).
A long-running calculation implemented purely in C (such as regular expression matching on a large body of text) may run uninterrupted for an arbitrary amount of time, regardless of any signals received. The Python signal handlers will be called when the calculation finishes.
Python signal handlers are always executed in the main Python thread of the main interpreter, even if the signal was received in another thread.
此处的 low-level signals 指的是 POSIX signals 或者 ANSI C signals。在不同的操作系统下,low-level signal handler 的实现方式是不一样的。而 Python 的 signal 是在 low-level signals 的基础上的进一步封装。
当 Python 进程接收到信号后,low-level signal handler 仅设置一个标志来告知 VM 有待处理的信号。运行于主线程中的 VM 每次执行 bytecode 前会先检查这个标志位,如果有待处理的信号则会调用 Python 中注册的信号处理器。也就是说,当 Python 进程收到信号后,主线程将会中断执行,转而调用注册的信号处理器。但是主线程只能在 VM 的 bytecode 指令之间中断,如果 Python 主线程此时正阻塞在 Windows API 或其他 Native 代码内,VM 是无法主动从阻塞状态中返回的。如此一来,VM 将没有机会去检查那个标志位,也无法调用信号处理器。那么对用户来说,就发生了使用 Ctrl+C
无法终止 Python 命令行进程的现象。
下面给出一个不太优雅的解决方案:
pythonimport threading
import socket
class BlockingOperation(threading.Thread):
def __init__(self):
super().__init__()
def run(self):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('', 8964))
s.recvfrom(1) # 阻塞直到收到数据
try:
task = BlockingOperation()
task.start()
while True:
task.join(1) # 阻塞一秒后返回,给信号处理器一个执行的机会
except KeyboardInterrupt:
pass
其原理为:主线程创建一个新的线程,用于执行会引起 Python 虚拟机阻塞的代码。另外,在主线程中,避免使用会引起完全阻塞的 task.join()
,而用 task.join(1)
循环代替。这样可以保证 Python 命令行进程能在一秒内响应信号。