(一)前言
在过去的两周时间里,在iOS之余把身体出卖给Python,写了几天的Tornado突然比较好奇它的非阻塞的实现,所以写下了这篇文章,权当记录吧。
(二)最简单的服务器实现
我们都知道,在经典的C/S架构的网络模型中,我们都是通过Socket编程来完成服务端与客户端的网络数据的交互的。那么,如果我们直接使用Socket编程来完成一个客户端,其实也并不是很难,大概的代码如下:
#coding:utf-8
import socket
from time import ctime
PORT = 8888
BUFSIZE = 1024
ADDR = ('127.0.0.1', PORT)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(ADDR)
sock.listen(5)
while True:
pip, addr = sock.accept()
while True:
data = pip.recv(BUFSIZE)
if not data:
break
pip.send('[%s] %s' % (ctime(), data))
pip.close()
sock.close()
这段代码也不难理解:首先我们创建一个socket对象,然后将其绑定到本地地址以及8888端口,值得一提的是这里的listen(5)
指的是最大的连接数。在while循环中,我们通过server.accept()
来获得了一个新的嵌套字对象和绑定的地址,并且该对象可以进行数据的收发操作。当不再能够接收到数据的时候,我们就把本次的连接关闭。这样的连接示意图大概是这样的:
但是这里存在两个问题:
- 连接的过程中存在着阻塞。
- 当一个连接尚未处理完毕,无法处理下一个连接。
很显然,对于现代的业务要求来说,这样的两个问题显然是我们没有办法接受的,那么为了解决这两个问题,我们首先要弄清楚这两个问题之所在。
(三)Socket缓冲区和阻塞模式
要知道,在我们进行socket通信的过程中,无论是read()
还是write()
都不是直接从网络读取或者说写入网络的。大致的流程是,从网卡到内核,内核写入内核缓冲区,最后socket从内核缓冲区拷贝到用户进程读取数据。
内核缓冲区:每当一个socket被创建之后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
缓冲区有以下几种特性:
- I/O缓冲区在每个TCP套接字中单独存在;
- I/O缓冲区在创建套接字时自动生成;
- 即使关闭套接字也会继续传送输出缓冲区中遗留的数据;
- 关闭套接字将丢失输入缓冲区中的数据。
但是所说的阻塞是什么呢?当用户进程发起recvform()
调用的时候,系统首先会检查是否有准备好的数据,如果发现系统还没有准备好数据,缓冲区没有可以读取的数据,那么当前的线程就会阻塞(Blocking),直到数据拷贝到用户进程当中或者有错误发生才会返回。示意图如下:
简单的来说,所谓的IO阻塞是指,IO系统调用(recvform)的时候,用户进程主动的等待了系统调用返回的结果。
(四)简单的解决方案
那么,Tornado是如何解决这个问题的呢?我们可以看到,在这里我们的阻塞主要发生在从发出系统调用到内核缓冲区准备好数据的这段时间内。那么发出调用之后,用户进程可不可以不进入“睡眠”状态呢?
轮询
首先想到的一种方案是,我们能不能在发起recvform
之后不阻塞进程呢,该而采用轮询的方式,不断的调用recvform
,如果数据还没有准备好,那么返回一个EWOULDBLOCK
的错误,直到内核缓冲区准备好了该有的数据再返回给我们一个成功的调用。这样与之前所述的阻塞模型相比,用户进程不会被IO调用所阻塞,每次调用都会立即返回结果,所以这就是另外一种IO模型 -- IO同步非阻塞模型。
然而这样的解决方案缺点也是非常的明显,我们把CPU浪费在了轮询的工作上面,这样的解决方案也明显看起来很愚蠢。
(五)select、poll和epoll
select出现于1983年的4.2BSD,我们可以通过它的调用来监视多个文件描述符(file descriptor)的数组,当select
方法返回之后,数组中就绪的文件描述符就会被内核修改标志位,使得进程可以获得这些文件修饰符来进行后续的操作。
这难道不正是我们想要的么,我们可以通过select来当做代理来管理我们所创建的socket,当内核缓冲区的数据准备好的时候,我们再发起recvform
调用,这样我们就可以避过了IO调用的阻塞。
这样的解决方案对应的IO模型就是 -- IO多路复用模型(I/O Multiplexing Model)
虽然select可以支持几乎所有的平台,但是select还是有缺点的:
- 单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
- select 所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
基于以上的缺点,在1986年的System V Release 3诞生了poll
,然而poll只改进了最大文件描述符的数量限制,从原来的1024放开到了理论上的无限,但是对于第二个缺点依旧没有很好的方法。
那有没有其他的方案呢?有,epoll!
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
所以,Tornado在实现的过程中也是参考了epoll,相当于Tornado的非阻塞的实现就是基于epoll实现的。
接下来我们也可以来看一下三者的对比:
- 支持一个进程所能打开的最大连接数
- FD剧增后带来的IO效率问题
- 消息传递方式
至此,我们也非常明了的完成了多路复用的三种具体实现函数的对比,也明白了Tornado使用epoll的原因。
(六)数据准备阶段的非阻塞
然而这就是完美的解决方案了么?明显不是,因为虽然我们避过了recvform
的数据准备阶段的阻塞,但是我们调用epoll函数的时候还是处于阻塞的状态。如果在此状态也可以非阻塞岂不是更好?
这个时候就需要信号驱动模型(Signal-Driven I/O Model)了。
上图我们可以清楚的看到,相比于我们调用了epoll
函数之后的阻塞,在信号驱动模型中,当我们发起sigaction
的系统调用之后,改调用会立刻返回,使得我们的用户进程可以继续处理其他事物。当数据从内核来到内核缓冲区之后,内核会发起一个SIGIO
的“回调”信号,这个时候,用户进程再调用recvform
将数据从内核缓冲区拷贝到用户进程,这样就完成了数据准备阶段的非阻塞。
然而,比较了这么多的模型,我们都没有一个模型可以真正的实现异步,因为在调用recvform
的时候,系统总是处于阻塞的状态,有没有什么办法可以从等待数据到缓冲区到拷贝数据到用户进程一直都保持用户进程通畅的呢?
(七)异步IO模型
在前面谈论的所有模型都是同步的,即在用户进程当中,总有函数调用会刮起等待其执行的结果,这样对于计算机的资源使用明显不是最高效合理的。
为了实现异步调用,我们就不能再使用revcform
调用了,我们改用aio_read
,流程示意图如下:
aio_read
和aio_write
都是Linux中的异步函数,两个函数分别提供了异步读取数据和写入数据的功能,当写入完毕用户进程就能接收到一个“callback”,然后处理接下来的事务。值得注意得是,这里的读取动作包含了之前的两个阶段:数据准备阶段和数据拷贝阶段,因此,当用户进程发起aio_read
之后将完全不会阻塞进程,大大了提高了用户进程的并发能力。
(八)五中模型的对比
正如我们所知,当请求线程在I/O操作完成之前一直处于阻塞状态,那么这个操作是一个同步的操作,反之就是异步操作。那么,阻塞模型,非阻塞模型,IO多路复用模型,以及信号驱动模型都属于同步模型,因为他们都调用了会产生阻塞的recvform
,只有异步IO模型属于真正的异步。
(九)异步
既然,Tornado通过epoll
来完成了接收socket的非阻塞操作,那么对于处理请求时的异步操作,Tornado又是如何实现的呢?
回调
不像Javascript、Swift等语言原生所拥有的闭包机制,Python并没有这些机制,但是Python还是可以做到回调的,使用Tornado中的@asynchronous
装饰器可以达到异步回调的目的。
class AsyncHandler(RequestHandler):
@asynchronous
def get(self):
http_client = AsyncHTTPClient()
http_client.fetch("http://example.com",
callback=self.on_fetch)
def on_fetch(self, response):
do_something_with_response(response)
self.render("template.html")
但是,一旦使用了该装饰器就一定要手动的调用self.finish()
,因为当使用该装饰器之后,所处理的请求自动变为了长连接,并且在调用self.finish()
之前一直处于pending
的状态。
当然这样的实现方式还是稍显不友好一些,因为如果回调的函数和发起回调的函数分开书写,各段的回调逻辑散落在代码的各个角落,无论对于书写人员还是对于维护人员都是非常不友好的。索性我们还有另外一种异步的方式。
协程
通过@gen.coroutine
这个装饰器,我们可以将上述的代码改写成这样:
class GenAsyncHandler(RequestHandler):
@gen.coroutine
def get(self):
http_client = AsyncHTTPClient()
response = yield http_client.fetch("http://example.com")
do_something_with_response(response)
self.render("template.html")
从实现原理上面来说,@gen.coroutine
和@asynchronous
并无太大的区别,同样都是讲请求放为长连接并且状态置为pending
。但是这里通过使用Python生成器的模式来将原来分开的调用和回调聚合在了一起,使得拥有了现代编程语言的“闭包”机制,因此,大多数情况之下我们更加推荐使用这种方式来编写异步的代码。
ThreadPoolExecutor
对于Tornado应用来说,还有第三种异步的方式就是ThreadPoolExecutor
,具体的代码如下:
class GenAsyncHandler(RequestHandler):
executor = ThreadPoolExecutor(10)
@run_on_executor
def get(self):
http_client = AsyncHTTPClient()
http_client.fetch("http://example.com")
do_something_with_response(response)
self.render("template.html")
ThreadPoolExecutor
的异步实现方案与上述两个方案略有不同,当我们使用executor = ThreadPoolExecutor(10)
,系统就会默认帮我们创建一个线程池,在本次例子中我们创建了10个线程,当我们执行被@run_on_executor
的时候,我们就会从线程池中拿取一个线程,然后在该线程之上执行代码,从而达到异步的效果。但是,缺点是当短时间处理大量的异步请求的时候,所有线程池中的线程都处于使用的状态,那么这样还是会导致阻塞。所以在使用改异步方法的时候一定要慎重选择。
感谢参考
IO模型
聊聊Linux 五种IO模型
Tornado
聊聊IO多路复用之select、poll、epoll详解
StackOverFlow