0. 结论
简单的结论:
subprocess.Popen永远要考虑将参数close_fds设置为True。
通用的结论
:
fork进程时要考虑到子进程会共享父进程的所有已打开文件,在某些场景下尤其需要考虑到这可能会造成资源未及时释放的问题。
1. 问题背景
偶尔会有流程超时问题出现,发现现网的母机上存在mount.ntfs-3g或qemu-nbd进程被Compute通过subprocess.Popen(类似于glibc里的popen)拉起后确没有获取到命令的返回结果。
一开始我并没有怀疑到是popen的问题,因为Compute通过subprocess.Popen调用外部命令是项目中最基本的代码,经验上也经受了数以亿次调用的考验,似乎不应该会是这段逻辑的问题。
通过strace分析发现,Compute卡在read管道,而管道被qemu-nbd或mount.ntfs-3g持有。
# strace -p 118693
Process 118693 attached - interrupt to quit
read(31,
# ls -l /proc/118693/fd/31
lr-x------ 1 root root 64 Aug 10 16:17 /proc/118693/fd/31 -> pipe:[881608557]
# lsof | grep 881608557
qemu-nbd 118823 root 31r FIFO 0,8 0t0 881608557 pipe
qemu-nbd 118823 root 32w FIFO 0,8 0t0 881608557 pipe
vstationd 200949 root 31r FIFO 0,8 0t0 881608557 pipe
2. 理论分析
不论是Python这样的基于字节码解释器的语言,还是裸用glibc,popen的原理都是相似的:父子进程之间通过pipe通信:
父进程创建pipe
父进程fork出子进程
父进程关闭pipe的写fd,子进程关闭pipe的读fd
子进程将pipe的写fd dup到标准输出(fd=2),即子进程的输出将重定向到管道
子进程退出(变成僵尸进程),父进程收到SIGCHLD信号,父进程的wait等调用返回,僵尸进程退出
父进程读管道,一般为了获取到所有输出,会循环读管道直到遇到EOF,读操作返回
标准错误的pipe方式也是类似的。
在这个过程中可以看到一个与现网现象非常相似的操作——读管道。
再反过来看在“数以亿计”的调用中出错的两类无返回命令: qemu-nbd和mount.ntfs-3g,它们有一个共同的特点:
都是daemon进程。
daemon进程从不退出,也就是说popen调用的命令如果是daemon,那这个命令的子进程退出,其子进程的子进程(daemon)却永远在运行。那假设有这么个过程,父进程的读管道永远不返回就得以构建:
父进程创建pipe
父进程fork出子进程
父进程关闭pipe的写fd,子进程关闭pipe的读fd
子进程将pipe的写fd dup到标准输出(fd=2),即子进程的输出将重定向到管道
子进程再fork子进程,并使其与当前会话解除,成为daemon进程,并持有管道
子进程退出(变成僵尸进程),父进程收到SIGCHLD信号,父进程的wait等调用返回
父进程读管道,但因为daemon持有管道,无法获取到EOF,卡住!
3. 测试程序
裸写一个daemon程序
#include
#include
#include
#include
#include
#include
#include
int main(){
pid_t daemon;
int i, fd;
daemon = fork();
if(daemon < 0){
printf("Fork Error!\n");
exit(1);
}
else if (daemon > 0 ) {
printf("Father process exited!\n");
exit(0);
}
setsid();
umask(0);
printf("in daemon!\n");
sleep(3600);
exit(0);
}
使用subprocess.Popen的包装如下:
def system(cmd, timeout=3):
data = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
wait_time = 0.2
use_time = 0.0
while use_time <= timeout:
retcode = data.poll()
if retcode is None:
use_time += wait_time
time.sleep(wait_time)
else:
out_msg = data.stdout.read()
err_msg = data.stderr.read()
return retcode, out_msg, err_msg
raise RuntimeError('cmd[%s]: took too long' % cmd)
调用system('./daemon'), 卡住,并且卡住在我们期望的地方:
out_msg = data.stdout.read()
而如果这个daemon像大多数的daemon一样,把stdin/stdout/stderr关闭,则可正常返回
#include
#include
#include
#include
#include
#include
#include
int main(){
pid_t daemon;
int i, fd;
daemon = fork();
if(daemon < 0){
printf("Fork Error!\n");
exit(1);
}
else if (daemon > 0 ) {
printf("Father process exited!\n");
exit(0);
}
setsid();
umask(0);
for (i = 0;i < 3; i++)
{
close(i);
}
printf("in daemon!\n");
sleep(3600);
exit(0);
}
4. 理论分析(二)
虽然看起来重现了问题,但仍然存在疑点。我们人为的让父进程卡在了read系统调用,是基于测试daemon的不完善。正儿八经的daemon是要把自己从父进程那继承过来的stdin/stdout/stderr的fd关闭的,那这样的话父进程死后,父进程的父进程(popen调用者)就能读到EOF然后愉快地返回啊?
qemu-nbd和mount.ntfs-3g作为正儿八经的程序,无论如何也不应该会做这种半调子daemon化的事情,而且如果是它们没有关闭标准输出,那所有的popen都应该卡住,而不是只在现网零星出现才对。
回过头来看已经截取到的现场:
# lsof | grep 881608557
qemu-nbd 118823 root 31r FIFO 0,8 0t0 881608557 pipe
qemu-nbd 118823 root 32w FIFO 0,8 0t0 881608557 pipe
vstationd 200949 root 31r FIFO 0,8 0t0 881608557 pipe
qemu-nbd进程的管道并不是标准输出或标准错误,而是另外一个作用尚不明确管道。逐一分析所有发生过这个问题的母机的VStation日志,终于发现一个共性的问题:
发生错误时,Compute的不同线程同时popen了qemu-nbd。
感觉像是qemu-nbd进程使用了某个锁,且未处理好并发而导致了死锁?
但是所有疑似卡死的daemon却又都是完全正常的,不正常的还是发生在Compute read管道上。
5. 测试程序 (二)
daemon用修改过的关闭了标准输出的更正式的版本,并发测试程序如下:
#!/usr/bin/env python
import os
import random
import subprocess
import time
import threading
import zlog
logger = zlog.Logger('test', stream=True, logfile='test.log')
def system(cmd, timeout=10):
data = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
logger.debug('start[%s]', cmd)
wait_time = 0.2
use_time = 0.0
while use_time <= timeout:
retcode = data.poll()
if retcode is None:
use_time += wait_time
time.sleep(wait_time)
else:
logger.debug('return value[%d]', retcode)
out_msg = data.stdout.read()
err_msg = data.stderr.read()
logger.debug('read end[%s], out[%s], err[%s]', cmd, out_msg, err_msg)
return retcode, out_msg, err_msg
raise RuntimeError('cmd[%s]: took too long' % cmd)
def ssystem(cmd):
#time.sleep(random.random() * 0.1)
logger.info('START run cmd[%s]', cmd)
stime = time.time()
ret, out, err = system(cmd)
logger.info('(%s) END run cmd[%s]', time.time() - stime, cmd)
if ret:
raise RuntimeError('cmd[%s]: ret[%s], out[%s], err[%s]' % (cmd, ret, out, err))
def test():
ths = []
for i in range(2):
ths.append(threading.Thread(target=ssystem, args=('./daemon')))
for th in ths:
th.start()
for th in ths:
th.join()
def run():
count = 0
while True:
logger.info('-' * 80)
logger.info('run count: [%d]', count)
test()
count += 1
if __name__ == '__main__':
run()
令人欣喜的卡死了!(概率超过50%)
而且time.sleep(random.random() * 0.1)里随机睡觉的时间越随机,越不容易卡死,这说明多线程中越是同时popen一个daemon,越是容易复现问题。
strace, ls /proc/$pid/fd等结果也和现网故障一致。至此,问题终于得以重现。
另外,测试程序中卡住的地方并不是对标准输出的读取(因为现在我们的daemon并不持有这个管道),而是在这一行:
data = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
卡在了Python的标准库中。
为了先不一头扎进标准库的代码,先做这样一个猜想:
虽然不是标准输出/标准错误的管道,但daemon仍然持有了一个它不应该持有的管道fd
粗暴地把daemon程序修改一下,将
for (i = 0;i < 3; i++)
{
close(i);
}
改为
for (i = 0;i < getdtablesize(); i++)
{
close(i);
}
重新跑测试程序,不再卡住。
6. 理论分析(三)
作为一个daemon,是关闭stdin/stdout/stderr,还是关闭所有的fd后在一个干净的环境中执行逻辑呢?
这当然取决于具体程序的合理性考量,按照我的经验和理解,绝大部分daemon程序都是不需要继承调用进程的fd并且应该主动关闭所有fd的,否则它可能会层层继承它所不需要的fd,造成文件未关闭、连接未关闭等情况。
但是已有的daemon大多就像qemu-nbd和mount.ntfs-3g一样只关闭了stdin/stdout/stderr,那么subprocess.Popen打开的所有程序默认都会将当前进程打开的所有文件共享给这些子进程,包括那个不知名的管道。而修改已有程序的关闭fd逻辑显得也是不现实的,那么可以在调用进程里将fork的子进程在exec具体可执行文件前关闭fd(类似于cgi server的通用做法)。
为了找到问题的根本原因,我们仍然看看这个管道是什么,以及subprocess.Popen本身是否对管道做了额外的处理, 找到为什么在并发情况下会存在read管道无法返回的问题。
subprocess.Popen的主要实现逻辑,修改自/usr/lib64/python2.6/subprocess.py
def _execute_child(self, args, executable, preexec_fn, close_fds,
cwd, env, universal_newlines,
startupinfo, creationflags, shell,
p2cread, p2cwrite,
c2pread, c2pwrite,
errread, errwrite):
"""Execute program (POSIX version)"""
errpipe_read, errpipe_write = os.pipe()
try:
try:
self._set_cloexec_flag(errpipe_write)
self.pid = os.fork()
self._child_created = True
if self.pid == 0:
# Child
try:
# Close parent's pipe ends
if p2cwrite is not None:
os.close(p2cwrite)
if c2pread is not None:
os.close(c2pread)
if errread is not None:
os.close(errread)
os.close(errpipe_read)
# Dup fds for child
if p2cread is not None:
os.dup2(p2cread, 0)
if c2pwrite is not None:
os.dup2(c2pwrite, 1)
if errwrite is not None:
os.dup2(errwrite, 2)
# Close pipe fds. Make sure we don't close the same
# fd more than once, or standard fds.
if p2cread is not None and p2cread not in (0,):
os.close(p2cread)
if c2pwrite is not None and c2pwrite not in (p2cread, 1):
os.close(c2pwrite)
if errwrite is not None and errwrite not in (p2cread, c2pwrite, 2):
os.close(errwrite)
# Close all other fds, if asked for
if close_fds:
self._close_fds(but=errpipe_write)
if cwd is not None:
os.chdir(cwd)
if preexec_fn:
preexec_fn()
if env is None:
os.execvp(executable, args)
else:
os.execvpe(executable, args, env)
except:
exc_type, exc_value, tb = sys.exc_info()
# Save the traceback and attach it to the exception object
exc_lines = traceback.format_exception(exc_type,
exc_value,
tb)
exc_value.child_traceback = ''.join(exc_lines)
os.write(errpipe_write, pickle.dumps(exc_value))
finally:
# be sure the FD is closed no matter what
os.close(errpipe_write)
if p2cread is not None and p2cwrite is not None:
os.close(p2cread)
if c2pwrite is not None and c2pread is not None:
os.close(c2pwrite)
if errwrite is not None and errread is not None:
os.close(errwrite)
# Wait for exec to fail or succeed; possibly raising exception
# Exception limited to 1M
data = _eintr_retry_call(os.read, errpipe_read, 1048576)
finally:
# be sure the FD is closed no matter what
os.close(errpipe_read)
经过二分查找,发现并发卡住时,代码卡在了
data = _eintr_retry_call(os.read, errpipe_read, 1048576)。
分析代码,我们可以看到,一共有不超过4组管道
p2cread, p2cwrite: 标准输入的管道(如果设置了stdin=subprocess.PIPE)
c2pread, c2pwrite: 标准输出的管道(如果设置了stdout=subprocess.PIPE)
errread, errwrite: 标准错误的管道(如果设置了stderr=subprocess.PIPE)
errpipe_read, errpipe_write: 一定会创建的一对临时管道
前面已经分析过,stdin/stdout/stderr会被qemu-nbd关闭,而且代码也确实卡在了父进程对临时管道的读操作。
errpipe_read, errpipe_write是做什么的呢?通过代码我们可以很清晰的看到,子进程在由fork创建后需要执行一些exec具体可执行文件之前的初始化操作,包括关闭子进程中不关注的管道的一端、切换子进程当前运行目录以及自定义的前置函数等。这对管道的作用是收集子进程在这个过程中可能抛出的异常,用于定位fork的子进程中的异常。
这确实是一个非常巧妙的做法,我们在手写popen时就常常因为子进程的报错无法被父进程优雅的感知而耗费大量的精力。而且subprocess.Popen还充分考虑到了errpipe_write的继承问题:
self._set_cloexec_flag(errpipe_write)
实际对应的代码是:
fcntl.fcntl(errpipe_write, fcntl.F_SETFD, old | fcntl.FD_CLOEXEC)
也就是说,这个fd根本不会被daemon进程继承!
那为什么,在并发popen时会导致问题呢?
于是,有一个新的猜想:
并发fcntl设置fd的不共享标志可能会失败
当然了,这个猜想在测试daemon和标准库的subprocess.py中添加了足够的调试信息后,就证明是错误的。不过经过对所有fd的打开与传递的调试信息的仔细分析终于找到并发popen卡住的原因:管道被另一线程的子进程daemon继承并持有。
subprocess.py期望的过程是这样的(忽略stdin/stdout/stderr的管道, 以下的fd都是上面分析的第4组管道, errpipe_read和errpipe_write):
父进程创建临时pipe: errpipe_read和errpipe_write
父进程fork出子进程
父进程关闭errpipe_write,子进程关闭errpipe_read
子进程设置errpipe_write的不可共享标志
子进程fork daemon,但errpipe_write不会被共享
子进程退出,父进程收到SIGCHLD信号,父进程的wait等调用返回
父进程读管道,因为子进程已经退出且子进程的daemon子进程未持有管道,故可读到管道中的所有内容
但在并发情况下,假设父进程有两个线程A和B同时popen,则时序可能如下线程A创建临时pipe: errpipe_read(A)和errpipe_write(A)
线程B创建临时pipe: errpipe_read(B)和errpipe_write(B)
线程A fork出子进程PA, PA持有的fd有:errpipe_read(A), errpipe_write(A),errpipe_read(B), errpipe_write(B)
线程A关闭errpipe_write(A),子进程PA关闭errpipe_read(A), PA持有的fd变成:errpipe_write(A), errpipe_read(B), errpipe_write(B)
子进程PA设置errpipe_write(A)的不可共享标志
子进程fork daemon进程DA,但errpipe_write(A)不会被共享, DA持有的fd有:errpipe_read(B), errpipe_write(B)
子进程PA退出(变成僵尸进程),线程A收到SIGCHLD信号,线程A的wait等调用返回
线程A读管道,因为子进程PA已经退出且子进程的daemon子进程DA未持有管道errpipe_write(A),故可读到管道中的所有内容
线程A成功返回,但线程B无法返回,因为线程A最终创建的daemon DA进程持有了errpipe_write(B), 线程B无法读到errpipe_read(B)的EOF标志!
7. 测试程序(三)
我们已经知道了并发时read管道卡住的真正原因,那对于现有代码如何解决这个问题呢?
不允许并发:不允许多个subprocess.Popen同时执行,可以通过条件锁的方式在线程间同步
但是这种方式过于逃避问题,而且实现丑陋。我们想另外的办法:
允许并发,但在执行命令前关闭除stdin/stdout/stderr外所有打开的fd
看了前面subprocess.py的代码,
if preexec_fn:
preexec_fn()
我们发现了将这一逻辑嵌入到subprocess.Popen中的方法:自定义一个将fd关闭的函数对象传递给subprocess.Popen。
或者还看到了另外一段:
if close_fds:
self._close_fds(but=errpipe_write)
只需要将close_fds=True参数传递给subprocess.Popen即可关闭打开文件(errpipe_write虽然没有关闭,但它不会传递到子进程)。
将测试程序的这句
data = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
改成
data = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
重新跑并发测试,不再出现读管道卡住的问题了。
将测试daemon换成qemu-nbd和mount.ntfs-3g,已经跑了5万多次,仍未出现问题。
8. 结论(二)
Python的popen实现subprocess.Popen如果未设置close_fds=True参数并发调用,可能会导致专门用于收集子进程异常信息的管道被其它线程的子进程持有,如果恰好对方是一个daemon或长期不退出的进程,当前线程读取管道中的内容时会被卡住。
所以对于这个问题,我们应该总是考虑使用close_fds=True。
9. 其它问题
1. 这是Python独有的问题吗?
严格来说,这算是Python标准库中popen实现上未考虑详尽的问题。但Python的系统编程接口本质上就是POSIX接口的面向对象封装,所以这个问题并不是Python独有的。
只要子进程不被控制地共享了父进程的fd,那就有可能因为fd未关闭造成问题。比如子进程继承了打开设备的fd导致设备无法推出,或子进程继承了一个大文件的fd导致文件未能在磁盘上删除,或子进程继承了socket连接导致tcp连接无法及时关闭。
回到这个问题来看,为什么subprocess.Popen要用第四组管道?子进程的异常完全可以通过读取子进程标准错误的内容解析得到,但若是这样就把exec前后的信息揉合到了一起,父进程无法区分哪些内容产生的源头。所以这本来应该是一个popen实现上的优化,如果将这个思路用在任何基础库的popen实现上,都会因为相同的原因而出错。
因此,子进程默认共享父进程的fd是在编码时应当充分考虑到的一件事。
Python 2.7.6的源码中做了一个优化,将CLOEXEC尽早地设置在管道fd上,但只是减少而没有彻底根除并发问题。如果仅仅考虑管道的共享问题,应该使用pipe2系统调用原子地直接创建一组已带有CLOEXEC标志的管道fd
2. 有什么启示?
如果是写fork的调用方,应该考虑在子进程中尽量关闭子进程不需要的fd。尤其对于fork后需要exec的子进程来说,子进程是几乎不可能需要继承父进程除stdin/stdout/stderr以外的fd的。
如果是写fork的被调方,应该考虑先将已打开的fd关闭,以免占用额外的资源。