[译]Python中的异步IO:一个完整的演练

  • 2019 年 10 月 3 日
  • 笔记

原文:Async IO in Python: A Complete Walkthrough
原文作者: Brad Solomon
原文发布时间:2019年1月16日
翻译:Tacey Wong
翻译时间:2019年7月22日

翻译仅便于个人学习,熟悉英语的请阅读原文


目录


Async IO是一种并发编程设计,Python中已经有了独立的支持,并且从Python3.4到Python3.7得到了快速发展。

你可能疑惑,“并发、并行、线程、多处理”。MMP这已经很多了,异步IO是哪根葱?”

本教程旨在帮助你回答这个问题,让你更牢固地掌握Python的异步IO。

以下是要介绍的内容:

  • 异步IO:一种与语言无关的范例(模型),它具有许多跨编程语言的实现
  • async/await:两个 用于定义协程的新Python关键字
  • asyncio:为运行和管理协程提供基础和API的Python包/库

协程(专用生成器函数)是Python中异步IO的核心,稍后我们将深入研究它们。

注意:在本文中,使用术语异步IO来表示与语言无关的异步IO设计,而asyncio指的是Python包。

开始之前,你需要确保已经配置搭建了可以使用asyncio及其他库的实验环境。

搭建自己的实验环境

你需要安装Python 3.7+以及aiohttpaiofiles包才能完整地跟随本文进行实验。

$ python3.7 -m venv ./py37async  $ source ./py37async/bin/activate  # Windows: .py37asyncScriptsactivate.bat  $ pip install --upgrade pip aiohttp aiofiles  # 可选项: aiodns

有关安装Python 3.7和设置虚拟环境的帮助,请查看Python 3安装和设置指南虚拟环境基础

ok,let’s go!

异步IO鸟瞰图

相较于它久经考验的表亲(多进程和多线程)来说,异步IO不太为人所知。本节将从高层全面地介绍异步IO是什么,以及哪些场景适合用它。

哪些场景适合异步IO?

并发和并行是个非常广泛的主题。因为本文重点介绍异步IO及其在Python中的实现,现在值得花一点时间将异步IO与其对应物进行比较,以了解异步IO如何适应更大、有时令人眼花缭乱的难题。

并行:同时执行多个操作。
多进程:是一种实现并行的方法,它需要将任务分散到计算机的中央处理单元(cpu或核心)上。多进程非常适合cpu密集的任务:密集for循环和密集数学计算通常属于这一类。
并发:并发是一个比并行更广泛的术语。 它表明多个任务能够以重叠方式运行。 (有一种说法是并发并不意味着并行。)
线程:是一种并发执行模型,多个线程轮流执行任务。 一个进程可以包含多个线程。 由于GIL(全局解释器锁)的存在,Python与线程有着复杂的关系,但这超出了本文的范围。

了解线程的重要之处是它更适合于io密集的任务。cpu密集型任务的特点是计算机核心从开始到结束都在不断地工作,而一个IO密集型任务更多的是等待IO的完成。

综上所述,并发既包括多进程(对于CPU密集任务来说是理想的),也包括线程(对于IO密集型任务来说是理想的)。多进程是并行的一种形式,并行是并发的一种特定类型(子集)。Python通过multiprocessing,threading, 和concurrent.futures标准库为这两者提供了长期支持。

现在是时候召集一名新成员了!在过去的几年里,一个独立的设计被更全面地嵌入到了CPython中:通过标准库的asyncio包和新的async/await语言关键字实现异步IO。需要说明的是,异步IO不是一个新发明的概念,它已经存在或正在构建到其他语言和运行时环境中,比如Golang、C#或者Scala。

Python文档将asyncio包称为用于编写并发代码的库。然而,异步IO既不是多线程也不是多进程,它不是建立在其中任何一个之上。事实上异步IO是一种单进程单线程设计:它使用协作式多任务操作方式,在本教程结束时你将理解这个术语。换句话说,尽管在单个进程中使用单个线程,但异步IO给人一种并发的感觉。协程(异步IO的一个核心特性)可以并发地调度,但它们本质上不是并发的。

重申一下,异步输入输出是并发编程的一种风格,但不是并行的。与多进程相比,它与线程更紧密地结合在一起,但与这两者截然不同,并且是并发技术包中的独立成员。

现在还留下了一个词没有解释。 异步是什么意思?这不是一个严格的定义,但是对于我们这里的目的,我可以想到/考虑到两个属性:

  • 异步例程能够在等待其最终结果时“暂停”,并允许其他例程同时运行。
  • 通过上面的机制,异步代码便于并发执行。 换句话说,异步代码提供了并发的外观和感觉

下面是一个一个将所有内容组合在一起的图表。 白色术语代表概念,绿色术语代表实现或实现它们的方式:

(Concurrencey并发、Threading线程、Async IO异步IO、Parallelism并行、Multiprocessing多进程)

