本文章主要目的是介绍tornado的工作原理及延伸的关键技术,为了便于读者理解,会通过几个简单易懂的例子,再配合原理图进行讲解。由于本文主要是分析tornado的工作原理,所以牵扯到一些操作系统的细节会简单略过,希望读者把握文章重点,不要迷失在理解各种操作系统名词的“深渊”里。当你真正了解了tornado的工作原理后,相应的应用场景也就能举一反三了。
本文主要通过以下三点进行讲解:
Tornado的背景及技术介绍
简单代码后鲜为人知的秘密
Tornado的应用场景
一、Tornado的背景及技术介绍
与传统框架的区别
首先谈一下传统的一些web服务器框架吧,没有对比就没有“伤害”嘛,这里主要和Django为代表的传统框架进行比较,这一类的Python web应用部署的时候一般是采用WSGI协议与服务器对接的,而这类服务器通常是基于多线程/多进程的,也就是说每有一个网络请求,服务器都会有一个线程/进程进行处理。
这里要重点介绍一下WSGI协议。
WSGI协议的由来:
例子:
中国有三家有名的通信运营商,分别是移动、联通和电信,这三家通信商的手机号是可以跨平台拨打的,假设三家通信商负责通信的协议不同且无法互通,用移动的手机号就无法给联通电信的手机打电话,为了方便通信就需要一个统一的规范。
WSGI协议的角色就是这个统一的规范,是描述web server如何与web application通信的规范,要实现WSGI协议,就必须同时实现web server和web application,目前常见的有Tornado、Flask和Django。
WSGI协议具体做了什么:
1. 定义调用方式:让Web服务器知道如何调用python应用程序,并把用户的请求告诉应用程序。
2. 定义接收方式:让python应用程序知道用户的请求是什么,以及如何返回结果给web服务器。
application对象形式: #application是定义的应用端的调用方式 defsimple_app(environ, start_response): # environ解释了第一点,将客户的需求告诉应用端 # start_response解释了第二点,回调函数 pass
3. 定义以上两点后,WSGI还去充当了服务器和应用程序间的中间件,即充当应用程序又充当服务器,可以形象的用下图表示。
其中大致流程是这样的:
1. Server收到客户端的HTTP请求后,生成了environ_s,并且已经定义了start_response_s。
2. Server调用Middleware的application对象,传递的参数是environ_s和start_response_s。
3. Middleware会根据environ执行业务逻辑,生成environ_m,并且已经定义了start_response_m。
4. Middleware决定调用Application的application对象,传递参数是environ_m和start_response_m。Application的application对象处理完成后,会调用start_response_m并且返回结果给Middleware,存放在result_m中。
5. Middleware处理result_m,然后生成result_s,接着调用start_response_s,并返回结果result_s给Server端。Server端获取到result_s后就可以发送结果给客户端了。
WSGI是个同步模型,不支持非阻塞的请求方式,Tornado默认是不推荐使用WSGI的,如果在Tornado中使用WSGI,将无法使用Tornado的异步非阻塞的处理方式,相应的异步接口也就无法使用,性能方面也就大打折扣,这个也是Tornado性能如此优越的原因。
二、C10K问题的提出
考虑两种高并发场景:
1. 用户量大,高并发。如秒杀抢购,双十一,618和春节抢票。
2. 大量的HTTP持久连接。
对于以上两种熟悉的场景,通常基于多进程/线程的服务器是很难应付的。
基于上述高并发场景,引出了C10k问题,是由一名叫DanKegel的软件工程师提出的,即当同时的连接数以万计的时候,服务器性能会出现急剧下降甚至直接崩溃的情况,这就是著名的C10k问题。
值得一提的,腾讯QQ就遇到过C10k问题,当时采用了udp的方式避开了这个问题,当然过程是相当痛苦的,后来也就专用了tcp,主要是当时还没有epoll技术。
简要地分析下C10k的本质问题,其实无外乎是操作系的问题,连接多了,创建的进程线程就多了,数据拷贝也就变得频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞),进程/线程上下文切换消耗又大,直接就导致操作系统奔溃。
可见,解决C10k问题的关键就是减少这些CPU等核心计算资源消耗,从而榨干单台机器的性能,突破C10k描述的瓶颈;那么常规的解决思路有哪些呢,其实无外乎下面两种方法:
解决一、对于每个连接处理分配一个独立的进程/线程。提升单台机器的能力,尽可能多提供进程/线程,一台机器不够就增加多台机器。
解决二、用一个进程/线程来同时处理若干个连接。
针对方法一,假设每台机器都达到了一万连接,同时有一亿个请求,那么就需要一万台机器,所以这种解决方法不太实际。
针对方法二,需要有新的技术支持这种方案,实际上是可行的,也是现在普遍采取的方法,针对这种方法,其实有过多种技术支持,接下来重点介绍下。
三、epoll技术的引入
接下来我们根据技术的迭代发展来引入epoll技术。
实现方式一:传统的循环遍历的方式处理多个连接
这种方式明显的缺点就是,当其中任何一个socket的文件数据不ready的时候,线程/进程会一直等待,进而导致后面要处理的连接都被阻塞,整个应用也就阻塞了。
实现方式二:select技术
首先解释下select,它是个系统调用函数,格式如下:
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timeval *timeout);
nfds:select监视的文件句柄数
readfds:select监视的可读文件句柄集合,就是个long类型的数组。
writefds: select监视的可写文件句柄集合。
exceptfds:select监视的异常文件句柄集合。
timeout:本次select()的超时结束时间。
还有几个对fds集合操作的宏:
FD_ZERO(fd_set *fdset):清空fdset与所有文件句柄的联系。
FD_SET(int fd,fd_set *fdset):建立文件句柄fd与fdset的联系。
FD_CLR(int fd,fd_set *fdset):清除文件句柄fd与fdset的联系。FD_ISSET(int fd,fd_set *fdset):检查fdset联系的文件句柄fd是否
可读写,当>0表示可读写。
实现过程:
1. 首先fdset集合里需要监控的文件句柄由程序员来添加,当前连接需要监控哪些文件句柄,那么通过FD_SET宏来进行添加。
2. 然后调用select()函数将fd_set从用户空间拷贝到内核空间。
3. 注册一个回调函数。
4. 内核对文件句柄进行监控。
5. 当有满足可读写等条件时/超时调用回调函数并将文件句柄集合拷贝回用户空间。
6. 应用通过轮询的方式查找所有文件句柄,用FD_ISSET宏来判读具体是哪个文件句柄可操作。
7. 当再次有新连接处理需要监控,再次重复以上步骤往内核拷贝fdset。
用图表示整个流程如下:
缺点分析:
2. 重复初始化:每次监控都重复将fdset从用户空间拷贝到内核空间,然后又从内核空间拷贝到用户空间,这个过程重复比较耗费系统资源。
3. 逐个排查文件效率不高: 检测哪些文件句柄可操作时,采用的是轮询遍历所有的文件句柄,用FD_ISSET宏来判断文件句柄是否可操作,然而实际情况,大部分文件句柄是不可操作的,这种逐个排查的方式效率太低。
实现方式三、poll技术
实现方式四、epoll技术
epoll技术整个流程其实和select、poll技术大体上是一样的,主要是针对造成效率低下的点进行优化,可以说是将select和poll技术的缺点一一解决才达到现在的高效率,接下来我们一一道来:
1. 句柄上限
句柄上限的问题poll技术已经解决,就不用多说了。
2. 重复初始化
这个问题就像中学时候读书书包带课本一样(中学的课程数量和书本数量之多大家应该都懂的),每天上学把所有几十本课本从家里背到学校,放学了再从学校将所有书背回家,但你今天家庭作业实际需要带的书可能就个别课程的个别几本书而已。
所以为了减轻我们身体的负担,是不是放学的时候只带几本今天需要做家庭作业的几本书就很轻松了,同样的为了减少重复初始化过程中用户空间和内核空间发生不必要的拷贝带来的资源浪费,epoll技术提供了epoll_ctl函数,在用epoll_ctl函数进行事件注册的时候,会将文件句柄都复制到内核中,所以不用每次都复制一遍,当有新的文件句柄时采用的也是增量往内核拷贝,确保了每个文件句柄只会被拷贝一次。
3. 逐个排查文件效率不高
epoll会用epoll_ctl为每个文件句柄注册一个回调函数,同时会在内核中通过epoll_create创建一个专用链表(还有包含存储fd的专用内存空间),当有文件句柄状态发生变更,通过回调函数会将状态发生变更的文件句柄加入该链表,epoll技术还提供了epoll_wait函数,来查看链表中有没有就绪的文件句柄,然后只将该链表中的就绪文件句柄从内核空间拷贝到用户空间,这样一来就不用遍历每个文件句柄,只处理状态发生变更的,效率自然就提升上去了。
总结一下,epoll技术提供了三个系统调用函数:
- epoll_create:用于创建和初始化一些内部使用的数据结构。 - epoll_crl: 用于注册时间、添加、删除和修改指定的df及其期待的事件。 - epoll_wait: 用于等待先前指定的fd事件,即就绪的fd。
五、Tornado是如何发挥优势的(背后不为人知的处理逻辑)
我们先看一段简单代码的demo:
如果你经常用Tornado,那么对这段代码一定非常熟悉了,那么我们今天的关注点就放在最后一句Torando.ioloop.IOLoop.current().start()代码上,先简单的分析下这句代码,前面一部分Torando.ioloop是Tornado的核心模块ioloop模块,IOLoop是ioloop模块的一个类,current()是IOLoop类的一个方法,结果是返回一个当前线程的IOLoop的实例,start()也是IOLoop的方法,调用后开启循环。
先看一张流程图:
然后我们分析下Tornado这段代码后的整个逻辑流程:
1. 首先Tornado需要建立监听,会创建一个socket用于监听,如果有客户端A请求建立连接之后,Tornado会基于原先的socket新创建一个包含客户端A连接的有关信息的socket(分配新的监听端口),用于监听和客户端A的请求。此时对Tornado来说就有两个socket需要进行监控,原先的socket继续用来监听建立新连接,新的socket用于和客户端A进行通信,假如没有epoll技术的话,Tornado需要自己去循环询问哪个socket有新的请求。
2. 有了epoll技术,Tornado只需要把所有的socket丢给epoll,epoll作为管家帮忙监控,然后Torando.ioloop.IOLoop.current().start()开启循环,不断的去询问epoll是否有请求需要处理,这就是ioloop所做的工作,也是Tornado的核心部分。
3. 当有客户端进行请求,epoll就发现有socket可处理,当ioloop再次询问epoll时,epoll就把需要处理的socket交由Tornado处理
4. Tornado对请求进行处理,取出报文,从报文中获取请求路径,然后从tornado.web.Applcation里配置的路由映射中把请求路径映射成对应的处理类,如上图IndexHandler就是处理类。
5. 处理类处理完成后,生成响应,将响应内容封装成http报文,通过请求时建立的连接(尚未中断)将响应内容返回给客户端。
6. 当有多个请求同时发生,Tornado会按顺序挨个处理。
看了上面的流程,假如Tornado在处理一个非常耗时的请求时,后面的请求是不是就会被卡死呢?答案是肯定的,所以提到了Tornado的另一个特性—异步处理,当一个请求特别耗时,Tornado就把它丢在那处理,然后继续处理下一个请求,确保后面的请求不会被卡死。
Tornado异步:原生Tornado框架提供异步网络库IOLoop和IOStream以及异步协程库tornado.gen(必须使用Tornado的web框架和HTTP服务器,否则异步接口可能无法使用),方便用户通过更直接的方法实现异步编程,而不是回调的方式,官方推荐yield协程方式完成异步。(异步是Tornado重要且核心部分,期待下篇技术好文重点介绍)。
通过上面所讲,基本上已经对Tornado的整个处理流程了解了,总结一下Tornado之所以能同时处理大量连接的原因:
1. 利用高效的epoll技术处理请求,单线程/单进程同时处理大量连接。
2. 没用使用传统的wsgi协议,而是利用Tornado自己的web框架和http服务形成了一整套WSGI方案进行处理。
3. 异步处理方式,Tornado提供了异步接口可供调用。
六、Tornado的应用场景
要性能,Tornado 首选;要开发速度,Django 和Flask 都行,区别是Flask 把许多功能交给第三方库去完成了,因此Flask 更为灵活。Django适合初学者或者小团队的快速开发,适合做管理类、博客类网站、或者功能十分复杂需求十分多的网站,Tornado适合高度定制,适合访问量大,异步情况多的网站。
以上便是本文是本人学习收集整理后的文章,如有错误,请多多留言指教。
关于作者
吴俊杰:达观数据后端开发工程师,负责达观数据产品后端开发、产品落地、客户定制化产品需求等设计。对后端开发使用到的web及服务器框架、http协议及相关应用方面有比较深入的了解。