python subprocess-更优雅的创建子进程

简介

在任何编程语言中,启动进程都是非常常见的任务,python也是如此,而不正确的启动进程方式会给程序带来很大安全风险。Subprocess模块开发之前,标准库已有大量用于进程创建的接口函数(如 os.system 、 os.spawn* ),但是略显混乱使开发者难以抉择,因此Subprocess的目的是打造一个“统一”模块来提供之前进程创建相关函数的功能实现。与之前的相关接口相比,提供了以下增强功能:

  • 一个“统一”的模块来提供以前进程创建相关函数的所有功能;
  • 跨进程异常优化:子进程中的异常会在父进程再次抛出,以便检测子进程执行情况;
  • 提供用于在 fork 和 exec 之间执行自定义代码的钩子;
  • 没有隐式调用 /bin/sh ,这意味着不需要对危险的shell meta characters进行转义;
  • 支持文件描述符重定向的所有组合;
  • 使用subprocess模块,可以控制在执行新程序之前是否应关闭所有打开的文件描述符;
  • 支持连接多个子进程 ;
  • 支持universal newline;
  • 支持 communication() 方法,它使发送stdin数据以及读取stdout和stderr数据变得容易,而没有死锁的风险;

subprocess 基础

subprocess.run

subprocess 推荐使用 run 函数来处理它所能够处理的一切cases, 如果需要更高级灵活的定制化使用,则可以使用其底层的 popen 接口来实现。 run 函数signature为:

def subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=None, *other_popen_kwargs)->subprocess.CompletedProcess:
  pass

上面写的参数只是最常见的参数,完整的函数列表在很大程度上与 popen 函数的参数相同,即这个函数的大多数参数都被传递到该接口。( timeout 、 input 、 check 和 capture_output 除外)。该函数常用参数如下:

  • args , 必选参数,数据类型应为一个string 或则 一个sequence(list, tuple等等)。通常最好传递一个sequence,因为它允许模块处理任何必需的参数转义和引用; 如果传递的是字符串,则shell必须为 True ,否则该字符串必须简单地为要执行的程序的名字,而不能指定任何参数。
    在复杂情况下,构建一个sequence-like的参数可以借助 shlex.split() 来实现
>>> import shlex, subprocess
>>> command_line = input()
/bin/vikings -input eggs.txt -output "spam spam.txt" -cmd "echo '$MONEY'"
>>> args = shlex.split(command_line)
>>> print(args)
['/bin/vikings', '-input', 'eggs.txt', '-output', 'spam spam.txt', '-cmd', "echo '$MONEY'"]
>>> p = subprocess.Popen(args) # Success!

shell模式执行等同于: Popen(['/bin/sh', '-c', args[0], args[1], ...])

## 以下两句代码等价,都是通过shell模式执行`ls -l`
subprocess.run('ls -l', shell=True)
subprocess.run(['/bin/sh', '-c', 'ls -l'], shell=False)
## 下面代码通过非shell模式执行`ls -l`
subprocess.run(['ls', '-l'], shell=False)
# 下面代码实际执行的是`ls`
subprocess.run(['/bin/sh', '-c', 'ls', '-l'], shell=False)

当使用 shell=True 时,要注意可能潜在的安全问题,需要确保所有空格和元字符都被适当地引用,以避免shell注入漏洞。如下面的例子:

