异步IO是个好东西,在网络读写场景中可以大大提高程序的并发能力,比如爬虫、web服务等。这样的好东西自然也要在Python中可以使用。不过,在漫长的Python2时代,官方并没有推出一个自己的异步IO库,到了Python 3.4 才推出。我们先来看看异步IO在Python中的发展历史。
Python 异步IO的历史
Python 2的异步IO库
Python 2 时代官方并没有异步IO的支持,但是有几个第三方库通过事件或事件循环(Event Loop)实现了异步IO,它们是:
twisted: 是事件驱动的网络库。
gevent: greenlet + libevent(后来是libev或libuv)。通过协程(greenlet)和事件循环库(libev,libuv)实现的gevent使用很广泛。
tornado: 支持异步IO的web框架。自己实现了IOLOOP。
Python 3 官方的异步IO
Python 3.4 加入了asyncio 库,使得Python有了支持异步IO的官方库。这个库,底层是事件循环(EventLoop),上层是协程和任务。asyncio自从3.4 版本加入到最新的 3.7版一直在改进中。
Python 3.4 刚开始的asyncio的协程还是基于生成器的,通过 yield from 语法实现,可以通过装饰器 @asyncio.coroutine (已过时)装饰一个函数来定义一个协程。比如:
Python 3.5 引入了两个新的关键字 await 和 async 用来替换 @asyncio.coroutine 和 yield from ,从语言本身来支持异步IO。从而使得异步编程更加简洁,并和普通的生成器区别开来。
注意: 对基于生成器的协程的支持已弃用,并计划在 Python 3.10 中移除。所以,写异步IO程序时只需使用 async 和 await 即可。
Python 3.7 又进行了优化,把API分组为高层级API和低层级API。我们先看看下面的代码,发现与上面的有什么不同?
除了用 async 替换 @asyncio.coroutine 和用 await 替换 yield from 外,最大的变化就是关于eventloop的代码不见了,只有一个 async.run()。这就是 3.7 的改进,把eventloop相关的API归入到低层级API,新引进run()作为高层级API让写应用程序的开发者调用,而不用再关心eventloop。除非你要写异步库(比如MySQL异步库)才会和eventloop打交道。
理解asyncio
理解asyncio并不能,关键是要动起手来,接下来我们以下面代码为例动手实践一番,通过实践来理解它。
这段代码很简单,我们定义了两个协程函数(在def前面加async),其中 hi() 我们把它叫做功能函数,通过一个 aysncio.sleep() 来模拟一个耗时的异步IO操作(比如下载网页), main() 叫做入口函数。其实就是在main() 里面调用 hi() 函数,通过不断改变 main() 的行为来理解异步IO(协程函数的调用)的运行过程。
1. 协程函数如何运行?
首先,我们要明确一个道理,hi() 是一个协程函数,直接调用它返回的是一个协程对象,并没有真正运行它。把main函数改成如下,我们来仔细看看协程函数 hi() 的运行。
下面是运行结果:
代码第19行,我们像运行普通函数一样运行 hi() ,得到的a只是一个协程对象,见结果第二行:
a is:
这个协程对象 a 虽然生成了,但是还没有运行,它需要一个时机。也就是asyncio的事件循环正在运行main,还没有空去运行它。
代码第21行,通过 await 告诉 event_loop(事件循环) ,main协程停在这里,你去运行其它协程吧。这时候 event_loop 去执行a协程,也就是去执行 hi() 函数里面的代码。等 hi() 运行完,event_loop 再回到main协程继续从21行开始执行,把 hi() 的返回值赋值给b,这时候 b 的值是1。
event_loop 在整个异步IO过程中扮演一个管家的角色,在不同的协程之间切换运行代码,切换是通过事件来进行的,通过 await 离开当前协程,await 的协程完成后又回到之前的协程对应的地方继续执行。
2. 协程函数如何并发?
异步IO的好处就是并发,但如何实现呢?我们先来看一个不是并发的例子:
这次,我们把main修改成一个for循环执行4次 hi() ,看看它运行的结果:
整个过程从21:48:30 到 21:48:40 结束,用了10秒。而hi()的执行时间分别是1秒,2秒,3秒,4秒总共10秒。也就是4个hi() 虽然是异步的但是顺序执行的,没有并发。
接下来,就到了并发的实现了,通过 asyncio.creat_task() 即可:
通过 create_task() 我们在for循环里面生成了4个task(也是协程对象),但是这4个协程任务并没有被执行,它们需要等待一个时机:当前协程(main)遇到 await。
第二个for循环开始逐一 await 协程,此时 event_loop 就可以空出手来去执行那4个协程,过程大致如下:
先执行hi(1, 1) ,打印“enter hi(), 1 @21:58:35”,遇到await asyncio.sleep(1),当前协程挂起;
接着执行 hi(2, 2),执行打印命令,遇到await asyncio.sleep(2) ,当前协程挂起;
接着执行 hi(3, 3),执行打印命令,遇到await asyncio.sleep(3) ,当前协程挂起;
接着执行 hi(4, 4),执行打印命令,遇到await asyncio.sleep(4) ,当前协程挂起;
以上4步只是协程的切换和打印语句,执行非常快,我们可以任务它们是同时执行起来的。
1秒后,hi(1,1)的sleep结束它会发出事件告诉 event_loop 我await结束了,过来执行我,event_loop 此时空闲就来执行它,继续执行sleep后面的打印语句;
2秒后,hi(2,2)的sleep结束它会发出事件告诉 event_loop 我await结束了,过来执行我,event_loop 此时空闲就来执行它,继续执行sleep后面的打印语句;
3秒后,hi(3,3)的sleep结束它会发出事件告诉 event_loop 我await结束了,过来执行我,event_loop 此时空闲就来执行它,继续执行sleep后面的打印语句;
4秒后,hi(4,4)的sleep结束它会发出事件告诉 event_loop 我await结束了,过来执行我,event_loop 此时空闲就来执行它,继续执行sleep后面的打印语句;
4秒后,生成的4个协程任务就都执行完毕。总耗时4秒,也就是我们的4个任务并发完成了。
所以,上面的代码运行的结果如下:
根据上面讲述的执行流程,可以看到结果对应起来了。4个任务都是在35秒时开始执行,以后每个1秒完成一个。main函数从35执行到39介绍,共耗时4秒。
3. 错误的运行
上面的并发很完美,但有时候你可能会犯错。比如下面的main(), 你可能只是并发 hi() 函数,但不需要它的返回结果,于是有了下面的 main():
先猜猜会有什么样的结果!!
你猜对了吗?下面是运行结果:
main()的for循环只是生成了4个task协程,然后就退出了。event_loop 收到main退出的事件就空出来去执行了那4个协程,进去了但都碰到了sleep。然后event_loop就空闲了。这时候run() 就收到了main() 执行完毕的事件,run() 就执行完了,最后执行print,整个程序就退出了。从main退出到整个程序退出就是一瞬间的事情,那4个协程还在傻傻的睡着,不,是在睡梦中死去了。
在main()中加一个sleep会出现什么结果:
在main()退出前,我们要先sleep 2秒,再来猜猜它的运行结果是什么?
如果你对上面没有sleep的过程搞清楚了,不难猜到正确的结果:
注意:main() 的退出和 hi(2, 2) 的退出顺序。简单讲,main() 先sleep 2秒,hi(2, 2) 后sleep两秒,所以main先退出。
理解了sleep(2) 的执行过程,那么你就可以知道 sleep(4) 和 sleep(5) 的结果了。如果没有自信的话,就自己改一下时间,运行看看结果。
4. 如何判断是否要把函数定义为协程函数?
定义一个协程函数很简单,在def前面加async即可。那么如何判断一个函数该不该定义为协程函数呢?
记住这一个原则:如果该函数是要进行IO操作(读写网络、读写文件、读写数据库等),就把它定义为协程函数,否则就是普通函数。
以上就是如何理解asyncio的方法,也就是如何使用async和await这两个关键字。如果你还不明白,那就把上面的代码都跑一遍,如果还不行,那就跑两遍,哈哈哈,你一定行的。
我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。
***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.com 原创,没有猿人学授权,请勿以任何形式转载。***