在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 | "ls", "-l"]) subprocess.call([ |
查看一个shell注入的实例,下面的代码本意是想cat一个文件,但最终却运行了恶意的rm -tf /
代码。
1 | from subprocess import call |
此外,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
10import 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
7self.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 | if proc.poll() == None: |
管道死锁
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 | ps -p 进程ID -o pgrp= |
在Python中,如果我们subprocess启动的进程启动了其他的子进程,那么在杀死该进程后子进程并不会被杀死,而是变为孤儿进程(注意区别僵尸进程),随后被同进程组的其他进程或者init接管。但是我们可以向整个进程组signal,也就是我们下面使用os.killpg
来终止进程组。
1 | # 从终止着手 |
容易发现这个方法存在一定缺陷,如果当前的程序不是该进程组的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 | # 启动时建立一个独立的进程组 |
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会在所有非守护线程退出后结束。
我们将在这篇文章中专门探讨这个机制。