高性能網絡編程6–reactor反應堆與定時器管理
作者:陶輝
反應堆開發模型被絕大多數高性能服務器所選擇,上一篇所介紹的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複用處理網絡事件驅動,還會實現定時器來處理時間事件的驅動(請求的超時處理或者定時任務的處理),就像下麵的示意圖:
這幅圖有5點意思:
(1)處理應用時基於OO思想,不同的類型的請求處理間是分離的。例如,A類型請求是用戶注冊請求,B類型請求是查詢用戶頭像,那麼當我們把用戶頭像新增多種分辨率圖片時,更改B類型請求的代碼處理邏輯時,完全不涉及A類型請求代碼的修改。
(2)應用處理請求的邏輯,與事件分發框架完全分離。什麼意思呢?即寫應用處理時,不用去管何時調用IO複用,不用去管什麼調用epoll_wait,去處理它返回的多個socket連接。應用代碼中,隻關心如何讀取、發送socket上的數據,如何處理業務邏輯。事件分發框架有一個抽象的事件接口,所有的應用必須實現抽象的事件接口,通過這種抽象才把應用與框架進行分離。
(3)反應堆上提供注冊、移除事件方法,供應用代碼使用,而分發事件方法,通常是循環的調用而已,是否提供給應用代碼調用,還是由框架簡單粗暴的直接循環使用,這是框架的自由。
(4)IO多路複用也是一個抽象,它可以是具體的select,也可以是epoll,它們隻必須提供采集到某一瞬間所有待監控連接中活躍的連接。
(5)定時器也是由反應堆對象使用,它必須至少提供4個方法,包括添加、刪除定時器事件,這該由應用代碼調用。最近超時時間是需要的,這會被反應堆對象使用,用於確認select或者epoll_wait執行時的阻塞超時時間,防止IO的等待影響了定時事件的處理。遍曆也是由反應堆框架使用,用於處理定時事件。
下麵用極簡流程來形象說明下反應堆是如何處理一個請求的,下圖中桔色部分皆為反應堆的分發事件流程:
可以看到,分發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簡單些的服務確可以是多線程。
最後更新:2017-04-03 08:26:25