Tornado IOLoop

事件驱动编程

事件驱动编程是一种网络编程范式,程序的执行流由外部事件来决定,特点是包含一个事件循环,当外部事件发生时会使用回调机制来触发相应的处理。与传统线性的编程模式相比而言,事件驱动程序在启动后就会进入等待,那么等待什么呢?等待被事件触发。

与传统的单线程(同步)和多线程编程范式相比,三种模式下程序执行效率各不相同。

  • 单进程:服务器每接收到一个请求就会创建一个新的进程来处理该请求
  • 多线程:服务器每接收到一个请求就会创建一个新的线程来处理该请求
  • 事件驱动:服务器每接收到一个请求首先放入事件列表,然后主进程通过非阻塞IO的方式来处理请求。
三种编程范式比较

上图可知:程序有三个任务需要完成,每个任务都在等待IO操作时阻塞,阻塞在IO操作上所耗费的时间使用灰色标注。经过比对可以发现,事件驱动模型对CPU的使用率时最高的,它不会因为某个任务的阻塞而导致整个进程的阻塞,从开始到结束,总是有一个可以运行的任务在执行。

Tornado是基于事件驱动模型实现的,IOLoop是Tornado的事件循环,也是Tornado的核心。

Tornado的事件循环机制是根据系统平台来选择底层驱动的,如果是Linux系统则使用的是Epoll,如果是类UNIX如BSD或MacOS系统则使用的是Kqueue,如果都不支持的话则会回退到Select模式。

IO多路复用

准确来说,Epoll是Linux内核升级的多路复用IO模型,在UNIX和MacOS上类似则是Kqueue。

IO多路复用

IO多路复用机制有selectpollepoll三种,所谓的IO多路复用是通过某种机制监听多个文件描述符,一旦文件描述符就绪(读就绪或写就绪),能够通知程序进行相应的读写操作。本质上selectpollepoll都是同步IO,因为他们都需要在读写事件就绪后自己负责读写,也就是说读写过程是阻塞的。异步IO无需自己进行读写,异步IO的实现会负责将数据从内核拷贝到用户空间。

  • select
int select(
  int nfds,
  fd_set *restrict readfds,
  fd_set *restrict writefds,
  fd_set *restrict errorfds,
  struct timeval *restrict timeout
)

select函数负责监视的文件描述符可分为三类,分别是writefdsreadfdsexceptfds。调用select函数会阻塞直到有描述符就绪,即有数据可读、可写或者有异常。或者超时,函数返回。当select函数返回时可以通过遍历所有描述符来寻找就绪的描述符。

目前几乎在所有的平台上都支持select,另外select对于超时提供了微秒级别的精度控制。

select的缺点在于单个进程能够监视的文件描述符的数量有有限的,在Linux中一般是1024,可通过修改宏定义重新编译内核的方式来提升,但同时会带来效率的降低。

select对Socket扫描时是线性的,也就是采用轮询的方式,效率低下。当Socket比较多的时候,每次select函数都需要通过遍历FD_SIZE个Socket来完成调度,不管Socket是否活跃都会遍历一次,这会浪费大量CPU时间。如果能给Socket注册某个回调函数,当Socket活跃时自动完成相关操作,就可以避免轮询,这也正是Epoll和Kqueue所做的。

select需要维护一个用来存放大量文件描述符的数据结构,这使得用户空间和内核空间在传递该数据结构时存在巨大的复制开销。

select是几乎所有的UNIX或Linux都支持的一种IO多路复用的方式,通过select函数发出IO请求后,县城会阻塞直到有数据准备完毕才能把数据从内核空间拷贝到用户空间,所以select是同步阻塞的方式。

  • poll
int poll(
  struct pollfd fd[],
  nfds_t nfds,
  int timeout
);
//poll通过pollfd数组向内核传递所需关注的事件,因此没有描述符个数限制。
//pollfd中的events和revents分别用于标识关注的事件和发生的事件,因此pollfd数组仅需初始化一次。
//poll的实现机制与select类似,对应内核中的sys_poll,只不过poll向内核传递pollfd数组,然后对pollfd中的每个描述符进行poll,相比fdset来说,poll的效率更高。
//poll返回后需要对pollfd中的每个元素检查其revents值,用来指明事件是否发生。
struct pollfd(
  int fd; //文件描述符
  short events;//请求的事件
  short revents;//返回的事件
)

poll不要求开发者计算最大文件描述符的大小,在应付大数量的文件描述符时比select效率更高,而且poll没有最大连接数的限制,因为poll采用的是基于链表来存储的。

