幾種網路伺服器模型的介紹與比較

來源:互聯網
上載者:User

原文的大標題叫“使用事件驅動模型實現高效穩定的網路伺服器程式”

 

-------------------------------------------------------------- 華麗的分割線 --------------------------------------------------------------

前言

事件驅動為廣大的程式員所熟悉,其最為人津津樂道的是在圖形化介面編程中的應用;事實上,在網路編程中事件驅動也被廣泛使用,並大規模部署在高串連數高輸送量的伺服器程式中,如 http 伺服器程式、ftp 伺服器程式等。相比於傳統的網路編程方式,事件驅動能夠極大的降低資源佔用,增大服務接待能力,並提高網路傳輸效率。

關於本文提及的伺服器模型,搜尋網路可以查閱到很多的實現代碼,所以,本文將不拘泥於原始碼的陳列與分析,而側重模型的介紹和比較。使用 libev 事件驅動庫的伺服器模型將給出實現代碼。

本文涉及到線程 / 時間圖例,只為表明線程在各個 IO 上確實存在阻塞時延,但並不保證時延比例的正確性和 IO 執行先後的正確性;另外,本文所提及到的介面也只是筆者熟悉的 Unix/Linux 介面,並未推薦 Windows 介面,讀者可以自行查閱對應的 Windows 介面。

 

阻塞型的網路編程介面

幾乎所有的程式員第一次接觸到的網路編程都是從 listen()、send()、recv() 等介面開始的。使用這些介面可以很方便的構建伺服器 / 客戶機的模型。

我們假設希望建立一個簡單的伺服器程式,實現向單個客戶機提供類似於“一問一答”的內容服務。

圖 1. 簡單的一問一答的伺服器 / 客戶機模型
 

我們注意到,大部分的 socket 介面都是阻塞型的。所謂阻塞型介面是指系統調用(一般是 IO 介面)不返回調用結果並讓當前線程一直阻塞,只有當該系統調用獲得結果或者逾時出錯時才返回。

實際上,除非特別指定,幾乎所有的 IO 介面 ( 包括 socket 介面 ) 都是阻塞型的。這給網路編程帶來了一個很大的問題,如在調用 send() 的同時,線程將被阻塞,在此期間,線程將無法執行任何運算或響應任何的網路請求。這給多客戶機、多商務邏輯的網路編程帶來了挑戰。這時,很多程式員可能會選擇多線程的方式來解決這個問題。

 

多線程的伺服器程式

應對多客戶機的網路應用,最簡單的解決方式是在伺服器端使用多線程(或多進程)。多線程(或多進程)的目的是讓每個串連都擁有獨立的線程(或進程),這樣任何一個串連的阻塞都不會影響其他的串連。

具體使用多進程還是多線程,並沒有一個特定的模式。傳統意義上,進程的開銷要遠遠大於線程,所以,如果需要同時為較多的客戶機提供服務,則不推薦使用多進程;如果單個服務執行體需要消耗較多的 CPU 資源,譬如需要進行大規模或長時間的資料運算或檔案訪問,則進程較為安全。通常,使用 pthread_create () 建立新線程,fork() 建立新進程。

我們假設對上述的伺服器 / 客戶機模型,提出更高的要求,即讓伺服器同時為多個客戶機提供一問一答的服務。於是有了如下的模型。

圖 2. 多線程的伺服器模型
 

在上述的線程 / 時間圖例中,主線程持續等待用戶端的串連請求,如果有串連,則建立新線程,並在新線程中提供為前例同樣的問答服務。

很多初學者可能不明白為何一個 socket 可以 accept 多次。實際上,socket 的設計者可能特意為多客戶機的情況留下了伏筆,讓 accept() 能夠返回一個新的 socket。下面是 accept 介面的原型:

 int accept(int s, struct sockaddr *addr, socklen_t *addrlen); 

