Ryan Dahl(Node.js 的发明者)在介绍他的项目背后的哲学时说:“我们处理 I/O 的方式彻底错了。”5 他把执行硬盘或网络 I/O 操作的函数定义为阻塞型函数,主张不能像对待非阻塞型函数那样对待阻塞型函数。为了说明原因,他展示了表 18-1 中的前两列。

5“Introduction to Node.js”(https://www.youtube.com/watch?v=M-sc73Y-zQA)视频 4:55 处。

表18-1:使用现代的电脑从不同的存储介质中读取数据的延迟情况;第三栏按比例换算成具体的时间,便于人类理解

存储介质

CPU 周期

按比例换算成“人类时间”

L1 缓存

3

3 秒

L2 缓存

14

14 秒

RAM

250

250 秒

硬盘

41 000 000

1.3 年

网络

240 000 000

7.6 年

为了理解表 18-1,请记住一点:现代的 CPU 拥有 GHz 数量级的时钟频率,每秒钟能运行几十亿个周期。假设 CPU 每秒正好运行十亿个周期,那么 CPU 可以在一秒钟内读取 L1 缓存 333 333 333 次,读取网络 4 次(只有 4 次)。表 18-1 中的第三栏是拿第二栏中的各个值乘以固定的因子得到的。因此,在另一个世界中,如果读取 L1 缓存要用 3 秒,那么读取网络要用 7.6 年!

有两种方法能避免阻塞型调用中止整个应用程序的进程:

多个线程是可以的,但是各个操作系统线程(Python 使用的是这种线程)消耗的内存达兆字节(具体的量取决于操作系统种类)。如果要处理几千个连接,而每个连接都使用一个线程的话,我们负担不起。

为了降低内存的消耗,通常使用回调来实现异步调用。这是一种低层概念,类似于所有并发机制中最古老、最原始的那种——硬件中断。使用回调时,我们不等待响应,而是注册一个函数,在发生某件事时调用。这样,所有调用都是非阻塞的。因为回调简单,而且消耗低,所以 Ryan Dahl 拥护这种方式。

当然,只有异步应用程序底层的事件循环能依靠基础设置的中断、线程、轮询和后台进程等,确保多个并发请求能取得进展并最终完成,这样才能使用回调。6 事件循环获得响应后,会回过头来调用我们指定的回调。不过,如果做法正确,事件循环和应用代码共用的主线程绝不会阻塞。

6其实,虽然 Node.js 不支持使用 JavaScript 编写的用户级线程,但是在背后却借助 libeio 库使用 C 语言实现了线程池,以此提供基于回调的文件 API——因为从 2014 年起,大多数操作系统都不提供稳定且便携的异步文件处理 API 了。

把生成器当作协程使用是异步编程的另一种方式。对事件循环来说,调用回调与在暂停的协程上调用 .send() 方法效果差不多。各个暂停的协程是要消耗内存,但是比线程消耗的内存数量级小。而且,协程能避免可怕的“回调地狱”;这一点会在 18.5 节讨论。

现在你应该能理解为什么 flags_asyncio.py 脚本的性能比 flags.py 脚本高 5 倍了:flags.py 脚本依序下载,而每次下载都要用几十亿个 CPU 周期等待结果。其实,CPU 同时做了很多事,只是没有运行你的程序。与此相比,在 flags_asyncio.py 脚本中,在 download_many 函数中调用 loop.run_until_complete 方法时,事件循环驱动各个 download_one 协程,运行到第一个 yield from 表达式处,那个表达式又驱动各个 get_flag 协程,运行到第一个 yield from 表达式处,调用 aiohttp.request(...) 函数。这些调用都不会阻塞,因此在零点几秒内所有请求全部开始。

asyncio 的基础设施获得第一个响应后,事件循环把响应发给等待结果的 get_flag 协程。得到响应后,get_flag 向前执行到下一个 yield from 表达式处,调用 resp.read() 方法,然后把控制权还给主循环。其他响应会陆续返回(因为请求几乎同时发出)。所有 get_ flag 协程都获得结果后,委派生成器 download_one 恢复,保存图像文件。

 为了尽量提高性能,save_flag 函数应该执行异步操作,可是 asyncio 包目前没有提供异步文件系统 API(Node 有)。如果这是应用的瓶颈,可以使用 loop.run_in_executor 方法(https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.BaseEventLoop.run_in_executor),在线程池中运行 save_flag 函数。示例 18-9 会说明做法。

因为异步操作是交叉执行的,所以并发下载多张图像所需的总时间比依序下载少得多。我使用 asyncio 包发起了 600 个 HTTP 请求,获得所有结果的时间比依序下载快 70 倍。

现在回到那个 HTTP 客户端示例,看看如何显示动态的进度条,并且恰当地处理错误。