如何在Windows操作系统机器上处理signal.SIGINT信号?

42

我正在Windows上尝试下面粘贴的代码,但是它没有处理信号而是杀死了进程。然而,在Ubuntu上相同的代码可以正常工作。

import os, sys
import time
import signal
def func(signum, frame):
    print 'You raised a SigInt! Signal handler called with signal', signum

signal.signal(signal.SIGINT, func)
while True:
    print "Running...",os.getpid()
    time.sleep(2)
    os.kill(os.getpid(),signal.SIGINT)
2个回答

106
Python的os.kill在Windows上包装了两个不相关的API。当sig参数为CTRL_C_EVENTCTRL_BREAK_EVENT时,它调用GenerateConsoleCtrlEvent。在这种情况下,pid参数是进程组ID。如果后者调用失败,并且对于所有其他sig值,它将调用OpenProcess,然后再调用TerminateProcess。在这种情况下,pid参数是进程ID,sig值作为退出代码传递。终止Windows进程类似于向POSIX进程发送SIGKILL。通常应避免这样做,因为它不允许进程干净地退出。
请注意,os.kill的文档错误地声称“kill()还可以接受要杀死的进程句柄”,这从未是真实的。它调用OpenProcess来获取进程句柄。
决定使用WinAPI CTRL_C_EVENTCTRL_BREAK_EVENT,而不是SIGINTSIGBREAK,对于跨平台代码来说是不幸的。当传递一个不是进程组ID的进程ID时,GenerateConsoleCtrlEvent的作用也没有定义。在接受进程ID的API中使用此函数最好是可疑的,潜在地非常错误。
针对您特定的需求,您可以编写一个适配器函数,使os.kill更加友好,以便于跨平台代码。例如:
import os
import sys
import time
import signal

if sys.platform != 'win32':
    kill = os.kill
    sleep = time.sleep
else: 
    # adapt the conflated API on Windows.
    import threading

    sigmap = {signal.SIGINT: signal.CTRL_C_EVENT,
              signal.SIGBREAK: signal.CTRL_BREAK_EVENT}

    def kill(pid, signum):
        if signum in sigmap and pid == os.getpid():
            # we don't know if the current process is a
            # process group leader, so just broadcast
            # to all processes attached to this console.
            pid = 0
        thread = threading.current_thread()
        handler = signal.getsignal(signum)
        # work around the synchronization problem when calling
        # kill from the main thread.
        if (signum in sigmap and
            thread.name == 'MainThread' and
            callable(handler) and
            pid == 0):
            event = threading.Event()
            def handler_set_event(signum, frame):
                event.set()
                return handler(signum, frame)
            signal.signal(signum, handler_set_event)                
            try:
                os.kill(pid, sigmap[signum])
                # busy wait because we can't block in the main
                # thread, else the signal handler can't execute.
                while not event.is_set():
                    pass
            finally:
                signal.signal(signum, handler)
        else:
            os.kill(pid, sigmap.get(signum, signum))

    if sys.version_info[0] > 2:
        sleep = time.sleep
    else:
        import errno

        # If the signal handler doesn't raise an exception,
        # time.sleep in Python 2 raises an EINTR IOError, but
        # Python 3 just resumes the sleep.

        def sleep(interval):
            '''sleep that ignores EINTR in 2.x on Windows'''
            while True:
                try:
                    t = time.time()
                    time.sleep(interval)
                except IOError as e:
                    if e.errno != errno.EINTR:
                        raise
                interval -= time.time() - t
                if interval <= 0:
                    break

def func(signum, frame):
    # note: don't print in a signal handler.
    global g_sigint
    g_sigint = True
    #raise KeyboardInterrupt

signal.signal(signal.SIGINT, func)

g_kill = False
while True:
    g_sigint = False
    g_kill = not g_kill
    print('Running [%d]' % os.getpid())
    sleep(2)
    if g_kill:
        kill(os.getpid(), signal.SIGINT)
    if g_sigint:
        print('SIGINT')
    else:
        print('No SIGINT')

讨论

Windows操作系统在系统层面上没有实现信号。Microsoft的C运行时实现了标准C所需的六个信号:SIGINTSIGABRTSIGTERMSIGSEGVSIGILLSIGFPE

SIGABRTSIGTERM仅为当前进程实现。您可以通过C raise调用处理程序。例如(在Python 3.5中):

>>> import signal, ctypes
>>> ucrtbase = ctypes.CDLL('ucrtbase')
>>> c_raise = ucrtbase['raise']
>>> foo = lambda *a: print('foo')
>>> signal.signal(signal.SIGTERM, foo)
<Handlers.SIG_DFL: 0>
>>> c_raise(signal.SIGTERM)
foo
0

SIGTERM是无用的。

使用信号模块也无法处理SIGABRT,因为abort函数会在处理程序返回后立即终止进程,而使用信号模块的内部处理程序时,处理程序会立即触发一个标志,以便在主线程中调用注册的Python可调用对象。对于Python 3,您可以使用faulthandler模块。或者通过ctypes调用CRT的signal函数,将ctypes回调设置为处理程序。

