李明 陳琳



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