服务器网络编程模型
1、循環服務器模型:在同一個時刻只可以響應一個客戶端的請求
1)循環服務器:UDP服務器
UDP循環服務器的實現:UDP服務器每次從套接字上讀取一個客戶端的請求,處理, 然后將結果返回給客戶機. 因為UDP是非面向連接的,沒有一個客戶端可以老是占住服務端. 只要處理過程不是死循環, 服務器對于每一個客戶機的請求總是能夠滿足.算法示例如下:?
socket(...); bind(...); while(1) { recvfrom(...); process(...); sendto(...); }2)循環服務器:TCP服務器
TCP循環服務器的實現:TCP服務器接受一個客戶端的連接,然后處理,完成了這個客戶的所有請求后,斷開連接. 算法示例如下:
socket(...); bind(...); listen(...); while(1) { accept(...); while(1) { read(...); process(...); write(...); } close(...); }TCP循環服務器一次只能處理一個客戶端的請求.只有在這個客戶的所有請求都滿足后, 服務器才可以繼續后面的請求.這樣如果有一個客戶端占住服務器不放時,其它的客戶機都不能工作了.因此,TCP服務器一般很少用循環服務器模型的。
2、并發服務器:同一個時刻可以響應多個客戶端的請求 1)并發服務器:子進程模式 并發服務器的思想是每一個客戶機的請求并不由服務器直接處理,而是服務器創建一個子進程來處理.算法如下: socket(...); bind(...); listen(...); while(1) { accept(...); if(fork(..)==0) //the child process. { while(1) { read(...); process(...); write(...); } close(...); exit(...); } close(...); }
子進程的TCP并發服務器可以解決TCP循環服務器客戶機獨占服務器的情況. 不過也同時帶來了一個不小的問題.為了響應客戶機的請求,服務器要創建子進程來處理. 而創建子進程是一種非常消耗資源的操作. ?
2)并發服務器:多路復用I/O
使用以上子進程模式,進程有可能在讀寫出阻塞,直到一定的條件滿足. 比如我們從一個套接字讀數據時,可能緩沖區里面沒有數據可讀(通信的對方還沒有 發送數據過來),這個時候我們的讀調用就會等待(阻塞)直到有數據可讀.如果我們不希望阻塞且解決創建子進程帶來的系統資源消耗,人們又想出了多路復用I/O模型.
如下使用select后的服務器程序算法:
初始化(socket,bind,listen); while(1) { 設置監聽讀寫文件描述符(FD_*); 調用select; 如果是傾聽套接字就緒,說明一個新的連接請求建立 { 建立連接(accept); 加入到監聽文件描述符中去; } 否則說明是一個已經連接過的描述符 { 進行操作(read或者write); } }相比于select,linux 2.6后使用的epoll最大的好處在于它不會隨著監聽fd數目的增長而降低效率。因為在內核中的select實現中,它是采用輪詢來處理的,輪詢的fd數目越多,自然耗時越多。同時由于服務器依次處理客戶的請求,所以可能會導致有的客戶會等待很久。并且,在linux/posix_types.h頭文件有這樣的聲明:
#define __FD_SETSIZE??? 1024
表示select最多同時監聽1024個fd,當然,可以通過修改頭文件再重編譯內核來擴大這個數目,但這似乎并不治本。
epoll的接口非常簡單,一共就三個函數:
1. int epoll_create(int size);
創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大。這個參數不同于select()中的第一個參數,給出最大監聽的fd+1的值。需要注意的是,當創建好epoll句柄后,它就是會占用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注冊函數,它不同與select()是在監聽事件時告訴內核要監聽什么類型的事件,而是在這里先注冊要監聽的事件類型。第一個參數是epoll_create()的返回值,第二個參數表示動作,用三個宏來表示:
EPOLL_CTL_ADD:注冊新的fd到epfd中;
EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中刪除一個fd;
第三個參數是需要監聽的fd,第四個參數是告訴內核需要監聽什么事,struct epoll_event結構如下:
struct epoll_event {
? __uint32_t events;? /* Epoll events */
? epoll_data_t data;? /* User data variable */
};
events可以是以下幾個宏的集合:
EPOLLIN :表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的文件描述符可以寫;
EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這里應該表示有帶外數據到來);
EPOLLERR:表示對應的文件描述符發生錯誤;
EPOLLHUP:表示對應的文件描述符被掛斷;
EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對于水平觸發(Level Triggered)來說的。
EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的產生,類似于select()調用。參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個maxevents的值不能大于創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。
epoll支持水平觸發(LT:level triggered)和邊緣觸發(ET:edge-triggered),理論上來說邊緣觸發性能更高,但是使用更加復雜,因為任何意外的丟失事件都會造成請求處理錯誤。Nginx就使用了epoll的邊緣觸發模型。它們的區別是只要句柄滿足某種狀態,水平觸發就會發出通知;而只有當句柄狀態改變時,邊緣觸發才會發出通知。例如一個socket經過長時間等待后接收到一段100k的數據,兩種觸發方式都會向程序發出就緒通知。假設程序從這個socket中讀取了50k數據,并再次調用監聽函數,水平觸發依然會發出就緒通知,而邊緣觸發會因為socket“有數據可讀”這個狀態沒有發生變化而不發出通知且陷入長時間的等待。因此在使用邊緣觸發時,要注意每次都要讀到 socket返回 EWOULDBLOCK為止。
水平觸發是epoll缺省的工作方式,并且同時支持block和no-block socket.在這種做法中,內核告訴你一個文件描述符是否就緒了,然后你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的,所以,這種模式編程出錯誤可能性要小一點。傳統的select/poll都是這種模型的代表.
邊緣觸發是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變為就緒時,內核通過epoll告訴你。然后它會假設你知道文件描述符已經就緒,并且不會再為那個文件描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再為就緒狀態了(比如,你在發送,接收或者接收請求,或者發送接收的數據少于一定量時導致了一個EWOULDBLOCK 錯誤)。但是請注意,如果一直不對這個fd作IO操作(從而導致它再次變成未就緒),內核不會發送更多的通知(only once)。
當使用epoll的ET模型來工作時,當產生了一個EPOLLIN事件后,讀數據的時候需要考慮的是當recv()返回的大小如果等于請求的大小,那么很有可能是緩沖區還有數據未讀完,也意味著該次事件還沒有處理完,所以還需要再次讀取。如下代碼示例:
while(rs) {buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);if(buflen < 0){// 由于是非阻塞的模式,所以當errno為EAGAIN時,表示當前緩沖區已無數據可讀// 在這里就當作是該次事件已處理處.if(errno == EAGAIN)break;elsereturn;}else if(buflen == 0){// 這里表示對端的socket已正常關閉. }if(buflen == sizeof(buf)rs = 1; // 需要再次讀取elsers = 0; }還有,假如發送端流量大于接收端的流量(即epoll所在的程序讀比轉發的socket要快),由于是非阻塞的socket,那么send()函數雖然返回,但實際緩沖區的數據并未真正發給接收端,這樣不斷的讀和發,當緩沖區滿后會產生EAGAIN錯誤,同時,不理會這次請求發送的數據.所以,需要封裝socket_send()的函數用來處理這種情況,該函數會盡量將數據寫完再返回,返回-1表示出錯。在socket_send()內部,當寫緩沖已滿(send()返回-1,且errno為EAGAIN),那么會等待后再重試.這種方式并不很完美,在理論上可能會長時間的阻塞在socket_send()內部,但暫沒有更好的辦法.
ssize_t socket_send(int sockfd, const char* buffer, size_t buflen) {ssize_t tmp;size_t total = buflen;const char *p = buffer;while(1){tmp = send(sockfd, p, total, 0);if(tmp < 0){// 當send收到信號時,可以繼續寫,但這里返回-1.if(errno == EINTR)return -1;// 當socket是非阻塞時,如返回此錯誤,表示寫緩沖隊列已滿,// 在這里做延時后再重試.if(errno == EAGAIN){usleep(1000);continue;}return -1;}if((size_t)tmp == total)return buflen;total -= tmp;p += tmp;}return tmp; }3、高并發服務器:多線程+IO復用服務器(one event loop per thread)
1)兩種I/O多路復用模式:Reactor和Proactor
一般地,I/O多路復用機制都依賴于一個事件多路分離器(Event Demultiplexer)。分離器對象可將來自事件源的I/O事件分離出來,并分發到對應的read/write事件處理器(Event Handler)。開發人員預先注冊需要處理的事件及其事件處理器(或回調函數);事件分離器負責將請求事件傳遞給事件處理器。兩個與事件分離器有關的模式是Reactor和Proactor。Reactor模式采用同步IO,而Proactor采用異步IO。
在Reactor中,事件分離器負責等待文件描述符或socket為讀寫操作準備就緒,然后將就緒事件傳遞給對應的處理器,最后由事件處理器負責完成實際的讀寫工作。而在Proactor模式中,處理器或者兼任處理器的事件分離器,只負責發起異步讀寫操作。IO操作本身由操作系統來完成。傳遞給操作系統的參數需要包括用戶定義的數據緩沖區地址和數據大小,操作系統才能從中得到寫出操作所需數據,或寫入從socket讀到的數據。事件分離器捕獲IO操作完成事件,然后將事件傳遞給對應處理器。比如,在windows上,處理器發起一個異步IO操作,再由事件分離器等待IOCompletion事件。典型的異步模式實現,都建立在操作系統支持異步API的基礎之上,我們將這種實現稱為“系統級”異步或“真”異步,因為應用程序完全依賴操作系統執行真正的IO工作。以讀操作為例:
在Reactor中實現讀:
- 注冊讀就緒事件和相應的事件處理器
- 事件分離器等待事件
- 事件到來,激活分離器,分離器調用事件對應的處理器。
- 事件處理器完成實際的讀操作,處理讀到的數據,注冊新的事件,然后返還控制權。
在Proactor中實現讀:
- 處理器發起異步讀操作(注意:操作系統必須支持異步IO)。在這種情況下,處理器無視IO就緒事件,它關注的是完成事件。
- 事件分離器等待操作完成事件
- 在分離器等待過程中,操作系統利用并行的內核線程執行實際的讀操作,并將結果數據存入用戶自定義緩沖區,最后通知事件分離器讀操作完成。
- 事件分離器呼喚處理器。
- 事件處理器處理用戶自定義緩沖區中的數據,然后啟動一個新的異步操作,并將控制權返回事件分離器。
通過上例可以看出,兩個模式的相同點,都是對某個IO事件的事件通知(即告訴某個模塊,這個IO操作可以進行或已經完成)。在結構上,兩者也有相同點:demultiplexor負責提交IO操作(異步)、查詢設備是否可操作(同步),然后當條件滿足時,就回調handler;不同點在于,異步情況下(Proactor),當回調handler時,表示IO操作已經完成;同步情況下(Reactor),回調handler時,表示IO設備可以進行某個操作(can read or can write)。
使用Proactor框架和Reactor框架都可以極大的簡化網絡應用的開發,但它們的重點卻不同。Reactor框架中用戶定義的操作是在實際操作之前調用的。比如你定義了操作是要向一個SOCKET寫數據,那么當該SOCKET可以接收數據的時候,你的操作就會被調用;而Proactor框架中用戶定義的操作是在實際操作之后調用的。比如你定義了一個操作要顯示從SOCKET中讀入的數據,那么當讀操作完成以后,你的操作才會被調用。
Proactor和Reactor都是并發編程中的設計模式。在我看來,他們都是用于派發/分離IO操作事件的。這里所謂的IO事件也就是諸如read/write的IO操作。"派發/分離"就是將單獨的IO事件通知到上層模塊。兩個模式不同的地方在于,Proactor用于異步IO,而Reactor用于同步IO。目前應用最廣泛的是Reactor模boost::asio,ACE和Windows I/O Completion Ports 實現了Proactor 模式,應用面似乎要窄一些。
2)線程模型
目前網絡編程中使用的線程模型主要有以下幾種:
1. 每個請求創建一個線程,使用阻塞式IO 操作。在Java 1.4 引入NIO 之前,這是Java 網絡編程的推薦做法。可惜伸縮性不佳。
2. 使用線程池,同樣使用阻塞式IO 操作。與1 相比,這是提高性能的措施。
3. 使用非阻塞(non-blocking IO) + one loop per thread。即Java NIO 的方式。
4. Leader/Follower 等高級模式
目前使用第3種的比較多,此種模型下,程序里的每個IO 線程有一個event loop (或者叫Reactor),用于處理讀寫和定時事件(無論周期性的還是單次的)。這種方式主要有如下好處:
- 線程數目基本固定,可以在程序啟動的時候設置,不會頻繁創建與銷毀。
- 可以很方便地在線程間調配負載。event loop 代表了線程的主循環,需要讓哪個線程干活,就把timer 或IO channel(TCP connection) 注冊到那個線程的loop 里即可。對實時性有要求的connection 可以單獨用一個線程;數據量大的connection 可以獨占一個線程,并把數據處理任務分攤到另幾個線程中;其他次要的輔助性connections 可以共享一個線程。
多線程程序對Reactor 提出了更高的要求,那就是“線程安全”。要允許一個線程往別的線程的loop 里塞東西,這個loop 必須得是線程安全的。此時對于沒有IO 光有計算任務的線程,使用event loop 有點浪費,我會用有一種補充方案,此時可以直接用blocking_queue<T> 實現數據的消費者-生產者隊,blocking_queue<T>的C++ 實現可以用deque 來做底層的容器 + 1 個mutex + 2 個condition variables。
?
總結以上分析,推薦的多線程服務端編程模式為:event loop per thread + thread pool。
- event loop 用作non-blocking IO 和定時器。可以直接使用libevent開源庫。
- thread pool 用來處理與客戶端的連接以及相關業務,具體可以是任務隊列或消費者-生產者隊列。
- 主線程只處理監聽客戶端的連接請求,并將請求平均分配給子線程。
整個程序實現可參照以下流程圖:
?
轉載于:https://www.cnblogs.com/startcool/archive/2013/01/06/2847878.html
總結
- 上一篇: 新年开题记
- 下一篇: Struts2 常量配置