【问题标题】:Python - prevent child threads from being affected from SIGINT signalPython - 防止子线程受到 SIGINT 信号的影响
【发布时间】:2018-10-11 15:12:00
【问题描述】:

我有一个程序,它由一个运行器(即主线程)组成,它创建 1 个或多个子线程,这些子线程主要使用子进程来触发 3rd 方应用程序。

我希望能够在收到 SIGINT 后优雅地终止所有线程,因此我在主线程中定义了一个处理程序,如下所示:

signal.signal(signal.SIGINT, handler)

我最初认为,一旦收到 SIGINT,它只会影响我的主线程,然后我将能够管理子线程以终止。

但是我实际观察到的是,按下 control+c 也会影响我的子线程(我看到一旦我按下 control+c,子线程中的子进程就会引发 RC 512 异常)。

有人可以建议是否有可能只有主线程会检测到这个信号而不影响子线程?

【问题讨论】:

标签: python multithreading signals


【解决方案1】:

如果您使用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)


非 Windows 操作系统上的 Python 3.3 及更高版本公开了信号掩码,可用于在一段时间内“阻止”信号。阻塞意味着信号的传递被推迟,直到解除阻塞;不被忽视。这正是上面的信号转移代码试图完成的。

信号没有排队,所以如果一个信号已经挂起,任何其他相同类型的信号都会被忽略。 (因此,每种类型只有一个信号,比如 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信号默认被阻塞。

阻塞的 SIGINT 信号也可以使用 signal.sigwait({signal.SIGINT})(阻塞直到一个被传递)或 signal.sigtimedwait({signal.SIGINT}, 0) 使用,如果一个未决的信号立即返回,否则 None


当子进程管理自己的信号掩码和信号处理程序时,我们不能让它忽略 SIGINT 信号。

在 Unix/POSIXy 机器上,我们可以阻止 SIGINT 被发送到子进程,但是,通过将子进程与控制终端分离,并在自己的会话中运行它。

subprocess.Popen() 中需要进行两组更改:

  • setsid 下执行命令或二进制文件:[ "setsid", "program", args.. ]"setsid sh -c 'command'",具体取决于您提供要作为列表还是作为字符串执行的二进制文件。

    setsid 是一个命令行实用程序,它在新会话中使用指定的参数运行指定的程序。新会话没有控制终端,这意味着如果用户按下 Ctrl+C,它将不会收到 SIGINT。

  • 如果父进程不使用管道用于子进程'stdinstdoutstderr,则应显式将它们打开到os.devnull

    stdin=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 处理程序。有什么想法吗?
  • 另外,当仅使用线程时(不涉及子进程),该机制完全按预期工作(SIGINT/control+c 仅触发在主线程中定义的处理程序)
  • @user3019483:一些进程,包括ssh,会安装自己的信号处理程序和信号掩码。父进程无法阻止它们处理 SIGINT 信号;它只能做到这一点,除非孩子另有决定,否则忽略 SIGINT。因此,为了保护它们免受键盘上按 Ctrl+C 的影响,它们需要与终端分离。这也意味着他们无法从键盘读取或写入终端;仅来自/来自其父进程、文件或套接字。可以使用非 Windows 解决方案吗?
  • 是的,我主要需要linux系统的解决方案。如果进程有自己的信号处理程序,知道如何在这种情况下实现这一点吗?
  • @user3019483:是的,附在我的回答后面。它归结为在setsid 下运行子进程,并确保它没有对控制终端打开任何句柄(通过在 subprocess.Popen() 调用中为所有标准流提供句柄)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-12-25
  • 2018-11-25
  • 1970-01-01
  • 2015-07-01
相关资源
最近更新 更多