Skip to content

subprocess

subprocess允许创建一个子进程(继承 Python 运行的主进程 fork),并且允许连接到这个子进程的输入(stdin)、输出(stdout)和错误(stderr)管道,并获取他们的返回码。可以将它理解为 python 中的 bash。

文件描述符和标准流

在 Linux 和编程中,存在 stdin、stdout 和 stderr 三个标准的输入输出流,它们用于程序与外部环境(如终端、文件或其他程序)之间进行通信。以下是它们的详细区别和用途:

特性 stdin stdout stderr
作用 用于程序从外部获取输入 向外部输出结果 向外部输出错误信息
默认来源 键盘输入(终端运行时) 终端屏幕 终端屏幕
文件描述符 0 1 2

他们是可以被重定向的:

Bash
#重定向 stdin: 将 input.txt 的内容作为命令的输入
wc -l < input.txt

# 重定向 stdout:
# 将 ls 命令的输出写入 output.txt(覆盖)
ls -l > output.txt
# 将 ls 命令的输出追加到 output.txt(不覆盖)
ls -l >> output.txt

# 重定向 stderr:
# 将错误信息写入 error.txt(覆盖)
ls /nonexistent 2> error.txt
# 将错误信息追加到 error.txt(不覆盖)
ls /nonexistent 2>> error.txt

# 同时重定向 stdout 和 stderr
# 将 stdout 和 stderr 都写入 output.txt
ls -l /nonexistent > output.txt 2>&1

# 如果要丢弃输出就重定向到 /dev/null
# 丢弃 stdout 和 stderr
ls -l /nonexistent > /dev/null 2>&1

# 重定向输出到另一个命令的输入
# 将 ls 的输出传递给 grep 过滤
ls -l | grep .txt

文件描述符

文件描述符是 Linux 中的一个概念,他使用非负整数来标识进程打开的文件以及输入输出流。在执行一些文件处理相关的函数时(open read write close)中都需要该参数。他具有几个特点:

  1. 文件描述符是进程级别的,每个进程都有一套自己的独立的文件描述符表(可以在 /proc/<PID>/fd 中查看隶属于该进程的所有文件描述符)
  2. 其中 0 1 2 分别对应了 stdin stdout stderr。这在创建新的子进程时自动创建的

文件描述符(File Descriptor)是操作系统提供的底层接口,在这基础上还提供了更高层次的抽象就是文件句柄(File Handle)。实际上在 Windows 中并没有文件描述符的概念而且文件句柄就是来自 Windows 中的术语:

C
// fd 返回的就是文件描述符
int fd = open("example.txt", O_RDONLY); // 文件描述符
char buffer[100];
read(fd, buffer, sizeof(buffer));       // 通过文件描述符读取文件
close(fd);                              // 关闭文件描述符

