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

简介

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

  • 一个“统一”的模块来提供以前进程创建相关函数的所有功能;
  • 跨进程异常优化:子进程中的异常会在父进程再次抛出,以便检测子进程执行情况;
  • 提供用于在forkexec之间执行自定义代码的钩子;
  • 没有隐式调用/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函数的参数相同,即这个函数的大多数参数都被传递到该接口。(timeoutinputcheckcapture_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=PIPEstderr = PIPE创建标准输出和标准错误对象;传递stdoutstderr参数时不能同时传递capture_output参数。如果希望捕获并将两个stream合并为一个,使用stdout=PIPEstderr = STDOUT

  • check,如果check=True,并且进程以非零退出代码退出,则将抛出CalledProcessError异常。

  • input,该参数传递给Popen.communicate(),然后传递给子进程的stdin。该参数数据类型应为字节序列(bytes);但如果指定了encoding , errors参数或则 text=True,参数则必须为字符串。使用该参数时,内部Popen对象,将使用stdin = PIPE自动创建该对象,不能同时使用stdin参数。

  • timeout,该参数传递给Popen.communicate(),如果指定时间之后子进程仍未结束,子进程将被kill,并抛出TimeoutExpired异常。

  • stdinstdoutstderr分别指定执行程序的标准输入,标准输出和标准错误文件的file handles。如subprocess.PIPE, subprocess.DEVNULL, 或者 None。此外,stderr可以设定为subprocess.STDOUT,这表示来自子进程的stderr数据应重定向到与stdout相同的file handle中。默认情况下,stdinstdoutstderr对应的file handle都是以binary的方式打开。

  • encoding, errors , text 。当传递encoding, errors参数 或 text=True时,stdinstdoutstderr对应的file handle以text的模式打开。universal_newlinestext同义,为了保持向下兼容而保留。默认情况下,文件对象以二进制的方式打开。

  • 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=PIPEstderr=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/shshell来执行子进程;而当shell=False时,这个用法似乎不太常见,我能想到的一个例子可能如下:

    ## 以下两行命令等价
    >>> subprocess.run(['bedtools','intersect', '--help'])
    >>> subprocess.run(['','intersect', '--help'], executable='bedtools')
    

    From 官方文档: executable replaces the program to execute specified by args. However, the original args is still passed to the program. Most programs treat the program specified by args as the command name, which can then be different from the program actually executed.

  • 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(...)
    try:
        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]
    
    1. 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 *
    try:
        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(), lv变体分别代表fixed parameters和variable parameters, p变体函数默认使用环境变量$PATH寻找program file (如ls, cp),e变体则是函数增加一个env mappings 参数来指定子进程执行的环境变量,不使用当前进程的环境变量,具体见官方文档 os.spawn*。官方建议这些函数都可用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实现的一个方法,用于从一个命令打开一个管道,存在rw两种模式。比如:

    >>> 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调用功能getstatusoutputgetoutput,查看python源码可以看到它的实现其实也非常简单,就是借助subprocess.check_output()函数捕获shell 命令的输出,最终返回return_code以及output:

def getstatusoutput(cmd):
    try:
        data = check_output(cmd, shell=True, text=True, stderr=STDOUT)
        exitcode = 0
    except CalledProcessError as ex:
        data = ex.output
        exitcode = ex.returncode
    if data[-1:] == '\n':
        data = data[:-1]
    return exitcode, data

def getoutput(cmd):
    return getstatusoutput(cmd)[1]

写在篇尾

subprocess是基于python2 中popen2模块发展而来,专门为替代python中众多繁杂的子进程创建方法而设计,平时使用的过程中,subprocess.run()以及subprocess.call可以满足我们大多数的使用需求,但是更深入的了解该package的设计思想可以让我们更加灵活的控制复杂场景下的子进程任务。

参考

python3 subprocess
PEP324

你可能感兴趣的:(Python,python,多进程)