从Tornado出发,理解非阻塞和异步

(一)前言

在过去的两周时间里,在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通讯.png

但是这里存在两个问题:

  1. 连接的过程中存在着阻塞。
  2. 当一个连接尚未处理完毕,无法处理下一个连接。

很显然,对于现代的业务要求来说,这样的两个问题显然是我们没有办法接受的,那么为了解决这两个问题,我们首先要弄清楚这两个问题之所在。

(三)Socket缓冲区和阻塞模式

要知道,在我们进行socket通信的过程中,无论是read()还是write()都不是直接从网络读取或者说写入网络的。大致的流程是,从网卡到内核,内核写入内核缓冲区,最后socket从内核缓冲区拷贝到用户进程读取数据。

内核缓冲区:每当一个socket被创建之后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
缓冲区有以下几种特性:

  • I/O缓冲区在每个TCP套接字中单独存在;
  • I/O缓冲区在创建套接字时自动生成;
  • 即使关闭套接字也会继续传送输出缓冲区中遗留的数据;
  • 关闭套接字将丢失输入缓冲区中的数据。
I/O缓冲区.png

但是所说的阻塞是什么呢?当用户进程发起recvform()调用的时候,系统首先会检查是否有准备好的数据,如果发现系统还没有准备好数据,缓冲区没有可以读取的数据,那么当前的线程就会阻塞(Blocking),直到数据拷贝到用户进程当中或者有错误发生才会返回。示意图如下:

阻塞IO模型.png

简单的来说,所谓的IO阻塞是指,IO系统调用(recvform)的时候,用户进程主动的等待了系统调用返回的结果

(四)简单的解决方案

那么,Tornado是如何解决这个问题的呢?我们可以看到,在这里我们的阻塞主要发生在从发出系统调用到内核缓冲区准备好数据的这段时间内。那么发出调用之后,用户进程可不可以不进入“睡眠”状态呢?

轮询

首先想到的一种方案是,我们能不能在发起recvform之后不阻塞进程呢,该而采用轮询的方式,不断的调用recvform,如果数据还没有准备好,那么返回一个EWOULDBLOCK的错误,直到内核缓冲区准备好了该有的数据再返回给我们一个成功的调用。这样与之前所述的阻塞模型相比,用户进程不会被IO调用所阻塞,每次调用都会立即返回结果,所以这就是另外一种IO模型 -- IO同步非阻塞模型。

同步非阻塞模型.png

然而这样的解决方案缺点也是非常的明显,我们把CPU浪费在了轮询的工作上面,这样的解决方案也明显看起来很愚蠢。

(五)select、poll和epoll

select出现于1983年的4.2BSD,我们可以通过它的调用来监视多个文件描述符(file descriptor)的数组,当select方法返回之后,数组中就绪的文件描述符就会被内核修改标志位,使得进程可以获得这些文件修饰符来进行后续的操作。

这难道不正是我们想要的么,我们可以通过select来当做代理来管理我们所创建的socket,当内核缓冲区的数据准备好的时候,我们再发起recvform调用,这样我们就可以避过了IO调用的阻塞。

这样的解决方案对应的IO模型就是 -- IO多路复用模型(I/O Multiplexing Model)

IO多路复用模型.png

虽然select可以支持几乎所有的平台,但是select还是有缺点的:

  1. 单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
  2. 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实现的。

接下来我们也可以来看一下三者的对比:

  1. 支持一个进程所能打开的最大连接数
image.png
  1. FD剧增后带来的IO效率问题
image.png
  1. 消息传递方式
image.png

至此,我们也非常明了的完成了多路复用的三种具体实现函数的对比,也明白了Tornado使用epoll的原因。

(六)数据准备阶段的非阻塞

然而这就是完美的解决方案了么?明显不是,因为虽然我们避过了recvform的数据准备阶段的阻塞,但是我们调用epoll函数的时候还是处于阻塞的状态。如果在此状态也可以非阻塞岂不是更好?

这个时候就需要信号驱动模型(Signal-Driven I/O Model)了。

信号驱动模型.png

上图我们可以清楚的看到,相比于我们调用了epoll函数之后的阻塞,在信号驱动模型中,当我们发起sigaction的系统调用之后,改调用会立刻返回,使得我们的用户进程可以继续处理其他事物。当数据从内核来到内核缓冲区之后,内核会发起一个SIGIO的“回调”信号,这个时候,用户进程再调用recvform将数据从内核缓冲区拷贝到用户进程,这样就完成了数据准备阶段的非阻塞。

然而,比较了这么多的模型,我们都没有一个模型可以真正的实现异步,因为在调用recvform的时候,系统总是处于阻塞的状态,有没有什么办法可以从等待数据到缓冲区到拷贝数据到用户进程一直都保持用户进程通畅的呢?

(七)异步IO模型

在前面谈论的所有模型都是同步的,即在用户进程当中,总有函数调用会刮起等待其执行的结果,这样对于计算机的资源使用明显不是最高效合理的。

为了实现异步调用,我们就不能再使用revcform调用了,我们改用aio_read,流程示意图如下:

异步IO模型.png

aio_readaio_write都是Linux中的异步函数,两个函数分别提供了异步读取数据和写入数据的功能,当写入完毕用户进程就能接收到一个“callback”,然后处理接下来的事务。值得注意得是,这里的读取动作包含了之前的两个阶段:数据准备阶段和数据拷贝阶段,因此,当用户进程发起aio_read之后将完全不会阻塞进程,大大了提高了用户进程的并发能力。

(八)五中模型的对比

对比.png

正如我们所知,当请求线程在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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容