I/O模型選擇
在網絡服務端編程中,一個常見的情景是服務器需要判斷多個已連接套接字是否可讀,如果某個套接字可讀,則讀取該套接字數據,並進行進一步處理。
在最常用的阻塞式I/O模型中,我們對每個連接套接字通過輪流read系統調用獲取可讀數據。如圖3-1所示,read系統調用將會把該線程阻塞,直到數據報到達且被復制到應用進程的緩沖區中時才會返回。
圖3-1 阻塞式I/O模型
在阻塞式I/O模型中,數據可讀和讀取數據這兩個操作被合並在了一個系統調用中,對於單個套接字是否可讀的判斷,必須要等到實際數據接收完成才行,阻塞耗時是不確定的。考慮到這樣一個情景,如果服務器中有十個已連接的的套接字,此時服務器給其中一個套接字調用read,而這個連接客戶下一次向服務器發送數據將是2小時后。則該服務器也將阻塞在read這個系統調用2個小時,這段時間內服務器就算收到其他九個已連接的套接字數據也無法進行處理。這種一次只能針對一個客戶連接的服務器I/O模型顯然不是我們應該考慮的。
我們可以在阻塞式I/O模型的基礎上進行修改,采用非阻塞式I/O模型。如圖3-2所示,我們在執行read系統調用時,如果沒有立即收到數據報,我們將不會把線程投入睡眠,而是返回一個錯誤。
圖3-2 非阻塞式I/O模型
在非阻塞I/O模型中,雖然數據可讀和讀取數據這兩個操作依舊在一個系統調用中,但是如果沒有數據可讀,系統調用將立即返回。此時我們可以對多個連接套接字輪流調用read,直到某次調用收到了實際數據,我們才針對這次收到的數據進行處理。通過這種方式,我們能夠初步解決服務器同時讀取多個客戶數據的問題。但是這種在一個線程內對多個非阻塞描述符循環調用read的方式,我們稱之為輪詢。應用持續輪詢內核,以查看某個操作是否就緒,這往往會消耗大量CPU時間,同時也會給整個服務器帶來極大的額外開銷。因此這種服務器I/O模型也不能很好的滿足我們對於高性能的追求。
一種改良方案是在以上兩種I/O模型的基礎上加入多線程支持,即thread-per-connection方案。在該方案中,服務器會給每個連接客戶分配一個線程,每個連接的讀寫都是在一個單獨的線程中進行。因此對一個客戶線程內的套接字進行讀取操作,最多只會阻塞該客戶線程,而不會對其他線程的其他連接產生影響。這是Java網絡編程常見方案。這種方式中線程創建和銷毀的開銷較大,因此並不適合可能會頻繁連接和斷開的短連接服務,當然頻繁的線程創建和銷毀可以通過線程池進行改良。同時這種方案的伸縮性同樣受到線程數的限制,對於存在的一兩百個連接而創建的一兩百個線程數,系統還能勉強支撐,但是如果同時存在幾千個線程的話,這將會對操作系統的調度程序產生極大的負擔。同時更多的線程也會對內存大小提出很高的要求。
我們從以上幾個例子可以看出,解決服務器對多個連接套接字的讀取的關鍵,其一是需將可讀判斷與實際讀取數據相分離;其二是能同時支持多個套接字可讀判斷。因此我們需要一種能夠預先告知內核的能力,使得內核一旦發現進程指定的一個或多個I/O條件就緒,即輸入已經准備好被讀取,它就通知進程。這個行為稱之為I/O復用。在Linux平台上,提供了select、poll和epoll這幾種系統調用作為I/O復用的方式。
Epoll是Linux內核為處理大批量文件描述符而做了改進的poll,是Linux下多路復用I/O接口的增強版本,支持水平觸發和邊緣觸發兩種方式。相對於select等I/O復用方式,它具有支持大數目的描述符,I/O效率不隨注冊的描述符數目增加而線性下降(傳統的select以及poll的效率會因為注冊描述符數量的線形遞增而導致呈二次乃至三次方的下降),和使用mmap加速內核與用戶空間的消息傳遞等優點。本系統將采用水平觸發的epoll作為具體I/O復用的系統調用。
圖3-3 I/O復用模型
如圖3-3所示,我們將多個連接套接字注冊在I/O復用的epoll系統調用中,並注冊讀事件,此時系統阻塞於epoll調用,等待某個數據報套接字變為可讀。當epoll返回時,將會返回可讀的套接字集合,我們只需遍歷這些可讀套接字,然后分別對每個可讀套接字調用read系統調用,讀出具體數據,並進一步進行相應處理即可。
通過使用epoll的這種I/O復用模型,我們能夠在不引入創建新線程開銷的前提下實現了服務器對多個連接套接字的讀取支持。此處的多個連接,只和系統內存大小相關。而且由於Linux內核對於epoll的優化,連接描述符數量的增加不會導致性能線性下降。因此基於epoll的I/O復用模型能夠滿足我們對於服務器系統高並發和高性能的需求。
Reactor模式介紹
在之前的研究中,我們選用epoll作為服務器系統的I/O復用模型。但是epoll太底層,它只是一個Linux的系統調用,需要通過某種事件處理機制進行進一步的封裝。
在普通的事件處理機制中,首先程序調用某個函數,然后函數執行,程序等待,當函數執行完畢后函數將結果和控制權返回給程序,最后程序繼續處理。在之前的I/O復用模型的研究中,我們同樣是采用這種事件處理順序進行的。
我們可以將整個問題抽象。每個已經連接的套接字描述符就是一個事件源,每一個套接字接收到數據后的進一步處理操作作為一個事件處理器。我們將需要被處理的事件處理源及其事件處理器注冊到一個類似於epoll的事件分離器中。事件分離器負責等待事件發生。一旦某個事件發送,事件分離器就將該事件傳遞給該事件注冊的對應的處理器,最后由處理器負責完成實際的讀寫工作。這種方式就是Reactor模式的事件處理方式。
相對於之前普通函數調用的事件處理方式,Reactor模式是一種以事件驅動為核心的機制。在Reactor模式中,應用程序不是主動的調用某個API完成處理,而是逆置了事件處理流程,應用程序需要提供相應的事件接口並注冊到Reactor上,如果相應的事件發生,Reactor將主動調用應用程序注冊的接口,通過注冊的接口完成具體的事件處理。
Reactor模式優點
Reactor模式是編寫高性能網絡服務器的必備技術之一,一些常用網絡庫如libevent、muduo等都是通過使用Reactor模式實現了網絡庫核心。它具有如下優點:
- 響應速度快,不必為單個同步時間所阻塞,雖然Reactor本身依然是需要同步的;
- 編程簡單,可以最大程度的避免復雜的多線程及同步問題,並且避免了多線程/進程的切換開銷;
- 可擴展性強,可以很方便的通過增加Reactor實例個數來充分利用CPU資源;
- 可復用性強,Reactor模式本身與具體事件處理邏輯無關,具有很高的復用性。
Reactor模式組成
圖3-4是Reactor類圖,圖中表明Reactor模式由事件源、事件反應器、事件分離器、事件處理器等組件組成,具體介紹如下:
圖3-4 Reactor類圖
- l 事件源(handle):由操作系統提供,用於識別每一個事件,如Socket描述符、文件描述符等。在服務端系統中用一個整數表示。該事件可能來自外部,如來自客戶端的連接請求、數據等。也可能來自內部,如定時器事件。
- l 事件反應器(reactor):定義和應用程序控制事件調度,以及應用程序注冊、刪除事件處理器和相關描述符相關的接口。它是事件處理器的調度核心,使用事件分離器來等待事件的發生。一旦事件發生,反應器先是分離每個事件,然后調度具體事件的事件處理器中的回調函數處理事件。
- l 事件分離器(demultiplexer):是一個有操作系統提供的I/O復用函數,在此我們選用epoll。用來等待一個或多個事件的發生。調用者將會被阻塞,直到分離器分離的描述符集上有事件發生。
- l 事件處理器(even handler):事件處理程序提供了一組接口,每個接口對應了一種類型的事件,供reactor在相應的事件發生時調用,執行相應的事件處理。一般每個具體的事件處理器總是會綁定一個有效的描述符句柄,用來識別事件和服務。
Reactor事件處理流程
Reactor事件處理流程如圖3-5所示,它分為兩個部分,其一為事件注冊部分,其二為事件分發部分,具體論述如下。
圖3-5 Reactor事件處理時序圖
在事件注冊部分,應用程序首先將期待注冊的套接字描述符作為事件源,並將描述符和該事件對應的事件處理回調函數封裝到具體的事件處理器中,並將該事件處理器注冊到事件反應器中。事件反應器接收到事件后,進行相應處理,並將注冊信息再次注冊到事件分離器epoll中。最后在epoll分離器中,通過epoll_ctl進行添加描述符及其事件,並層層返回注冊結果。
在事件處理部分,首先事件反應器通過調用事件分離器的epoll_wait,使線程阻塞等待注冊事件發生。此時如果某注冊事件發生,epoll_wait將會返回,並將包含該注冊事件在內的事件集返回給事件反應器。反應器接收到該事件后,根據該事件源找到該事件的事件處理器,並判斷事件類型,根據事件類型在該事件處理器調用之前注冊時封裝的具體回調函數,在這個具體回調函數中完成事件處理。
根據Reactor模式具體的事件處理流程可知,應用程序只參與了最開始的事件注冊部分。對於之后的整個事件等待和處理的流程中,應用程序並不直接參與,最終的事件處理也是委托給了事件反應器進行。因此通過使用Reactor模式,應用程序無需關心事件是怎么來的,是什么時候來的,我們只需在注冊事件時設置好相應的處理方式即可。這也反映了設計模式中的“好萊塢原則”,具體事件的處理過程被事件反應器控制反轉了。
Reactor模式的使用
到目前為止,Reactor事件處理模式已經初步成型,我們通過進一步對Reactor模式的使用和完善來逐步實現服務端網絡框架底層。
我們將Reactor的事件分離器epoll返回,再到服務器下一次調用epoll阻塞,稱之為一次事件循環。如圖3-6所示,我們將從業務的角度,對服務器系統開始監聽客戶端連接到處理每一個連接的可讀數據,這一整個業務流程進行詳細分析。
圖3-6 Reactor模式的事件循環
在服務器剛啟動時,我們完成相關初始化工作,並將服務器監聽套接字及相應的監聽套接字處理器注冊到Reactor反應器。之后系統進入反應器的事件循環中等待注冊事件發生。
此時如果有事件發生且事件為監聽套接字的可讀事件,則表示有新連接產生。Reactor回調之前注冊的監聽套接字handler。在這個handler處理中,我們通過accept系統調用獲取新連接的套接字,並將該新連接的套接字及其相應的連接套接字處理器注冊到Reactor反應器中。執行完該監聽套接字handler后,如果仍有事件未處理,我們繼續進入該事件的handler中進行回調處理,否則我們進入下一次事件循環中,繼續調用epoll阻塞等待新的事件。
此時我們在Reactor反應器中已經注冊了兩類事件,一個是監聽套接字可讀事件,代表有新的連接到來;另一個是連接套接字可讀事件,代表該連接套接字收到了客戶端發來的數據。
如果再次有事件發生,且為監聽套接字可讀事件,則繼續做如上處理。如果為連接套接字,Reactor回調注冊的連接套接字handler。在這個handler回調中,我們通過調用read系統調用獲取客戶端發送過來的數據,並根據我們具體的業務需求做進一步處理。直到所有事件處理完畢后,我們再次進入下次事件循環,繼續調用epoll阻塞等待新的事件。
通過以上業務實現,我們完成了在單線程下的服務器建立新的客戶端連接,以及接收和處理客戶端數據的工作。我們借助Reactor模式,不但保證了高並發和高性能的需求,同時也實現了網絡細節與業務邏輯的分離。我們只需在注冊的不同事件handler中實現具體的業務邏輯即可。