在 Python 中启动外部进程

认为将您的 Python 程序连接到 UNIX shell 很复杂?再想想!

在之前的文章中,我研究了 Python 中通过线程实现的并发(请参阅“并发思考:现代网络应用程序如何处理多个连接”“Python 中的线程”)。线程的好处是它们相对容易使用,并且可以让您在线程之间共享数据,而不会遇到太多麻烦。坏消息是,如果您不小心,您可能会遇到严重的问题——因为数据没有共享,而且 Python 数据结构不是线程安全的。但也许更大的问题是 Python 的全局解释器锁 (GIL) 保证一次只有一个线程运行。

在许多情况下,这实际上不是问题。特别是,如果您正在编写与文件系统或网络交互的程序,您可能不会太感受到 Python 线程的痛苦。这是因为,虽然一次只有一个线程运行,但每当线程使用 I/O 时,它都会放弃对 CPU 的控制。这是因为磁盘和网络比 CPU 慢得多;当您等待文件系统为您提供您请求的数据时,另一个线程可以正在运行。

也就是说,确实有一些时候 Python 的线程会显示出其局限性。特别是,如果您正在编写 CPU 密集型代码——也就是说,CPU 是瓶颈——您会发现线程是有限的。毕竟,如果您有一台不错的 48 核机器可以玩,难道只让其中一个核心实际做一些事情看起来很傻吗?

当然,有一个解决方案可以解决这些问题——许多传统的 UNIX 用户认为在许多情况下,这个方案更优越:进程。与其在新线程中运行函数,不如在新进程中运行它!

因此,在本文中,我初步了解如何在 Python 中使用进程来完成一项非常常见的任务:调用外部命令。在这样做时,我还将介绍如何构建进程的工作方式,从而引出我的下一篇文章的主题:“multiprocessing”模块。

进程基础知识

对于 Linux 用户来说,没有什么比进程更基本和日常的了。当我启动 Emacs 时,我启动了一个进程。当我启动 Apache HTTP 服务器时,我启动了一个进程,然后它启动了多个额外的进程。当我在命令行调用 ls 时,我正在启动一个进程。当我告诉我的电脑关机时,它通过杀死每个进程来完成关机。

将进程视为一个数据结构,它代表了计算机在特定时刻的状态。一个进程有正在运行的代码(包括尚未运行的代码);它有程序工作的数据;它可以访问内存来存储和检索额外的数据,并且它可以与外部设备对话,从文件系统和网络到键盘和屏幕。

一台 Linux 机器可以同时运行许多许多进程。在进程运行的短暂瞬间,它会产生完全控制计算机的错觉。这要归功于现代计算机速度如此之快,以至于您可以运行如此多的进程,并且它们都看起来是并发运行的。没错,现代计算机有多个 CPU(也称为“核心”),这使您可以将工作分配到这些核心之间。

在 Python 中有很多种启动进程的方法。在现代版本的语言中,您可以使用“subprocess”模块来启动进程,甚至检索结果。例如,您可以在新进程中调用 ls 程序,然后查看结果


>>> subprocess.check_output('ls')

 

从这个函数中,您会得到一个包含 ls 命令输出的字符串。这是一个非常难看的字符串,特别是如果您习惯于看到整齐打印的东西。在这种情况下,您不想查看返回的字符串,而是要打印它。问题是,这似乎不起作用,至少在 Python 3 中不起作用


>>> print(subprocess.check_output('ls'))

 

问题在于,默认情况下,subprocess.check_output 返回一个“字节串”,类似于 Python 2 字符串,因为它包含一个字节序列,而不是一个 Unicode 字符序列。这里的问题是,当您打印字节串时,Python 实际上并没有在新行看到 \n 时换行。

您可以通过告诉 Python 自由地解释换行符并返回字符串而不是字节串来解决这个问题


>>> print(subprocess.check_output('ls', universal_newlines=True))

 

这似乎效果很好。但是,如果您只想打印当前目录中文件的子集呢?很自然地会想说,例如,ls -l。让我们试试看


