示例:网络下载的三种风格

为了高效处理网络 I/O,需要使用并发,因为网络有很高的延迟,所以
为了不浪费 CPU 周期去等待,最好在收到网络响应之前做些其他的
事。

为了通过代码说明这一点,我写了三个示例程序,从网上下载 20 个国
家的国旗图像。第一个示例程序 flags.py 是依序下载的:下载完一个图
像,并将其保存在硬盘中之后,才请求下一个图像。另外两个脚本是并
发下载的:几乎同时请求所有图像,每下载完一个文件就保存一个文
件。flags_threadpool.py 脚本使用 concurrent.futures 模块,而
flags_asyncio.py 脚本使用 asyncio 包。

示例 17-1 是运行这三个脚本得到的结果,每个脚本都运行三次。我还
在 YouTube 上发布了一个 73 秒的视频(https://www.youtube.com/watch?
v=A9e9Cy1UkME),让你观看这些脚本的运行情况,你会看到一个 OS
X Finder 窗口,显示运行过程中保存的国旗图像文件。这些脚本从
flupy.org 下载图像,而这个网站架设在 CDN 之后,因此第一次运行时
可能要等很久才能看到结果。示例 17-1 中显示的结果是运行几次之后
收集的,因此 CDN 中已经有了缓存。

示例 17-1 运行 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 ➋
$ 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 ➌
$ 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 ➍
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

❶ 每次运行脚本后,首先显示下载过程中下载完毕的国家代码,最后
显示一个消息,说明耗时。
❷ flags.py 脚本下载 20 个图像平均用时 7.18 秒。
❸ flags_threadpool.py 脚本平均用时 1.40 秒。
❹ flags_asyncio.py 脚本平均用时 1.35 秒。
❺ 注意国家代码的顺序:对并发下载的脚本来说,每次下载的顺序都
不同。

两个并发下载的脚本之间性能差异不大,不过都比依序下载的脚本快 5
倍多。这只是一个特别小的任务,如果把下载的文件数量增加到几百
个,并发下载的脚本能比依序下载的脚本快 20 倍或更多。

下面我们来分析示例 17-1 测试的两个脚本——flags.py 和
flags_threadpool.py,看看它们的实现方式。第三个脚本 flags_asyncio.py
留到第 18 章再分析。将这三个脚本一起演示是为了表明一个观点:在
I/O 密集型应用中,如果代码写得正确,那么不管使用哪种并发策略
(使用线程或 asyncio 包),吞吐量都比依序执行的代码高很多。

依序下载的脚本

示例 17-2 不太有吸引力,不过实现并发下载的脚本时会重用其中的大
部分代码和设置,因此值得分析一下。

示例 17-2 flags.py:依序下载的脚本;另外两个脚本会重用其中
几个函数

import os
import time
import sys
import requests ➊
POP20_CC = ('CN IN US ID BR PK NG BD RU JP '
'MX PH VN ET EG DE IR TR CD FR').split() ➋
BASE_URL = 'http://flupy.org/data/flags' ➌
DEST_DIR = 'downloads/' ➍
def save_flag(img, filename): ➎
  path = os.path.join(DEST_DIR, filename)
  with open(path, 'wb') as fp:
    fp.write(img)
def get_flag(cc): ➏
  url = '{}/{cc}/{cc}.gif'.format(BASE_URL, cc=c    c.lower())
  resp = requests.get(url)
  return resp.content
def show(text): ➐
  print(text, end=' ')
  sys.stdout.flush()
def download_many(cc_list): ➑
  for cc in sorted(cc_list): ➒
  image = get_flag(cc)
  show(cc)
  save_flag(image, cc.lower() + '.gif')
  return len(cc_list)
def main(download_many): ➓
  t0 = time.time()
  count = download_many(POP20_CC)
  elapsed = time.time() - t0
  msg = '\n{} flags downloaded in {:.2f}s'
  print(msg.format(count, elapsed))
  if __name__ == '__main__':
    main(download_many) ⓫

❶ 导入 requests 库。这个库不在标准库中,因此依照惯例,在导入标
准库中的模块(os、time 和 sys)之后导入,而且使用一个空行分隔
开。
❷ 列出人口最多的 20 个国家的 ISO 3166 国家代码,按照人口数量降序
排列。
❸ 获取国旗图像的网站。
❹ 保存图像的本地目录。
❺ 把 img(字节序列)保存到 DEST_DIR 目录中,命名为 filename。
❻ 指定国家代码,构建 URL,然后下载图像,返回响应中的二进制内
容。
❼ 显示一个字符串,然后刷新 sys.stdout,这样能在一行消息中看到
进度。在 Python 中得这么做,因为正常情况下,遇到换行才会刷新
stdout 缓冲。
❽ download_many 是与并发实现比较的关键函数。
❾ 按字母表顺序迭代国家代码列表,明确表明输出的顺序与输入一
致。返回下载的国旗数量。
❿ main 函数记录并报告运行 download_many 函数之后的耗时。
⓫ main 函数必须调用执行下载的函数;我们把 download_many 函数
当作参数传给 main 函数,这样 main 函数可以用作库函数,在后面的
示例中接收 download_many 函数的其他实现。

flags.py 脚本中没有什么新知识,只是与其他脚本对比的基准,而且我
把它作为一个库使用,避免实现其他脚本时重复编写代码。下面分析使
用 concurrent.futures 模块重新实现的版本。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