我将在这里停止对并发编程模型的比较。本教程重点介绍异步IO的子组件,如何使用它、以及围绕它创建的API。要深入研究线程、多处理和异步IO,请暂停这里并查看Jim Anderson对(Python中并发性的概述)[https://realpython.com/python-concurrency/]。Jim比我有趣得多,而且参加的会议也比我多。

译者注:要了解多种并发模型的比较,可以参考(《七周七并发模型》

异步IO释义

异步IO乍一看似乎违反直觉,自相矛盾。如何使用一个线程和一个CPU内核来简化并发代码?我从来都不擅长编造例子,所以我想借用Miguel Grinberg2017年PyCon演讲中的一个例子,这个例子很好地解释了一切:

国际象棋大师JuditPolgár举办了一个国际象棋比赛,在那里她扮演多个业余选手。 她有两种方式进行比赛:同步和异步。
假设:

  • 24个对手
  • Judit在5秒钟内完成一个棋子的移动
  • 每个对手移动一个棋子需要55秒
  • 游戏平均30对移动(总计60次移动)
    同步版本:Judit一次只玩一场游戏,从不同时玩两场,直到游戏结束。每场比赛需要(55 + 5)* 30 == 1800秒,或30分钟。 整个比赛需要24 * 30 == 720分钟,或12小时。
    异步版本:Judit从一张桌子走到另一张桌子,每张桌子走一步。她离开了牌桌,让对手在等待的时间里采取下一步行动。在所有24场比赛中,一个动作需要Judit 24 * 5 == 120秒,即2分钟。整个比赛现在被缩减到120 * 30 == 3600秒,也就是1小时。

只有一个JuditPolgár,她只有两只手,一次只做一次动作。但是,异步进行将展览时间从12小时减少到1小时。因此,协同多任务处理是一种奇特的方式,可以说一个程序的事件循环(稍后会有更多)与多个任务通信,让每个任务在最佳时间轮流运行。

异步IO需要很长的等待时间,否则函数将被阻塞,并允许其他函数在停机期间运行。

异步IO使用起来不容易

我听人说过“当你能够的时候使用异步IO;必要时使用线程”。事实是,构建持久的多线程代码可能很难,并且容易出错。异步IO避免了一些线程设计可能遇到的潜在速度障碍。

但这并不是说Python中的异步IO很容易。警告:当你稍微深入其中时,异步编程也会很困难!Python的异步模型是围绕诸如回调,事件,传输,协议和future等概念构建的 -——术语可能令人生畏。事实上,它的API一直在不断变化,这使得它变得比较难。

幸运的是,asyncio已经相对成熟,其大部分功能不再处于临时性状态,而其文档也有了大规模的改善,并且该主题的一些优质资源也开始出现。

asyncio 包和 async/await

现在你已经对异步输IO作为一种设计有了一定的了解,让我们来探讨一下Python的实现。Python的asyncio包(在Python 3.4中引入)和它的两个关键字async和wait服务于不同的目的,但是它们会一起帮助你声明、构建、执行和管理异步代码。

async/await 语法和原生协程

警告:小心你在网上读到的东西。Python的异步IO API已经从Python 3.4迅速发展到Python 3.7。一些旧的模式不再被使用,一些最初不被允许的东西现在通过新的引入被允许。据我所知,本教程也将很快加入过时的行列。

异步IO的核心是协程。协程是Python生成器函数的一个专门版本。让我们从一个基线定义开始,然后随着你在此处的进展,以此为基础进行构建:协程是一个函数,它可以在到达返回之前暂停执行,并且可以在一段时间内间接将控制权传递给另一个协程。

稍后,你将更深入地研究如何将传统生成器重新用于协程。目前,了解协程如何工作的最简单方法是开始编写一些协程代码。

让我们采用沉浸式方法,编写一些异步输入输出代码。这个简短的程序是异步IO的Hello World,但它对展示其核心功能大有帮助:

#!/usr/bin/env python3  # countasync.py    import asyncio    async def count():      print("One")      await asyncio.sleep(1)      print("Two")    async def main():      await asyncio.gather(count(), count(), count())    if __name__ == "__main__":      import time      s = time.perf_counter()      asyncio.run(main())      elapsed = time.perf_counter() - s      print(f"{__file__} executed in {elapsed:0.2f} seconds.")

当你执行此文件时,请注意与仅用def和time.sleep()定义函数相比,看起来有什么不同:

$ python3 countasync.py  One  One  One  Two  Two  Two  countasync.py executed in 1.01 seconds.

该输出的顺序是异步IO的核心。与count()的每个调用通信是一个事件循环或协调器。当每个任务到达asyncio.sleep(1)时,函数会向事件循环发出呼叫,并将控制权交还给它,例如,“我将休眠1秒。在这段时间里,做一些有意义的事情吧”。

将此与同步版本进行对比::

#!/usr/bin/env python3  # countsync.py    import time    def count():      print("One")      time.sleep(1)      print("Two")    def main():      for _ in range(3):          count()    if __name__ == "__main__":      s = time.perf_counter()      main()      elapsed = time.perf_counter() - s      print(f"{__file__} executed in {elapsed:0.2f} seconds.")

执行时,顺序和执行时间会有轻微但严重的变化:

$ python3 countsync.py  One  Two  One  Two  One  Two  countsync.py executed in 3.01 seconds.

虽然使用time.sleep()asyncio.sleep()看起来很普通,但是它们可以替代任何涉及等待时间的时间密集型进程。(您可以等待的最普通的事情是一个sleep()调用,它基本上什么也不做。)也就是说,time.sleep()可以表示任何耗时的阻塞函数调用,而asyncio.sleep()用于代替非阻塞调用(但也需要一些时间来完成)。

你将在下一节中看到,等待某些东西(包括asyncio.sleep()的好处是,周围的函数可以暂时将控制权交给另一个更容易立即执行某些操作的函数。相比之下,time.sleep()或任何其他阻塞调用与异步Python代码不兼容,因为它会在睡眠时间内停止所有工作。

异步IO规则

此时,异步、wait和它们创建的协程函数的更正式定义已经就绪。这一节有点密集,但是掌握async/await是很有帮助的,所以如果需要的话,可以回到这里:

  • 语法async def引入了原生协程或异步生成器。async withasync for表达式也是有效的,稍后你将看到它们。
  • 关键词await将函数控制传递回事件循环(它暂停执行周围的协程)。如果Python在g()的范围内遇到await f()表达式,这就是await告诉事件循环,“暂停执行g()直到我等待的f()的结果 返回 。 与此同时,让其他东西运行。“

在代码中,第二个要点大致是这样的:

async def g():      # 在这里暂停 ,f()执行完之后再返回到这里。      return r

关于何时以及能否使用async / await,还有一套严格的规则。无论您是在学习语法还是已经使用async / await,这些都非常方便:

  • 使用async def引入的函数是协程。它可以使用waitreturnyield,但所有这些都是可选的。声明async def noop(): pass是合法的:
    • 使用wait和/或return创建一个coroutine函数。要调用coroutine函数,你必须等待它得到结果。
    • 在异步def块中使用yield不太常见(并且最近才在Python中合法)。这将创建一个异步生成器,您可以使用异步生成器进行迭代。 暂时忘掉异步生成器,重点关注使用await和/或return的协程函数的语法。
    • 任何使用async def定义的东西都不能使用yield from,这会引发SyntaxError(语法错误)。
  • 就像在def函数之外使用yield是一个SyntaxError一样,在async def协程之外使用wait也是一个SyntaxError

以下是一些简洁的示例,旨在总结以上几条规则:

async def f(x):      y = await z(x)  # OK - `await` and `return` allowed in coroutines      return y    async def g(x):      yield x  # OK - this is an async generator    async def m(x):      yield from gen(x)  # No - SyntaxError    def m(x):      y = await z(x)  # Still no - SyntaxError (no `async def` here)      return y

最后,当您使用await f()时,它要求f()是一个awaitable对象。嗯,这不是很有帮助,是吗? 现在,只要知道一个等待对象是(1)另一个协程或(2)定义返回一个迭代器.__ await __()dunder方法的对象。如果你正在编写一个程序,在大多数情况下,你只需要担心第一种情况。

这又给我们带来了一个你可能会看到的技术上的区别:将函数标记为coroutine的一个老方法是用@asyncio.coroutine来修饰一个普通的def函数。结果是基于生成器的协同程序。自从在Python 3.5中引入async/await语法以来,这种结构已经过时了。

这两个协程本质上是等价的(都是可 awaitable的),但是第一个协程是基于生成器的,而第二个协程是一个原生协程:

import asyncio    @asyncio.coroutine  def py34_coro():      """Generator-based coroutine, older syntax"""      yield from stuff()    async def py35_coro():      """Native coroutine, modern syntax"""      await stuff()

如果你自己编写任何代码,为了显式最好使用本机协程。基于生成器的协程将在Python 3.10中删除

在本教程的后半部分,我们将仅出于解释的目的来讨论基于生成器的协同程序。引入async / await的原因是使协同程序成为Python的独立功能,可以很容易地与正常的生成器函数区分开来,从而减少歧义。

不要陷入基于生成器的协程中,这些协同程序已随着async / await的出现而过时了。如果你坚持async/await语法,它们有自己的小规则集(例如,await不能在基于生成器的协同程序中使用),这些规则在很大程度上是不相关的。

废话不多说,让我们来看几个更复杂的例子。

下面是异步IO如何减少等待时间的一个例子:给定一个协程makerandom(),它一直在[0,10]范围内产生随机整数,直到其中一个超过阈值,你想让这个协程的多次调用不需要等待彼此连续完成。你可以在很大程度上遵循上面两个脚本的模式,只需稍作修改:

#!/usr/bin/env python3  # rand.py    import asyncio  import random    # ANSI colors  c = (      "