- 前言
- 目标读者
- 非目标读者
- 本书的结构
- 以实践为基础
- 硬件
- 杂谈:个人的一点看法
- Python 术语表
- Python 版本表
- 排版约定
- 使用代码示例
- 第一部分 序幕
- 第 1 章 Python 数据模型
- 第二部分 数据结构
- 第 2 章 序列构成的数组
- 第 3 章 字典和集合
- 第 4 章 文本和字节序列
- 第三部分 把函数视作对象
- 第 5 章 一等函数
- 第 6 章 使用一等函数实现设计模式
- 第 7 章 函数装饰器和闭包
- 第四部分 面向对象惯用法
- 第 8 章 对象引用、可变性和垃圾回收
- 第 9 章 符合 Python 风格的对象
- 第 10 章 序列的修改、散列和切片
- 第 11 章 接口:从协议到抽象基类
- 第 12 章 继承的优缺点
- 第 13 章 正确重载运算符
- 第五部分 控制流程
- 第 14 章 可迭代的对象、迭代器和生成器
- 14.1 Sentence 类第1版:单词序列
- 14.2 可迭代的对象与迭代器的对比
- 14.3 Sentence 类第2版:典型的迭代器
- 14.4 Sentence 类第3版:生成器函数
- 14.5 Sentence 类第4版:惰性实现
- 14.6 Sentence 类第5版:生成器表达式
- 14.7 何时使用生成器表达式
- 14.8 另一个示例:等差数列生成器
- 14.9 标准库中的生成器函数
- 14.10 Python 3.3 中新出现的句法:yield from
- 14.11 可迭代的归约函数
- 14.12 深入分析 iter 函数
- 14.13 案例分析:在数据库转换工具中使用生成器
- 14.14 把生成器当成协程
- 14.15 本章小结
- 14.16 延伸阅读
- 第 15 章 上下文管理器和 else 块
- 第 16 章 协程
- 第 17 章 使用期物处理并发
- 第 18 章 使用 asyncio 包处理并发
- 第六部分 元编程
- 第 19 章 动态属性和特性
- 第 20 章 属性描述符
- 第 21 章 类元编程
- 结语
- 延伸阅读
- 附录 A 辅助脚本
- Python 术语表
- 作者简介
- 关于封面
18.3 避免阻塞型调用
Ryan Dahl(Node.js 的发明者)在介绍他的项目背后的哲学时说:“我们处理 I/O 的方式彻底错了。”5 他把执行硬盘或网络 I/O 操作的函数定义为阻塞型函数,主张不能像对待非阻塞型函数那样对待阻塞型函数。为了说明原因,他展示了表 18-1 中的前两列。
5“Introduction to Node.js”视频 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 方法,在线程池中运行 save_flag 函数。示例 18-9 会说明做法。
因为异步操作是交叉执行的,所以并发下载多张图像所需的总时间比依序下载少得多。我使用 asyncio 包发起了 600 个 HTTP 请求,获得所有结果的时间比依序下载快 70 倍。
现在回到那个 HTTP 客户端示例,看看如何显示动态的进度条,并且恰当地处理错误。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论