事件驅(qū)動編程
事件驅(qū)動編程是一種網(wǎng)絡(luò)編程范式,程序的執(zhí)行流由外部事件來決定演熟,特點是包含一個事件循環(huán)频敛,當外部事件發(fā)生時會使用回調(diào)機制來觸發(fā)相應的處理。與傳統(tǒng)線性的編程模式相比而言膛檀,事件驅(qū)動程序在啟動后就會進入等待,那么等待什么呢咖刃?等待被事件觸發(fā)。
與傳統(tǒng)的單線程(同步)和多線程編程范式相比花鹅,三種模式下程序執(zhí)行效率各不相同。
- 單進程:服務器每接收到一個請求就會創(chuàng)建一個新的進程來處理該請求
- 多線程:服務器每接收到一個請求就會創(chuàng)建一個新的線程來處理該請求
- 事件驅(qū)動:服務器每接收到一個請求首先放入事件列表翠胰,然后主進程通過非阻塞IO的方式來處理請求自脯。
上圖可知:程序有三個任務需要完成,每個任務都在等待IO操作時阻塞锻狗,阻塞在IO操作上所耗費的時間使用灰色標注。經(jīng)過比對可以發(fā)現(xiàn)焕参,事件驅(qū)動模型對CPU的使用率時最高的轻纪,它不會因為某個任務的阻塞而導致整個進程的阻塞,從開始到結(jié)束叠纷,總是有一個可以運行的任務在執(zhí)行刻帚。
Tornado是基于事件驅(qū)動模型實現(xiàn)的,IOLoop是Tornado的事件循環(huán)涩嚣,也是Tornado的核心崇众。
Tornado的事件循環(huán)機制是根據(jù)系統(tǒng)平臺來選擇底層驅(qū)動的,如果是Linux系統(tǒng)則使用的是Epoll航厚,如果是類UNIX如BSD或MacOS系統(tǒng)則使用的是Kqueue顷歌,如果都不支持的話則會回退到Select模式。
IO多路復用
準確來說幔睬,Epoll是Linux內(nèi)核升級的多路復用IO模型眯漩,在UNIX和MacOS上類似則是Kqueue。
IO多路復用
IO多路復用機制有select
麻顶、poll
赦抖、epoll
三種舱卡,所謂的IO多路復用是通過某種機制監(jiān)聽多個文件描述符,一旦文件描述符就緒(讀就緒或?qū)懢途w)摹芙,能夠通知程序進行相應的讀寫操作灼狰。本質(zhì)上select
、poll
浮禾、epoll
都是同步IO交胚,因為他們都需要在讀寫事件就緒后自己負責讀寫,也就是說讀寫過程是阻塞的盈电。異步IO無需自己進行讀寫蝴簇,異步IO的實現(xiàn)會負責將數(shù)據(jù)從內(nèi)核拷貝到用戶空間。
- select
int select(
int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout
)
select
函數(shù)負責監(jiān)視的文件描述符可分為三類匆帚,分別是writefds
熬词、readfds
、exceptfds
互拾。調(diào)用select
函數(shù)會阻塞直到有描述符就緒颜矿,即有數(shù)據(jù)可讀骑疆、可寫或者有異常箍铭≌┗穑或者超時冷守,函數(shù)返回教沾。當select
函數(shù)返回時可以通過遍歷所有描述符來尋找就緒的描述符译断。
目前幾乎在所有的平臺上都支持select
孙咪,另外select
對于超時提供了微秒級別的精度控制翎蹈。
select
的缺點在于單個進程能夠監(jiān)視的文件描述符的數(shù)量有有限的荤堪,在Linux中一般是1024澄阳,可通過修改宏定義重新編譯內(nèi)核的方式來提升低剔,但同時會帶來效率的降低肮塞。
select
對Socket掃描時是線性的猜欺,也就是采用輪詢的方式替梨,效率低下装黑。當Socket比較多的時候糠睡,每次select
函數(shù)都需要通過遍歷FD_SIZE
個Socket來完成調(diào)度狈孔,不管Socket是否活躍都會遍歷一次均抽,這會浪費大量CPU時間油挥。如果能給Socket注冊某個回調(diào)函數(shù)深寥,當Socket活躍時自動完成相關(guān)操作则酝,就可以避免輪詢沽讹,這也正是Epoll和Kqueue所做的妥泉。
select
需要維護一個用來存放大量文件描述符的數(shù)據(jù)結(jié)構(gòu)盲链,這使得用戶空間和內(nèi)核空間在傳遞該數(shù)據(jù)結(jié)構(gòu)時存在巨大的復制開銷刽沾。
select
是幾乎所有的UNIX或Linux都支持的一種IO多路復用的方式侧漓,通過select
函數(shù)發(fā)出IO請求后布蔗,縣城會阻塞直到有數(shù)據(jù)準備完畢才能把數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間纵揍,所以select
是同步阻塞的方式泽谨。
- poll
int poll(
struct pollfd fd[],
nfds_t nfds,
int timeout
);
//poll通過pollfd數(shù)組向內(nèi)核傳遞所需關(guān)注的事件吧雹,因此沒有描述符個數(shù)限制雄卷。
//pollfd中的events和revents分別用于標識關(guān)注的事件和發(fā)生的事件陕凹,因此pollfd數(shù)組僅需初始化一次。
//poll的實現(xiàn)機制與select類似拂盯,對應內(nèi)核中的sys_poll谈竿,只不過poll向內(nèi)核傳遞pollfd數(shù)組空凸,然后對pollfd中的每個描述符進行poll,相比fdset來說道逗,poll的效率更高献烦。
//poll返回后需要對pollfd中的每個元素檢查其revents值巩那,用來指明事件是否發(fā)生。
struct pollfd(
int fd; //文件描述符
short events;//請求的事件
short revents;//返回的事件
)
poll
不要求開發(fā)者計算最大文件描述符的大小,在應付大數(shù)量的文件描述符時比select
效率更高东囚,而且poll
沒有最大連接數(shù)的限制抛蚁,因為poll
采用的是基于鏈表來存儲的惕橙。
poll
的缺點在于包含大量文件描述符的數(shù)組會被整體復制于用戶態(tài)和內(nèi)核的地址空間之間弥鹦,不論文件描述符是否就緒,開銷隨著文件描述符數(shù)量的增加線性增大膝晾。另外與select
一樣血当,poll
返回后需要輪詢所有的描述符來獲取就緒的描述符。
通過poll
函數(shù)發(fā)出IO請求后禀忆,線程會阻塞直到數(shù)據(jù)準備完畢臊旭,poll
函數(shù)在pollfd
中通過revents
返回事件,然后線程會將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間箩退,所以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
對文件描述符的操作有兩種模式分別是水平觸發(fā)和邊緣觸發(fā)
水平觸發(fā)LT, Level Trigger
當被監(jiān)控的文件描述符fd
上有可讀寫事件發(fā)生時戴涝,epoll_wait()
函數(shù)會通知處理程序讀寫滋戳。如果本次沒有將數(shù)據(jù)一次性全部讀寫完畢,比如讀寫緩沖區(qū)太小啥刻。那么下次再調(diào)用epoll_wait()
函數(shù)時,會通知你在上次沒有讀寫完的文件描述符fd
上繼續(xù)進行讀寫钝满。如果一直不去讀寫,那它會一直通知你。如果系統(tǒng)中有大量不需要讀寫的就緒文件描述符fd
,并且它們每次都會返回,這樣會大大降低處理程序檢索自己關(guān)系的就緒文件描述符的效率。
邊緣觸發(fā)ET, Edge Trigger
邊緣觸發(fā)是當被監(jiān)控的文件描述符fd
上有可讀寫事件發(fā)生時署照,epoll_wait()
函數(shù)會通知處理程序去讀寫。如果本次沒有將數(shù)據(jù)全部讀寫,比如讀寫緩沖區(qū)太小灾挨。那么下次調(diào)用epoll_wait()
時秒拔,它就不會通知你食磕,也就是說它只會通知你一次,知道該文件描述符出現(xiàn)第二次可讀寫事件時才會通知你搂橙。這種模式比水平觸發(fā)模式效率更高侄泽,系統(tǒng)中不會充斥大量你所不關(guān)心的就緒文件描述符。
epoll
支持阻塞和非阻塞兩種方式艰垂,而邊緣模式只能配合非阻塞使用。
Python中Epoll的事件默認使用的是水平觸發(fā)LT
(Level Trigger)模式谷遂,Python中的Epoll可通過select.EPOLLET
設(shè)置為ET模式集晚。
epoll.register(connection.fileno(), select.EPOLLIN)
epoll.register(connection.fileno(), select.EPOLLIN | select.EPOLLET)
Epoll
Tornado優(yōu)秀的大并發(fā)處理能力得益于Web服務器從底層開始就實現(xiàn)了一整套基于Epoll的單線程異步架構(gòu),而tornado.ioloop
就是Web服務器最底層的實現(xiàn)蛤签。IOLoop是對IO多路復用的封裝钙蒙,其實現(xiàn)為一個單例并保存在IOLoop._instance
中扛施。
Epoll是Linux內(nèi)核中實現(xiàn)的一種可擴展的IO事件通知機制谍肤,是對POSIX系統(tǒng)中select
和poll
的替代非凌,具有更高的性能和擴展性箕肃。FreeBSD中類似的實現(xiàn)是Kqueue。
Tornado中基于Python C擴展實現(xiàn)的Epoll模塊對Epoll的使用進行了封裝今魔,使得IOLoop對象可通過相應的事件處理機制對IO進行調(diào)度勺像。
IOLoop的實現(xiàn)是基于Epoll的,那么什么是Epoll呢错森?
Epoll是Linux內(nèi)核為處理大批量文件描述符fd
而做了改進后的Poll吟宦,那什么又是 Poll呢?
Socket通信時的服務器在接受accept
客戶端連接并建立通信后connection
才會開始通信涩维,而此時服務器并不知道連接的客戶端有沒有將數(shù)據(jù)發(fā)送完畢殃姓,此時有兩種選擇方案:
- 第一種是一直在這里等待直到收發(fā)數(shù)據(jù)結(jié)束
- 第二種是每隔一段時間來查看是否存在數(shù)據(jù)。
第一種方案雖然可以解決問題瓦阐,但需要注意的是對于一個線程或進程蜗侈,同時是只能處理一個Socket通信,與此同時其他連接只能被阻塞睡蟋,顯然這種方式在單進程情況下是不現(xiàn)實的踏幻。
第二種方案比第一種方案相對要好一些,多個連接可以統(tǒng)一在一定時間段內(nèi)輪流查看是否有數(shù)據(jù)需要讀寫戳杀,看上去是可以同時處理多個連接了该面,這種方式也就是Poll/Select的解決方案。
第二種方案的問題是隨著連接越來越多豺瘤,輪詢所耗費的時間將會越來越長吆倦,然而服務器連接的Socket大多不是活躍的,因此輪詢所耗費的大部分時間將是無用的坐求。為了 解決這個問題蚕泽,Epoll被創(chuàng)建了出來,Epoll的概念和Poll類似,不過每次輪詢時只會將有數(shù)據(jù)活躍的Socket挑選出來進行輪詢须妻,這樣在大量連接時會節(jié)省大量時間仔蝌。
對于Epoll的操作主要是通過4個API完成的
-
epoll_create
用于創(chuàng)建一個Epoll描述符 -
epoll_ctl
用于操作Epoll中的事件Event
-
epoll_wait
用于讓Epoll開始工作,參數(shù)timeout為0時會立即返回荒吏,timeout為-1時會一直監(jiān)聽敛惊,timeout大于0時為監(jiān)聽阻塞時長。在監(jiān)聽時若有數(shù)據(jù)活躍的連接時绰更,會返回活躍的文件句柄列表瞧挤。 -
close
用于關(guān)閉Epoll
Epoll中的事件包括
-
EPOLL_CTL_ADD
添加一個新的Epoll事件 -
EPOLL_CTL_DEL
刪除一個Epoll事件 -
EPOLL_CTL_MOD
修改一個事件的監(jiān)聽方式
Epoll事件的監(jiān)聽方式可分為七種,重點關(guān)注其中的三種儡湾。
-
EPOLLIN
緩沖區(qū)滿特恬,此時有數(shù)據(jù)可讀。 -
EPOLLOUT
緩沖區(qū)空徐钠,此時可寫數(shù)據(jù)癌刽。 -
EPOLLERR
發(fā)生錯誤
IOLoop
- IOLoop對Epoll的封裝和I/O調(diào)度的具體實現(xiàn)
- IOLoop模塊對網(wǎng)絡(luò)事件類型的封裝與Epoll一致,分別為
READ
/WRITE
/ERROR
三種類型尝丐。
IOLoop是基于Epoll實現(xiàn)的底層網(wǎng)絡(luò)IO的核心調(diào)度模塊显拜,用于處理Socket相關(guān)的連接、響應爹袁、異步讀寫等網(wǎng)絡(luò)事件远荠。每個Tornado進程都會初始化一個全局的IOLoop實例,在IOLoop中通過靜態(tài)方法instance()
進行封裝呢簸,獲取IOLoop實例后直接調(diào)用instance()
方法即可矮台。
IOLoop實現(xiàn)了Reactor模型乏屯,將所有需要處理的IO事件注冊到一個中心IO多路復用器上根时,同時主線程/進程阻塞在多路復用器上。一旦有IO事件到來或是準備就緒辰晕,即文件描述符或Socket可讀寫時蛤迎,多路復用器會返回并將事先注冊的相應IO事件分發(fā)到對應的處理器上。
Tornado服務器啟動時會創(chuàng)建并監(jiān)聽Socket含友,并將Socket的文件描述符fd, file descriptor
注冊到IOLoop實例中替裆,IOLoop添加對Socket的IOLoop.READ
事件監(jiān)聽并傳入回調(diào)處理函數(shù)。當某個Socket通過accept
接收連接請求后調(diào)用注冊的回調(diào)函數(shù)進行讀寫窘问。
tornado.ioloop
表示主事件循環(huán)辆童,典型的應用程序?qū)⑹褂脝蝹€IOLoop對象并在IOLoop.instance
單例中。通常在main()
函數(shù)結(jié)束時調(diào)用IOLoop.start()
方法惠赫。非典型應用可以使用多個IOLoop把鉴,比如每個線程一個IOLoop
。
IOLoop的核心調(diào)度集中在start()
方法中,IOLoop實例對象調(diào)用start
后開始Epoll事件循環(huán)機制庭砍,start()
方法會一直運行直到IOLoop對象調(diào)用stop
函數(shù)场晶、當前所有事件循環(huán)完成。start()
方法中主要分為三部分:
- 對超時的相關(guān)處理
- Epoll事件通知阻塞和接收
- Epoll返回IO事件的處理
Tornado在IOLoop中會去循環(huán)檢查三類事件:
- 可立即執(zhí)行的事件
ioloop._callbacks
可立即執(zhí)行的事件一般是Future在set_result
時將Future中的所有call_back
以這種類型的事件添加到IOLoop中怠缸。IOLoop中當存在可立即執(zhí)行的事件時會立即調(diào)度它們的回調(diào)函數(shù)诗轻。
添加可立即執(zhí)行的事件的接口:ioloop.add_callback(callback)
- 定時器事件
ioloop.timer
IOLoop中維護了一個定時器事件列表,按照timeout
超時時間以最小堆的形式存儲揭北,在IOLoop循環(huán)至定時器事件時扳炬,會不斷地判斷堆頂?shù)亩〞r器是否會超時,如果超時則取出搔体,直到取出所有超時的定時器鞠柄,之后會調(diào)度定時器對應的回調(diào)函數(shù)。
添加定時器事件的接口:ioloop.call_at(deadline, callback)
- IO事件
當可立即執(zhí)行的事件嫉柴、定時器事件的回調(diào)函數(shù)都執(zhí)行完畢后厌杜,IOLoop會檢查是否有新的可立即執(zhí)行的事件加入,如果有則IO事件的阻塞事件會設(shè)置為0即非阻塞计螺,否則檢查距離最近的一個定時器超時還有多長事時間夯尽,將該時間設(shè)置為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)
時會發(fā)生阻塞,因為recv
函數(shù)會不停的從緩沖區(qū)中讀取數(shù)據(jù)陈轿,如果網(wǎng)絡(luò)數(shù)據(jù)還沒有到達時圈纺,就會阻塞在等待數(shù)據(jù)的到來。
當程序使用阻塞Socket的時候麦射,通常會使用以一個線程甚至是專用連接在每個Socket上執(zhí)行通信蛾娶。主程序線程會監(jiān)聽服務器Socket,服務器端的Socket會接受來自客戶端傳入的連接潜秋。服務器每次都會創(chuàng)建有一個新的Socket用于接受客戶端的連接蛔琅,并將新建的Socket傳遞給一個單獨的線程,然后該線程將會與客戶端進行交互峻呛。因為一個連接都具有一個新的線程進行通信罗售,所以任何阻塞都不會影響到其他線程執(zhí)行的任務。這就是最傳統(tǒng)的IO模型PPCprocess per connection
和TPCthread per connection
钩述。
實時的Web應用程序通常會針對每個用戶創(chuàng)建一個持久化的連接寨躁,對于傳統(tǒng)的同步服務器,這意味著需要給每個用戶單獨創(chuàng)建一個線程牙勘,不過這樣做的代價是非常大得职恳。為了減少并發(fā)連接得消耗,Tornado采用了單線程事件循環(huán)模型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循環(huán)模塊封裝了Linux的Epoll和BSD的kqueue贺氓,這是Tornado高性能的基石。
在Tornado服務器中IOLoop是調(diào)度的核心模塊床蜘,Tornado服務器會將所有的Socket描述符都注冊到IOLoop上辙培,注冊的時候需要在指明回調(diào)處理函數(shù),IOLoop內(nèi)部不斷的監(jiān)聽IO事件邢锯,一旦發(fā)現(xiàn)某個Socket可讀寫就可以調(diào)用其在注冊時指定的回調(diào)函數(shù)扬蕊。
Nginx和Lighthttpd都是高性能的Web服務器,而Tornado也是著名的高抗負載的應用丹擎,它們之間有說明相似之處呢尾抑?
首先需要明白的是在TCPServer三段式create-bind-listen
階段,效率時很低的蒂培,為什么呢再愈?因為只有當一個連接被斷開后新連接才能被接收。因此护戳,想要開發(fā)高性能的服務器翎冲,就必須在accept
階段上下功夫。
新連接得到來一般是經(jīng)典的三次握手媳荒,只有當服務器收到一個SYN
時才說明有一個新連接出現(xiàn)但還沒有建立抗悍,此時監(jiān)聽的文件描述符fd
是可讀的,可以調(diào)用accept
钳枕。在此之前服務器是可以干點兒別的事兒的缴渊,這有就是Linux中SELECT/POLL/EPOLL
網(wǎng)絡(luò)IO模式的思路。
只有等到TCP的三次握手成功后么伯,accept
才會返回疟暖,此時監(jiān)聽文件描述符fd
是讀完成狀態(tài)卡儒,似乎服務器再次之前可以轉(zhuǎn)身去干別的田柔,等到都完成后再調(diào)用accept
就不會有延遲了,這也就是異步網(wǎng)絡(luò)IOAIO
的思路骨望,不過在*nix
平臺上支持的并不是很廣泛硬爆。
另外,accept
得到的新文件描述符fd
不一定是可讀的擎鸠,因為客戶端請求可能還沒有到達缀磕,所以可以在等待新文件描述符fd
可讀時在read
,但可能會存在一點兒的延遲。也可以用異步網(wǎng)絡(luò)IOAIO
等讀完后在read
讀取袜蚕,就不會產(chǎn)生延遲了糟把。同樣類似的,對于write
和close
也有類似的事件牲剃。
總的來說遣疯,在我們關(guān)心的文件描述符fd
上注冊需關(guān)注的多個事件,事件發(fā)生了就啟動回調(diào)凿傅,沒有發(fā)生就看點別的缠犀。這是單線程的,多線程的相對復雜一點聪舒,但原理相似辨液。Nginx和Lighttpd以及Tornado都使用了類似的方式,只不過是多進程和多線程或單線程之間的區(qū)別而已箱残。