subprocess.Popen close_fds=True导致执行命令耗时长

一、背景

在使用subprocess.Popen调用系统命令时,如果配置close_fds=True,命令执行耗时会显著加长。
首先,我们看下一个普通的ls命令,耗时只需要0.002秒。

[root@wenf-dev subprocess_test]# time ls
subprocess_close_fds.py

real    0m0.002s # 直接执行,耗时0.002s
user    0m0.000s
sys     0m0.002s

然后,subprocess.Popen,配置close_fds=True,执行ls命令,耗时需要整整0.06秒(0.002秒的30倍)。

[root@wenf-dev subprocess_test]# cat subprocess_close_fds.py 
import subprocess
import time

start = time.time()
_PIPE = subprocess.PIPE
obj = subprocess.Popen(["ls"],
                       stdin=_PIPE,
                       stdout=_PIPE,
                       stderr=_PIPE,
                       close_fds=True) 
result = obj.communicate()
print "cost time: %s" % (time.time() - start)
[root@wenf-dev subprocess_test]# python subprocess_close_fds.py 
cost time: 0.0697808265686

最后,我们把close_fds=False,耗时又再次只需要0.002秒了。

[root@wenf-dev subprocess_test]# cat subprocess_close_fds.py 
import subprocess
import time

start = time.time()
_PIPE = subprocess.PIPE
obj = subprocess.Popen(["ls"],
                       stdin=_PIPE,
                       stdout=_PIPE,
                       stderr=_PIPE,
                       close_fds=False)
result = obj.communicate()
print "cost time: %s" % (time.time() - start)
[root@wenf-dev subprocess_test]# python subprocess_close_fds.py 
cost time: 0.0025429725647 # 设置close_fds=False,执行耗时0.002s

二、问题定位

subprocess.Popen执行系统命令时,总的来说分为七步:

  1. 创建四个管道,分别用于标准输入(p2c_pipe,fd 0)、标准输出(c2p_pipe,fd 1)、标准错误(err_pipe,fd 2)以及os.execvp执行失败的异常(execvp_err_pipe)

  2. fork子进程

  3. 如果close_fds=True,子进程关闭除了0,1,2之外的所有fd

  4. 子进程os.execvp执行系统命令

  5. 父进程持续从管道execvp_err_pipe中读取数据

    a. 有异常时,execvp_err_pipe读取到子进程返回的异常信息,结束循环

    b. 子进程成功执行os.execvp,关闭管道execvp_err_pipe,os.read返回空,结束循环

  6. 父进程通过poll/select持续监听c2p_pipe、err_pipe两个fd,收到数据后则结束循环

  7. 父进程调用waitpid(子进程PID),获取子进程返回码,整改过程结束

subprocess.Popen调用系统命令比较慢的原因就在于第三步关闭fd耗时。

在第三步中,关闭的fd数量直接取决于进程的最大文件句柄数。每个进程的最大文件句柄数,默认值是1024。

通过ulimit -n可以查看当前配置。

一些应用会把这个值调高成百万级别,甚至千万级别。
这时候,下面的close_fd函数耗时将直线上升,最终导致subprocess.Popen调用系统命令耗时比较长。
参考:https://bugs.python.org/issue11284

try:
    MAXFD = os.sysconf("SC_OPEN_MAX")
except:
    MAXFD = 256
    
def _close_fds(self, but):
    if hasattr(os, 'closerange'):
        os.closerange(3, but)
        os.closerange(but + 1, MAXFD)
    else:
        for i in xrange(3, MAXFD):
            if i == but:
                continue
            try:
                os.close(i)
            except:
                pass

那子进程为什么需要关闭fd呢 ?
这是因为,子进程会继承父进程所有的fd信息。

引用《UNIX系统编程手册》24.2节

执行 fork()时,子进程会获得父进程所有文件描述符的副本。这些副本的创建方式类似于
dup(),这也意味着父、子进程中对应的描述符均指向相同的打开文件句柄(即 open file
description,详见 5.4 节译注)。正如 5.4 节所述,打开文件句柄包含有当前文件偏移量(由 read()、
write()和 lseek()修改)以及文件状态标志(由 open()设置,通过 fcntl()的 F_SETFL 操作改变)。
一个打开文件的这些属性因之而在父子进程间实现了共享。举例来说,如果子进程更新了文
件偏移量,那么这种改变也会影响到父进程中相应的描述符。

三、解决方案

既然耗时的长短跟最大文件句柄数有关,那么我们只要避免配置过大的值,就能避免遇上这个问题。

echo "root soft nofile 655350" >> /etc/security/limits.conf 
echo "root hard nofile 655350" >> /etc/security/limits.conf 
posted @ 2020-04-30 19:18  cnwenf  阅读(2729)  评论(0)    收藏  举报