>>> print(subprocess.check_output('ls -l', universal_newlines=True))

 

当您这样做时,您会得到


Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/Cellar/python3/3.6.2/Frameworks/
↪Python.framework/Versions/3.6/lib/python3.6/subprocess.py",
 ↪line 336, in check_output
    **kwargs).stdout
  File "/usr/local/Cellar/python3/3.6.2/Frameworks/
↪Python.framework/Versions/3.6/lib/python3.6/subprocess.py",
 ↪line 403, in run
    with Popen(*popenargs, **kwargs) as process:
  File "/usr/local/Cellar/python3/3.6.2/Frameworks/
↪Python.framework/Versions/3.6/lib/python3.6/subprocess.py",
 ↪line 707, in __init__
    restore_signals, start_new_session)
  File "/usr/local/Cellar/python3/3.6.2/Frameworks/
↪Python.framework/Versions/3.6/lib/python3.6/subprocess.py",
 ↪line 1333, in _execute_child
    raise child_exception_type(errno_num, err_msg)
FileNotFoundError: [Errno 2] No such file or directory: 'ls -l'

 

这里有什么问题?很简单,Python 正在尝试运行一个外部进程,并给它 Linux 命令 ls -l。您可能会认为这是正常且合理的,因为运行 ls -l 是您在日常生活中很可能经常做的事情。但是请记住,ls 是命令,而 -l 是该命令的标志。您可以理解其中的区别,并且 shell 通常会为您分隔它们。但是,如果您只是将该命令名称交给 Linux,它会感到困惑并抱怨。

因此,您需要传递字符串列表,而不是传递单个字符串,其中每个字符串代表命令的“单词”。例如


>>> print(subprocess.check_output(['ls', '-l'], universal_newlines=True))

 

这可以正常工作。您可以添加其他参数,包括文件名


>>> print(subprocess.check_output(['ls', '-l', 'urls.txt'],
>>> universal_newlines=True))

 

如果您想获取所有“.txt”文件的长列表呢?只需尝试这样做


>>> print(subprocess.check_output(['ls', '-l', *.txt'],
 ↪universal_newlines=True))

>>> print(subprocess.check_output(['ls', '-l', '*.txt'],
>>> universal_newlines=True))
ls: cannot access '*.txt': No such file or directory
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/Cellar/python3/3.6.2/Frameworks/
↪Python.framework/Versions/3.6/lib/python3.6/subprocess.py",
 ↪line 336, in check_output
    **kwargs).stdout
  File "/usr/local/Cellar/python3/3.6.2/Frameworks/
↪Python.framework/Versions/3.6/lib/python3.6/subprocess.py",
 ↪line 418, in run
    output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['ls', '-l', '*.txt']'
 ↪returned non-zero exit status 2.

 

它抱怨说“*.txt”不是合法的文件。这是因为,虽然您可能认为 Linux 总是知道 * 代表目录中的所有文件,但事实并非如此——是 shell 执行了对诸如“*”之类的字符的解释,将其分解,然后传递给底层操作系统。

那么,如何列出所有带有“*.txt”后缀的文件呢?您可以再次调用相同的调用,但告诉 Python 通过 UNIX shell 传递参数


>>> print(subprocess.check_output(['ls', '-l', '*.txt'],
                                  shell=True,
                                  universal_newlines=True))

 

啊哈!现在看来工作正常。

那么,这里发生了什么?这启动了一个新进程(如果愿意,可以称为“子进程”),并在该进程中执行了一个 UNIX 程序。该程序返回了一些文本,Python 捕获了该文本,然后将其打印出来。

Python 文档明确指出,在您对 subprocess.check_output (和其他函数)的调用中包含 shell=True 存在潜在的安全风险。如果您从未知或不受信任的用户那里获取输入,则该人可以将任意命令插入到运行 check_output 的系统中。在使用 shell=True 之前,请务必考虑其安全隐患。

更普遍而言

subprocess.check_output 是一个特定的函数,旨在运行程序并检索其输出。如果您想要更大的灵活性,您可以运行“subprocess”中的其他函数。

