高性能網絡編程6--reactor反應堆與定時器管理

http://taohui.org.cn/tcpperf6.html

陶輝寫的高性能網絡編程系列裏面的一篇,其它篇有時間的時候也學習一下,寫的確實非常好,對於學習服務器網絡編程很有幫助。


反應堆開發模型被絕大多數高性能服務器所選擇,上一篇所介紹的IO多路複用是它的實現基礎。定時觸發功能通常是服務器必備組件,反應堆模型往往還不得不將定時器的管理囊括在內。本篇將介紹反應堆模型的特點和用法。

首先我們要談談,網絡編程界爲什麼需要反應堆?有了IO複用,有了epoll,我們已經可以使服務器併發幾十萬連接的同時,維持高TPS了,難道這還不夠嗎?

我的答案是,技術層面足夠了,但在軟件工程層面卻是不夠的。

程序使用IO複用的難點在哪裏呢?1個請求雖然由多次IO處理完成,但相比傳統的單線程完整處理請求生命期的方法,IO複用在人的大腦思維中並不自然,因爲,程序員編程中,處理請求A的時候,假定A請求必須經過多個IO操作A1-An(兩次IO間可能間隔很長時間),每經過一次IO操作,再調用IO複用時,IO複用的調用返回裏,非常可能不再有A,而是返回了請求B。即請求A會經常被請求B打斷,處理請求B時,又被C打斷。這種思維下,編程容易出錯。

形象的說,傳統編程方法就好像是到了銀行營業廳裏,每個窗口前排了長隊,業務員們在窗口後一個個的解決客戶們的請求。一個業務員可以盡情思考着客戶A依次提出的問題,例如:

“我要買2萬XX理財產品。“

“看清楚了,5萬起售。”

“等等,查下我活期餘額。”

“餘額5萬。”

“那就買 5萬吧。”

業務員開始錄入信息。

”對了,XX理財產品年利率8%?”

“是預期8%,最低無利息保本。“

”早不說,拜拜,我去買餘額寶。“

業務員無表情的刪着已經錄入的信息進行事務回滾。

”下一個!“

用了IO複用則是大師業務員開始挑戰極限,在超大營業廳裏給客戶們人手一個牌子,黑壓壓的客戶們都在大廳中,有問題時舉牌申請提問,大師目光敏銳點名指定某人提問,該客戶迅速得到大師的答覆後,要經過一段時間思考,查查自己的銀袋子,諮詢下LD,才能再次進行下一個提問,直到得到完整的滿意答覆退出大廳。例如:大師剛指導A填寫轉帳單的某一項,B又來申請兌換泰銖,給了B兌換單後,C又來辦理定轉活,然後D與F在爭搶有限的圓珠筆時出現了不和諧現象,被大師叫停業務,暫時等待。

這就是基於事件驅動的IO複用編程比起傳統1線程1請求的方式來,有難度的設計點了,客戶們都是上帝,既不能出錯,還不能厚此薄彼。

當沒有反應堆時,我們可能的設計方法是這樣的:大師把每個客戶的提問都記錄下來,當客戶A提問時,首先查閱A之前問過什麼做過什麼,這叫聯繫上下文,然後再根據上下文和當前提問查閱有關的銀行規章制度,有針對性的回答A,並把回答也記錄下來。當圓滿回答了A的所有問題後,刪除A的所有記錄。

回到碼農生涯,即,某一瞬間,服務器共有10萬個併發連接,此時,一次IO複用接口的調用返回了100個活躍的連接等待處理。先根據這100個連接找出其對應的對象,這並不難,epoll的返回連接數據結構裏就有這樣的指針可以用。接着,循環的處理每一個連接,找出這個對象此刻的上下文狀態,再使用read、write這樣的網絡IO獲取此次的操作內容,結合上下文狀態查詢此時應當選擇哪個業務方法處理,調用相應方法完成操作後,若請求結束,則刪除對象及其上下文。

這樣,我們就陷入了面向過程編程方法之中了,在面向應用、快速響應爲王的移動互聯網時代,這樣做早晚得把自己玩死。我們的主程序需要關注各種不同類型的請求,在不同狀態下,對於不同的請求命令選擇不同的業務處理方法。這會導致隨着請求類型的增加,請求狀態的增加,請求命令的增加,主程序複雜度快速膨脹,導致維護越來越困難,苦逼的程序員再也不敢輕易接新需求、重構。

反應堆是解決上述軟件工程問題的一種途徑,它也許並不優雅,開發效率上也不是最高的,但其執行效率與面向過程的使用IO複用卻幾乎是等價的,所以,無論是nginx、memcached、redis等等這些高性能組件的代名詞,都義無反顧的一頭扎進了反應堆的懷抱中。

反應堆模式可以在軟件工程層面,將事件驅動框架分離出具體業務,將不同類型請求之間用OO的思想分離。通常,反應堆不僅使用IO複用處理網絡事件驅動,還會實現定時器來處理時間事件的驅動(請求的超時處理或者定時任務的處理),就像下面的示意圖:

/image/反應堆模型.jpg

這幅圖有5點意思:

(1)處理應用時基於OO思想,不同的類型的請求處理間是分離的。例如,A類型請求是用戶註冊請求,B類型請求是查詢用戶頭像,那麼當我們把用戶頭像新增多種分辨率圖片時,更改B類型請求的代碼處理邏輯時,完全不涉及A類型請求代碼的修改。

(2)應用處理請求的邏輯,與事件分發框架完全分離。什麼意思呢?即寫應用處理時,不用去管何時調用IO複用,不用去管什麼調用epoll_wait,去處理它返回的多個socket連接。應用代碼中,只關心如何讀取、發送socket上的數據,如何處理業務邏輯。事件分發框架有一個抽象的事件接口,所有的應用必須實現抽象的事件接口,通過這種抽象才把應用與框架進行分離。

