Python异步IO实现全过程

Async IO 是在Python中专门用来支持并发编程的一种设计,从Python3.4发布开始到Python3.7,async IO得到了飞快的发展,甚至有可能会更好。

你可能会有一种疑问,“现在并发,并行,线程,多线程,这已经很多了,异步IO又适用于哪里呢?”

这篇教程将帮助你回答这个问题,让你更加深入地掌握Python的异步IO方法。

将介绍以下内容:

    1. 异步IO (async IO):一种由多种语言实现的与语言无关的范例(或模型)。

    2. async/await:两个用于定义协程的关键字。

    3. asyncio:为Python中协程运行和管理提供基础和API的库。

协程(特别的生成器方法)是Python异步IO的核心,稍后我们将深入研究。

注:在本文章中,我将使用async IO表示与语言无关的异步IO,而asyncio 则表示Python的库。

在开始之前,请先确保你已经安装了本教程所需的 asyncio 和一些其他的库。

环境配置

为全面的理解本文,你需要安装Python3.7及以上版本,还有 aiohttp 和 aiofiles 包:

安装Python3.7以及虚拟环境配置,请参考Python3安装与配置指南或者虚拟环境入门。

有了这些,我们就可以开始了。

Async IO概览

相比于多进程和线程久经考验,async IO则略逊一筹。本文将带你总览什么是async IO以及它是如何应用于与之相关的环境的。

Async IO适用于哪里?

并发与并行是一个宽泛的主题,并不容易实现。而本文所关注的由Python实现的async IO,则很值得花些时间比较一下与它相似的库,以便于了解async IO是如何适应更大的,有时令人困惑的难题。

并行表示同时执行多个操作,多进程是实现并行的一种手段,它需要中央处理单元(CPU或内核)调度任务。多进程非常擅长处理计算密集型(CPU-bound)任务:强密集循环和数学计算都属于此类。

并发则比并行略微广泛一些。它表示多个任务可以重叠运行。(还有句话说并发并不意味着并行。)

线程是一种多个线程轮流执行任务的并发执行模型。一个进程中可以有多个线程。由于GIL的存在,Python与线程有着复杂的关系,但这超出了本文的范围。

了解线程的重点在于线程更善于处理I/O密集型任务。计算密集型任务的特点是需要计算机内核从头到尾一直在工作,而I/O密集型任务则需要等待很多输入、输出才能完成。

回顾上文,并发包括多进程(适合计算密集型任务)和线程(适合I/O密集型任务)。并发是并行的一种特殊类型(或者说子类),而多进程则是并行的一种表现形式。Python通过它的包 multiprocessing,threading 和 concurrent.futures 已经对这两种形式都提供了长期的支持。

而现在是时候为其增加一个新成员了。在过去的几年里,CPython逐步构建并完善了一个独立的设计:asyncio。它通过标准库中的 asyncio 包和 async,await 两个关键字来提供支持。需要注意的是,async IO 并不是新发明的概念,它已经存在或正在被构建到其他语言及运行时环境中,如 Go,C# 和 Scala 等。

Python文档将 asyncio 包称为 一个编写并发代码的库。但是,async IO 并不是线程,也不是多进程,它并不基于它们中的任何一个。

事实上,async IO是一种单进程、单线程的设计:它使用协同多任务处理机制,到本篇教程结束时你会对它有个完整的认识。尽管是在单进程中使用单线程运行,但它仍然给人一种并发的感觉。协程(async IO的核心功能)可以被同时调度,但它本身并不是并发。

重审一下,async IO是一种并发编程风格,但并不是真正意义上的并行。相比于多进程,async IO与线程联系更为紧密一些,但是与这两者却完全不同,它是基于并发模式的独立成员。

那现在有一个词,异步代表的什么意思?这并不是一个严格的定义,但是出于我们的目的,我可以想到它的两个属性:

    * 异步例程能够在等待其最终结果时“暂停”,并允许其他的例程同时运行。

    * 通过上面的机制,异步的编码方式便于执行并发操作。换句话说,异步代码提供了并发的观感。

这是一个将所有内容整合到一起的图。白色文字部分表示概念,绿色文字部分表示其实现的方式或结果。

Python异步IO实现全过程_第1张图片

阿斯蒂芬萨芬第三番

到此我将先停止对并发编程模型之间的比较,本教程关注的侧重点在于介绍如何使用async IO的子组件,以及围绕它所创建的API。本文关于线程,多进程和异步IO的讨论就此打住,更详细的信息请参考 Jim Anderson 的Python并发概述。Jim 比我风趣的多,而且,也比我多参加了好多讲座。

