subprocess模块用法

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

简单的调用

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

  1. call(args, *, stdin=None, stdout=None, stderr=None, shell=False)
    返回错误码returncode
  2. check_call(args, *, stdin=None, stdout=None, stderr=None, shell=False)
    returncode为0时返回0,否则抛出subprocess.CalledProcessError异常。
  3. 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文档

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

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

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

  4. 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意味着子进程不会继承任何从父进程过来的句柄,也就是说我们不能重定向标准输入输出了,一个可行的解决方案

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

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

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

  8. startupinfo (Windows)
    Windows的CreateProcess的对应参数

不同操作系统的不一致性

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

有关重定向标准流的问题

Popenstdin等参数用来重定向子程序的三个标准流,常见选项是subprocess.PIPENone和一个打开的文件。使用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输入数据,然后接受来自stdoutstderr的输出,直到读到EOF或者子进程结束。Python官方鼓励使用communicate()替代.wait()方法,这是因为如果我们在wait时子进程还在往stdout/stderr中写数据,我们由阻塞在wait上不能读取,当管道的缓存被写满后子进程就会停止写等待我们读取缓冲区,而此时我们还在傻傻地wait子进程!这就造成了死锁。

强制终止进程

subprocess提供了kill()函数来终止进程,但这常常不能如愿,这常发生在我们想要kill的进程启动了其他的子进程时。父进程终结并不意味着子进程终结。
我们知道Linux中包含有进程组(process group)和会话(session)两个概念。进程组由forkexec产生,按照父子关系传递,进程组的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的家族chgrand_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参数来解决。注意如果daemonFalsetimeout参数无效,这里的daemon表示守护线程。守护线程通常是一些不是那么重要的线程,Python会在所有非守护线程退出后结束。
我们将在这篇文章中专门探讨这个机制。