例如,假设您想从 ls 获取输出并将其放入文件中。在 UNIX 命令行上,您可以说


ls -l > file-list.txt

 

在 Python 中,这有点复杂,但如果您使用 subprocess.run,则不会太复杂。此函数是新的(自 Python 3.5 起),但它使生活变得更轻松。

您可以尝试这样做


>>> subprocess.run(['/bin/ls', '-l'], universal_newlines=True)

 

如您所见,subprocess.run 接受许多与 subprocess.check_output 类似的参数。但不同之处在于,即使将 universal_newlines 设置为 True,它也不会返回字符串。相反,它返回 subprocess.CompletedProcess 的实例,其中包含有关运行进程的各种信息。

您可以获取它,然后查看 CompletedProcess 包含什么


>>> cp = subprocess.run(['/bin/ls', '-l'], universal_newlines=True)
>>> vars(cp)

 

您将得到返回


{'args': ['/bin/ls', '-l'], 'returncode': 0, 'stderr': None,
'stdout': None}

 

嗯,这可能不太符合您的期望。args 很好,并且 returncode 准确地显示为 0,这意味着一切都正常结束。但是输出发生了什么?答案是,当涉及到 subprocess.run 时,您需要指示输出应该去哪里。

指示您想要获取某些内容的方式是将 subprocess.PIPE 作为 stdout 关键字参数的值传递


>>> cp = subprocess.run(['/bin/ls', '-l'], stdout=subprocess.PIPE,
>>> universal_newlines=True)
>>> vars(cp)

 

您现在将获得以下内容


{'args': ['/bin/ls', '-l'],
 'returncode': 0,
 'stderr': None,
 'stdout': 'total 344\ndrwxr-xr-x  1454 reuven  staff  49436
   ↪Sep 17 09:29 Archive\ndrwxr-xr-x    37 reuven  staff   1

 

我什至不打算向您展示其余部分,因为它太长了,但是 stdout 值完全正确。

您还可以将 stderr 分配给 subprocess.PIPE 以接收它。请注意,对于 stdout 和 stderr,您可以分配的不仅仅是 subprocess.PIPE,它允许您获取和处理程序的输出,还可以分配一个打开的(可写)文件对象。这意味着您可以调用外部进程并将其输出放入任意文件中。我认为,在大多数情况下,您在 Python 中执行外部进程的原因是您想对文本做一些事情,但这也可以工作。

您可能想知道您是否不仅可以写入 stderr 和 stdout,还可以从 stdin 读取。答案是肯定的。只需提供一个文件对象,subprocess.run 将完成其余的工作。例如


>>> cp = subprocess.run(['/bin/cat', '-n'], stdin=open('/etc/passwd'),
                    stdout=subprocess.PIPE, universal_newlines=True)

 

在这种情况下,您使用 -n 选项运行 /bin/cat,对文件的行进行编号。输入文件是什么?/etc/passwd。输出去哪里?到您的 subprocess.PIPE 对象,这是一种与外部进程通信的通道。

对我来说,最有趣的是 CompletedProcess 对象 (cp),您可以从中获取有关已完成进程的不同信息。请注意,subprocess.run 仅在外部程序完成运行后才会返回,此时将设置 cp 变量。从那里,您可以获取 stdout,它通常是字节串,但如果您将 universal_newlines 设置为 True,则它是实际的(Unicode)字符串。

结论

您现在已经了解了如何使用“subprocess”模块与外部进程通信。但让我们面对现实。这并没有完全解决最初的问题:分解问题并使用不同的进程来处理它。相反,这在某种程度上展示了 Python 如何与进程一起工作,以及它与进程通信的基本方式,使用字节串和管道。这是因为进程是独立的,不能简单地与主线程共享变量,而这正是您在使用线程时所做的事情。

Reuven Lerner 在世界各地的公司教授 Python、数据科学和 Git。您可以订阅他的免费每周“更好的开发者”电子邮件列表,并从他的书籍和课程中学习:http://lerner.co.il。Reuven 与他的妻子和孩子住在以色列的莫迪因。

加载 Disqus 评论