在Python中我们可以通过 os.system 来以控制台的形式运行程序,但当涉及到需要进行进程间通信时,就需要用到subprocess模块。本文原来是和 multiprocessing 作为一个整体来介绍的,后来进行了拆分,但内容仍然会有所重叠,并且会涉及Python的线程和进程相关机制。

简单的调用

subprocess提供了一下三个函数来实现简单的调用-检查结果的功能,下面列出了它们接受的 常用 参数。

  • call(args, *, stdin=None, stdout=None, stderr=None, shell=False)
    返回错误码 returncode
  • check_call(args, *, stdin=None, stdout=None, stderr=None, shell=False)
    returncode 为0时返回0,否则抛出 subprocess.CalledProcessError 异常。
  • check_output(args, *, stdin=None, stderr=None, shell=False, universal_newlines=False)
    返回标准输出,在错误时抛出 subprocess.CalledProcessError 异常。注意在前面两个函数中实际上也可以通过设置 stdout 等参数来重定向标准输出和标准错误。
  • 有关shell参数的说明

    容易发现shell参数为 True 时第一个参数推荐是一个字符串,而为 False 时我们需要传入一个原命令行 .split(" ") 的列表过去,而不能够直接将参数直接和程序名写到一个字符串里面,这么做的目的是为了防止shell注入的发生。

    1
    2
    3
    4
    5
    >>> subprocess.call(["ls", "-l"])
    0

    >>> subprocess.call("ls -1", shell=True)
    1

    查看一个shell注入的实例,下面的代码本意是想cat一个文件,但最终却运行了恶意的 rm -tf / 代码。

    1
    2
    3
    4
    5
    >>> from subprocess import call
    >>> filename = input("What file would you like to display?\n")
    What file would you like to display?
    non_existent; rm -rf / #
    >>> call("cat " + filename, shell=True) # Uh-oh. This will end badly...

    此外,shell参数为 True 时还能运行一些命令,例如在windows中我们运行 dir /B 只能通过shell来做,这是因为 dir 并不是一个程序,而是cmd内置的一个命令。 Subprocess文档 指出这是我们唯一在Windows下需要指定 shell = True 运行一个程序 的情况,subprocess根据 COMSPEC 环境参数来运行shell。但在下面的讨论中我们看到 shell 的设置也影响了输入输出流重定向的细节。

    Popen

    对于大多数的灵活需求,我们都需要 Popen 这个类来解决。 Popen 的定义如下所示:

    1
    Popen(args, bufsize=0, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=False, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0

    部分参数说明

    以下说明来自 Subprocess文档

  • bufsize
    等同于 open 函数的 buffering 参数。设为0表示无缓存。设为1表示行缓存。大于1表示使用这个size的缓冲区。负数表示系统默认。

  • executable
    有一些晦涩而不常使用的作用,比较有用的是 shell = True 时会用它来指定用的shell。

  • preexec_fn (POSIX)
    这个在下面的 强制终止进程 的讨论中会用到,其作用是在子程序执行前在 子程序上下文 中需要执行的语句,实际上是一个Hook。

  • close_fds (POSIX)
    对于Linux,子进程在执行前会先关闭除标准输入输出外的所有fd。我们使用以下的代码来测试这个特性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import os, sys, time, signal
    import subprocess
    # Parent
    if __name__ == '__main__':
    fd = os.open("test.txt", os.O_RDWR | os.O_CREAT)
    os.write(fd, "line1 \n")
    proc = subprocess.Popen(["python", "ch.py"])
    # Child
    if __name__ == '__main__':
    os.write(3, "line2 \n")

    运行发现test.txt中有两行的输出。特别地,这个特性是由Linux保证的,查看subprocess源码,发现它在内部采用了先fork再exec的策略。而 exec也是保持file descriptor 的。

    1
    2
    3
    4
    5
    6
    7
    self.pid = _posixsubprocess.fork_exec(
    args, executable_list,
    close_fds, sorted(fds_to_keep), cwd, env_list,
    p2cread, p2cwrite, c2pread, c2pwrite,
    errread, errwrite,
    errpipe_read, errpipe_write,
    restore_signals, start_new_session, preexec_fn)

    对于Windows事情就没有这么美妙了, close_fds = True 意味着子进程不会继承任何从父进程过来的句柄,也就是说我们不能重定向标准输入输出了, 一个可行的解决方案

  • cwd
    这个设置子线程的环境目录

  • env
    这个设置子线程的环境变量

  • universal_newlines
    这个用来统一所有的换行符模式为 \n

  • startupinfo (Windows)
    Windows的 CreateProcess 的对应参数

    不同操作系统的不一致性

    首先,我们可以通过 if 'posix' in sys.builtin_module_names 来判断操作系统是否为POSIX。

    有关重定向标准流的问题

    Popen stdin 等参数用来重定向子程序的三个标准流,常见选项是 subprocess.PIPE None 和一个打开的文件。使用 None 继承父进程的标准流,例如当 shell = False 时则所有父程序的输入会被转发给子程序,但当 shell = True 时会先启动一个shell再运行程序,这时候实际上是接受的shell的标准输入。使用 subprocess.PIPE 则和子程序之间建立管道。特别地,我们可以指定 open(os.devnull, 'w') 来忽略一个流。
    可以调用 Popen.communicate(input) 来通过管道向子进程传递信息,之后程序会阻塞在 communicate 上,直到从子程序传回信息。
    communicate 方法对应的是 Popen.stdin.write() 方法,
    这两个有一些区别 。此外 communicate 会默认调用 stdin.close() ,这相当于向对方发送一个EOF。所以当需要多次向子程序写数据时,并且子程序侦测来自主程序的EOF作为结束提示时,应当使用 stdin.write
    在写 ATP 时我还遇到程序子程序无法获得 stdin.write() 写入的数据的情况,这是需要设置 shell=True
    在写Nuft的时候,有一次发现 subprocess.Popen("./bin/node -f./settings.txt -i0", subprocess.PIPE, open("n0.out", "w"), open("n0.err", "w") 这样的是没有输出的,后来我索性在控制台里面执行了 > n1.out 这样的操作,发现在Ctrl+C中断之后也是没有重定向的内容的。所以这个并不是subprocess的问题。根据 SoF ,这实际上是Linux的lazy write问题,我们需要在进程结束的时候手动 fflush 一下。我们可以注册一个SIGTERM和SIGINT等事件的钩子,这样就可以发送SIGTERM来终止进程了。注意我们可能在SIGTERM之后还要在收尾SIGKILL一下,如下所示

    1
    2
    3
    4
    5
    6
    if proc.poll() == None:
    # Give a chance to save work
    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
    time.sleep(0.2)
    # Note we can't add a `if` here, otherwise we can't eliminate all child procs.
    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)

    管道死锁

    subprocess中给我们提供了一个 .communicate(input=None) 方法,我们向子进程的 stdin 输入数据,然后接受来自 stdout stderr 的输出,直到读到EOF或者子进程结束。Python官方鼓励使用 communicate() 替代 .wait() 方法,这是因为如果我们在wait时子进程还在往 stdout / stderr 中写数据,我们由阻塞在wait上不能读取,当管道的缓存被写满后子进程就会停止写等待我们读取缓冲区,而此时我们还在傻傻地wait子进程!这就造成了死锁。

    强制终止进程

    subprocess提供了 kill() 函数来终止进程,但这常常不能如愿,这常发生在我们想要kill的进程启动了其他的子进程时。父进程终结并不意味着子进程终结。
    我们知道Linux中包含有进程组(process group)和会话(session)两个概念。进程组由 fork exec 产生,按照父子关系传递,进程组的pgid由进程组leader的pid决定,但leader可以先挂,此时进程组仍然存在,并保有相同的pgid。我们可以通过以下的bash代码查看/枚举pgid/sid

    1
    2
    3
    4
    ps -p 进程ID -o pgrp=
    ps -A -o pgrp=
    ps -p 进程ID -o sid=
    ps -A -o sid=

    在Python中,如果我们subprocess启动的进程启动了其他的子进程,那么在杀死该进程后子进程并不会被杀死,而是变为孤儿进程(注意区别僵尸进程),随后被同进程组的其他进程或者init接管。但是我们可以向整个进程组signal,也就是我们下面使用 os.killpg 来终止进程组。

    1
    2
    3
    4
    5
    6
    7
    # 从终止着手
    if 'posix' in sys.builtin_module_names:
    # 杀掉proc.pid所在的用户组
    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
    else:
    # F表示强制终止,T表示终止该进程和所有以此启动的子进程
    subprocess.call(['taskkill', '/F', '/T', '/PID', str(self.proc.pid)], stdout = open(os.devnull, 'w'))

    容易发现这个方法存在一定缺陷,如果当前的程序不是该进程组的leader,那简单粗暴的 os.killpg 会误伤该进程组中的其他进程。例如,假设 fa 进程启动了 ch 进程, ch 进程启动了 grand_ch 进程,这时候我们想终止 fa ,使用 os.killpg 是可以的,因为 fa 是当前进程组的leader, fa 的家族 ch grand_ch 都是我们想终止的对象。但当这个 fa 进程是由 grand_fa 进程启动时,当前进程组是以 grand_fa 为leader的家族,我们使用 os.killpg(fa.pid, signal.SIGKILL) 就会误杀 grand_fa
    会话则层级更高,由一个前台进程组和若干后台进程组组成(统称为作业job)建立,每个会话可以关联一个终端(称为控制终端)来实现与人的交互,例如键盘的输入 Ctrl+C 等都会交给此时前台的进程组。会话也有leader,同样是由第一个创建的进程(通常是bash)决定,当终端的链接断开时,会话leader就会收到SIGHUP信号,而这个信号的默认处理就是关闭所有子进程。因而我们常通过 nohup cmd & 或者 disown 来在后台执行长期任务。

    1
    2
    3
    4
    5
    6
    # 启动时建立一个独立的进程组
    if 'posix' in sys.builtin_module_names:
    # 创建一个会话组,设置当前进程为会话组组长
    return subprocess.Popen(exe, stdin = fin, stdout = fout, stderr = ferr, shell = in_shell, preexec_fn = os.setsid)
    else:
    return subprocess.Popen(exe, stdin = fin, stdout = fout, stderr = ferr, shell = in_shell)

    subprocess的惯用法与坑

    threading.Thread.join不能捕获SIGINT等信号

    根据 Python官网的Bug ,我们在主线程中等待子线程 join ,这个阻塞的过程不能被SIGINT(2)所唤醒,而SIGINT也不能执行其默认的退出处理,稍后尝试了 SIGTERM 等信号也同样不能响应,唯有SIGSTOP和SIGKILL可用。这是因为至少在POSIX系统中 Lock.acquire() PyThread_acquire_lock() )中无论是信号量还是CV的实现都会忽略信号。这个问题可以通过设置 .join() timeout 参数来解决。注意如果 daemon False timeout 参数无效,这里的 daemon 表示守护线程。守护线程通常是一些不是那么重要的线程,Python会在所有 非守护 线程退出后结束。
    我们将在 这篇文章 中专门探讨这个机制。

  •