Async IO释义

Async IO乍一看有点违背常理,适用于并发的代码怎么使用单核CPU单线程呢?我向来不擅长举例说明,所以我想引用 Miguel Grinberg 在2017年PyCon的演讲中的释义,这个解释精准而且优雅:

----------

国际象棋大师 Judit Polgár 举办了一场国际象棋展览会,在会上她与多个业余选手对决。她有两种方式进行对决:同步和异步。

假设:

    * 有24个对手

    * Judit 每次移动象棋需要5秒

    * 对手每次移动象棋需要55秒

    * 游戏对局平均移动30回合(共移动60次)

同步版本:Judit一次进行一场对局,而非同时进行两场对局,直到每场游戏结束。每场比赛需要 (55+5)*30 == 1800 秒,也就是30分钟。整个展览会需要 24*30 == 7200 分钟,也就是12个小时。

异步版本:Judit从一张桌子移动到另一张桌子,在每个桌子上移动一次象棋,然后离开这个桌子等待对手进行下一步移动。在24张对局桌子上都移动一次需要 24*5 == 120 秒,也就是2分钟。整个展览会现在耗时减少到 120*30 == 3600 秒,仅仅只有1小时。

仅仅 Judit Polgár 一个人,两只手,一次只做一个动作,却将展览会所用的时间从12小时减少到1小时。因此,这种程序的事件循环(稍后详述)与多个任务之间交流的协同多任务处理机制,可以实现让每个任务在最佳的时间轮流执行。

在异步IO较长的等待期间,函数会阻塞并在此期间允许其他函数运行。(函数会在它启动到返回期间阻塞其他函数。)

Async IO并不容易

我曾听说,“如果可能尽量使用异步IO,在必须的时候才使用线程”。实际上,构建持久稳定的多线程代码可能很难而且容易出错。异步IO避免了一些线程设计上可能遇到的潜在速度障碍。

但这并不是说Python中的异步IO就很容易。需要注意的是:当你的尝试更加深入的时候,异步编程也会变得很困难。Python的异步IO模型是围绕诸如回调,事件,传输,协议和新功能等概念构建的,只是术语有些令人望而生畏。实际它的API一直在变化,也越来越复杂。

幸运的是,asyncio 的大部分功能已经趋于成熟稳定,其文档也进行了大规模的改革,并且基于该主题的优质资源也相继出现。

async 包与 async/await 关键字

现在你已经有了一定的异步IO的背景知识,现在来看看Python内的实现。Python的 asyncio 包(从Python 3.4开始引入)和 async、await 两个关键字虽然用于不同的目的,但是他们共同实现了声明、构建、执行以及管理异步代码的功能。

async/swait句法以及原生协程

警告:警惕你在网上读到的内容,Python的async IO已经从Python 3.4迅速发展到Python 3.7了。很多就旧的模式都被废弃了,而一些原先被禁止的也在新版本中开始使用。就我所知,本教程也很快就会过时。

异步IO的核心是协程,协程是Python生成器方法的一个特定版本。我们先从基础定义开始,然后再进行构建:协程是一个可以在自身结束之前挂起的方法,并且它可以将控制器传给其它协程一段时间。

之后,你将深入了解到传统生成器是如何作用于协程的。现在,了解协程的最简单方法是实现一个协程。

现在我们采取一种直观的方式编写一些异步IO代码。这虽然是一个很简短的实现了异步IO的 Hello World 方法,但是对了解其核心功能很有帮助。

Python异步IO实现全过程_第2张图片

运行这个文件的时候,注意它与仅仅使用 def 和 time.sleep() 的定义有什么不同:

Python异步IO实现全过程_第3张图片

这个输出的顺序是异步IO的核心,由单一事件循环或协调器负责与每一个 count() 方法调用交流。当每一个任务执行到 await asyncio.sleep(1))时,函数会通知事件循环并交出控制权限,“我要睡眠一秒钟,在此期间,继续做其他有意义的事”。

与同步版本对比:

Python异步IO实现全过程_第4张图片

执行后,在输出顺序和时间上有一些轻微但很重要的变化

Python异步IO实现全过程_第5张图片

使用 time.sleep() 和 asyncio.sleep() 看起来有点简陋,这里一般用来替代标准输入等耗时的操作。(最简单的等待就是使用 sleep(),基本上什么也不做。)也就是说 time.sleep() 可以表示任何耗时的阻塞函数的调用,而 asyncio.sleep() 用于表示非阻塞的函数调用(但是也是需要一定时间来完成)。

