抨击线程的往往是系统程序员,他们考虑的使用场景对一般的应用程序员来说,也许一生都不会遇到……应用程序员遇到的使用场景,99% 的情况下只需知道如何派生一堆独立的线程,然后用队列收集结果。
示例:网络下载的三种风格
为了高效处理网络 I/O,需要使用并发,因为网络有很高的延迟,所以为了不浪费 CPU 周期去等待,最好在收到网络响应之前做些其他的事。
为了通过代码说明这一点,我写了三个示例程序,从网上下载 20 个国家的国旗图像。第一个示例程序 flags.py 是依序下载的:下载完一个图像,并将其保存在硬盘中之后,才请求下一个图像。另外两个脚本是并发下载的:几乎同时请求所有图像,每下载完一个文件就保存一个文件。flags_threadpool.py 脚本使用 concurrent.futures 模块,而flags_asyncio.py 脚本使用 asyncio 包。
运行 flags.py、flags_threadpool.py 和 flags_asyncio.py 脚本得到的结果
$ python3 flags.py BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN ➊ 每次运行脚本后,首先显示下载过程中下载完毕的国家代码,最后显示一个消息,说明耗时 20 flags downloaded in 7.26s ➋ flags.py 脚本下载 20 个图像平均用时 7.18 秒 $ python3 flags.py BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN 20 flags downloaded in 7.20s $ python3 flags.py BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN 20 flags downloaded in 7.09s $ python3 flags_threadpool.py DE BD CN JP ID EG NG BR RU CD IR MX US PH FR PK VN IN ET TR 20 flags downloaded in 1.37s ➌ flags_threadpool.py 脚本平均用时 1.40 秒 $ python3 flags_threadpool.py EG BR FR IN BD JP DE RU PK PH CD MX ID US NG TR CN VN ET IR 20 flags downloaded in 1.60s $ python3 flags_threadpool.py BD DE EG CN ID RU IN VN ET MX FR CD NG US JP TR PK BR IR PH 20 flags downloaded in 1.22s $ python3 flags_asyncio.py ➍ flags_asyncio.py 脚本平均用时 1.35 秒 BD BR IN ID TR DE CN US IR PK PH FR RU NG VN ET MX EG JP CD 20 flags downloaded in 1.36s $ python3 flags_asyncio.py RU CN BR IN FR BD TR EG VN IR PH CD ET ID NG DE JP PK MX US 20 flags downloaded in 1.27s $ python3 flags_asyncio.py RU IN ID DE BR VN PK MX US IR ET EG NG BD FR CN JP PH CD TR ➎ 注意国家代码的顺序:对并发下载的脚本来说,每次下载的顺序都不同 20 flags downloaded in 1.42s
两个并发下载的脚本之间性能差异不大,不过都比依序下载的脚本快 5倍多。这只是一个特别小的任务,如果把下载的文件数量增加到几百个,并发下载的脚本能比依序下载的脚本快 20 倍或更多。
依序下载的脚本
🌰 flags.py:依序下载的脚本;另外两个脚本会重用其中几个函数
1 import os 2 import sys 3 import time 4 5 import requests 6 7 POP20_CC = ('CN IN US ID BR PK NG BD RU JP ' 8 'MX PH VN ET EG DE IR TR CD FR').split() 9 10 BASE_URL = 'http://flupy.org/data/flags' # 下载国旗网站的入口 11 12 DEST_DIR = 'downloads/' 13 14 os.mkdir(DEST_DIR) if not os.path.exists(DEST_DIR) else 'ok' #判断目录是否存在,不存在在就创建 15 16 17 def save_flag(img, filename): # 保存图片的函数,传递img的二进制流和还有国旗的名称 18 path = os.path.join(DEST_DIR, filename) 19 with open(path, 'wb') as fp: 20 fp.write(img) 21 22 23 def get_flag(cc): # 获取国旗的下载地址,通过requests中的content获取二进制流 24 url = '{}/{cc}/{cc}.gif'.format(BASE_URL, cc=cc.lower()) 25 resp = requests.get(url) 26 return resp.content 27 28 29 def show(text): # 显示一个字符串,然后刷新sys.stdout 30 print(text, end=' ') 31 sys.stdout.flush() 32 33 34 def download_many(cc_list): # download_many 是与并发实现比较的关键函数 35 for cc in sorted(cc_list): 36 image = get_flag(cc) 37 show(cc) 38 save_flag(image, cc.lower() + '.gif') 39 40 return len(cc_list) 41 42 43 def main(download_many): # main 函数记录并报告运行 download_many 函数之后的耗时 44 t0 = time.time() 45 count = download_many(POP20_CC) 46 elapsed = time.time() - t0 47 msg = '\n{} flags downloaded in {:.2f}s' 48 print(msg.format(count, elapsed)) 49 50 51 if __name__ == "__main__": 52 main(download_many)
使用concurrent.futures模块下载
concurrent.futures 模块的主要特色是 ThreadPoolExecutor 和ProcessPoolExecutor 类,这两个类实现的接口能分别在不同的线程或进程中执行可调用的对象。这两个类在内部维护着一个工作线程或进程池,以及要执行的任务队列。不过,这个接口抽象的层级很高,像下载国旗这种简单的案例,无需关心任何实现细节。
🌰 flags_threadpool.py:使用futures.ThreadPoolExecutor 类实现多线程下载的脚本
1 from concurrent import futures 2 3 from flags import save_flag, get_flag, show, main 4 5 6 MAX_WORKERS = 20 # 设定 ThreadPoolExecutor 类最多使用几个线程 7 8 9 def download_one(cc): # 下载一个图像的函数;这是在各个线程中执行的函数 10 image = get_flag(cc) 11 show(cc) 12 save_flag(image, cc.lower() + '.gif') 13 return cc 14 15 16 def download_many(cc_list): 17 workers = min(MAX_WORKERS, len(cc_list)) # 设定工作的线程数量:使用允许的最大值(MAX_WORKERS)与要 18 # 处理的数量之间较小的那个值,以免创建多余的线程。 19 with futures.ThreadPoolExecutor(workers) as executor: # 使用工作的线程数实例化 ThreadPoolExecutor类 20 res = executor.map(download_one, sorted(cc_list)) # map 方法的作用与内置的 map 函数类似,不过 download_one 函数会在 21 # 多个线程中并发调用;map 方法返回一个生成器,因此可以迭代,获取各个函数返回的值。 22 return len(list(res)) #返回获取的结果数量;如果有线程抛出异常,异常会在这里抛出 23 24 if __name__ == "__main__": 25 main(download_many)
期物在哪里
期物是 concurrent.futures 模块和 asyncio 包的重要组件,从 Python 3.4 起,标准库中有两个名为 Future 的类:concurrent.futures.Future 和 asyncio.Future。这两个类的作用相同:两个 Future 类的实例都表示可能已经完成或者尚未完成的延迟计算。这与 Twisted 引擎中的 Deferred 类、Tornado 框架中的Future 类,以及多个 JavaScript 库中的 Promise 对象类似。
期物封装待完成的操作,可以放入队列,完成的状态可以查询,得到结果(或抛出异常)后可以获取结果(或异常)。
我们要记住一件事:通常情况下自己不应该创建期物,而只能由并发框架(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 异常。
为了使用 futures.as_completed 函数,只需修改 download_many 函数,把较抽象的 executor.map 调用换成两个 for 循环:一个用于创建并排定期物,另一个用于获取期物的结果。同时,我们会添加几个print 调用,显示运行结束前后的期物。修改后的 download_many 函数如示例。
🌰 flags_threadpool_ac.py:把download_many 函数中的executor.map 方法换成 executor.submit 方法和futures.as_completed 函数
1 from concurrent import futures 2 3 from flags_threadpool import download_one, main 4 5 def download_many(cc_list): 6 cc_list = cc_list[:5] # 这次演示只使用人口最多的 5 个国家 7 with futures.ThreadPoolExecutor(max_workers=3) as executor: # 把 max_workers 硬编码为 3,以便在输出中观察待完成的期物 8 to_do = [] 9 for cc in sorted(cc_list): # 按照字母表顺序迭代国家代码,明确表明输出的顺序与输入一致 10 future = executor.submit(download_one, cc) # executor.submit 方法排定可调用对象的执行时间, 11 # 然后返回一个期物,表示这个待执行的操作。 12 to_do.append(future) # 存储各个期物,后面传给 as_completed 函数 13 msg = 'Scheduled for {}: {}' 14 print(msg.format(cc, future)) # 显示一个消息,包含国家代码和对应的期物 15 16 results = [] 17 for future in futures.as_completed(to_do): # as_completed 函数在期物运行结束后产出期物 18 res = future.result() # 获取该期物的结果 19 msg = '{} result: {!r}' 20 print(msg.format(future, res)) # 显示期物及其结果 21 results.append(res) 22 23 return len(results) 24 25 if __name__ == "__main__": 26 main(download_many)
flags_threadpool_ac.py 脚本的输出
$ python3 flags_threadpool_ac.py Scheduled for BR: <Future at 0x100791518 state=running> ➊ 排定的期物按字母表排序;期物的repr()方法会显示期物的状态前三个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> ➋ 后两个期物的状态是pending,等待有线程可用 Scheduled for US: <Future at 0x101807128 state=pending> CN <Future at 0x100791710 state=finished returned str> result: 'CN' ➌ 这一行里的第一个CN是运行在一个工作线程中的download_one函数里输出的,随后的内容是download_many函数输出的 BR ID <Future at 0x100791518 state=finished returned str> result: 'BR' ➍ 这里有两个线程输出国家代码,然后主线程中有download_many函数输出第一个线程的结果 <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
阻塞型I/O和GIL
CPython 解释器本身就不是线程安全的,因此有全局解释器锁(GIL),一次只允许使用一个线程执行 Python 字节码。因此,一个 Python 进程通常不能同时使用多个 CPU 核心。
编写 Python 代码时无法控制 GIL;不过,执行耗时的任务时,可以使用一个内置的函数或一个使用 C 语言编写的扩展释放 GIL。其实,有个使用 C 语言编写的 Python 库能管理 GIL,自行启动操作系统线程,利用全部可用的 CPU 核心。这样做会极大地增加库代码的复杂度,因此大多数库的作者都不这么做。
然而,标准库中所有执行阻塞型 I/O 操作的函数,在等待操作系统返回结果时都会释放 GIL。这意味着在 Python 语言这个层次上可以使用多线程,而 I/O 密集型 Python 程序能从中受益:一个 Python 线程等待网络响应时,阻塞型 I/O 函数会释放 GIL,再运行一个线程。
使用concurrent.futures模块启动进程
concurrent.futures 模块的文档(https://docs.python.org/3/library/concurrent.futures.html)副标题是“Launching parallel tasks”(执行并行任务)。这个模块实现的是真正的并行计算,因为它使用 ProcessPoolExecutor 类把工作分配给多个Python 进程处理。因此,如果需要做 CPU 密集型处理,使用这个模块能绕开 GIL,利用所有可用的 CPU 核心。
ProcessPoolExecutor 和 ThreadPoolExecutor 类都实现了通用的Executor 接口,因此使用 concurrent.futures 模块能特别轻松地把基于线程的方案转成基于进程的方案。
下载国旗的示例或其他 I/O 密集型作业使用 ProcessPoolExecutor 类得不到任何好处。这一点易于验证,只需把下面 🌰 中这几行:
def download_many(cc_list): workers = min(MAX_WORKERS, len(cc_list)) with futures.ThreadPoolExecutor(workers) as executor:
改成:
def download_many(cc_list): with futures.ProcessPoolExecutor() as executor:
对简单的用途来说,这两个实现 Executor 接口的类唯一值得注意的区别是,ThreadPoolExecutor.__init__ 方法需要 max_workers 参数,指定线程池中线程的数量。在 ProcessPoolExecutor 类中,那个参数是可选的,而且大多数情况下不使用——默认值是os.cpu_count() 函数返回的 CPU 数量。这样处理说得通,因为对CPU 密集型的处理来说,不可能要求使用超过 CPU 数量的值程。而对I/O 密集型处理来说可以在一个 ThreadPoolExecutor 实例中使用 10个、100 个或 1000 个线程;最佳线程数取决于做的是什么事,以及可用内存有多少,因此要仔细测试才能找到最佳的线程数。
实验Executor.map方法
若想并发运行多个可调用的对象,最简单的方式是使用 🌰 中见过的 Executor.map 方法
🌰 demo_executor_map.py:简单演示ThreadPoolExecutor 类的 map 方法
1 from time import sleep, strftime 2 from concurrent import futures 3 4 5 def display(*args): # 打印传递的参数,并打印时间戳[HH:MM:SS]格式 6 print(strftime('[%H:%M:%S]'), end=' ') 7 print(*args) 8 9 10 def loiter(n): # 打印信息,休眠n秒 11 msg = '{}loiter({}): doing nothing for {}s...' 12 display(msg.format('\t' * n, n, n)) 13 sleep(n) 14 msg = '{}loiter({}): done.' 15 display(msg.format('\t' * n, n)) 16 return n * 10 # loiter 函数返回 n * 10,以便让我们了解收集结果的方式 17 18 19 def main(): 20 display('Script starting.') 21 executor = futures.ThreadPoolExecutor(max_workers=3) # 创建 ThreadPoolExecutor 实例,有 3 个线程 22 results = executor.map(loiter, range(5)) # 把5个任务交给executor(3个会提前运行,另外2个等待) 23 display('results:', results) # 生成器,显示results的结果 24 display('Waiting for individual results:') 25 for i, result in enumerate(results): 26 display('result {}: {}'.format(i, result)) 27 28 main()
以上代码执行的结果为:
[20:15:11] Script starting. [20:15:11] loiter(0): doing nothing for 0s... [20:15:11] loiter(0): done. [20:15:11] loiter(1): doing nothing for 1s... [20:15:11] loiter(2): doing nothing for 2s... [20:15:11] results: <generator object Executor.map.<locals>.result_iterator at 0x102360f10> [20:15:11] Waiting for individual results: [20:15:11] result 0: 0 [20:15:11] loiter(3): doing nothing for 3s... [20:15:12] loiter(1): done. [20:15:12] loiter(4): doing nothing for 4s... [20:15:12] result 1: 10 [20:15:13] loiter(2): done. [20:15:13] result 2: 20 [20:15:14] loiter(3): done. [20:15:14] result 3: 30 [20:15:16] loiter(4): done. [20:15:16] result 4: 40
显示下载进度
TQDM 包特别易于使用,项目的 README.md 文件(https://github.com/noamraph/tqdm/blob/master/README.md)中有个 GIF动画,演示了最简单的用法。安装 tqdm 包之后, 在 Python 控制台中输入下述代码,会在注释那里看到进度条动画:
>>> import time >>> from tqdm import tqdm >>> for i in tqdm(range(1000)): ... time.sleep(.01) ... 51%|████████████████████▎ | 509/1000 [00:05<00:05, 83.79it/s]
除了这个灵巧的效果之外,tqdm 函数的实现方式也很有趣:能处理任何可迭代的对象,生成一个迭代器;使用这个迭代器时,显示进度条和完成全部迭代预计的剩余时间。为了计算预计剩余时间,tqdm 函数要获取一个能使用 len 函数确定大小的可迭代对象,或者在第二个参数中指定预期的元素数量。