十七、使用期物处理并发
期物指一种对象,表示异步执行的操作。
期物是 concurrent.futures 模块和 asyncio 包的重要组件,可是,作为这两个库的用户,我们有时却见不到期物,编写的代码没有直接使用。
期物封装待完成的操作,可以放入队列,完成的状态可以查询,得到结果(或抛出异常)后可以获取结果(或异常)。
我们要记住一件事:通常情况下自己不应该创建期物,而只能由并发框架(concurrent.futures 或 asyncio)实例化。原因很简单:期物表示终将发生的事情,而确定某件事会发生的唯一方式是执行的时间已经排定。因此,只有排定把某件事交给 concurrent.futures.Executor 子类处理时,才会创建 concurrent.futures.Future 实例。例如,Executor.submit() 方法的参数是一个可调用的对象,调用这个方法后会为传入的可调用对象排期,并返回一个期物。
客户端代码不应该改变期物的状态,并发框架在期物表示的延迟计算结束后会改变期物的状态,而我们无法控制计算何时结束。
这两种期物都有 .done() 方法,这个方法不阻塞,返回值是布尔值,指明期物链接的可调用对象是否已经执行。客户端代码通常不会询问期物是否运行结束,而是会等待通知。因此,两个 Future 类都有 .add_done_callback() 方法:这个方法只有一个参数,类型是可调用的对象,期物运行结束后会调用指定的可调用对象。
此外,还有 .result() 方法。在期物运行结束后调用的话,这个方法在两个 Future 类中的作用相同:返回可调用对象的结果,或者重新抛出执行可调用的对象时抛出的异常。可是,如果期物没有运行结束,result 方法在两个 Future 类中的行为相差很大。对 concurrency.futures.Future 实例来说,调用 f.result() 方法会阻塞调用方所在的线程,直到有结果可返回。此时,result 方法可以接收可选的 timeout 参数,如果在指定的时间内期物没有运行完毕,会抛出 TimeoutError 异常。读到 18.1.1 节你会发现,asyncio.Future.result 方法不支持设定超时时间,在那个库中获取期物的结果最好使用 yield from 结构。不过,对 concurrency.futures.Future 实例不能这么做。
这两个库中有几个函数会返回期物,其他函数则使用期物,以用户易于理解的方式实现自身。使用 17-3 中的 Executor.map 方法属于后者:返回值是一个迭代器,迭代器的__next__
方法调用各个期物的 result 方法,因此我们得到的是各个期物的结果,而非期物本身。
为了从实用的角度理解期物,我们可以使用 concurrent.futures.as_completed 函数,这个函数的参数是一个期物列表,返回值是一个迭代器,在期物运行结束后产出期物。
def download_many(cc_list):
cc_list = cc_list[:5]
with futures.ThreadPoolExecutor(max_workers=3) as executor:
to_do = []
for cc in sorted(cc_list):
# 排定可调用对象的执行时间,然后返回一个期物,表示这个待执行的操作
future = executor.submit(download_one, cc)
to_do.append(future) # 存储期物传给 as_complete 函数
results = []
for future in futures.as_completed(to_do): # as_complete 函数运行后,产出期物
res = future.result() # 获取期物结果
results.append(res)
return len(results)
"""
注意,在这个示例中调用 future.result() 方法绝不会阻塞,因为 future 由 as_completed 函数产出。
多次运行 flags_threadpool_ac.py 脚本,看到的结果有所不同。如果把 max_workers 参数的值增大到 5,结果的顺序变化更多。把 max_workers 参数的值设为 1,代码依序运行,结果的顺序始终与调用 submit 方法的顺序一致。
"""
$ python3 flags_threadpool_ac.py
Scheduled for BR: <Future at 0x100791518 state=running>
Scheduled for CN: <Future at 0x100791710 state=running>
Scheduled for ID: <Future at 0x100791a90 state=running>
Scheduled for IN: <Future at 0x101807080 state=pending>
Scheduled for US: <Future at 0x101807128 state=pending>
CN <Future at 0x100791710 state=finished returned str> result: 'CN'
BR ID <Future at 0x100791518 state=finished returned str> result: 'BR'
<Future at 0x100791a90 state=finished returned str> result: 'ID'
IN <Future at 0x101807080 state=finished returned str> result: 'IN'
US <Future at 0x101807128 state=finished returned str> result: 'US'
5 flags downloaded in 0.70s
单线程依次执行,多线程执行,异步模块执行
flags.py 脚本下载 20 个图像平均用时 7.18 秒。单线程
flags_threadpool.py 脚本平均用时 1.40 秒。多线程
flags_asyncio.py 脚本平均用时 1.35 秒。异步模块
Executor.map
from time import sleep, strftime
from concurrent import futures
def l(n):
sleep(5-n)
print(n)
return n
def main():
e = futures.ThreadPoolExecutor(max_workers=3)
res = e.map(l,range(3))
# print(res) # 生成器:<generator object Executor.map.<locals>.result_iterator at 0x000002165FC0A620>
# 2 print(list(res))
for i,r in enumerate(res):
print(i,r)
main()
# 1
2
1
0
0 0
1 1
2 2
# 2
2
1
0
[0, 1, 2]
# 结论:按顺序输出结果
阻塞型I/O 和 GIL
CPython 解释器本身就不是线程安全的,因此有全局解释器锁(GIL),一次只允许使用一个线程执行 Python 字节码。因此,一个 Python 进程通常不能同时使用多个 CPU 核心。
多线程:I/O 密集型
多进程:CPU 密集型
这个模块实现的是真正的并行计算,因为它使用 ProcessPoolExecutor 类把工作分配给多个 Python 进程处理。如果需要做 CPU 密集型处理,使用这个模块能绕开 GIL,利用所有可用的 CPU 核心。
对简单的用途来说,这两个实现 Executor 接口的类唯一值得注意的区别是,ThreadPoolExecutor.__init
__ 方法需要 max_workers
参数,指定线程池中线程的数量。在 ProcessPoolExecutor
类中,那个参数是可选的,而且大多数情况下不使用——默认值是 os.cpu_count()
函数返回的 CPU 数量。
使用 ProcessPoolExecutor
类派生 4 个工作的进程后(如果有 4 个 CPU 核心的话),性能可以提高两倍。
如果使用 PyPy 和 4 个职程,与使用 CPython 和 4 个职程相比,速度能提高 3.8 倍。以表 17-1 中使用 CPython 和一个职程的运行时间为基准,速度提升了 7.8 倍
如果使用 Python 处理 CPU 密集型工作,应该试试 PyPy(http://pypy.org)。使用 PyPy 运行 arcfour_futures.py 脚本,速度快了 3.8~5.1 倍;具体的倍数由职程的数量决定。
Executor.map 函数易于使用,不过有个特性可能有用,也可能没用,具体情况取决于需求:这个函数返回结果的顺序与调用开始的顺序一致。如果第一个调用生成结果用时 10秒,而其他调用只用 1 秒,代码会阻塞 10 秒,获取 map 方法返回的生成器产出的第一个结果。在此之后,获取后续结果时不会阻塞,因为后续的调用已经结束。如果必须等到获取所有结果后再处理,这种行为没问题;不过,通常更可取的方式是,不管提交的顺序,只要有结果就获取。为此,要把 Executor.submit 方法和 futures.as_completed 函数结合起来使用。
executor.submit 和 futures.as_completed 这个组合比 executor.map 更灵活,因为 submit 方法能处理不同的可调用对象和参数,而 executor.map 只能处理参数不同的同一个可调用对象。此外,传给 futures.as_completed 函数的期物集合可以来自多个 Executor 实例,例如一些由 ThreadPoolExecutor 实例创建,另一些由 ProcessPoolExecutor 实例创建。
tqdm 包:进度条
为了计算预计剩余时间,tqdm 函数要获取一个能使用 len 函数确定大小的可迭代对象,或者在第二个参数中指定预期的元素数量。
In [17]: import time
In [18]: from tqdm import tqdm
In [19]: for i in tqdm(range(1000)):
...: time.sleep(.01)
...:
32%|█████████████████████████▎ | 320/1000
浅拷贝
浅拷贝列表,删除原列表所有元素,现有列表仍然引用着这些元素,原列表为空,但是现有列表完好无损。
多线程和多进程的替代方案
Python 自 0.9.8 版(1993 年)就支持线程了,concurrent.futures 只不过是使用线程的最新方式。Python 3 废弃了原来的 thread 模块,换成了高级的 threading 模块。 如果 futures.ThreadPoolExecutor 类对某个作业来说不够灵活,可能要使用 threading 模块中的组件(如 Thread、Lock、Semaphore 等)自行制定方案,比如说使用 queue 模块创建线程安全的队列,在线程之间传递数据。futures.ThreadPoolExecutor 类已经封装了这些组件。threading 模块自 Python 1.5.1(1998 年)就已存在,不过有些人仍然继续使用旧的 thread 模块。Python 3 把thread 模块重命名为 _thread,以此强调这是低层实现,不应该在应用代码中使用。
对 CPU 密集型工作来说,要启动多个进程,规避 GIL。创建多个进程最简单的方式是,使用 futures.ProcessPoolExecutor 类。不过和前面一样,如果使用场景较复杂,需要更高级的工具。multiprocessing 模块的 API 与 threading 模块相仿,不过作业交给多个进程处理。对简单的程序来说,可以用 multiprocessing 模块代替 threading 模块,少量改动即可。不过,multiprocessing 模块还能解决协作进程遇到的最大挑战:在进程之间传递数据。
GIL 简化了 CPython 解释器和 C 语言扩展的实现。得益于 GIL,Python 有很多 C 语言扩展——这绝对是如今 Python 如此受欢迎的主要原因之一。
多年以来,我一直觉得 GIL 导致 Python 线程几乎没有用武之地,只能开发一些玩具应用。直到发现标准库中每一个阻塞型 I/O 函数都会释放 GIL 之后,我才意识到Python 线程特别适合在 I/O 密集型系统(鉴于我的工作经验,客户经常付费让我开发这种应用)中使用。