// windows 中
// 实际上 HANDLE 类似文件描述符的 int
HANDLE hFile = CreateFile("example.txt", GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
char buffer[100];
DWORD bytesRead;
ReadFile(hFile, buffer, sizeof(buffer), &bytesRead, NULL); // 通过文件句柄读取文件
CloseHandle(hFile);                                        // 关闭文件句柄

而在 python 中:

Python
# 直接返回的就是文件句柄,可以在文件句柄上执行操作
f = open("example.txt", "r")  # 文件句柄
content = f.read()            # 通过文件句柄读取文件
f.close()                     # 关闭文件句柄

Tips

现代编程语言中直接操作文件的接口通常都依赖于文件句柄,只不过在 Linux 平台他是对文件描述符的简单封装。有点类似面向过程和面向对象编程的区别

subprocess.Popen

该函数类似于 Linux 中的 popen() 函数,他会创建一个管道然后调用 fork 来产生一个子进程并执行 shell 然后运行传递给他的命令:

Python
Class subprocess.Popen(
    args,   # 要执行的命令,可以是字符串或列表,1. 如果是字符串且 shell=True 这通过 shell 执行 2. 如果是列表,则直接执行命令
    stdin=None,     # 标准输入
    stdout=None,    # 标准输出
    stderr=None,    # 标准错误
    close_fds=True, # True 表示除了 0 1 2 外的所有文件描述符在子进程执行前被关闭
    shell:bool=False,   # 是否使用 shell 来运行,大致等价于 `/bin/sh -c args` -c 表示从字符串中获取命令,他的好处是能够使用 shell 提供的 ls、各种管道 | < > 相关的命令
    cwd:str=None,       # 子进程的工作目录
    env:dict=None,      # 子进程的环境变量(以字典形式提供)
    encoding=None,      # 如果指定了 encoding 且 stdin/stdout/stderr 指定文件,则他们以文本模式打开,否则他们以二进制模式打开
    text=None, # 如果 text=True,同样以文本模式打开
    ):
    def wait(timeout=None):
        """等待子进程被终止,可以设置 timeout 在多少 s 后没有禁止抛出 TimeoutExpired 异常"""
        pass
    def communicate(input=None, timeout=None):
        """与进程交互, input 表示传入 stdout 的数据
        他的返回值是 (stdout_data, stderr_data)
        如果以文本文件打开就是字符串,否则是字节
        """
        pass
    def kill():
        """杀死子进程"""
        pass
    def poll():
        """检查子进程是否已经被终止"""
        pass

传入命令

命令接受字符串和列表的形式,通常情况下如果是字符串建议设置 shell=True 来完成,他就相当于你在 shell 中执行的命令:

Python
import subprocess

# 使用字符串传入命令
process = subprocess.Popen('ls -l | grep .py', shell=True, stdout=subprocess.PIPE, text=True)
output, _ = process.communicate()
print(output)

优点是能够直接使用 shell 特性和在 shell 中的写法完全一样即可。缺点也很明显如果命令来自于外部会导致命令注入漏洞,需要额外启动一个 shell 进程,性能较低。

推荐的是命令以列表形式传入,此时不需要设置 shell=True:

Python
import subprocess

# 使用列表传入命令
process = subprocess.Popen(['ls', '-l'], stdout=subprocess.PIPE, text=True)
output, _ = process.communicate()
print(output)

在使用中需要注意几个要点:

  1. 命令和参数必须分开
  2. 避免使用 Shell 中提供的特殊功能字符
  3. 路径和文件名中的空格不需要特殊处理
  4. 并不支持 $HOME、通配符(*.txt) 这样的形式,他们是 Shell 特有的
  5. 命令必须位于系统的 PATH 环境变量中,否则必须提供完整路径,因此通常建议通过 shutil.which() 检测

重定向标准流

这其中比较重要且比较麻烦的就是 stdin/stdout/stderr 的指定,他具有几个不同的情形:

  1. None: 默认,也就是使用默认的键盘输入、屏幕输出的形式,当然对于 Python 的 Popen 会封装到对象的对应属性上
  2. file object: 文件句柄即 open() 的返回值
  3. subprocess.DEVNULL: 他等价于重定向到 /dev/null 即不输出
  4. stderr=subprocess.PIPE: 表示标准错误与标准输出使用同一个句柄,类似 2>&1
  5. subprocess.PIPE: 最为特殊的值,用于打开标准流的管道,他是子进程和主进程进行通信的主要方式
  6. 重定向到另一个子进程,他配合 subprocess.PIPE 就等价于 shell 中的 |

上面的除了最后两个外都比较好理解:

Python
import subprocess

# 第一个子进程:ls -l
# 结果通过管道穿出
p1 = subprocess.Popen(['ls', '-l'], stdout=subprocess.PIPE, text=True)

# 第二个子进程:grep .py
p2 = subprocess.Popen(['grep', '.py'], stdin=p1.stdout, stdout=subprocess.PIPE, text=True)

# 关闭 p1 的 stdout,避免死锁
p1.stdout.close()

# 读取 p2 的输出
output, _ = p2.communicate()
print(output)

上面的大概等价于: ls -l | grep .py。当然 subprocess.PIPE 也更加灵活能够实现更加复杂的进程间通信。首先他能够作为标准流的重定向参数来作为子进程的输入和输出,其次允许父进程与子进程之间实时交互。只要一个进程的标准流被指定为 subprocess.PIPE 我们就可以通过 process.communicate() 来实现与子进程的通信:

Python
import subprocess

# 创建子进程,将 stdout 重定向到 PIPE
process = subprocess.Popen(['ls', '-l'], stdout=subprocess.PIPE, text=True)

# 读取子进程的输出
output, _ = process.communicate()
print(output)

# 创建子进程,将 stdin 重定向到 PIPE
process = subprocess.Popen(['grep', 'hello'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)

# 向子进程发送输入
output, _ = process.communicate(input='hello world\nhello python\n')
# 打印输出
print(output)

subprocesss.run

subprocess.run 是对 subprocess.Popen 的封装:

Python
def subprocess.run(
    args,
    stdin=None,
    stdout=None,
    stderr=None,
    shell=False,
    cwd=None,
    encoding=None,
    errors=None,
    text=None,
    env=None,
    capture_output=None, # 是否捕获 stdout stderr
    check=None,          # 检测返回非 0 退出是抛出 CalledProcessError 异常
    **other_popen_kwargs):
    pass

他们两个的区别:

特性 subprocess.run subprocess.Popen
执行方式 阻塞执行,等待命令完成 非阻塞执行,立即返回
返回值 返回 CompletedProcess 对象 返回 Popen 对象
易用性 更简单,适合简单场景 更灵活,适合复杂场景
输入输出控制 通过参数控制(如 capture_output) 通过 stdin、stdout、stderr 控制
异常处理 支持 check=True 自动抛出异常 需要手动检查返回码或异常
适用场景 简单的命令执行 需要与子进程交互或复杂控制的场景

Tips

他两个最核心的区别一个异步一个同步