輸入參數 s 是從 socket(),bind() 和 listen() 中沿用下來的 socket 控制代碼值。執行完 bind() 和 listen() 後,作業系統已經開始在指定的連接埠處監聽所有的串連請求,如果有請求,則將該串連請求加入請求隊列。調用 accept() 介面正是從 socket s 的請求隊列抽取第一個串連資訊,建立一個與 s 同類的新的 socket 返回控制代碼。新的 socket 控制代碼即是後續 read() 和 recv() 的輸入參數。如果請求隊列當前沒有請求,則 accept()
將進入阻塞狀態直到有請求進入隊列。

上述多線程的伺服器模型似乎完美的解決了為多個客戶機提供問答服務的要求,但其實並不盡然。如果要同時響應成百上千路的串連請求,則無論多線程還是多進程都會嚴重佔據系統資源,降低系統對外界響應效率,而線程與進程本身也更容易進入假死狀態。

很多程式員可能會考慮使用“線程池”或“串連池”。“線程池”旨在減少建立和銷毀線程的頻率,其維持一定合理數量的線程,並讓閒置線程重新承擔新的執行任務。“串連池”維持串連的緩衝池,盡量重用已有的串連、減少建立和關閉串連的頻率。這兩種技術都可以很好的降低系統開銷,都被廣泛應用很多大型系統,如 websphere、tomcat 和各種資料庫等。

但是,“線程池”和“串連池”技術也只是在一定程度上緩解了頻繁調用 IO 介面帶來的資源佔用。而且,所謂“池”始終有其上限,當請求大大超過上限時,“池”構成的系統對外界的響應並不比沒有池的時候效果好多少。所以使用“池”必須考慮其面臨的響應規模,並根據響應規模調整“池”的大小。

對應上例中的所面臨的可能同時出現的上千甚至上萬次的用戶端請求,“線程池”或“串連池”或許可以緩解部分壓力,但是不能解決所有問題。

總之,多執行緒模式可以方便高效的解決小規模的服務要求,但面對大規模的服務要求,多執行緒模式並不是最佳方案。下一章我們將討論用非阻塞介面來嘗試解決這個問題。

 

非阻塞的伺服器程式

以上面臨的很多問題,一定程度是 IO 介面的阻塞特性導致的。多線程是一個解決方案,還一個方案就是使用非阻塞的介面。

非阻塞的介面相比於阻塞型介面的顯著差異在於,在被調用之後立即返回。使用如下的函數可以將某控制代碼 fd 設為非阻塞狀態。

 fcntl( fd, F_SETFL, O_NONBLOCK ); 

下面將給出只用一個線程,但能夠同時從多個串連中檢測資料是否送達,並且接受資料。

圖 3. 使用非阻塞的接收資料模型
 

在非阻塞狀態下,recv() 介面在被調用後立即返回,傳回值代表了不同的含義。如在本例中,

  • recv() 傳回值大於 0,表示接受資料完畢,傳回值即是接受到的位元組數;
  • recv() 返回 0,表示串連已經正常斷開;
  • recv() 返回 -1,且 errno 等於 EAGAIN,表示 recv 操作還沒執行完成;
  • recv() 返回 -1,且 errno 不等於 EAGAIN,表示 recv 操作遇到系統錯誤 errno。

可以看到伺服器線程可以通過迴圈調用 recv() 介面,可以在單個線程內實現對所有串連的資料接收工作。

但是上述模型絕不被推薦。因為,迴圈調用 recv() 將大幅度推高 CPU 佔用率;此外,在這個方案中,recv() 更多的是起到檢測“操作是否完成”的作用,實際作業系統提供了更為高效的檢測“操作是否完成“作用的介面,例如 select()。

 

使用 select() 介面的基於事件驅動的伺服器模型

大部分 Unix/Linux 都支援 select 函數,該函數用於探測多個檔案控制代碼的狀態變化。下面給出 select 介面的原型:

 FD_ZERO(int fd, fd_set* fds)  FD_SET(int fd, fd_set* fds)  FD_ISSET(int fd, fd_set* fds)  FD_CLR(int fd, fd_set* fds)  int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,         struct timeval *timeout) 

