- 前言
- 目标读者
- 非目标读者
- 本书的结构
- 以实践为基础
- 硬件
- 杂谈:个人的一点看法
- 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.1 线程与协程对比
有一次讨论线程和 GIL 时,Michele Simionato 发布了一个简单但有趣的示例:在长时间计算的过程中,使用 multiprocessing 包在控制台中显示一个由 ASCII 字符 "|/-\" 构成的动画旋转指针。
我改写了 Simionato 的示例,一个借由 threading 模块使用线程实现,一个借由 asyncio 包使用协程实现。我这么做是为了让你对比两种实现,理解如何不使用线程来实现并发行为。
示例 18-1 和示例 18-2 的输出是动态的,因此你一定要运行这两个脚本,看看结果如何。如果你在坐地铁(或者在某个没有 Wi-Fi 连接的地方),可以看图 18-1,想象单词“thinking”之前的 \ 线是旋转的。
图 18-1:spinner_thread.py 和 spinner_asyncio.py 两个脚本的输出类似:旋转指针对象的字符串表示形式和文本“Answer: 42”。在这个截图中,spinner_asyncio.py 脚本仍在运行中,旋转指针显示的是“\ thinking!”消息;脚本运行结束后,那一行会替换成“Answer: 42”
首先,分析 spinner_thread.py 脚本(见示例 18-1)。
示例 18-1 spinner_thread.py:通过线程以动画形式显示文本式旋转指针
import threading import itertools import time import sys class Signal: ➊ go = True def spin(msg, signal): ➋ write, flush = sys.stdout.write, sys.stdout.flush for char in itertools.cycle('|/-\\'): ➌ status = char + ' ' + msg write(status) flush() write('\x08' * len(status)) ➍ time.sleep(.1) if not signal.go: ➎ break write(' ' * len(status) + '\x08' * len(status)) ➏ def slow_function(): ➐ # 假装等待I/O一段时间 time.sleep(3) ➑ return 42 def supervisor(): ➒ signal = Signal() spinner = threading.Thread(target=spin, args=('thinking!', signal)) print('spinner object:', spinner) ➓ spinner.start() ⓫ result = slow_function() ⓬ signal.go = False ⓭ spinner.join() ⓮ return result def main(): result = supervisor() ⓯ print('Answer:', result) if __name__ == '__main__': main()
❶ 这个类定义一个简单的可变对象;其中有个 go 属性,用于从外部控制线程。
❷ 这个函数会在单独的线程中运行。signal 参数是前面定义的 Signal 类的实例。
❸ 这其实是个无限循环,因为 itertools.cycle 函数会从指定的序列中反复不断地生成元素。
❹ 这是显示文本式动画的诀窍所在:使用退格符(\x08)把光标移回来。
❺ 如果 go 属性的值不是 True 了,那就退出循环。
❻ 使用空格清除状态消息,把光标移回开头。
❼ 假设这是耗时的计算。
❽ 调用 sleep 函数会阻塞主线程,不过一定要这么做,以便释放 GIL,创建从属线程。
❾ 这个函数设置从属线程,显示线程对象,运行耗时的计算,最后杀死线程。
❿ 显示从属线程对象。输出类似于 <Thread(Thread-1, initial)>。
⓫ 启动从属线程。
⓬ 运行 slow_function 函数,阻塞主线程。同时,从属线程以动画形式显示旋转指针。
⓭ 改变 signal 的状态;这会终止 spin 函数中的那个 for 循环。
⓮ 等待 spinner 线程结束。
⓯ 运行 supervisor 函数。
注意,Python 没有提供终止线程的 API,这是有意为之的。若想关闭线程,必须给线程发送消息。这里,我使用的是 signal.go 属性:在主线程中把它设为 False 后,spinner 线程最终会注意到,然后干净地退出。
下面来看如何使用 @asyncio.coroutine 装饰器替代线程,实现相同的行为。
第 16 章的小结说过,asyncio 包使用的“协程”是较严格的定义。适合 asyncio API 的协程在定义体中必须使用 yield from,而不能使用 yield。此外,适合 asyncio 的协程要由调用方驱动,并由调用方通过 yield from 调用;或者把协程传给 asyncio 包中的某个函数,例如 asyncio.async(...) 和本章要介绍的其他函数,从而驱动协程。最后,@asyncio.coroutine 装饰器应该应用在协程上,如下述示例所示。
我们来分析示例 18-2。
示例 18-2 spinner_asyncio.py:通过协程以动画形式显示文本式旋转指针
import asyncio import itertools import sys @asyncio.coroutine ➊ def spin(msg): ➋ write, flush = sys.stdout.write, sys.stdout.flush for char in itertools.cycle('|/-\\'): status = char + ' ' + msg write(status) flush() write('\x08' * len(status)) try: yield from asyncio.sleep(.1) ➌ except asyncio.CancelledError: ➍ break write(' ' * len(status) + '\x08' * len(status)) @asyncio.coroutine def slow_function(): ➎ # 假装等待I/O一段时间 yield from asyncio.sleep(3) ➏ return 42 @asyncio.coroutine def supervisor(): ➐ spinner = asyncio.async(spin('thinking!')) ➑ print('spinner object:', spinner) ➒ result = yield from slow_function() ➓ spinner.cancel() ⓫ return result def main(): loop = asyncio.get_event_loop() ⓬ result = loop.run_until_complete(supervisor()) ⓭ loop.close() print('Answer:', result) if __name__ == '__main__': main()
❶ 打算交给 asyncio 处理的协程要使用 @asyncio.coroutine 装饰。这不是强制要求,但是强烈建议这么做。原因在本列表后面。
❷ 这里不需要示例 18-1 中 spin 函数中用来关闭线程的 signal 参数。
❸ 使用 yield from asyncio.sleep(.1) 代替 time.sleep(.1),这样的休眠不会阻塞事件循环。
❹ 如果 spin 函数苏醒后抛出 asyncio.CancelledError 异常,其原因是发出了取消请求,因此退出循环。
❺ 现在,slow_function 函数是协程,在用休眠假装进行 I/O 操作时,使用 yield from 继续执行事件循环。
❻ yield from asyncio.sleep(3) 表达式把控制权交给主循环,在休眠结束后恢复这个协程。
❼ 现在,supervisor 函数也是协程,因此可以使用 yield from 驱动 slow_function 函数。
❽ asyncio.async(...) 函数排定 spin 协程的运行时间,使用一个 Task 对象包装 spin 协程,并立即返回。
❾ 显示 Task 对象。输出类似于 <Task pending coro=<spin() running at spinner_ asyncio.py:12>>。
❿ 驱动 slow_function() 函数。结束后,获取返回值。同时,事件循环继续运行,因为 slow_function 函数最后使用 yield from asyncio.sleep(3) 表达式把控制权交回给了主循环。
⓫ Task 对象可以取消;取消后会在协程当前暂停的 yield 处抛出 asyncio.CancelledError 异常。协程可以捕获这个异常,也可以延迟取消,甚至拒绝取消。
⓬ 获取事件循环的引用。
⓭ 驱动 supervisor 协程,让它运行完毕;这个协程的返回值是这次调用的返回值。
除非想阻塞主线程,从而冻结事件循环或整个应用,否则不要在 asyncio 协程中使用 time.sleep(...)。如果协程需要在一段时间内什么也不做,应该使用 yield from asyncio.sleep(DELAY)。
使用 @asyncio.coroutine 装饰器不是强制要求,但是强烈建议这么做,因为这样能在一众普通的函数中把协程凸显出来,也有助于调试:如果还没从中产出值,协程就被垃圾回收了(意味着有操作未完成,因此有可能是个缺陷),那就可以发出警告。这个装饰器不会预激协程。
注意,spinner_thread.py 和 spinner_asyncio.py 两个脚本的代码行数差不多。supervisor 函数是这两个示例的核心。下面详细对比二者。示例 18-3 只列出了线程版示例中的 supervisor 函数。
示例 18-3 spinner_thread.py:线程版 supervisor 函数
def supervisor(): signal = Signal() spinner = threading.Thread(target=spin, args=('thinking!', signal)) print('spinner object:', spinner) spinner.start() result = slow_function() signal.go = False spinner.join() return result
为了对比,示例 18-4 列出了 supervisor 协程。
示例 18-4 spinner_asyncio.py:异步版 supervisor 协程
@asyncio.coroutine def supervisor(): spinner = asyncio.async(spin('thinking!')) print('spinner object:', spinner) result = yield from slow_function() spinner.cancel() return result
这两种 supervisor 实现之间的主要区别概述如下。
asyncio.Task 对象差不多与 threading.Thread 对象等效。Victor Stinner(本章的特约技术审校)指出,“Task 对象像是实现协作式多任务的库(例如 gevent)中的绿色线程(green thread)”。
Task 对象用于驱动协程,Thread 对象用于调用可调用的对象。
Task 对象不由自己动手实例化,而是通过把协程传给 asyncio.async(...) 函数或 loop.create_task(...) 方法获取。
获取的 Task 对象已经排定了运行时间(例如,由 asyncio.async 函数排定);Thread 实例则必须调用 start 方法,明确告知让它运行。
在线程版 supervisor 函数中,slow_function 函数是普通的函数,直接由线程调用。在异步版 supervisor 函数中,slow_function 函数是协程,由 yield from 驱动。
没有 API 能从外部终止线程,因为线程随时可能被中断,导致系统处于无效状态。如果想终止任务,可以使用 Task.cancel() 实例方法,在协程内部抛出 CancelledError 异常。协程可以在暂停的 yield 处捕获这个异常,处理终止请求。
supervisor 协程必须在 main 函数中由 loop.run_until_complete 方法执行。
上述比较应该能帮助你理解,与更熟悉的 threading 模型相比,asyncio 是如何编排并发作业的。
线程与协程之间的比较还有最后一点要说明:如果使用线程做过重要的编程,你就知道写出程序有多么困难,因为调度程序任何时候都能中断线程。必须记住保留锁,去保护程序中的重要部分,防止多步操作在执行的过程中中断,防止数据处于无效状态。
而协程默认会做好全方位保护,以防止中断。我们必须显式产出才能让程序的余下部分运行。对协程来说,无需保留锁,在多个线程之间同步操作,协程自身就会同步,因为在任意时刻只有一个协程运行。想交出控制权时,可以使用 yield 或 yield from 把控制权交还调度程序。这就是能够安全地取消协程的原因:按照定义,协程只能在暂停的 yield 处取消,因此可以处理 CancelledError 异常,执行清理操作。
下面说明 asyncio.Future 类与第 17 章所用的 concurrent.futures.Future 类之间的区别。
18.1.1 asyncio.Future:故意不阻塞
asyncio.Future 类与 concurrent.futures.Future 类的接口基本一致,不过实现方式不同,不可以互换。“PEP 3156—Asynchronous IO Support Rebooted: the‘asyncio’Module”对这个不幸状况是这样说的:
未来可能会统一 asyncio.Future 和 concurrent.futures.Future 类实现的期物(例如,为后者添加兼容 yield from 的 __iter__ 方法)。
如 17.1.3 节所述,期物只是调度执行某物的结果。在 asyncio 包中,BaseEventLoop.create_task(...) 方法接收一个协程,排定它的运行时间,然后返回一个 asyncio.Task 实例——也是 asyncio.Future 类的实例,因为 Task 是 Future 的子类,用于包装协程。这与调用 Executor.submit(...) 方法创建 concurrent.futures.Future 实例是一个道理。
与 concurrent.futures.Future 类似,asyncio.Future 类也提供了 .done()、.add_done_callback(...) 和 .result() 等方法。前两个方法的用法与 17.1.3 节所述的一样,不过 .result() 方法差别很大。
asyncio.Future 类的 .result() 方法没有参数,因此不能指定超时时间。此外,如果调用 .result() 方法时期物还没运行完毕,那么 .result() 方法不会阻塞去等待结果,而是抛出 asyncio.InvalidStateError 异常。
然而,获取 asyncio.Future 对象的结果通常使用 yield from,从中产出结果,如示例 18-8 所示。
使用 yield from 处理期物,等待期物运行完毕这一步无需我们关心,而且不会阻塞事件循环,因为在 asyncio 包中,yield from 的作用是把控制权还给事件循环。
注意,使用 yield from 处理期物与使用 add_done_callback 方法处理协程的作用一样:延迟的操作结束后,事件循环不会触发回调对象,而是设置期物的返回值;而 yield from 表达式则在暂停的协程中生成返回值,恢复执行协程。
总之,因为 asyncio.Future 类的目的是与 yield from 一起使用,所以通常不需要使用以下方法。
无需调用 my_future.add_done_callback(...),因为可以直接把想在期物运行结束后执行的操作放在协程中 yield from my_future 表达式的后面。这是协程的一大优势:协程是可以暂停和恢复的函数。
无需调用 my_future.result(),因为 yield from 从期物中产出的值就是结果(例如,result = yield from my_future)。
当然,有时也需要使用 .done()、.add_done_callback(...) 和 .result() 方法。但是一般情况下,asyncio.Future 对象由 yield from 驱动,而不是靠调用这些方法驱动。
下面分析 yield from 和 asyncio 包的 API 如何拉近期物、任务和协程的关系。
18.1.2 从期物、任务和协程中产出
在 asyncio 包中,期物和协程关系紧密,因为可以使用 yield from 从 asyncio.Future 对象中产出结果。这意味着,如果 foo 是协程函数(调用后返回协程对象),抑或是返回 Future 或 Task 实例的普通函数,那么可以这样写:res = yield from foo()。这是 asyncio 包的 API 中很多地方可以互换协程与期物的原因之一。
为了执行这些操作,必须排定协程的运行时间,然后使用 asyncio.Task 对象包装协程。对协程来说,获取 Task 对象有两种主要方式。
asyncio.async(coro_or_future, *, loop=None)
这个函数统一了协程和期物:第一个参数可以是二者中的任何一个。如果是 Future 或 Task 对象,那就原封不动地返回。如果是协程,那么 async 函数会调用 loop.create_task(...) 方法创建 Task 对象。loop= 关键字参数是可选的,用于传入事件循环;如果没有传入,那么 async 函数会通过调用 asyncio.get_event_loop() 函数获取循环对象。
BaseEventLoop.create_task(coro)
这个方法排定协程的执行时间,返回一个 asyncio.Task 对象。如果在自定义的 BaseEventLoop 子类上调用,返回的对象可能是外部库(如 Tornado)中与 Task 类兼容的某个类的实例。
BaseEventLoop.create_task(...) 方法只在 Python 3.4.2 及以上版本中可用。如果是 Python 3.3 或 Python 3.4 的旧版,要使用 asyncio.async(...) 函数,或者从 PyPI 中安装较新的 asyncio 版本。
asyncio 包中有多个函数会自动(内部使用的是 asyncio.async 函数)把参数指定的协程包装在 asyncio.Task 对象中,例如 BaseEventLoop.run_until_complete(...) 方法。
如果想在 Python 控制台或者小型测试脚本中试验期物和协程,可以使用下述代码片段:3
3摘自 Petr Viktorin 于 2014 年 9 月 11 日在 Python-ideas 邮件列表中发布的消息。
>>> import asyncio >>> def run_sync(coro_or_future): ... loop = asyncio.get_event_loop() ... return loop.run_until_complete(coro_or_future) ... >>> a = run_sync(some_coroutine())
在 asyncio 包的文档中,“18.5.3. Tasks and coroutines”一节说明了协程、期物和任务之间的关系。其中有个注解说道:
这份文档把一些方法说成是协程,即使它们其实是返回 Future 对象的普通 Python 函数。这是故意的,为的是给以后修改这些函数的实现留下余地。
掌握这些基础知识后,接下来要分析异步下载国旗的 flags_asyncio.py 脚本。这个脚本的用法在示例 17-1(第 17 章)中与依序下载版和线程池版一同演示过。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论