李明 陳琳



摘要:Linux作為一個穩定、開源、擁有完善的網絡功能的操作系統,在涉及網絡相關的軟件開發時具有得天獨厚的優勢。在進行網絡通信程序的開發時,通常采用 socket 來進行網絡同信。在基于 socket編程的基礎上,對比了 Linux 系統下三種多路復用 I/O 接口:select、poll、epoll 后。,確定了以 socket、epoll 機制以及線程池為基礎來設計與實現一個客戶端/服務器(client/server)模型的高并發服務器。基于該模型的基礎上,研究epoll 和事件驅動模型(Reactor)的實現原理。
關鍵詞:Linux;epoll;socket;高并發;事件驅動模型
中圖分類號:TP3? ? ? ?文獻標識碼:A
文章編號:1009-3044(2019)23-0259-03
開放科學(資源服務)標識碼(OSID):
1 引言
網絡上的應用程序通常通過“套接字”向網絡發出請求或者應答請求,建立網絡通信連接至少需要一對端口號(socket)。Socket 的本質是封裝了 TCP/IP 后提供給程序員進行網絡開發的接口。而要實現高并發的網絡通信服務器,除了掌握 socket 的知識外,還需要了解 I/O 多路復用機制。作為 Linux 下多路復用 I/O的機制,select 模型具有最大的并發數限制,和效率問題,以及內核/用戶控件內存拷貝的問題。隨后提出的Poll 模型雖然在 select 機制的基礎上解決了最大并發數的限制,但依然存在效率問題和內存拷貝的問題。在基于前面二者的基礎上,Linux2.6 版本之后推出了 epoll 模型來解決上述問題。
2 Socket 原理及應用
2.1 socket通訊原理
在 Linux 環境下,Socket 是一種用于表示進程間網絡通信的特殊文件類型。本質為內核借助緩沖區形成的偽文件。作為一種全雙工通信的模式,一個文件描述符對應 socket 的2個緩沖區,一個用于讀,一個用于寫。 在 TCP/IP 協議中,IP 加端口可以唯一確定一個Socket ,而想要建立連接的額兩個進程各自有一個 socket 對應,這兩個 socket 組成的 socket pair 就可以確定一個唯一的連接。因此可以用 socket 來描述網絡中的一對一連接關系。套接字通信原理如圖1所示:
2.2 socket 通信流程
利用 socket 進行網絡通信的 C/S 模型分為客戶端和服務器端。服務器端要做的工作主要為創建 socket、綁定 IP 地址和端口、設置同時最大連接數、監聽并接受客戶端的連接請求、讀取客戶端發送的數據、處理請求、回寫數據到客戶端、完成并關閉這次連接。
客戶端需要進行的工作則為創建 socket、建立連接、向服務器端寫數據、讀取服務器端回寫的數據、結束這次連接。圖二展示了一個完整的網絡通信的過程。
2.3 TCP 連接的建立與釋放
在實現高并發的 C/S 模型服務器是,選擇了TCP作為通信協議。TCP是一個面向連接的協議,相對于無連接的 UDP協議,TCP通過三次握手建立連接的方式在很大程度上保證連接與傳輸的可靠性。三次握手的流程如下:
l 客戶端向服務器發送一個 SYN J
l 服務器向客戶端發響應一個SYN K, 并對SYN J進行確認 ACK J + 1
l 客戶端再向服務器端發送一個確認 ACK K + 1
而在某個應用進程完成通信后,就需要釋放連接,而 TCP是通過四次握手來釋放連接。
l 客戶端首先調用close主動關閉連接,TCP 發送一個FIN M
l 服務端接收 FIN M之后,執行被動關閉,對FIN M進行確認。
l 一段時間后,接收到文件結束符的應用進程調用 close 關閉自身的socket,同時發送一個FIN N
l 接收到 FIN N 的客戶端TCP 對 FIN N進行確認。
TCP的連接與釋放的示意圖如圖3所示:
3 epoll + 線程池實現高并發服務器
3.1 epoll 反應堆模型
3.1.1 epoll API詳解
目前。epoll是Linux 大規模并發網絡程序中的熱門首選模型。epoll為開發者提供了3個系統調用。它們的定義如下:
#include
int epoll_create (int size);
int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait (int epfd, struct epoll_event *events, int maxevents, int timeout);
通過使用 epoll_create () 函數來創建一個句柄,并且在系統內核維護了一個紅黑樹和就緒list鏈表。所以在每次調用epoll_wait時,不需要傳遞整個fd列表給內核,epoll_ctl每次只需要進行增量式操作即可。在調用了epoll_create 之后,內核已經準備了一個數據結構用于存放需要監控的 fd,即注冊上來的事件了。
通過 epoll_ctl () 函數來注冊需要監聽的fd以及對應的事件類型。在調用epoll_ctl向句柄上注冊百萬個fd時,epoll_wait依然能夠快速返回并且有效地將觸發的事件fd返回給用戶,因為在調用epoll_create時,內核除了幫我們在epoll文件系統新建file節點,同時在內核cache創建紅黑樹用于存儲以后由epoll_ctl注冊上來的fd外,另外建立了一個list鏈表用于存儲準備就緒的事件。當epoll_wait被調用時,只觀察list鏈表有無數據即可。如果list鏈表中有數據則返回鏈表中數據,沒有數據則等待timeout超時返回。即使在高并發情況下我們需要監控百萬級別的fd時,通常情況下,一次也只返回少量準備就緒的fd而已。所以每次調用epoll_wait時只需要從內核態復制少量就緒的fd到用戶空間即可。
最后通過 epoll_wait () 來將list鏈表上準備就緒的fd復制到用戶空間,然后返回給用戶。而該list鏈表是通過給內核中斷處理程序注冊一個回調函數,當fd中斷到達時,就將它放入到list鏈表中。
因此,通過一顆紅黑樹、一張準備就緒的fd鏈表以及少量的內核cache,就解決了高并發下的fd處理問題。
3.1.2 epoll 反應堆模型
epoll除了提供 select/poll 那種I/O事件的水平觸發外,還提供了邊沿觸發,。通過epoll API 、邊沿觸發、非阻塞 I/O 的方式加上一個自定義的結構體來實現一個反應堆模式進一步提高程序的高并發能力和效率。
Epoll 默認方式為水平觸發即有事件發生時且緩沖區中有數據可讀或有空間可寫時,則會持續觸發直到數據處理完畢,而邊沿觸發則是當狀態發生改變時則觸發。通過設置邊沿觸發在處理大量數據時可以只讀取數據頭,在服務器解析頭部后決定繼續讀取數據還是丟棄處理以此節省更多服務器開銷。而為了避免邊沿觸發導致死鎖的形成,需要配合非阻塞I/O的方式來進行處理。
為了實現epoll的反應堆模型,需要自定義一個結構體 my_events 來保存相關的元素。該結構體最少應該包含文件描述符、事件類型、一個泛型指針、一個回調函數。同時聲明一個該結構體的數組用于保存連接上來的客戶端。
struct my_events{
int fd;
int events;
void *arg;
void (*call_back)(int fd, int events, void* arg);? ? ? ? ? ? ? ?/
int status;
char buf[BUFLEN];
int len;
long last_active;
};
struct my_events g_events[MAX_EVENTS+1];
通過該結構體中的泛型指針指向這個結構體本身可以使得每個事件擁有自己的回調函數。從而使得主程序只負責監聽就緒的事件,而將數據的處理放到回調函數中進行。epoll Reactor模式的大致流程如下:
l 程序設置邊沿觸發和fd的非阻塞I/O
l 利用 epoll_create 來創建一個句柄和內部實現的紅黑樹
l 初始化創建并綁定監聽 socket,并返回一個文件描述符 listen_fd,并添加到紅黑樹上
l 監聽可讀事件(ET) ? 數據到來 ? 觸發事件 ? epoll_wait()返回 ? 讀取完數據(可讀事件回調函數內) ? 將該節點從紅黑樹上摘下(可讀事件回調函數內) ? 設置可寫事件和對應可寫回調函數(可讀事件回調函數內) ? 掛上樹(可讀事件回調函數內) ? 處理數據(可讀事件回調函數內)
l 監聽可寫事件(ET) ? 對方可讀 ? 觸發事件 ? epoll_wait()返回 ? 寫完數據(可寫事件回調函數內) ? 將該節點從紅黑樹上摘下(可寫事件回調函數內) ? 設置可讀事件和對應可讀回調函數(可寫讀事件回調函數內) ? 掛上樹(可寫事件回調函數內) ? 處理收尾工作(可寫事件回調函數內)
l 程序循環執行
3.2 線程池
在目前的大多數網絡服務器中,單位時間內必須處理數目巨大的連接請求,但處理時間卻相對較短。傳統的每接收一個請求就創建一個線程的模式在處理大量的短連接,任務執行時間短的連接請求時將會使服務器長時間處于創建線程和銷毀線程的狀態中,極大的浪費CPU資源。線程池是一種線程使用模式,線程過多會帶來調度的開銷進而影響緩存局部性和整體性能,而線程池維護著多個線程,等待著監督管理者分配可并發執行的任務。這避免了在處理短時間任務時創建與銷毀線程的代價,它保證了內核的充分利用,防止了過度調用。
構建一個線程池的框架一般具有如下幾個部分。一個自定義結構體threadpool_t用于描述線程池的相關信息,包括有用于線程間同步的互斥鎖和信號量、保存工作線程線程號的數組、一個管理者線程、管理任務的任務隊列、以及記錄線程池內最小線程數,工作線程數,最大線程數等的變量。然后定義相關的函數API,其中主要的函數定義如下所示:
1. threadpool_t *threadpool_create(): 用于創建和初始化一個線程池
2. int threadpool_add():用于向線程池的任務隊列添中加一個任務
3. void *threadpool_thread():線程池中的各工作線程
4. void *adjust_thread () : 管理者線程,負責線程池的維護
在程序開始時,預創建一個線程池和最小數量的線程放入空閑隊列中等待喚醒,在任務到來之前,線程處于阻塞狀態不會占用CPU資源,在任務到達以后,在線程池中喚醒一個線程來接收此任務并處理。當任務隊列中任務較多而當前工作線程數量不夠支撐時,線程池會通過管理者線程向線程池中添加一定數量的新線程,而當空閑線程數過多而任務隊里任務較少時,管理者線程也會從線程中銷毀一部分線程,回收系統資源,動態的管理線程池。通過這種預處理技術,線程創建和銷毀帶來的開銷則分攤到各個具體的任務上,執行次數越多,每個任務分攤的線程本身開銷越小,達到了提高系統效率,節約系統資源的目的。
在實際的應用當中,線程池并不適用于所有場景。它致力于減少線程本身的開銷對應用所產生的影響。但是對于一些任務執行時間較長的服務例如FTP和TELNET,相較于文件傳輸的時間,線程創建和銷毀的開銷可以忽略不計,此時使用線程池并不能帶倆效率上的明顯提高。總結起來。線程池使用于單位時間內處理任務頻繁且任務處理事件短、對實時性要求高、以及高突發性的事件。
4 總結
通過使用epoll多路I/O復用,設置邊沿觸發和非阻塞的方式,基于事件驅動模式實現的服務器已經能夠實現高并發的需求,同時將接受到連接請求和具體的任務通過線程池的方式來處理,進一步提高了并發服務器的處理效率和并發能力,同時對與突發性的訪問量突增的情況也能良好的適應與處理。
但是在設計與實現不同的高并發服務器時,epoll和線程池并不適用于所有場景。其他的多路I/O復用 sleclt/poll加上傳統的“及時創建,及時銷毀“多線程策略也有適合的應用場景。因此需要根據實際情況和不同場景選擇不同的設計模式,實現最符合需求的并發服務器。
參考文獻:
[1] Andrew S Tanenbaum, 計算機網絡[M]. 熊桂喜等譯, 北京:清華大學出版社,1998.
[2] 陳碩,Linux 多線程服務端編程[M]. 北京:電子工業出版社,2013.
[3] 唐富強, 于鴻洋, 張萍.? Linux下通用線程池的改進與實現[J].計算機工程與應用, 2012, 48(28): 77-78.
[4] 邱杰,朱曉姝,孫小雁. 基于Epoll模型的消息推送研究與實現[J]. 合肥工業大學學報, 2016, 39(4): 476-477.
[5] 余光遠. 基于Epoll的消息推送系統的設計與實現[D]. 武漢:華中科技大學, 2011.
[6] 張超,潘旭東 Linux下基于EPOLL機制的海量網絡信息處理模型[J].強激光與粒子束, 2013,25(Z1):46-50.
[7] 楊開杰,劉秋菊,徐汀榮.線程池的多線程并發控制技術研究[J].計算機應用與軟件,2010,27(1):169-170.
[8] 劉新強,曾兵義.用線程池解決服務器并發請求的方案設計[J].現代電子技術,2011,34(15):142-143.
【通聯編輯:梁書】