尽管有 GIL 限制,线程在某些情况下确实很有用。例如以下情况:
• 构建响应式界面。
• 委派工作。
• 构建多用户应用程序。
构建响应式界面
假设你要求系统通过图形用户界面将文件从文件夹复制到另一个文件夹。任务可能被
推入到后台,并且界面窗口将不断地被主线程刷新。这样,你可以获得有关整个过程进度
的实时反馈。你也可以取消操作。这比起在所有工作完成之前不提供任何反馈的原始 cp
或 copy shell 命令更加友好。
响应式界面还允许用户同时处理多个任务。例如,在使用 Gimp 时,你可以浏览一张
图片,同时对另一张图片进行过滤,因为两个任务是独立的。
当试图实现这样的响应式界面时,一种好的方法是尝试将长时间运行的任务推入到后台,
或者至少尝试经常向用户提供反馈。实现的最简单的方法是使用线程。在这种场景下,它们不是
为了提高性能,而是旨在确保用户仍然可以操作界面,即使它需要在较长时间段内处理一些数据。
在后台任务执行大量的 I/O 操作的情况下,你仍然可以从多核 CPU 中受益。于是,这
是一个双赢的局面。
委派工作
如果你的进程依赖第三方资源,线程可能真的加快了一切。
让我们考虑这样一种情况,一个函数索引文件夹中的文件并将构建的索引推送到数据
库中。根据文件的类型,函数调用不同的外部程序。例如,一个专门用于 PDF,另一个专
用于 OpenOffice 文件。
通过执行正确的程序,然后将结果存储到数据库中,函数可以为每个转换器设置一个
线程,并通过队列向每个转换器推送作业,而不是按顺序处理每个文件。函数所花费的总
时间将更接近于最慢转换器的处理时间,而不是所有工作的总和。
可以在一开始就初始化转换器线程,负责将结果推送到数据库的代码也可以是一个线
程,它会消费队列中可用的结果。
注意,这种方法在某种程度上是多线程和多进程之间的混合。如果将工作委派给外部
进程,例如使用 subprocess 模块中的 run()函数,实际上这是在多个进程中进行工作,
因此存在多进程的症状。但在我们的场景中,我们正在等待隔离的线程中的处理结果,所
以从 Python 代码的角度来看,这仍然是多线程。
线程的另一个常见使用实例是对外部服务执行多个 HTTP 请求。例如,如果你要从远
程 Web API 获取多个结果,那么同步执行可能需要花费很多时间。如果你在发出新请求之
前要等待每个之前的响应,那么你将花费大量时间等待外部服务响应,并且将向每个此类
请求添加额外的往返时间延迟。如果你正在与高效的服务(例如,Google Maps API)进行
通信,则很有可能它会同时处理大部分请求,而不会影响各自请求的响应时间。因此,在
不同的线程中执行多个查询是合理的。记住,当发起一个 HTTP 请求时,大多数时间花在
从 TCP 套接字读取。这是一个阻塞 I/O 操作,因此 CPython 会在执行 recv()C 函数时释
放 GIL。这可以大大提高应用程序的性能。
多用户应用
线程也可以作为多用户应用程序的并发基础。例如,Web 服务器会将用户请求推送到
一个新线程,然后它又进入空闲状态,等待新的请求。使用专用线程处理每个请求简化了
大量的工作,但开发人员需要负责锁定资源。但考虑到并发事务,当所有的共享数据被推
送到一个关系数据库中,这不是一个问题。因此,多用户应用程序中的线程几乎像隔离的
独立进程。它们在同一个进程下,只是简化在应用程序级别的管理。
例如,Web 服务器会将所有请求放入队列中,并等待线程可用后,再将请求发送给线
程进行处理。此外,它允许内存共享,这可以提高一些工作并且减少内存负载。两个非常
受欢迎的 Python WSGI 兼容的网络服务器:Gunicorn(参考 http://gunicorn.org/)和 uWSGI(参考 https://uwsgi-docs.readthedocs.org),它们使用工作线程处理 HTTP 请求,这些工作线
程通常遵循上述原则。
在多用户应用程序中,通过多线程启用并发要比使用多进程的代价要小。单独的进程需要
更多的资源,因为需要为每一个进程加载新的解释器。另一方面,启用太多线程也是很昂贵的。
我们知道 GIL 对于 I/O 密集型的应用程序不会有这样的问题,总是会有一个时间,你需要执行
Python 代码。由于你无法使用裸线程并行化所有应用程序部件,因此你将永远无法利用具有多
核 CPU 和单个 Python 进程的机器上的所有资源。所以,通常最优解是多进程和多线程的混合,
即多线程运行的多个工作进程。幸运的是,许多兼容 WSGI 的 Web 服务器允许这样的设置。
但是在多线程与多进程混合使用之前,结合付出的代价,请考虑这种方法是否真的值
得。这种方法使用多进程来获得更好的资源利用率,另外还有更多并发性的多线程,这应
该比运行多个进程更轻。但也可能不需要这样做。也许摆脱线程并且增加进程的数量不是
你想象的那么昂贵?当选择最佳设置时,你总是需要对应用程序进行负载测试(参见第 10
章)。此外,使用多线程的会有副作用,环境会不太安全,其中共享内存会有数据损坏或致
命的死锁的风险。也许,使用一些异步方法会是一个更好的选择,这种方法通常基于事件
循环,轻量级线程或协程。稍后我们将在异步编程部分讨论这些解决方案。此外,没有合
理的负载测试和实验,你无法知道什么方法在你的上下文是最有效的。
一个多线程应用的例子
为了解 Python 线程在实践中的工作原理,让我们构建一个示例应用程序,它可以从多
线程实现中受益。我们将会讨论一个在你的专业实践中可能会遇到的简单问题即进行多个
并行 HTTP 查询。我们已经提到过这个问题,这是一个多线程的常见用例。
假设我们需要使用多个查询从一些 Web 服务获取数据,这些查询无法通过单个大型
HTTP 请求批量处理。作为一个现实例子,我们将使用来自 Google Maps API 的地理编码端
点,选择的理由如下。
• 它是非常受欢迎并且文档充分的服务。
• 有一个该 API 的自由层,不需要任何验证密钥。
• PyPI 上有一个 python-gmaps 包,使用它你可以与各种 Google Maps API 端点进
行交互,并且非常易于使用。
地理编码的意思是简单地将地址或地点转换成坐标。我们将尝试将预定义的城市列表
编码为纬度/经度元组,并使用 python-gmaps 在标准输出中显示结果。它很简单,如下
面的代码所示:
from gmaps import Geocoding
api = Geocoding()geocoded = api.geocode(‘Warsaw’)[0]
print(“{:>25s}, {:6.2f}, {:6.2f}”.format(
… geocoded[‘formatted_address’],
… geocoded[‘geometry’][‘location’][‘lat’],
… geocoded[‘geometry’][‘location’][‘lng’],
… ))
Warsaw, Poland, 52.23, 21.01
因为我们的目标是展示对于并发问题的多线程解决方案与标准同步解决方案的比较,
我们将从一个不使用线程的实现开始。下面是一个程序的代码,它遍历城市列表,查询
Google Maps API,并在文本中以表格的形式显示有关其地址和坐标的信息:
import time
from gmaps import Geocoding
api = Geocoding()
PLACES = (
‘Reykjavik’, ‘Vien’, ‘Zadar’, ‘Venice’,
‘Wrocław’, ‘Bolognia’, ‘Berlin’, ‘Słubice’,
‘New York’, ‘Dehli’,
)
def fetch_place(place):
geocoded = api.geocode(place)[0]
print(“{:>25s}, {:6.2f}, {:6.2f}”.format(
geocoded[‘formatted_address’],
geocoded[‘geometry’][‘location’][‘lat’],
geocoded[‘geometry’][‘location’][‘lng’],
))
def main():
for place in PLACES:
fetch_place(place)
if name == “main”:
started = time.time()
main()
elapsed = time.time() - started
print()
print(“time elapsed: {:.2f}s”.format(elapsed))
在执行 main()函数的前后,我们添加了一些语句,用于测量完成作业所需的时间。
在我的计算机上,此程序通常需要 2~3 秒来完成其任务,如下所示:
$ python3 synchronous.py
Reykjavík, Iceland, 64.13, -21.82
Vienna, Austria, 48.21, 16.37
Zadar, Croatia, 44.12, 15.23
Venice, Italy, 45.44, 12.32
Wrocław, Poland, 51.11, 17.04
Bologna, Italy, 44.49, 11.34
Berlin, Germany, 52.52, 13.40
Slubice, Poland, 52.35, 14.56
New York, NY, USA, 40.71, -74.01
Dehli, Gujarat, India, 21.57, 73.22
time elapsed: 2.79s
每一项使用一个线程
现在对实现做一些改进。我们不在 Python 中做很多处理,长执行时间是由与外部服务
的通信引起的。我们向服务器发送一个 HTTP 请求,它计算答案,然后我们等待,直到响
应被传回。这里涉及到很多 I/O,所以多线程看起来像一个可行的选择。 我们可以在不同
的线程中立即发起所有请求,然后等待,直到它们接收数据。如果我们正在通信的服务能
够同时处理我们的请求,那么我们肯定会看到性能提高。
所以,让我们从最简单的方法开始。Python 中的 threading 模块在系统线程之上提
供了干净,易于使用的抽象。这个标准库的核心是 Thread 类,它代表一个单独的线程实例。
以下是一个修改版本的 main()函数,它为每个地方创建并启动一个新的线程进行地理编
码的处理,然后等待直到所有线程完成:
from threading import Thread
def main():
threads = []
for place in PLACES:
thread = Thread(target=fetch_place, args=[place])
thread.start()
threads.append(thread)
while threads:
threads.pop().join()
这是一个应急的修改,有一些严重的问题,随后我们将尝试解决。它以一种轻率的方
式处理这个问题,而不是一种编写可靠的软件的方式,毕竟软件要为成千上万的用户服务。
但是,它可以工作如下所示:
$ python3 threaded.py
Wrocław, Poland, 51.11, 17.04
Vienna, Austria, 48.21, 16.37
Dehli, Gujarat, India, 21.57, 73.22
New York, NY, USA, 40.71, -74.01
Bologna, Italy, 44.49, 11.34
Reykjavík, Iceland, 64.13, -21.82
Zadar, Croatia, 44.12, 15.23
Berlin, Germany, 52.52, 13.40
Slubice, Poland, 52.35, 14.56
Venice, Italy, 45.44, 12.32
time elapsed: 1.05s
因此,我们认识到线程对我们的应用程序的影响是有益的,此时,应该以一种合情合
理的方式使用它们。首先,我们需要认清上面代码中的问题:
我们为每个参数启动一个新的线程。线程初始化也需要一些时间,但这个小的开销不是唯
一的问题。线程也消耗其他资源,如内存和文件描述符。我们的示例输入有一个严格定义的项
目数,如果没有呢?你肯定不想运行未绑定数量的线程,它依赖于任意大小的数据输入。
在线程中执行的 fetch_place()函数调用内置的 print()函数,在实践中,你不可能在
主应用程序线程之外执行它。首先,这应归于标准输出在 Python 中是如何缓冲的。当对此函数的多个调用在线程之间交错时,你可能会遇到格式不正确的输出。此外,print()函数被认为
是慢的。如果在多线程中毫无顾忌的使用它,中可能会导致串行化,这将撤消多线程的所有好处。
最后但同样重要的是,通过将每个函数调用委托给一个单独的线程,我们将难以控制
处理输入的速率。是的,我们希望尽可能快地完成这项工作,但外部服务往往对来自单个
客户端的请求的速率会有严格的限制。有时,以一种能够限制处理速度的方式设计程序是
合理的,因此你的应用程序不会被外部 API 列入滥用其使用限制的黑名单。