poll的缺点在于包含大量文件描述符的数组会被整体复制于用户态和内核的地址空间之间,不论文件描述符是否就绪,开销随着文件描述符数量的增加线性增大。另外与select一样,poll返回后需要轮询所有的描述符来获取就绪的描述符。

通过poll函数发出IO请求后,线程会阻塞直到数据准备完毕,poll函数在pollfd中通过revents返回事件,然后线程会将数据从内核空间拷贝到用户空间,所以poll同样是同步阻塞方式,性能与select相比并没有改进。

  • epoll
int epoll_create(int size);

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll对文件描述符的操作有两种模式分别是水平触发和边缘触发

水平触发LT, Level Trigger

当被监控的文件描述符fd上有可读写事件发生时,epoll_wait()函数会通知处理程序读写。如果本次没有将数据一次性全部读写完毕,比如读写缓冲区太小。那么下次再调用epoll_wait()函数时,会通知你在上次没有读写完的文件描述符fd上继续进行读写。如果一直不去读写,那它会一直通知你。如果系统中有大量不需要读写的就绪文件描述符fd,并且它们每次都会返回,这样会大大降低处理程序检索自己关系的就绪文件描述符的效率。

边缘触发ET, Edge Trigger

边缘触发是当被监控的文件描述符fd上有可读写事件发生时,epoll_wait()函数会通知处理程序去读写。如果本次没有将数据全部读写,比如读写缓冲区太小。那么下次调用epoll_wait()时,它就不会通知你,也就是说它只会通知你一次,知道该文件描述符出现第二次可读写事件时才会通知你。这种模式比水平触发模式效率更高,系统中不会充斥大量你所不关心的就绪文件描述符。

epoll支持阻塞和非阻塞两种方式,而边缘模式只能配合非阻塞使用。

Python中Epoll的事件默认使用的是水平触发LT(Level Trigger)模式,Python中的Epoll可通过select.EPOLLET设置为ET模式。

epoll.register(connection.fileno(), select.EPOLLIN)

epoll.register(connection.fileno(), select.EPOLLIN | select.EPOLLET)

Epoll

Tornado优秀的大并发处理能力得益于Web服务器从底层开始就实现了一整套基于Epoll的单线程异步架构,而tornado.ioloop就是Web服务器最底层的实现。IOLoop是对IO多路复用的封装,其实现为一个单例并保存在IOLoop._instance中。

Epoll是Linux内核中实现的一种可扩展的IO事件通知机制,是对POSIX系统中selectpoll的替代,具有更高的性能和扩展性。FreeBSD中类似的实现是Kqueue。

Tornado中基于Python C扩展实现的Epoll模块对Epoll的使用进行了封装,使得IOLoop对象可通过相应的事件处理机制对IO进行调度。

IOLoop的实现是基于Epoll的,那么什么是Epoll呢?

Epoll是Linux内核为处理大批量文件描述符fd而做了改进后的Poll,那什么又是 Poll呢?

Socket通信时的服务器在接受accept客户端连接并建立通信后connection才会开始通信,而此时服务器并不知道连接的客户端有没有将数据发送完毕,此时有两种选择方案:

  • 第一种是一直在这里等待直到收发数据结束
  • 第二种是每隔一段时间来查看是否存在数据。

第一种方案虽然可以解决问题,但需要注意的是对于一个线程或进程,同时是只能处理一个Socket通信,与此同时其他连接只能被阻塞,显然这种方式在单进程情况下是不现实的。

第二种方案比第一种方案相对要好一些,多个连接可以统一在一定时间段内轮流查看是否有数据需要读写,看上去是可以同时处理多个连接了,这种方式也就是Poll/Select的解决方案。

第二种方案的问题是随着连接越来越多,轮询所耗费的时间将会越来越长,然而服务器连接的Socket大多不是活跃的,因此轮询所耗费的大部分时间将是无用的。为了 解决这个问题,Epoll被创建了出来,Epoll的概念和Poll类似,不过每次轮询时只会将有数据活跃的Socket挑选出来进行轮询,这样在大量连接时会节省大量时间。

对于Epoll的操作主要是通过4个API完成的

  • epoll_create 用于创建一个Epoll描述符
  • epoll_ctl 用于操作Epoll中的事件Event
  • epoll_wait 用于让Epoll开始工作,参数timeout为0时会立即返回,timeout为-1时会一直监听,timeout大于0时为监听阻塞时长。在监听时若有数据活跃的连接时,会返回活跃的文件句柄列表。
  • close用于关闭Epoll