在下一节你将看到,等待(包括 syncio.sleep())的好处是可以让正在执行的函数暂时将控制权限交给另一个可以立即执行某些操作的函数。相对的,time.sleep() 或者其他阻塞调用不可用于异步的Python代码,因为它将在等待时间内暂停进程内的全部操作。

Async IO的规则

至此,async,await 和协程创建的规则有一种更为正式的定义。本节的内容有一些繁重,但是有助于了解 async/await。如有必要,可以先回顾以下内容:

    * 句法 async def引入了原生协程或者说异步生成器。表达式async with 和 async for 也是允许的,稍后就可以看到。

    * 关键字 await 将控制器传递给时间循环。(挂起当前运行的协程。)Python执行的时候,在  g() 函数范围内如果遇到表达式 await f(),就是 await 在告诉事件循环“挂起 g() 函数,直到 f() 返回结果,在此期间,可以运行其他函数。”

上述第二点在代码中大致如下:

关于要不要用 async/await,以及何时使用,如何使用,都有一套严格的规则。无论你是在使用语法还是已经使用 async/await,这些规则都会很方便:

    1. 协程是引入了 async def的函数。你可能会用到await,return或者yield,但是这些都是可选的。Python允许使用async def noop(): pass声明:

        1.1. 使用 await 与 return 的组合创建协程函数。想要调用一个协程函数,必须使用 await 等待返回结果。

        1.2. 在 async def 代码块中使用 yield 的情况并不多见(只有Python的近期版本才可用)。当你使用 async for 进行迭代的时候,会创建一个异步生成器。暂时先忘掉异步生成器,将目光放在使用 await 与 return 的组合创建协程函数的语法上。

        1.3. 在任何使用 async def 定义的地方都不可以使用 yield from,这会引发异常 SyntaxError。

    2. 一如在def定义的函数之外使用yield会引发异常SyntaxError,在async def定义的协程之外使用await也会引发异常SyntaxError。你只能在协程内部使用await。

这里有一个简介的例子,总结了上面的几条规则:

Python异步IO实现全过程_第6张图片

最后,当你使用 await f() 时,要求 f() 是一个可等待的对象。但这并没有什么用。现在,只需要知道可等待对象要么是(1)其他的协程,要么就是(2)定义了 .await()函数且返回迭代器的对象。如果你正在编写程序,绝大多数情况只需要关注案例#1。

这给我们带来了一些技术上的差异:将一个函数标记为协程的旧的一个方式是使用 @asyncio.coroutine装饰一个普通的函数。这是基于生成器的协程。但是这种方式自Python 3.5中出现了async/await 语法后就已经过时了。

下面两个协程基本上是等价的(都是可等待的),但第一个是基于生成器的,而第二个是原生协程。

Python异步IO实现全过程_第7张图片

如果你写代码的时候更趋向于显式声明而不是隐式声明,那么最好是使用原生协程。基于生成器的协程将会在Python 3.10版本移除。

本教程的后半部分,我们会再涉及一些基于生成器的协程的优点。为了使协程成为Python中独立的标准功能,并与常规生成器区分开,以减少歧义,Python引入 async/await。

不要沉迷于基于生成器的协程,它已经被 async/await 取代了。如果你要使用 async/await 语法的话,注意它的一些特有的规则(比如,await 不能用于基于生成器的协程),这些规则很大程度上与基于生成器的协程不兼容

闲话少说,现在来看几个更复杂点的例子。

这是一个介绍异步IO如何减少等待时间的例子:给定一个协程 makerandom(),它在 0-10 之间产生一个随机整数,直到有一个数超出阈值为止。现在想多次调用这个协程而不必等待每一次调用结束,可以参照上面的两个脚本的模式,只需稍作修改:

Python异步IO实现全过程_第8张图片

对于上面脚本的运行方式,彩色输出要比我说的描述的更清楚。

Python异步IO实现全过程_第9张图片

rand.py execution

这个程序通过 3 个不同的输入来运行同一个协程 makerandom。大多数程序包含小的模块化的协程和一个用于将每一个小协程链接到一起的包装函数。这里 main() 函数通过将主协程映射到某些迭代或池来聚集多个 task(features)。

在这个小例子中,池是 range(3)。稍后更完整的示例中,池变成了一组需要请求,解析和处理的URL,而 main()则是为每一个URL封装了一个协程。

