(图片: rizk@unsplash,字数: 3100,时间: 3分钟)
异步编程是一个相对高级的编程概念。对于没怎么接触过并发或性能编程的同学来说,想要把这个概念弄清楚,并不是一件容易的事情。
今天,肖哥尝试用自己的方式,面向异步编程小白,以Python为例,介绍一下异步编程的基本概念,工作原理和使用方法。
在介绍异步之前,我们先回顾同步编程。有人会问,同步编程好像也没听说过?这很正常,因为对于同步编程,我们是"只见其人,未闻其声"。我们平常所见的代码大多是同步编程,只是没有这样称呼它们而已。
基于大家都非常熟悉的Hello World程序,构造了下面这个同步编程示例:
import timedef hellworld(sleep_time): print('hellworld starts, then sleep for %s seconds' % sleep_time) time.sleep(sleep_time) print('hellworld ends, after sleep: %s seconds' % sleep_time) start = time.time() hellworld(1) hellworld(2) print("Running program takes: %s seconds" % (time.time() - start))
执行代码,结果如下:
hellworld starts, then sleep for 1 seconds hellworld ends, after sleep: 1 seconds hellworld starts, then sleep for 2 seconds hellworld ends, after sleep: 2 seconds Running program takes: 3.002882480621338 seconds
结合这个示例及其执行结果,可以看到同步编程有如下特点:
1,程序的执行顺序是串行的,线性的。在示例中,我们按顺序以不同的入参两次调用helloworld函数(即子程序,subroutine)。从执行结果可知,两次调用的执行顺序有严格的先后之分。换句话说,程序实际的动态执行顺序,与我们在代码中编写的静态顺序是一致的。
2,子程序是无状态(stateless)的。在示例中,针对同一子程序helloworld的反复调用,并不会共享任何状态(例如局部变量sleep_time)。当重复调用时,前一次执行时的状态不会保存,后一次执行时的状态是全新的,是独立于前一次执行的。
从最终结果来看,同步程序的总执行时间为3秒左右,是两次执行helloworld子程序各自所花时间的总和。
那么,有没有可能改进这个同步程序的性能,减少它的总执行时间呢?异步编程正是为了这个目的而产生的。
接下来,我们就看看上述示例的异步版本:
import timeimport asyncioasync def hellworld(sleep_time): print('hellworld starts, then sleep for %s seconds' % sleep_time) await asyncio.sleep(sleep_time) print('hellworld ends, then sleep for %s seconds' % sleep_time)loop = asyncio.get_event_loop()start = time.time()loop.run_until_complete(asyncio.gather(hellworld(1), hellworld(2)))print("Running program takes: %s seconds" % (time.time() - start))
执行代码,结果如下:
hellworld starts, then sleep for 1 secondshellworld starts, then sleep for 2 secondshellworld ends, after sleep: 1 secondshellworld ends, after sleep: 2 secondsRunning program takes: 2.0024850368499756 seconds
结合这个示例及其执行结果,我们可以观察到以下现象:
1,在异步编程中,异步子程序在形式上同步子程序相似。在示例中我们定义的helloworld函数,与同步编程中的版本一样,也包含3条语句,并且其编写顺序也是一样的。
2,程序的实际执行顺序是交织的,非线性的。从打印结果看,子程序helloworld两次调用的执行顺序并没有严格的先后之分。在第一次执行还没有结束的时候,第二次执行就已经开始。并且,尽管两次执行顺序之间有交织,但是两次执行都是成功的,并没有彼此干扰。
从最终结果来看,使用异步方式,程序总执行时间为2秒,小于同步方式下的总执行时间。这说明我们通过异步编程优化程序性能的目标达到了。
那么,为什么两次执行能够"交织而不干扰"?为什么程序性能会得到提升?现在我们就透过现象深入一下本质,探讨探讨Python异步编程的工作原理。
注意到,在上述异步编程示例中,虽然子程序helloworld与它的同步版本相似,但是也有细微而重要的区别。
在函数定义关键字def的前面,出现async关键字。这就表明,helloworld不再是普通子程序了,而成为特殊子程序。在Python中,它叫协程(coroutine)。协程的特殊性在于,它的执行是可以暂停和恢复的。并且由于协程是有状态(stateful)的,在暂停的时候会保存当前执行状态,以便于后续恢复执行。
更进一步的,我们澄清一些关于协程的疑问。
1,什么时候暂停协程?
从理论上说,在协程执行过程中,任何时候都可以暂停它。但在实际中,一般是在协程进入等待状态时进行。比如等待定时器计时结束(上述示例),等待网卡返回请求数据,等待数据库返回查询结果。
由于协程处于等待或阻塞状态,后续步骤依赖于所等待的结果,以致无法往下执行。此时,如果不暂停,不仅当前的协程被阻塞,其他想要获得执行机会的协程也被阻塞,而CPU却闲置了,这显然是低效的。因此,在这个时候暂停,是必要且正确的。
2,如何暂停协程?
答案是使用await关键字,并传给await一个表示其他协程的参数,从而将当前协程暂停,并将执行权交给其他协程。在上面示例helloworld子程序中,接收执行权的协程是asyncio.sleep。它是标准库asyncio中的一个协程。
3,谁来暂停协程?
暂停操作由协程自身来完成。
4,如何恢复已暂停的协程?
当协程重新获得执行机会时,它从暂停时所保存的状态中得到恢复,并继续往下执行,直到调用return语句或遇到异常退出。
5,谁来调度和分配协程的执行权?
这个问题就有点难了,它牵涉到Python异步编程的重要概念: 事件循环(event loop)。我个人将Python异步编程理解为主从(master-slave)结构。在这个结构中,slave对应协程,数量可以有很多,它们负责执行具体的程序;而master只有一个,它就是事件循环,负责管理和调度协程。
在上面的示例中,我们构造了一个事件循环loop,并将两个协程注册到该事件循环中,然后启动事件循环:
loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.gather(hellworld(1), hellworld(2)))
类似的,我们澄清一些关于事件循环的疑问。
1,事件循环是一个循环吗?
是。
2,在任何时候,只能有一个协程处于执行状态吗?
是。
3,什么样的协程可能会被事件循环授予执行机会?
当协程没有等待什么东西,或者等待的东西已经获得时,协程进入可执行状态,有可能获得执行机会。
4,当有多协程都进入可执行状态时,事件循环如何选择?
这依赖于特定的算法,例如先到先得,随机选择,周期轮转等,都是有可能的。
5,事件循环何时退出?
当事件循环中的协程全部执行结束之后,事件循环退出。
在上述示例中,我们有4个协程:2个helloworld协程,2个asyncio.sleep协程。在事件循环的调度下,程序的实际执行顺序,以及这4个协程的生命周期变化情况为:
关于事件循环的深层次问题,例如如何注册协程,如何知道某个协程等待的资源是否就绪,如何唤醒某个协程等,我们不在这里讨论。
异步编程其实能够在生活中找到对应的思想。例如一道小学数学题,问"烧水需要12分钟,做饭需要20分钟,完成烧水和做饭总共需要多少时间"。在同步方式下,需要12+20分钟,而在异步方式下,就只要max(12, 20)=20分钟了。
在我看来,异步编程比较神奇的一点就是,在单个进程的单个线程中,实现了多任务并发。个中奇妙之处,大家可以去体会。
另外,需要强调的一点是,异步编程并不会让单个子程序或协程的执行速度加快。烧水仍然需要12分钟,做饭仍然需要20分钟,这并没有变化。而总时间之所以能够减少,原因在于我们减少了等待时间,减少了时间的浪费,提高了单位时间的利用率。
在上述示例中,我们调用await asyncio.sleep来交出协程执行权。这样做只是为了示例而已。在实际编程中,更多是await一个IO(网络/磁盘等)请求的结果。异步编程之所以越来越重要,NodeJS/Go之所以越来越火热,正是因为它们表现出了高效执行在网络时代十分普遍的IO密集型应用的优势。
总的来说,这篇文章介绍了异步编程与同步编程的区别,协程和事件循环的概念与基本原理,以及如何运用Python标准库asyncio实现简单的异步程序。
本文的内容只是冰山一角。关于异步编程,关于并发,有许多重要的话题没有涉及,感兴趣的同学可以去了解。
这些话题包括但不限于:
1,协程与多线程,多进程之间的联系和区别
2,多线程或多进程环境下的异步编程
3,Python事件循环中task的概念 (事实上,事件循环本质上调度的是在协程之上又封装了一层的task)
4,Python事件循环中future的概念
5,Python的著名异步编程库,例如aiohttp,aioredis
6,异步编程的另外一种实现方式,即回调(callback)
7,协程与生成器之间的联系与区别
8,Python2中的异步编程(本文示例仅工作于Python3.4+)
9,CPU密集型与IO密集型应用的特点和并发策略选择。这部分可以参考:毁三观?如果CPU周期是1秒,那么重启电脑需要4000年!
谢谢大家阅读!另外,文章若有不正确之处,欢迎大家批评指正。
推荐阅读:
软件测试的"小秘密"
《测试不将就》原创文章导读