Epoll中的事件包括

  • EPOLL_CTL_ADD 添加一个新的Epoll事件
  • EPOLL_CTL_DEL 删除一个Epoll事件
  • EPOLL_CTL_MOD 修改一个事件的监听方式

Epoll事件的监听方式可分为七种,重点关注其中的三种。

  • EPOLLIN 缓冲区满,此时有数据可读。
  • EPOLLOUT 缓冲区空,此时可写数据。
  • EPOLLERR 发生错误

IOLoop

  • IOLoop对Epoll的封装和I/O调度的具体实现
  • IOLoop模块对网络事件类型的封装与Epoll一致,分别为READ / WRITE / ERROR 三种类型。

IOLoop是基于Epoll实现的底层网络IO的核心调度模块,用于处理Socket相关的连接、响应、异步读写等网络事件。每个Tornado进程都会初始化一个全局的IOLoop实例,在IOLoop中通过静态方法instance()进行封装,获取IOLoop实例后直接调用instance()方法即可。

IOLoop实现了Reactor模型,将所有需要处理的IO事件注册到一个中心IO多路复用器上,同时主线程/进程阻塞在多路复用器上。一旦有IO事件到来或是准备就绪,即文件描述符或Socket可读写时,多路复用器会返回并将事先注册的相应IO事件分发到对应的处理器上。

Tornado服务器启动时会创建并监听Socket,并将Socket的文件描述符fd, file descriptor注册到IOLoop实例中,IOLoop添加对Socket的IOLoop.READ事件监听并传入回调处理函数。当某个Socket通过accept接收连接请求后调用注册的回调函数进行读写。

tornado.ioloop表示主事件循环,典型的应用程序将使用单个IOLoop对象并在IOLoop.instance单例中。通常在main()函数结束时调用IOLoop.start()方法。非典型应用可以使用多个IOLoop,比如每个线程一个IOLoop

IOLoop的核心调度集中在start()方法中,IOLoop实例对象调用start后开始Epoll事件循环机制,start()方法会一直运行直到IOLoop对象调用stop函数、当前所有事件循环完成。start()方法中主要分为三部分:

  • 对超时的相关处理
  • Epoll事件通知阻塞和接收
  • Epoll返回IO事件的处理

Tornado在IOLoop中会去循环检查三类事件:

  • 可立即执行的事件ioloop._callbacks

可立即执行的事件一般是Future在set_result时将Future中的所有call_back以这种类型的事件添加到IOLoop中。IOLoop中当存在可立即执行的事件时会立即调度它们的回调函数。

添加可立即执行的事件的接口:ioloop.add_callback(callback)

  • 定时器事件ioloop.timer

IOLoop中维护了一个定时器事件列表,按照timeout超时时间以最小堆的形式存储,在IOLoop循环至定时器事件时,会不断地判断堆顶的定时器是否会超时,如果超时则取出,直到取出所有超时的定时器,之后会调度定时器对应的回调函数。

添加定时器事件的接口:ioloop.call_at(deadline, callback)

  • IO事件

当可立即执行的事件、定时器事件的回调函数都执行完毕后,IOLoop会检查是否有新的可立即执行的事件加入,如果有则IO事件的阻塞事件会设置为0即非阻塞,否则检查距离最近的一个定时器超时还有多长事时间,将该时间设置为IO事件的阻塞时间。

IO事件的接口:

  • ioloop.add_handle(fd, handler_event)
  • ioloop.update_handler(fd, events)
  • ioloop.remove_handler(fd)

例如:阻塞的HTTP服务器

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import socket

HOST = "127.0.0.1"
PORT = 8000
PROCESS = 1
EOL = b"\n\n"

response = b"HTTP/1.0 200 OK\r\nDate:Mon, 1 Jan 1996 01:01:01 GMT\r\n"
response += b"Content-Length:text/plain\r\nContent-Length:13\r\n\r\n"
response += b"hello world"

sdf = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sdf.bind((HOST, PORT))
sdf.listen(PROCESS)

try:
    while True:
        connection, address = sdf.accept()
        request = b""
        while EOL not in request:
            request += connection.recv(1024)
        connection.send(response)
        connection.close()
finally:
    sdf.close()

上述阻塞的HTTP服务器中,请求是顺序被处理的,当流程到达request += connection.recv(1024)时会发生阻塞,因为recv函数会不停的从缓冲区中读取数据,如果网络数据还没有到达时,就会阻塞在等待数据的到来。