from shlex import quote
>>> filename = 'somefile; rm -rf ~' # 有这么一个奇怪的文件名
>>> command = 'ls -l {}'.format(filename)
>>> print(command)  # executed by a shell: boom!
ls -l somefile; rm -rf ~
>>> subprocess.run(command, shell=True)  # 这时就会有极大的安全隐患
>>> command = 'ls -l {}'.format(quote(filename))  # 使用shlex.quote对文件名进行正确的转义
>>> print(command)
ls -l 'somefile; rm -rf ~'
>>> subprocess.run(command, shell=True)
  • capture_output , 如果 capture_output=True ,则将捕获stdout和stderr,调用时内部的Popen对象将自动使用 stdout=PIPE 和 stderr = PIPE 创建标准输出和标准错误对象;传递 stdout 和 stderr 参数时不能同时传递 capture_output 参数。如果希望捕获并将两个stream合并为一个,使用 stdout=PIPE 和 stderr = STDOUT 。
  • check ,如果 check=True ,并且进程以非零退出代码退出,则将抛出 CalledProcessError 异常。
  • input ,该参数传递给 Popen.communicate() ,然后传递给子进程的stdin。该参数数据类型应为字节序列(bytes);但如果指定了 encoding , errors 参数或则 text=True ,参数则必须为字符串。使用该参数时,内部Popen对象,将使用stdin = PIPE自动创建该对象,不能同时使用stdin参数。
  • timeout ,该参数传递给 Popen.communicate() ,如果指定时间之后子进程仍未结束,子进程将被kill,并抛出 TimeoutExpired 异常。
  • stdin , stdout 和 stderr 分别指定执行程序的标准输入,标准输出和标准错误文件的file handles。如 subprocess.PIPE , subprocess.DEVNULL , 或者 None 。此外,stderr可以设定为 subprocess.STDOUT ,这表示来自子进程的stderr数据应重定向到与stdout相同的file handle中。默认情况下, stdin , stdout 和 stderr 对应的file handle都是以binary的方式打开。
  • encoding , errors , text 。当传递 encoding , errors 参数 或 text=True 时, stdin , stdout 和 stderr 对应的file handle以text的模式打开。 universal_newlines 和 text 同义,为了保持向下兼容而保留。默认情况下,文件对象以二进制的方式打开。
  • env ,通过传递mappings对象,给子进程提供环境变量,该参数直接传递给 Popen 函数。
  • shell , 如果 shell=True ,则将通过Shell执行指定的命令。当使用 shell=True 时, shlex.quote() 函数可用于正确地转义字符串中的空格和Shell元字符。
  • 函数返回数据类型为 subprocess.CompletedProcess , 该对象包含以下属性或方法:
    • args , 调用该进程的参数,同 subprocess.run(args,***) 中的 args ;
    • returncode ,当值为0时,代表子进程执行成功;负值 -N 指示进程被signal N 所终止 (POSIX only); None 代表未终止;
    • stdout , stderr ,代表子进程的标准输出和标准错误;
    • check_returncode() , check子进程是否执行成功,若执行失败将抛出异常;

old high level interfaces

run 函数在 Python 3.5 新增,之前使用该模块的high level interface包括三个函数: call() , check_call() , check_output() 。这三个函数参数和 subprocess.run() 的函数参数含义相同。但需要注意的是,这三个函数的参数列表略微不同,函数signature如下:

  • subprocess.call(args, *, stdin=None, stdout=None, stderr=None, shell=False, cwd=None, timeout=None, **other_popen_kwargs)

​ 执行 args 参数所指定的程序并等待其完成。当 shell=True , 无论子进程执行成功与否,返回值为return code;当 shell=False ,子进程如果执行失败,将会抛出异常; 该函数旨在对 os.system() 进行功能增强,同时易于使用 。

  • subprocess.check_call(args, *, stdin=None, stdout=None, stderr=None, shell=False, cwd=None, timeout=None, **other_popen_kwargs)
    执行 args 参数所指定的程序并等待其完成,如果子进程返回0,则函数返回;若子进程失败,则抛出异常;
  • subprocess.check_output(args, *, stdin=None, stderr=None, shell=False, cwd=None, encoding=None, errors=None, universal_newlines=None, timeout=None, text=None, **other_popen_kwargs)
    执行 args 参数所指定的程序并返回其输出,如果子进程执行失败将抛出异常; 该函数的返回值默认为 bytes