這裡,fd_set 類型可以簡單的理解為按 bit 位標記控制代碼的隊列,例如要在某 fd_set 中標記一個值為 16 的控制代碼,則該 fd_set 的第 16 個 bit 位被標記為 1。具體的置位、驗證可使用 FD_SET、FD_ISSET 等宏實現。在 select() 函數中,readfds、writefds 和 exceptfds 同時作為輸入參數和輸出參數。如果輸入的 readfds 標記了 16 號控制代碼,則 select() 將檢測 16 號控制代碼是否可讀。在 select() 返回後,可以通過檢查
readfds 有否標記 16 號控制代碼,來判斷該“可讀”事件是否發生。另外,使用者可以設定 timeout 時間。

下面將重新類比上例中從多個用戶端接收資料的模型。

圖 4. 使用 select() 的接收資料模型
 

上述模型只是描述了使用 select() 介面同時從多個用戶端接收資料的過程;由於 select() 介面可以同時對多個控制代碼進行讀狀態、寫狀態和錯誤狀態的探測,所以可以很容易構建為多個用戶端提供獨立問答服務的伺服器系統。

圖 5. 使用 select() 介面的基於事件驅動的伺服器模型
 

這裡需要指出的是,用戶端的一個 connect() 操作,將在伺服器端激發一個“可讀事件”,所以 select() 也能探測來自用戶端的 connect() 行為。

上述模型中,最關鍵的地方是如何動態維護 select() 的三個參數 readfds、writefds 和 exceptfds。作為輸入參數,readfds 應該標記所有的需要探測的“可讀事件”的控制代碼,其中永遠包括那個探測 connect() 的那個“母”控制代碼;同時,writefds 和 exceptfds 應該標記所有需要探測的“可寫事件”和“錯誤事件”的控制代碼 ( 使用 FD_SET() 標記 )。

作為輸出參數,readfds、writefds 和 exceptfds 中的儲存了 select() 捕捉到的所有事件的控制代碼值。程式員需要檢查的所有的標記位 ( 使用 FD_ISSET() 檢查 ),以確定到底哪些控制代碼發生了事件。

上述模型主要類比的是“一問一答”的服務流程,所以,如果 select() 發現某控制代碼捕捉到了“可讀事件”,伺服器程式應及時做 recv() 操作,並根據接收到的資料準備好待發送資料,並將對應的控制代碼值加入 writefds,準備下一次的“可寫事件”的 select() 探測。同樣,如果 select() 發現某控制代碼捕捉到“可寫事件”,則程式應及時做 send() 操作,並準備好下一次的“可讀事件”探測準備。描述的是上述模型中的一個執行循環。

圖 6. 一個執行循環
 

這種模型的特徵在於每一個執行循環都會探測一次或一組事件,一個特定的事件會觸發某個特定的響應。我們可以將這種模型歸類為“事件驅動模型”。

相比其他模型,使用 select() 的事件驅動模型只用單線程(進程)執行,佔用資源少,不消耗太多 CPU,同時能夠為多用戶端提供服務。如果試圖建立一個簡單的事件驅動的伺服器程式,這個模型有一定的參考價值。

但這個模型依舊有著很多問題。

首先,select() 介面並不是實現“事件驅動”的最好選擇。因為當需要探測的控制代碼值較大時,select() 介面本身需要消耗大量時間去輪詢各個控制代碼。很多作業系統提供了更為高效的介面,如 linux 提供了 epoll,BSD 提供了 kqueue,Solaris 提供了 /dev/poll …。如果需要實現更高效的伺服器程式,類似 epoll 這樣的介面更被推薦。遺憾的是不同的作業系統特供的 epoll 介面有很大差異,所以使用類似於 epoll 的介面實現具有較好跨平台能力的伺服器會比較困難。

其次,該模型將事件探測和事件響應夾雜在一起,一旦事件響應的執行體龐大,則對整個模型是災難性的。如下例,龐大的執行體 1 的將直接導致響應事件 2 的執行體遲遲得不到執行,並在很大程度上降低了事件探測的及時性。

圖 7. 龐大的執行體對使用 select() 的事件驅動模型的影響
 