当程序使用阻塞Socket的时候,通常会使用以一个线程甚至是专用连接在每个Socket上执行通信。主程序线程会监听服务器Socket,服务器端的Socket会接受来自客户端传入的连接。服务器每次都会创建有一个新的Socket用于接受客户端的连接,并将新建的Socket传递给一个单独的线程,然后该线程将会与客户端进行交互。因为一个连接都具有一个新的线程进行通信,所以任何阻塞都不会影响到其他线程执行的任务。这就是最传统的IO模型PPCprocess per connection和TPCthread per connection

实时的Web应用程序通常会针对每个用户创建一个持久化的连接,对于传统的同步服务器,这意味着需要给每个用户单独创建一个线程,不过这样做的代价是非常大得。为了减少并发连接得消耗,Tornado采用了单线程事件循环模型IOLoop,这也就意味着所有得应用代码都必须是异步非阻塞得,因为一次只能由一个活跃的操作。

Tornado本身是一个异步非阻塞的Web框架,强大的异步IO机制可提高服务器的响应能力。

例如:简单的HTTPServer

$ vim server.py
#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from tornado.options import define, options
from tornado.httpserver import HTTPServer
from tornado.web import Application, RequestHandler
from tornado.ioloop import IOLoop

define("port", type=int, default=8000)

class IndexHandler(RequestHandler):
    def get(self):
        self.write("hello world")

class App(Application):
    def __init__(self):
        handlers = [
            (r"/", IndexHandler)
        ]
        settings = dict(
            debug = True
        )
        Application.__init__(self, handlers, **settings)

def main():
    app = App()
    server = HTTPServer(app)
    server.listen(options.port)
    IOLoop.instance().start()

if __name__ == "__main__":
    main()

Tornado的核心IO循环模块封装了Linux的Epoll和BSD的kqueue,这是Tornado高性能的基石。

Tornado IOLoop

在Tornado服务器中IOLoop是调度的核心模块,Tornado服务器会将所有的Socket描述符都注册到IOLoop上,注册的时候需要在指明回调处理函数,IOLoop内部不断的监听IO事件,一旦发现某个Socket可读写就可以调用其在注册时指定的回调函数。

IOLoop

Nginx和Lighthttpd都是高性能的Web服务器,而Tornado也是著名的高抗负载的应用,它们之间有说明相似之处呢?

首先需要明白的是在TCPServer三段式create-bind-listen阶段,效率时很低的,为什么呢?因为只有当一个连接被断开后新连接才能被接收。因此,想要开发高性能的服务器,就必须在accept阶段上下功夫。

新连接得到来一般是经典的三次握手,只有当服务器收到一个SYN时才说明有一个新连接出现但还没有建立,此时监听的文件描述符fd是可读的,可以调用accept。在此之前服务器是可以干点儿别的事儿的,这有就是Linux中SELECT/POLL/EPOLL网络IO模式的思路。

只有等到TCP的三次握手成功后,accept才会返回,此时监听文件描述符fd是读完成状态,似乎服务器再次之前可以转身去干别的,等到都完成后再调用accept就不会有延迟了,这也就是异步网络IOAIO的思路,不过在*nix平台上支持的并不是很广泛。

另外,accept得到的新文件描述符fd不一定是可读的,因为客户端请求可能还没有到达,所以可以在等待新文件描述符fd可读时在read,但可能会存在一点儿的延迟。也可以用异步网络IOAIO等读完后在read读取,就不会产生延迟了。同样类似的,对于writeclose也有类似的事件。

总的来说,在我们关心的文件描述符fd上注册需关注的多个事件,事件发生了就启动回调,没有发生就看点别的。这是单线程的,多线程的相对复杂一点,但原理相似。Nginx和Lighttpd以及Tornado都使用了类似的方式,只不过是多进程和多线程或单线程之间的区别而已。

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

推荐阅读更多精彩内容

  • 本文摘抄自linux基础编程 IO概念 Linux的内核将所有外部设备都可以看做一个文件来操作。那么我们对与外部设...
    VD2012阅读 1,009评论 0 2
  • 原创文章出自公众号:「码农富哥」,如需转载请请注明出处!文章如果对你有收获,可以收藏转发,这会给我一个大大鼓励哟!...
    大富帅阅读 12,137评论 5 16
  • 必备的理论基础 1.操作系统作用: 隐藏丑陋复杂的硬件接口,提供良好的抽象接口。 管理调度进程,并将多个进程对硬件...
    drfung阅读 3,451评论 0 5
  • 简介 Tornado龙卷风是一个开源的网络服务器框架,它是基于社交聚合网站FriendFeed的实时信息服务开发而...
    JunChow520阅读 53,788评论 4 46
  • 1. 硬链接和软连接区别 硬连接-------指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区...
    杰伦哎呦哎呦阅读 2,141评论 0 2