Python - 防止子线程受到 SIGINT 信号的影响

4

我有一个程序,由一个执行器(主线程)和创建1个或多个子线程组成,这些子线程主要使用子进程触发第三方应用程序。

我想在收到SIGINT信号时优雅地终止所有线程,因此我在主线程中定义了以下处理程序:

signal.signal(signal.SIGINT, handler)

我最初认为一旦收到SIGINT信号,它将只影响我的主线程,然后我就能管理子线程以终止。
然而,实际上我观察到的是,按下control+c也会影响我的子线程(我看到子线程中的子进程在我按下control+c后引发了RC 512的异常)。
请问有人能否指导如何只让主线程检测到这个信号而不影响子线程吗?

这不是线程问题。当您在终端窗口中键入^C时,Linux不会将SIGINT发送到特定进程,而是将信号发送给_进程组的所有成员。除非您采取特殊步骤将它们分离_,否则您的程序创建的每个子进程都将成为与父进程相同组的成员。(附言:我不是Python巫师,所以我不确定您可以调用哪些库来实现这一点。) - Solomon Slow
@stovfl,我看到了这个话题,但是很遗憾在那里没有找到答案。 我所看到的是,Control+C 可以先针对子线程,而不是直接针对我定义的处理程序。 - user3019483
dup 告诉我们不同的情况。现在是展示 [mcve] 的时候了,为什么 signal.signal(signal.SIGINT, signal_handler) 对你行不通。相关信息:在线链接 - stovfl
1个回答

5

如果您使用subprocess.Popen()创建子进程,并且不希望它们被SIGINT信号杀死,请使用preexec_fn参数将SIGINT信号的处理设置为在执行新二进制文件之前忽略:

child = subprocess.Popen(...,
                         preexec_fn = lambda: signal.signal(signal.SIGINT, signal.SIG_IGN))

在这里,... 是您当前参数的占位符。

如果使用实际线程(无论是线程还是线程模块),Python 的信号模块会设置所有内容,以便只有主/初始线程可以接收信号或设置信号处理程序。因此,在 Python 中,正确的线程实际上不会受到信号的影响。

subprocess.Popen() 的情况下,子进程最初继承了进程的一个副本,包括信号处理程序。这意味着在子进程捕获信号的小窗口期间,它可以使用与父进程相同的代码来捕获信号;但是,因为它是一个单独的进程,只有它的副作用是可见的。(例如,如果信号处理程序调用 sys.exit(),只有子进程将退出。子进程中的信号处理程序不能更改父进程中的任何变量。)

为避免这种情况,父进程可以暂时切换到不同的信号处理程序,仅在子进程创建期间记住是否捕获了信号:

import signal

# Global variables for sigint diversion
sigint_diverted   = False     # True if caught while diverted
sigint_original   = None      # Original signal handler

def sigint_divert_handler():
    global sigint_diverted
    sigint_diverted = True

def sigint_divert(interrupts=False):
    """Temporarily postpone SIGINT signal delivery."""
    global sigint_diverted
    global sigint_original
    sigint_diverted = False
    sigint_original = signal.signal(signal.SIGINT, sigint_divert_handler)
    signal.siginterrupt(signal.SIGINT, interrupts)

def sigint_restore(interrupts=True):
    """Restore SIGINT signal delivery to original handler."""
    global sigint_diverted
    global sigint_original
    original = sigint_original
    sigint_original = None
    if original is not None:
        signal.signal(signal.SIGINT, original)
        signal.siginterrupt(signal.SIGINT, interrupts)
    diverted = sigint_diverted
    sigint_diverted = False
    if diverted and original is not None:
        original(signal.SIGINT)

使用以上的辅助函数,你可以在创建子进程之前(使用 subprocess 模块或 os 模块中的一些函数)调用 sigint_divert() 来实现信号的分流。子进程继承了被分流的 SIGINT 处理程序的副本。在创建子进程后,通过调用 sigint_restore() 恢复 SIGINT 的处理。(注意,如果在设置原始 SIGINT 处理程序后调用了 signal.siginterrupt(signal.SIGINT, False),以使其传递不会引发 IOError 异常,则应在这里调用 sigint_restore(False) 代替。)
这样,子进程中的信号处理程序就是被分流的信号处理程序,它只设置一个全局标志,不做其他任何事情。当然,你仍然希望使用 preexec_fn = 参数来 subprocess.Popen(),以便在子进程中执行实际二进制文件时完全忽略 SIGINT 信号。
sigint_restore() 不仅恢复了原始的信号处理程序,而且如果分流的信号处理程序捕获了 SIGINT 信号,它将通过直接调用原始的信号处理程序“重新引发”该信号。这假设原始处理程序是你已经安装过的;否则,你可以使用 os.kill(os.getpid(), signal.SIGKILL)。
Python 3.3 及之后的非 Windows 操作系统提供了信号掩码,可用于阻止信号一段时间。阻止意味着信号的传递被推迟,直到解除阻止;而不是被忽略。这正是以上信号分流代码试图实现的内容。
信号不会排队,因此如果一个信号已经挂起,那么同一类型的任何进一步信号都会被忽略。(所以,同一类型的信号,比如 SIGINT,同时只能挂起一个。)
这允许使用两个辅助函数的模式,
def block_signals(sigset = { signal.SIGINT }):
    mask = signal.pthread_sigmask(signal.SIG_BLOCK, {})
    signal.pthread_sigmask(signal.SIG_BLOCK, sigset)
    return mask

