到11月为止,已经使用Tornado这个框架有半年的时间了,对于Tornado如何实现异步以及异步与协程之间的联系,一直想要写一篇简要的总结,但真正要写的时候才发现自己还无法使用简短的文字来说清楚它们低层的具体实现,但是不管怎样,能写一点东西出来终归是好的。
首先还是从一些关键的知识点开始吧:
为什么我们需要实现异步
作为一个web服务,最主要的工作是处理用户发送过来的请求,请求抵达我们的Server侧经过后台处理完毕之后,再返回响应数据给用户;但是如果对于用户的请求需要进行耗时处理才能返回响应的时候,就会出现问题,因为如果我们的server在处理这个耗时的响应的过程当中,又有其他用户要访问我们的web服务的话,就会造成其它用户的阻塞;
所以在这种场景下,我们可以在服务端使用异步处理方式来响应用户的请求,简单来说就是不用一直盯着这个用户的请求,等待真正处理好用户的请求之后再给用户返回响应,而在此期间,我们的Server能够去处理其它用户的请求。
但是这里又有一个疑问了,既然这个用户的请求需要进行耗时处理,那为什么我们的Server还有“余力”去处理其它用户的请求呢?这里我们就需要了解一下计算密集型任务和IO密集型任务的特点了
计算密集型任务和IO密集型任务
- 计算密集型任务:需要耗费我们的CPU计算资源来完成的工作,比如使用CPU来进行模型渲染、编解码之类的工作,而对于一个普通的web请求来说,主要的响应处理时长并不会耗费在本地的CPU上面
- IO密集型任务:需要我们的等待本地传输IO、等待外部网络IO来完成的工作,比如接收到用户请求后我们再去远端的服务器上对用户进行鉴权、查询本地数据库获取用户想要的信息等,主要的响应处理时长在于远端服务器的响应时长,或者数据库的处理时长
对于这两种任务,我们可以发现,在处理IO密集型任务的时候,大部分耗时都不在本地CPU上面,所以本地的Server能够有“余力”去处理其它的用户请求,事实上我们在讲异步提高并发能力的时候,大部分也是针对的IO密集型任务。那么对于计算密集型任务,应该用什么办法提高其效率呢?以我当前的认知,一般都会结合多核CPU采用多任务的方式来提高计算密集型任务的效率,当然这里的多任务可以是多进程或者多线程的方式,但是任务的个数不需要太多,因为任务个数太多的话任务之间切换的消耗也会对效率造成负面的影响
协程是怎么一个概念
协程其实是一种特殊的函数方法,我们知道对于我们代码中的普通的函数方法,函数会有一个入口以及一个出口提供输入与输出,但是对于协程来说,它可能会有多个入口与出口,这样便能实现一种场景,就是我们可以根据我们的需要,在整个协程方法并未执行完毕的时候,从方法的一个出口当中出去,之后再从下一个入口回到这个协程方法当中去,在跳出协程的这段时间内,协程方法本身是被解释器挂起的,相当于处于一种暂存的状态,结合之前对于异步的理解,协程实现的这种机制是非常适合进行异步处理的,因为我们需要将耗时的处理步骤从主函数当中剥离出去,让其单独运行(单独占用IO资源),然后在它运行完毕的时候再回到主函数当中;其实除了协程,也可以使用回调函数的方法来配合异步实现,例如后台接收了一个耗时操作的Request,我们可以为这个Request的执行方法绑定一个回调函数,当耗时操作执行完毕,可以通过调用回调函数构造Response发送给用户,但是回调函数的方式是采用普通的函数方法,调用回调函数的时候并不能回到我们的主函数当中去,这种情况造成了回调函数的代码读起来会非常跳跃,回调函数和实际的处理代码之间会各种跳跃,而我们的协程能够跟随我们的处理逻辑而跳转,可读性更好
既然说到了协程,其实协程只是一种设计思想,它的本质是能够在函数处理过程当中自由切换,我们知道对于高并发的处理方法经历了如下的发展过程:
- 为每一个客户端请求创建一个对应的进程进行处理
- 为每一个客户端请求创建一个对应的线程进行处理
- 利用非阻塞机制,利用一个线程处理多个客户端请求
- 为每一个客户端请求创建一个协程进行处理
我们知道,线程是由操作系统来创建并管理的,在我们处理高并发场景的时候,往往需要多个线程对应处理大量的请求连接,由于Python解释器的GIL的存在,CPU在同一时间只能执行一个线程上下文当中的线程,所以多线程的使用伴随着大量的线程间切换操作,操作系统需要保存一个线程的上下文环境,同时加载到另一个线程的上下文环境,除此之外操作系统还需要针对线程进行创建、回收、竞争管理等,这些操作都会消耗一定的系统资源,所以使用多线程是一定的代价的;而对于多个协程的情况,由于协程的创建和切换逻辑完全是编码者自行定义的,是由解释器来实现的,所以协程之前创建、切换是不直接通过操作系统来完成的,所以协程的消耗要比线程的消耗低得多。
Tornado当中是如何使用协程实现异步的
当前项目当中还使用的是2.7版本的python解释器,所以以python27为例,在Tornado当中,按照前面介绍的基本思想,我们是使用协程的方式,在处理耗时操作的地方跳出主函数,然后等待耗时操作返回结果的时候,再适时的返回主函数当中继续执行;
在Tornado当中,这里的主函数就是我们处理具体请求的Handler方法,在执行到耗时处理的时候,我们使用response = yield 的表达式进行声明,同时我们使用了@gen.coroutine装饰器装饰Handler方法;
@gen.coroutine装饰器的主要作用是使用一个Future对象来包装一个生成器生成的对象,由于我们在Handler方法当中使用了yield表达式,这里Handler方法就可以作为一个生成器,而耗时的操作将会被包装为一个Future对象,此对象将会把自身的结果作为回调函数,也就是说等待其执行完毕的时候回调用其回调函数返回Handler方法当中;
Future对象其实比较简单,它可以设置运行状态并绑定回调函数,但为何Future对象可以在执行完毕的时候自动跳转回到之前跳出去的地方呢?其实背后的原因是@gen.coroutine装饰器在包装好future后会将其放入Tornado的IOLoop当中,IOLoop为操作系统层级的一个Socket轮询机制,它会不断的去查询当前那些Socket连接处于活跃状态,让当前进程只去数据收发活跃的连接而不用一直去那些没有数据收发的连接;在IOLoop的内部也会有一个任务队列,我们的future对象放入其中,便会在IOLoop执行,在执行完毕的时候会调用其回调函数,其回调函数会在@gen.coroutine当中触发send方法,从而回到之前yield跳转后的语句继续执行;
在这里还有一个要点需要说明,就是由于python解释器GIL(全局线程锁)的存在,本来单个python解释器是无法再同一时间处理多个任务的,也就和高并发的要求相冲突,但是我们这里讲的都是IO密集型任务,在执行这些任务的时候,99%的时间消耗都在IO上面,所以在任务运行期间,GIL会释放,从而我们可以并发处理下一个任务,所以根据此原理,我们一般会在开始Handler方法的时候创建一个线程池,根据当前系统CPU的数量建立多个线程,然后可以针对IO密集型任务实现并发处理,更进一步的话,我们还可以创建多个Tornado进程(为每一个进程分配不同的端口),然后在NginX上面配置反向代理做负载均衡,这样我们又可以结合多进程的方式进一步提高并发处理能力