尽管“生成随机整数”(主要是计算密集型任务)可能不是展示 asyncio 的首选,但是在示例中的 asyncio.sleep()目的是模仿一个等待时间不确定的I/O密集型任务。比如,调用 asyncio.sleep() 可以表示在消息应用中客户端与服务端之间收发消息的非整数随机时间。

Async IO设计模式

本节将向你介绍一些异步IO自带的可行的脚本设计。

链接协程

协程的一个关键特性是它们可以被链接到一起。(记住,一个协程是可等待的,所以另一个协程可以使用 await 来等待它。)这个特性允许你将程序划分成更小的,可管理可回收的协程:

Python异步IO实现全过程_第10张图片

Python异步IO实现全过程_第11张图片

注意观察输出,part1() 的睡眠时间是可变的,而当它的返回结果可用的时候,part2() 开始执行并使用这些结果。

Python异步IO实现全过程_第12张图片

按照设定,main()函数执行的时间应该与它聚集在一起的任务中最长的一个执行时间相同。

使用队列

在 asyncio 包中提供了与队列模块中相似的队列类。目前为止,我们的例子中还没有使用到队列结构。在 chained.py 中的每一个 task(feature) 都由一组协程组成,这些协程都有一个单一的输入链,并显式的等待其它协程。

还有一种结构同样可以配合异步IO使用:许多互不关联的生产者将元素加入到一个队列中,每一个生产者可能在不同的时间,随机且无序的加入多个元素到队列中。还有一组消费者不管任何信号,不停地从队列中拉取元素。

这种设计中,任何一个生产者和消费者都没有关联。消费者事先并不知道生产者的数量,甚至不知道将累计添加的队列中的元素数。

它需要一个单独的生产者或消费者在一个可指定的时间内,分别向队列中放入或从队列中提取元素。生产者与消费者通过队列的吞吐进行通信,而不是两者直接通信。

----------

注:由于 queue.Queue() 是线程安全的,所以它经常被用于开发基于线程的程序,而在异步IO编程中你不需要关心线程安全问题。(除非你将这两者合并在一起使用,但在本教程中并没有这么做。)

队列的一种用法(比如这里的情况)是充当生产者与消费者之间的通信通道,从而避免它们直接关联或联系。

----------

这个程序的同步版本看起来有些让人不忍直视:一组生产者连续且阻塞的向队列中添加元素,一次只有一个生产者在工作。只有当所有生产者都运行结束,消费者才会从队列中一个接一个的取出元素并处理。这会造成大量的延时。元素可能会在队列中被搁置,而不是被立刻取出并处理。

而异步版本的程序 asyncq.py 如下所示。运行过程中的难点是向消费者发送生产者已经完成的信号。否则,await q.get() 将会因为队列已满而被无限挂起,但是消费者却不知道生产者已经完成的信息。

这里是全部的脚本文件:

Python异步IO实现全过程_第13张图片

几个协程作为辅助函数返回随机字符串,几分之一秒的性能计数器以及随机整数。生产者将1-5的元素放入队列中,每一个元素都是一个 (i, t) 的元组,其中 i 是一个随机字符串,t 是生产者尝试将元组放入队列所需要的时间。

当消费者从队列取出元素时,它只使用元素放入队列时所使用的时间戳计算耗费时间。

牢记 asyncio.sleep() 用于模仿其他复杂的协程,如果这里是常规阻塞函数,则会耗尽时间并阻塞其他所有函数的运行。

这里有一个实现了两个生产者和五个消费者的测试例子

Python异步IO实现全过程_第14张图片

这个例子中,元素在几分之一秒内被处理好,产生延时可能有两个原因:

    1. 很大程度上不可避免的标准开销

    2. 元素出现在队列中,而所有消费者都在睡眠的情况

幸运的是,关于第二个原因,正常情况下,将消费者扩展到成百上个也是允许的。你不应该对 python3 asyncq.py -p 5 -c 100 有什么疑问。这里有一点比较关键,理论上你可以使用不同的操作系统和用户来管理和控制生产者和消费者,而队列作为中间消息吞吐的媒介。

目前,你已经进入到异步IO的学习中,并且看到了三个由 async 和 await 定义的协程以及 asyncio 调用的相关示例。如果你只是想深入了解Python新式协程的实现机制,而并不是想全盘关注,下一节将会有一个直观的介绍。

英文原文:https://realpython.com/async-io-python/ 

译者:冰川

你可能感兴趣的:(python)