注意: 请勿在 subprocess.call 及``subprocess.check_call 中使用 stdout=PIPE 或 stderr=PIPE 。如果子进程输出信息过大将会耗尽OS管道缓冲区的缓冲,该子进程将阻塞; 要禁止这两个函数的stdout或stderr,可以通过 subprocess.DEVNULL`设置。

subprocess.Popen

Popen构造函数

上面四个high level interfaces 底层的进程创建及进程管理实际上都是基于 subprocess.Popen 类来实现,当需要定制化更灵活的进程调用时,这个函数会是一个更好的选择。首先看该类的构造函数如下:

class subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, shell=False, cwd=None, env=None, universal_newlines=None, startupinfo=None, creationflags=0,start_new_session=False,restore_signals=True,close_fds=True,pass_fds=(), *, encoding=None, errors=None, text=None):
  pass

此时,你会发现很多熟悉的参数。因为high level function本身大部分参数也确实传递给了 Popen 类。该类的作用即是创建 (fork) 并执行 (exec) 子进程。在POSIX,该类使用类似 os.execvp() 的方式执行子进程;在windows上,则使用windows系统的 CreateProcess() 函数执行子进程。 Popen 构造函数参数十分丰富,除了上面介绍的还有一大堆参数需要注意。 close_fds , pass_fds 与file handle相关; restore_signals 与POSIX信号相关; start_new_session , startupinfo , creationflags 则和子进程的创建相关;另外,以下参数可能需要特别关注:

  • bufsize , 在创建stdin / stdout / stderr管道文件对象时, bufsize 将作为 open() 函数的相应参数
    • 0 代表unbuffered
    • 1 代表 line buffered
    • 其他正数代表buffer size
    • 负数代表使用系统默认的buffer size ( io.DEFAULT_BUFFER_SIZE )
  • excutable ,这个参数不常见,当 shell=True 时, 在POSIX上,可使用该参数指定不同于默认的 /bin/sh shell来执行子进程;而当 shell=False 时,这个用法似乎不太常见,我能想到的一个例子可能如下:
## 以下两行命令等价
>>> subprocess.run(['bedtools','intersect', '--help'])
>>> subprocess.run(['','intersect', '--help'], executable='bedtools')
  • preexec_fn , 该参数可绑定一个callable对象,该对象将在子进程执行之前在子进程中调用。需要注意的是,在应用程序中存在线程的情况下,该参数应该避免使用,可能会引发死锁。
def say_hello():
  print('hello!!!')
subprocess.run(['ls'], preexec_fn=say_hello)
  • cwd , 指定该参数时,函数在执行子进程之前将会将工作目录设置为 cwd 。

Popen方法与属性

  • Popen.poll() check子进程是否已终止,如果结束则返回return code,反之返回None
  • Popen.wait(timeout=None) 等待子进程终止,如果timeout时间内子进程不结束,则会抛出 TimeoutExpired 异常
    当使用 stdout=PIPE 或则 stderr=PIPE 时,避免使用该函数,使用``Popen.communicate()`以避免死锁的发生。
  • Popen.communicate(input=None, timeout=None)

与进程交互:将 input 指定数据发送到stdin;从stdout和stderr读取数据,直到到达文件末尾,等待进程终止。所以,返回值是一个tuple: (stdout_data, stderr_data) 。如果timeout时间内子进程不结束,则会抛出 TimeoutExpired 异常。其中需要注意的是,捕获异常之后,可以再次调用该函数,因为子进程并没有被kill。因此,如果超时结束程序的话,需要现正确kill子进程:

proc = subprocess.Popen(...)
    outs, errs = proc.communicate(timeout=15)
except TimeoutExpired:
    proc.kill()
    outs, errs = proc.communicate()
  • Popen.send_signal(signal) 向子进程发送信号
  • Popen.terminate() 停止子进程,在POSIX上,实际上即是向子进程发送 SIGTERM 信号;在windows上则是调用 TerminateProcess() 函数
  • Popen.kill() 杀掉子进程,在POSIX上,实际上即是向子进程发送 SIGKILL 信号;在windows上则是调用 terminate() 函数
  • 属性包括 .args :子进程命令; .returncode :子进程终止返回值; .pid :子进程进程号; .stdin , .stdout , .stderr 分别代表标准输入输出,标准错误,默认为bytes,这几个属性类似于 open() 函数返回值,是一个可读的stream对象

异常处理

subprocess 模块共包含三个异常处理类: 基类 SubprocessError , 及其两个子类 TimeoutExpired , CalledProcessError ,前者在在等待子进程超时时抛出;后者在调用 check_call() 或 check_output() 返回非零状态值时抛出。他们共同的属性包括:

  • cmd , 该子进程的命令
  • output , 子进程所capture的标准输出 (如调用 run() 或则 check_output() ),否则为 None
  • stdout , output 的别名
  • stderr , 子进程所capture的标准错误 (如调用 run() ) ,否则为 None

TimeoutExpired 还包括 timeout ,指示所设置的 timeout 的值; CalledProcessError 则还包括属性 returncode ;

Subprocess 应用

在官方文档中给出很多例子指导我们如何使用subprocess替代旧的接口,具体例子如下:

  1. shell 命令行, 比如要实现一个简单的shell command line 命令 ls -lhrt ,可以有以下几种等价的方式:
## shell cmd ls -lhrt
>>> output = check_output(["ls", "-lhrt"])
>>> subprocess.run(['ls', '-lhrt'], stdout=subprocess.PIPE).stdout
>>> output = subprocess.Popen(["ls", "-lhrt"], stdout=subprocess.PIPE).communicate()[0]

shell 管道操作,比如想看一个文件前100行中哪些数据包含关键字’python’,shell cmd可以这样写: cat test.txt | head -n 100 | grep python ,使用subprocess可以这样写:

>>> from subprocess import *
>>> p1 = Popen(["cat", 'test.txt'], stdout=PIPE)
>>> p2 = Popen(["head", "-n", "100"], stdin=p1.stdout, stdout=PIPE)
>>> p3 = Popen(["grep", "python"], stdin=p2.stdout, stdout=PIPE)
>>> output = p3.communicate()[0]
>>> output.decode()

2.替代 os.system() ,前面有提到 subprocess.call() 是为 os.system 设置的增强版,应用如下:

from subprocess import *
    retcode = call("ls" + " -hrtl", shell=True)
    if retcode < 0:
        print("Child was terminated by signal", -retcode, file=sys.stderr)
    else:
        print("Child returned", retcode, file=sys.stderr)
except OSError as e:
    print("Execution failed:", e, file=sys.stderr)

3.替代 os.spawn*() ,该家族包括八个变体, os.spawnl() , os.spawnle() , os.spawnlp() , os.spawnlpe() , os.spawnv() , os.spawnve() , os.spawnvp() , os.spawnvpe() , l 和 v 变体分别代表fixed parameters和variable parameters, p 变体函数默认使用环境变量 $PATH 寻找program file (如 ls , cp ), e 变体则是函数增加一个 env mappings 参数来指定子进程执行的环境变量,不使用当前进程的环境变量。官方建议这些函数都可用subprocess替代,如常见的两个场景如下:

### 场景1 P_NOWAIT
pid = os.spawnlp(os.P_NOWAIT, "ls", "ls", "-hlrt")
pid = Popen(["/bin/mycmd", "myarg"]).pid
### 场景2
retcode = os.spawnlp(os.P_WAIT, "/bin/mycmd", "mycmd", "myarg")
retcode = call(["/bin/mycmd", "myarg"])

4.替代 os.popen*() ,该系列一共包括4个变体,分别是 os.popen() , os.popen2() , os.popen3() , os.popen()4 ,首先需要理解的是 os.popen() 是基于 subprocess.Popen 实现的一个方法,用于从一个命令打开一个管道,存在 r 或 w 两种模式。比如:

>>> f = os.popen(cmd='ls -lhrt', mode='r', buffering=-1)  # cmd必须是字符串,其以shell的方式执行
>>> f.read()
'total 0\n-rw-r--r-- 1 liunianping qukun 8 Jan 29 20:50 test.txt\n'
>>> f.close()

而剩下三个变体其实不是基于subprocess来实现的,并且功能差别仅仅在于返回值,三者返回值依次是: (child_stdin, child_stdout) , (child_stdin, child_stdout, child_stderr) , (child_stdin, child_stdout_and_stderr) , 因此,我们自然也可以使用 subprocess 模块函数来替代它:

### popen2
(child_stdin, child_stdout) = os.popen2(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
          stdin=PIPE, stdout=PIPE, close_fds=True)
(child_stdin, child_stdout) = (p.stdin, p.stdout)
### popen3
(child_stdin,
 child_stdout,
 child_stderr) = os.popen3(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
          stdin=PIPE, stdout=PIPE, stderr=PIPE, close_fds=True)
(child_stdin,
 child_stdout,
 child_stderr) = (p.stdin, p.stdout, p.stderr)
### popen4
 (child_stdin, child_stdout_and_stderr) = os.popen4(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
          stdin=PIPE, stdout=PIPE, stderr=STDOUT, close_fds=True)
(child_stdin, child_stdout_and_stderr) = (p.stdin, p.stdout)

其他

subprocess 中还提供另外两个python2.x中 commands 模块中的旧版shell调用功能 getstatusoutput 和 getoutput ,查看python源码可以看到它的实现其实也非常简单,就是借助 subprocess.check_output() 函数捕获shell 命令的输出,最终返回 return_code 以及 output :

def getstatusoutput(cmd):
        data = check_output(cmd, shell=True, text=True, stderr=STDOUT)
        exitcode = 0
    except CalledProcessError as ex:
        data = ex.output
        exitcode = ex.returncode