(3)反應堆上提供註冊、移除事件方法,供應用代碼使用,而分發事件方法,通常是循環的調用而已,是否提供給應用代碼調用,還是由框架簡單粗暴的直接循環使用,這是框架的自由。

(4)IO多路複用也是一個抽象,它可以是具體的select,也可以是epoll,它們只必須提供採集到某一瞬間所有待監控連接中活躍的連接。

(5)定時器也是由反應堆對象使用,它必須至少提供4個方法,包括添加、刪除定時器事件,這該由應用代碼調用。最近超時時間是需要的,這會被反應堆對象使用,用於確認select或者epoll_wait執行時的阻塞超時時間,防止IO的等待影響了定時事件的處理。遍歷也是由反應堆框架使用,用於處理定時事件。

下面用極簡流程來形象說明下反應堆是如何處理一個請求的,下圖中桔色部分皆爲反應堆的分發事件流程:

/image/反應堆例子.jpg

可以看到,分發IO、定時器事件都由反應堆框架來完成,應用代碼只會關注於如何處理可讀、可寫事件。 當然,上圖是極度簡化的流程,實際上要處理的異常情況都沒有列入。

這裏可以看到,爲什麼定時器集合需要提供最近超時事件距離現在的時間?因爲,調用epoll_wait或者select時,並不能夠始終傳入-1作爲timeout參數。因爲,我們的服務器主營業務往往是網絡請求處理,如果網絡請求很少時,那麼CPU的所有時間都會被頻繁卻又不必要的epoll_wait調用所佔用。在服務器閒時使進程的CPU利用率降低是很有意義的,它可以使服務器上其他進程得到更多的執行機會,也可以延長服務器的壽命,還可以省電。這樣,就需要傳入準確的timeout最大阻塞時間給epoll_wait了。

什麼樣的timeout時間纔是準確的呢?這等價於,我們需要準確的分析,什麼樣的時段進程可以真正休息,進入sleep狀態?

一個沒有意義的答案是:不需要進程執行任務的時間段內是可以休息的。

這就要求我們仔細想想,進程做了哪幾類任務,例如:

1、所有網絡包的處理,例如TCP連接的建立、讀寫、關閉,基本上所有的正常請求都由網絡包來驅動的。對這類任務而言,沒有新的網絡分組到達本機時,就是可以使進程休息的時段。

2、定時器的管理,它與網絡、IO複用無關,雖然它們在業務上可能有相關性。定時器裏的事件需要及時的觸發執行,不能因爲其他原因,例如阻塞在epoll_wait上時耽誤了定時事件的處理。當一段時間內,可以預判沒有定時事件達到觸發條件時(這也是提供接口查詢最近一個定時事件距當下的時間的意義所在),對定時任務的管理而言,進程就可以休息了。

3、其他類型的任務,例如磁盤IO執行完成,或者收到其他進程的signal信號,等等,這些任務明顯不需要執行的時間段內,進程可以休息。

於是,使用反應堆模型的進程代碼中,通常除了epoll_wait這樣的IO複用外,其他調用都會基於無阻塞的方式使用。所以,epoll_wait的timeout超時時間,就是除網絡外,其他任務所能允許的進程睡眠時間。而只考慮常見的定時器任務時,就像上圖中那樣,只需要定時器集合能夠提供最近超時事件到現在的時間即可。

從這裏也可以推導出,定時器集合通常會採用有序容器這樣的數據結構,好處是:

1、容易取到最近超時事件的時間。

2、可以從最近超時事件開始,向後依次遍歷已經超時的事件,直到第一個沒有超時的事件爲止即可停止遍歷,不用全部遍歷到。

因此,粗暴的採用無序的數據結構,例如普通的鏈表,通常是不足取的。但事無絕對,redis就是用了個毫無順序的鏈表,原因何在?因爲redis的客戶端連接沒有超時概念,所以對於併發的成千上萬個連上,都不會因爲超時被斷開。redis的定時器唯一的用途在於定時的將內存數據刷到磁盤上,這樣的定時事件通常只有個位數,其性能無關緊要。

如果定時事件非常多,綜合插入、遍歷、刪除的使用頻率,使用樹的機會最多,例如小根堆(libevent)、二叉平衡樹(nginx紅黑樹)。當然,場景特殊時,儘可以用有序數組、跳躍表等等實現。

綜上所述,反應堆模型開發效率上比起直接使用IO複用要高,它通常是單線程的,設計目標是希望單線程使用一顆CPU的全部資源,但也有附帶優點,即每個事件處理中很多時候可以不考慮共享資源的互斥訪問。可是缺點也是明顯的,現在的硬件發展,已經不再遵循摩爾定律,CPU的頻率受制於材料的限制不再有大的提升,而改爲是從核數的增加上提升能力,當程序需要使用多核資源時,反應堆模型就會悲劇,爲何呢? 如果程序業務很簡單,例如只是簡單的訪問一些提供了併發訪問的服務,就可以直接開啓多個反應堆,每個反應堆對應一顆CPU核心,這些反應堆上跑的請求互不相關,這是完全可以利用多核的。例如Nginx這樣的http靜態服務器。

如果程序比較複雜,例如一塊內存數據的處理希望由多核共同完成,這樣反應堆模型就很難做到了,需要昂貴的代價,引入許多複雜的機制。所以,大家就可以理解像redis、nodejs這樣的服務,爲什麼只能是單線程,爲什麼memcached簡單些的服務確可以是多線程。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章