CRT通过为相应的Windows异常设置Windows 结构化异常处理程序来实现SIGSEGVSIGILLSIGFPE

STATUS_ACCESS_VIOLATION          SIGSEGV
STATUS_ILLEGAL_INSTRUCTION       SIGILL
STATUS_PRIVILEGED_INSTRUCTION    SIGILL
STATUS_FLOAT_DENORMAL_OPERAND    SIGFPE
STATUS_FLOAT_DIVIDE_BY_ZERO      SIGFPE
STATUS_FLOAT_INEXACT_RESULT      SIGFPE
STATUS_FLOAT_INVALID_OPERATION   SIGFPE
STATUS_FLOAT_OVERFLOW            SIGFPE
STATUS_FLOAT_STACK_CHECK         SIGFPE
STATUS_FLOAT_UNDERFLOW           SIGFPE
STATUS_FLOAT_MULTIPLE_FAULTS     SIGFPE
STATUS_FLOAT_MULTIPLE_TRAPS      SIGFPE

CRT对这些信号的实现与Python的信号处理不兼容。异常过滤器调用注册的处理程序,然后返回 EXCEPTION_CONTINUE_EXECUTION。但是,Python的处理程序只会为解释器触发一个标志以在主线程中的某个时候调用已注册的可调用对象。因此,导致异常的错误代码将继续在无限循环中触发。在Python 3中,您可以使用faulthandler模块处理这些基于异常的信号。
这只留下了SIGINT,Windows添加了非标准的SIGBREAK。控制台和非控制台进程都可以raise这些信号,但只有控制台进程可以从另一个进程接收它们。CRT通过SetConsoleCtrlHandler注册控制台控制事件处理程序来实现这一点。

控制台通过在附加进程中创建一个新线程来发送控制事件,该线程从kernel32.dll或kernelbase.dll(未记录)的CtrlRoutine开始执行。处理程序不在主线程上执行可能会导致同步问题(例如,在REPL或使用input时)。此外,如果主线程在等待同步对象或等待同步I/O完成时被阻塞,则控制事件不会中断主线程。如果应该由SIGINT中断,则需要注意避免在主线程中阻塞。Python 3尝试通过使用Windows事件对象解决这个问题,该对象也可以用于应该由SIGINT中断的等待。

当控制台向进程发送 CTRL_C_EVENTCTRL_BREAK_EVENT 时,CRT 的处理程序分别调用已注册的 SIGINTSIGBREAK 处理程序。当其窗口关闭时,控制台发送的 CTRL_CLOSE_EVENT 也会调用 SIGBREAK 处理程序。Python 默认通过在主线程中引发 KeyboardInterrupt 来处理 SIGINT。但是,SIGBREAK 最初是默认的 CTRL_BREAK_EVENT 处理程序,它调用 ExitProcess(STATUS_CONTROL_C_EXIT)
您可以通过 GenerateConsoleCtrlEvent 向当前控制台连接的所有进程发送控制事件。这可以针对属于进程组的进程子集或目标组 0,以将事件发送到当前控制台连接的所有进程。

进程组并不是 Windows API 的一个被很好记录的方面。没有公共 API 可以查询进程所属的组,但是在 Windows 会话中的每个进程都属于一个进程组,即使它只是 wininit.exe 组(服务会话)或 winlogon.exe 组(交互式会话)。通过在创建新进程时传递创建标志 CREATE_NEW_PROCESS_GROUP,可以创建一个新的组。组 ID 是创建进程的进程 ID。据我所知,控制台是唯一使用进程组的系统,这仅限于 GenerateConsoleCtrlEvent

当目标ID不是进程组ID时,控制台的行为未定义,不能依赖它。如果进程及其父进程都连接到控制台,则发送控制事件基本上就像目标是0组一样。如果父进程没有连接到当前控制台,则GenerateConsoleCtrlEvent失败,os.kill调用TerminateProcess。奇怪的是,如果你针对“System”进程(PID 4)及其子进程smss.exe(会话管理器),则调用成功,但除了目标错误地添加到已连接进程列表中(即GetConsoleProcessList),什么也不会发生。这可能是因为父进程是“Idle”进程,由于它的PID为0,被隐式接受为广播PGID。父进程规则也适用于非控制台进程。针对非控制台子进程不起作用--除了通过添加未连接的进程来错误地破坏控制台进程列表。我希望你清楚地知道,你应该只向0组或通过CREATE_NEW_PROCESS_GROUP创建的已知进程组发送控制事件。

请不要依赖于除了组0以外的任何东西发送CTRL_C_EVENT,因为在新的进程组中它最初是禁用的。虽然向新组发送此事件并非不可能,但目标进程首先必须通过调用SetConsoleCtrlHandler(NULL, FALSE)来启用CTRL_C_EVENT

CTRL_BREAK_EVENT是您能依赖的所有内容,因为它无法被禁用。发送此事件是一种优雅地杀死使用CREATE_NEW_PROCESS_GROUP启动的子进程的简单方法,假设它具有Windows CTRL_BREAK_EVENT或C SIGBREAK处理程序。如果没有,则默认处理程序将终止进程,并将退出代码设置为STATUS_CONTROL_C_EXIT。例如:

>>> import os, signal, subprocess
>>> p = subprocess.Popen('python.exe',
...         stdin=subprocess.PIPE,
...         creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
>>> os.kill(p.pid, signal.CTRL_BREAK_EVENT)
>>> STATUS_CONTROL_C_EXIT = 0xC000013A
>>> p.wait() == STATUS_CONTROL_C_EXIT
True

请注意,由于示例针对子进程的进程组(包括所有已连接到控制台等的子进程),因此未将CTRL_BREAK_EVENT发送到当前进程。如果示例使用了组0,则当前进程也将被杀死,因为我没有定义SIGBREAK处理程序。让我们尝试一下,但设置一个处理程序:
>>> ctrl_break = lambda *a: print('^BREAK')
>>> signal.signal(signal.SIGBREAK, ctrl_break)
<Handlers.SIG_DFL: 0>
>>> os.kill(0, signal.CTRL_BREAK_EVENT)
^BREAK

Windows系统具有异步过程调用(APC)功能,可以将目标函数排入线程队列。请参阅文章深入了解Windows APC,详细分析Windows APC的作用,特别是澄清内核模式APC的角色。您可以通过QueueUserAPC向线程排队用户模式APC。它们也可以由ReadFileExWriteFileEx用于I/O完成例程。

一个用户模式的APC在线程进入可警告等待时执行(例如 WaitForSingleObjectExSleepEx,其中 bAlertableTRUE)。另一方面,内核模式的APC会立即调度(当IRQL低于APC_LEVEL时)。它们通常由I/O管理器在发出请求的线程的上下文中完成异步I/O请求包(例如从IRP复制数据到用户模式缓冲区)。请参见 Waits and APCs 中显示APC如何影响可警告和不可警告等待的表格。请注意,内核模式的APC不会中断等待,而是由等待例程在内部执行。

Windows可以使用APC实现类似POSIX信号的功能,但实际上它使用其他方法来达到相同的目的。例如:

窗口消息可以发送和发布到所有共享调用线程的桌面且处于相同或较低完整性级别的线程。发送窗口消息将其放入系统队列以在线程调用PeekMessageGetMessage时调用窗口过程。发布消息将其添加到线程的消息队列中,该队列具有默认配额10,000条消息。具有消息队列的线程应该有一个消息循环来通过GetMessageDispatchMessage处理队列。仅限控制台进程的线程通常没有消息队列。但是,控制台主机进程conhost.exe显然有。当单击关闭按钮或通过任务管理器或taskkill.exe杀死控制台的主要进程时,会向控制台窗口的线程的消息队列发布WM_CLOSE消息。控制台随即向其所有附加进程发送CTRL_CLOSE_EVENT。如果进程处理事件,则在强制终止之前给予其5秒钟优雅退出。


38
基本上,如果 sys.platform == 'win32',则放弃所有进入此处的希望。 - alex
使用 signal.CTRL_BREAK_EVENT 停止 Python 进程会阻止调用 atexit 处理程序,因此我不会将其称为优雅的关机。但感谢这个伟大的总结,它真的很有帮助。 - schlamar
1
@schlamar,也许您错过了使用标志CREATE_NEW_PROCESS_GROUP创建子进程并使子进程为signal.SIGBREAK设置处理程序的部分。这适用于父进程发出的有意信号。不幸的是,由于Python的C信号处理程序仅设置指示信号的标志并立即返回(信号处理程序仅在主线程中调用),因此它无法处理关闭控制台窗口的情况。为了处理这种情况,我应该修改此答案,提供一个设置控制台控制处理程序的ctypes示例。 - Eryk Sun
@ErykSun 谢谢您的详细解释!我尝试使用这种方法进行优雅的终止,但是始终找不到一种组合方式,让 Python 信号处理程序真正获得控制权。对于进程创建,我尝试了:(1) 分别在每个 CMD 终端上运行交互式 Python,(2) 使用 Popen 创建目标进程并设置 CREATE_NEW_PROCESS_GROUP 标志。对于目标进程中的 Python 级信号处理程序,我尝试了 SIGINT 和 SIGBREAK。对于在原始进程中发送信号,我使用了 os.kill() 函数,并使用了 SIGINT、SIGBREAK、CTRL_C_EVENT 和 CTRL_BREAK_EVENT。这是在 Windows 7 上使用 Python 3.7 进行的实验。 - Andreas Maier
1
请注意,在Python >=3.8中,signal.raise_signal(signal.SIGINT)在这里运行得非常好。 - visch

3

对于Python >=3.8,使用signal.raise_signal。这会直接在当前进程中触发信号,避免了os.kill错误地解释进程ID的复杂性。

import os
import time
import signal

def func(signum, frame):
    print (f"You raised a SigInt! Signal handler called with signal {signum}")

signal.signal(signal.SIGINT, func)
while True:
    print(f"Running...{os.getpid()}")
    time.sleep(2)
    signal.raise_signal(signal.SIGINT)

非常好用!


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接