幸運的是,有很多高效的事件驅動庫可以屏蔽上述的困難,常見的事件驅動庫有 libevent 庫,還有作為 libevent 替代者的 libev 庫。這些庫會根據作業系統的特點選擇最合適的事件探測介面,並且加入了訊號 (signal) 等技術以支援非同步響應,這使得這些庫成為構建事件驅動模型的不二選擇。下章將介紹如何使用 libev 庫替換 select 或 epoll 介面,實現高效穩定的伺服器模型。

 

使用事件驅動庫 libev 的伺服器模型

Libev 是一種高效能事件迴圈 / 事件驅動庫。作為 libevent 的替代作品,其第一個版本發布與 2007 年 11 月。Libev 的設計者聲稱 libev 擁有更快的速度,更小的體積,更多功能等優勢,這些優勢在很多測評中得到了證明。正因為其良好的效能,很多系統開始使用 libev 庫。本章將介紹如何使用 Libev 實現提供問答服務的伺服器。

(事實上,現存的事件迴圈 / 事件驅動庫有很多,作者也無意推薦讀者一定使用 libev 庫,而只是為了說明事件驅動模型給網路伺服器編程帶來的便利和好處。大部分的事件驅動庫都有著與 libev 庫相類似的介面,只要明白大致的原理,即可靈活挑選合適的庫。)

與前章的模型類似,libev 同樣需要迴圈探測事件是否產生。Libev 的迴圈體用 ev_loop 結構來表達,並用 ev_loop( ) 來啟動。

 void ev_loop( ev_loop* loop, int flags ) 

Libev 支援八種事件類型,其中包括 IO 事件。一個 IO 事件用 ev_io 來表徵,並用 ev_io_init() 函數來初始化:

 void ev_io_init(ev_io *io, callback, int fd, int events) 

初始化內容包括回呼函數 callback,被探測的控制代碼 fd 和需要探測的事件,EV_READ 表“可讀事件”,EV_WRITE 表“可寫事件”。

現在,使用者需要做的僅僅是在合適的時候,將某些 ev_io 從 ev_loop 加入或剔除。一旦加入,下個迴圈即會檢查 ev_io 所指定的事件有否發生;如果該事件被探測到,則 ev_loop 會自動執行 ev_io 的回呼函數 callback();如果 ev_io 被登出,則不再檢測對應事件。

無論某 ev_loop 啟動與否,都可以對其添加或刪除一個或多個 ev_io,添加刪除的介面是 ev_io_start() 和 ev_io_stop()。

 void ev_io_start( ev_loop *loop, ev_io* io )  void ev_io_stop( EV_A_* ) 

由此,我們可以容易得出如下的“一問一答”的伺服器模型。由於沒有考慮伺服器端主動終止串連機制,所以各個串連可以維持任意時間,用戶端可以自由選擇退出時機。

圖 8. 使用 libev 庫的伺服器模型
 

上述模型可以接受任意多個串連,且為各個串連提供完全獨立的問答服務。藉助 libev 提供的事件迴圈 / 事件驅動介面,上述模型有機會具備其他模型不能提供的高效率、低資源佔用、穩定性好和編寫簡單等特點。

由於傳統的 網頁伺服器,ftp 伺服器及其他網路應用程式都具有“一問一答”的通訊邏輯,所以上述使用 libev 庫的“一問一答”模型對構建類似的伺服器程式具有參考價值;另外,對於需要實現遠程監視或遠程遙控的應用程式,上述模型同樣提供了一個可行的實現方案。

 

總結

本文圍繞如何構建一個提供“一問一答”的伺服器程式,先後討論了用阻塞型的 socket 介面實現的模型,使用多線程的模型,使用 select() 介面的基於事件驅動的伺服器模型,直到使用 libev 事件驅動庫的伺服器模型。文章對各種模型的優缺點都做了比較,從比較中得出結論,即使用“事件驅動模型”可以的實現更為高效穩定的伺服器程式。文中描述的多種模型可以為讀者的網路編程提供參考價值。

-------------------------------------------------------------- 華麗的分割線 --------------------------------------------------------------

 

轉自:http://www.ibm.com/developerworks/cn/linux/l-cn-edntwk/index.html?ca=drs-

 

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.