Python 子进程与 SIGPIPE 信号

众所周知,head 命令用于过滤标准输入前面 10 行(或由用户指定数量)的内容。即便上游命令的输出很多,且执行费时,但是一旦用管道连接到 head 命令,只要输出指定的行数后,上游命令也结束。下面是一个例子:

grep xxx abc.txt | head

上面的例子中,假设 abc.txt 的内容较长,这样 grep 命令的执行时间比 head 命令长。那么背后发生了什么呢?当 head 命令结束时,管道的读端被关闭,这时 grep 命令再写入管道,内核立刻把 SIGPIPE 信号发给 grep 进程,而 SIGPIPE 默认的行为是终止进程。

Python 中,为了能自己处理管道错误,抛出 OSError 异常,Python 启动时把 SIGPIPE 的处置方式设为“忽略”,那么底层系统调用里 write(2) 就能返回 EPIPE 错误。但是这种做法存在一个问题,子进程会继承信号的处置方式,若信号的处置方式为“默认”或“忽略”,则 exec() 后仍保留。因此,在 Python 使用 subprocess 模块执行上面的命令,我们会见到 grep 命令输出管道错误的信息。

其中一种解决方法是:调用 subprocess 模块前,先把 SIGPIPE 的处置方式恢复为“默认”。但是,在这句代码与调用 subprocess 的 Python 代码之间,无法达到原来 Python 希望达到的效果,即写入管道错误时抛出异常。

有没有两全其美的方法?答案是“有”。方法是给 SIGPIPE 信号设置一个处理函数,且内容为空:

import signal

def sigpipe_handler(sig, frame):
    pass

signal.signal(signal.SIGPIPE, sigpipe_handler)

首先,当写入管道失败时,如果 SIGPIPE 处理函数能返回,则底层会返回 EPIPE 错误,而不是 EINTR 错误,因此 Python 层面仍然能抛出一样的 OSError 异常。其次,当子进程进行 exec(),那么有处理函数的信号的处置方式重置为“默认”,也达到我们想要的效果。