第一章我们深入探讨了并发,研究了如何通过进程和线程实现并发。我们也探索了如何利用非阻塞 I/O 和事件循环,仅用一个线程就能实现并发。在本章中,我们将学习如何使用这种单线程并发模型来编写程序。通过本章所学的技术,你将能够处理耗时较长的操作,比如网络请求、数据库查询和网络连接,并让它们并行执行。
我们会更深入地了解协程这一概念,以及如何使用 asyncawait 语法来定义和运行协程。我们还将研究如何使用任务来并发运行协程,并通过创建一个可重复使用的计时器,来直观感受并发带来的性能提升。最后,我们还会探讨软件工程师在使用 asyncio 时可能遇到的常见错误,并学习如何使用调试模式来发现这些问题。
你可以把协程想象成一个普通的 Python 函数,但它多了一个“超能力”:当遇到一个可能需要很长时间才能完成的操作时,它可以暂停执行。等这个耗时操作完成后,我们可以“唤醒”它,让它继续执行剩下的代码。在协程被暂停等待期间,我们还可以运行其他代码。正是这种“等待的同时还能跑别的”的机制,才让我们的应用实现了并发。我们甚至可以同时运行多个耗时操作,这能给应用程序带来巨大的性能提升。
要创建和暂停一个协程,我们需要掌握 Python 的 async 和 await 关键字。async 关键字用于定义一个协程;而 await 关键字则用于在遇到耗时操作时暂停协程的执行。
建议使用哪个版本的 Python?本书中的代码假设你使用的是写作时最新的 Python 版本,即 Python 3.10。如果使用更早版本的 Python,某些 API 方法可能缺失,功能可能不同,甚至可能存在漏洞。
创建协程非常简单,跟创建普通 Python 函数差不多。唯一的区别是,我们不用 def,而是用 async def。async 关键字会把函数标记为一个协程,而不是一个普通的函数。
async def my_coroutine() -> None: print('Hello world!')
上面这个协程目前什么也没做,只是打印了一句“Hello world!”。值得注意的是,这个协程并没有执行任何耗时操作;它只是打印消息然后返回。这意味着,当我们把这个协程放入事件循环时,它会立即执行,因为我们没有阻塞的 I/O 操作,也没有任何地方会暂停执行。
虽然语法很简单,但你实际上是在创建一个与普通函数截然不同的东西。为了说明这一点,我们来创建一个对整数加一的函数,再写一个功能相同的协程,对比一下调用它们的结果。我们还会用 type 函数来看看调用协程后返回的类型,与调用普通函数有何不同。
async def coroutine_add_one(number: int) -> int: return number + 1def add_one(number: int) -> int: return number + 1function_result = add_one(1)coroutine_result = coroutine_add_one(1)print(f'Function result is {function_result} and the type is {type(function_result)}')print(f'Coroutine result is {coroutine_result} and the type is {type(coroutine_result)}')
Method result is 2 and the type is <class 'int'>Coroutine result is <coroutine object coroutine_add_one at 0x1071d6040> and the type is <class 'coroutine'>
注意看,当你调用普通的 add_one 函数时,它立刻执行并返回了我们预期的结果——一个整数。然而,当你调用 coroutine_add_one 时,你的协程代码根本没有被执行。你得到的只是一个协程对象。
这是个非常关键的一点:协程在直接调用时并不会执行。相反,你创建的是一个稍后可以运行的协程对象。要运行一个协程,你必须显式地把它放到事件循环上运行。那么,我们该如何创建事件循环并运行我们的协程呢?
在 Python 3.7 之前,如果还没有事件循环,我们必须手动创建。不过,asyncio 库已经提供了几个函数来抽象化事件循环的管理。有一个很方便的函数叫 asyncio.run,我们可以用它来运行协程。如下所示:
import asyncioasync def coroutine_add_one(number: int) -> int: return number + 1result = asyncio.run(coroutine_add_one(1))print(result)
运行第 2.3 个示例,你会看到输出 “2”,这正是我们期望的结果。我们成功地把协程放进了事件循环,并且顺利执行了!
asyncio.run 在这个场景下做了几件重要的事。首先,它创建了一个全新的事件循环。一旦创建成功,它就会把传入的协程拿过来,一直运行直到完成,然后返回结果。此外,它还会清理掉主协程结束后可能残留的任何东西。当一切都结束时,它会关闭并关闭事件循环。
asyncio.run 最重要的一点是,它被设计为你创建的 asyncio 应用程序的主入口点。它只执行一个协程,而这个协程应该负责启动应用中的所有其他部分。随着我们深入学习,我们会把 asyncio.run 作为几乎所有应用的入口点。由 asyncio.run 执行的那个协程会创建并运行其他协程,从而让我们充分利用 asyncio 的并发特性。
我们在第 2.3 个示例中看到的例子其实并不需要是协程,因为它只执行非阻塞的 Python 代码。asyncio 真正的强大之处在于,它能暂停执行,让事件循环在耗时操作期间去运行其他任务。要暂停执行,我们使用 await 关键字。await 关键字后面通常跟着一个协程的调用(更准确地说,是一个被称为可等待对象的东西,它不总是协程;我们将在本章后面更详细地讨论可等待对象)。
使用 await 关键字会让紧随其后的协程开始运行,这与直接调用协程(只会生成一个协程对象)完全不同。await 表达式也会暂停包含它的协程,直到我们等待的协程完成并返回结果。当我们要等待的协程完成时,我们就能拿到它返回的结果,包含该协程也会“醒来”来处理这个结果。
我们可以通过在协程调用前加上 await 来使用 await 关键字。扩展一下我们之前的程序,我们可以写一个程序,在一个名为“main”的异步函数中调用 add_one 函数,并获取结果。
import asyncioasync def add_one(number: int) -> int: return number + 1async def main() -> None: one_plus_one = await add_one(1) # ❶ two_plus_one = await add_one(2) # ❷ print(one_plus_one) print(two_plus_one)asyncio.run(main())
在第 2.4 个示例中,我们暂停了两次执行。我们首先 await 调用 add_one(1)。一旦拿到结果,main 函数就会“恢复”,我们将 add_one(1) 返回的值赋给变量 one_plus_one,在这个例子中就是 2。然后我们对 add_one(2) 做同样的事情,接着打印结果。我们可以把应用程序的执行流程可视化,如图 2.1 所示。图中的每个区块代表某一时刻正在执行的一行或多行代码。
图 2.1 当我们遇到 await 表达式时,我们会暂停父协程,并运行 await 表达式中的协程。一旦它完成,我们就恢复父协程并分配返回值。
现在,这段代码的行为与普通的顺序代码没有区别。我们实际上是在模拟一个正常的调用栈。接下来,让我们看一个简单的例子,通过引入一个模拟的睡眠操作,来展示在等待时如何运行其他代码。