在Python中优雅地捕获Ctrl+C / SIGINT并退出多进程

95

如何在多进程 Python 程序中捕获 Ctrl+C 并优雅地退出所有进程,我需要的解决方案要在 Unix 和 Windows 上都能工作。我尝试了以下方法:

import multiprocessing
import time
import signal
import sys

jobs = []

def worker():
    signal.signal(signal.SIGINT, signal_handler)
    while(True):
        time.sleep(1.1234)
        print "Working..."

def signal_handler(signal, frame):
    print 'You pressed Ctrl+C!'
    # for p in jobs:
    #     p.terminate()
    sys.exit(0)

if __name__ == "__main__":
    for i in range(50):
        p = multiprocessing.Process(target=worker)
        jobs.append(p)
        p.start()

现在它已经有点能用了,但我认为这不是正确的解决方案。

3个回答

97

先前被接受的解决方案 存在竞态条件,且不能与 mapasync 函数一起使用。


使用 multiprocessing.Pool 正确处理 Ctrl+C/SIGINT 的方法是:

  1. 在创建进程池之前让主进程忽略 SIGINT 信号。这样创建的子进程将继承 SIGINT 处理程序。
  2. 在创建 Pool 后在主进程中恢复原始的 SIGINT 处理程序。
  3. 使用 map_asyncapply_async,而不是阻塞式的 mapapply
  4. 使用超时等待结果,因为默认的阻塞等待会忽略所有信号。这是 Python 错误 https://bugs.python.org/issue8296

将它们组合起来:

#!/bin/env python
from __future__ import print_function

import multiprocessing
import os
import signal
import time

def run_worker(delay):
    print("In a worker process", os.getpid())
    time.sleep(delay)

def main():
    print("Initializng 2 workers")
    original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
    pool = multiprocessing.Pool(2)
    signal.signal(signal.SIGINT, original_sigint_handler)
    try:
        print("Starting 2 jobs of 5 seconds each")
        res = pool.map_async(run_worker, [5, 5])
        print("Waiting for results")
        res.get(60) # Without the timeout this blocking call ignores all signals.
    except KeyboardInterrupt:
        print("Caught KeyboardInterrupt, terminating workers")
        pool.terminate()
    else:
        print("Normal termination")
        pool.close()
    pool.join()

if __name__ == "__main__":
    main()

正如@YakovShklarov所指出的那样,在父进程忽略信号和重新关注它之间有一个时间窗口,此时信号可能会丢失。在父进程中使用pthread_sigmask代替暂时阻止信号传递将防止信号丢失,但是它在Python-2中不可用。


1
似乎你需要使用map_async而不是map,有人能够透露一下单个处理的区别吗?(在map_async结果上调用.get似乎也不必要) - ThorSummoner
11
在Windows 10上,我使用Python 3.6.1时出现问题,无法捕获KeyboardInterrupt。 - szx
1
@Boop 我不确定,需要进行调查。 - Maxim Egorushkin
6
这种解决方案不具备可移植性,因为它只能在Unix系统上运行。此外,如果用户设置了maxtasksperchild池参数,它将无法工作。新创建的进程会再次继承标准的SIGINT处理程序。pebble库默认禁用用户的SIGINT信号,一旦创建了新进程就会生效。 - noxdafox
1
值得注意的是,signal.signal(signal.SIGINT, signal.SIG_IGN) 返回先前配置的信号 - Chen A.
显示剩余14条评论

38

这个解决方案基于这个链接这个链接,它解决了我的问题,但我必须转移到使用Pool

import multiprocessing
import time
import signal
import sys

def init_worker():
    signal.signal(signal.SIGINT, signal.SIG_IGN)

def worker():
    while(True):
        time.sleep(1.1234)
        print "Working..."

if __name__ == "__main__":
    pool = multiprocessing.Pool(50, init_worker)
    try:
        for i in range(50):
            pool.apply_async(worker)

        time.sleep(10)
        pool.close()
        pool.join()

    except KeyboardInterrupt:
        print "Caught KeyboardInterrupt, terminating workers"
        pool.terminate()
        pool.join()

1
@MaximYegorushkin - 信号被阻塞在init_worker中,在调用apply_async之前调用 - 这就是你所说的吗? - zenpoy
现在还不晚。另外,在调用apply_async之前没有调用init_worker函数。发生的情况是,将init_worker函数(作为对象)传递给Pool对象。然后调用pool.apply_async,并在子进程分叉之前内部调用init_worker函数。不确定Pool如何处理解除阻塞信号。 - Chris Koston
9
这仅因为使用了 time.sleep 才能奏效。如果你尝试获取 map_async 调用的结果,中断会延迟到处理完成之后。 - Clément
1
这是错误的答案。正确答案:https://dev59.com/f2gu5IYBdhLWcg3wdGwb#35134329 - Maxim Egorushkin
2
肯定它能工作。但是这是错误的。从文档中可以看出:“每个工作进程在启动时都会调用initializer(*initargs)。” 这是“when”,而不是“before”。 因此:存在竞争条件。以下是可能发生的情况:子进程被创建,但在signal.signal()完成之前,SIGINT被发送! 子进程因未捕获的KeyboardInterrupt而中止。 这很罕见,但不能保证不会发生。 (实际上,如果您正在生成大量工作程序,则可能并不那么罕见。)如果您不阻塞,则最糟糕的情况似乎只是终端上的垃圾。 仍然,这是不好的做法。 - Yakov Shklarov
显示剩余5条评论

15

只需在您的工作进程中处理 KeyboardInterrupt-SystemExit 异常:

def worker():
    while(True):
        try:
            msg = self.msg_queue.get()
        except (KeyboardInterrupt, SystemExit):
            print("Exiting...")
            break

对于使Python引发SystemExit的信号,这确实有效,即使在Python 3.6上也是如此。不过我想知道,这包括哪些信号?我猜测是SIGKILL和SIGTERM……? - Petri
1
您可以轻松地检查其中包含哪些信号,答案是:我认为没有。根据文档,SystemExit仅由sys.exit引发。只需执行try: time.sleep(60) except BaseException as e: print(e),您就会看到是否捕获了特定的信号(我只看到了SIGINT)。这也是manpage所述的。 - t.animal
@Petri 可能只是 SIGINT。我相信 SIGKILL 是无法捕获的,而 SIGTERM 是另外一回事。 - dstromberg

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