def restore_signals(mask):
    signal.pthread_sigmask(signal.SIG_SETMASK, mask)

因此,在创建线程或子进程之前,需要调用mask = block_signals(),然后在其后调用restore_signals(mask)。在创建的线程或子进程中,默认情况下会阻止SIGINT信号。
还可以使用signal.sigwait({signal.SIGINT})(该方法会阻塞直到信号被传递)或signal.sigtimedwait({signal.SIGINT}, 0)(如果有待处理的信号,则立即返回该信号,否则返回None)来消耗被阻止的SIGINT信号。
当子进程管理自己的信号掩码和信号处理程序时,我们无法让它忽略SIGINT信号。
但是,在Unix/POSIXy机器上,我们可以通过将子进程与控制终端分离并在其自己的会话中运行来阻止发送SIGINT信号给子进程。
subprocess.Popen()中需要进行两组更改:
1. 在setsid下执行命令或二进制文件:要么是[ "setsid", "program", args.. ],要么是"setsid sh -c 'command'",具体取决于您是否将要执行的二进制文件作为列表或字符串提供。 setsid是一个命令行实用程序,它在新会话中使用指定的参数运行指定的程序。新会话没有控制终端,这意味着如果用户按下Ctrl+C,它将不会收到SIGINT。
2. 如果父进程未使用管道来处理子进程的stdinstdoutstderr,则应将它们明确地打开到os.devnullstdin=open(os.devnull, 'rb')stdout=open(os.devnull, 'wb')stderr=open(os.devnull, 'wb')。 这可以确保子进程不会回退到控制终端。(当用户按下Ctrl+C时,控制终端会向每个进程发送SIGINT信号。)
如果父进程希望,可以使用os.kill(child.pid, signal.SIGINT)向子进程发送SIGINT信号。

我正在使用Python 3.3+,我尝试了您提供的解决方案,但是子进程仍然受到SIGINT的影响。我发现我的主线程创建工作线程,这些工作线程经常触发“Popen”(SSH命令)。 在“Popen”之前,我调用了“mask = block_signals()”,在Popen之后,我调用了“restore_signals(mask)”,并且“preexec_fn”被定义为您建议的方式。但是当我开始按control+c时,我仍然看到“Popen”子进程引发异常(由于RC!=0),而不是触发在主线程中定义的SIGINT处理程序。有什么想法吗? - user3019483
此外,仅使用线程时(不涉及子进程),机制的工作方式与预期完全相同(SIGINT/control+c 仅触发在主线程中定义的处理程序)。 - user3019483
@user3019483:一些进程,包括 ssh,会安装自己的信号处理程序和信号掩码。父进程无法阻止它们处理 SIGINT 信号;它只能使其除非子进程决定否则忽略 SIGINT。因此,为了保护它们免受键盘上按下的 Ctrl+C 的影响,它们需要与终端分离。这也意味着它们无法从键盘读取或向终端写入;只能与其父进程、文件或套接字进行读写。您能使用非 Windows 解决方案吗? - Nominal Animal
是的,我主要需要针对Linux系统的解决方案。您有想法如何在进程具有自己的信号处理程序的情况下实现此目标吗? - user3019483
@user3019483:是的,已经添加到我的答案中了。它归结为在setsid下运行子进程,并确保它没有打开到控制终端的任何句柄(通过在subprocess.Popen()调用中提供所有标准流的句柄)。 - Nominal Animal
@NominalAnimal 我的理解是,shell决定是否将SIGINT传播到子进程(在进程组中)?您能详细说明一下这个决策过程是如何工作的,以及为什么shell倾向于默认发送SIGINT到子进程?此外,为什么打开的句柄(stdin/stdout/stderr)在这里起作用?(如果Ctrl+C被发送到stdin,那么对我来说是有意义的,但实际上它被shell转换为SIGINT。) - balu

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