如何用C寫一個(gè)web服務(wù)器之I/O多路復(fù)用
前言
I/O模型
接觸過(guò) socket 編程的同學(xué)應(yīng)該都知道一些 I/O 模型的概念,linux 中有阻塞 I/O、非阻塞 I/O、I/O 多路復(fù)用、信號(hào)驅(qū)動(dòng) I/O 和 異步 I/O 五種模型。
其他模型的具體概念這里不多介紹,只簡(jiǎn)單地提一下自己理解的 I/O 多路復(fù)用:簡(jiǎn)單的說(shuō)就是由一個(gè)進(jìn)程來(lái)管理多個(gè) socket,即將多個(gè) socket 放入一個(gè)表中,在其中有 socket 可操作時(shí),通知進(jìn)程來(lái)處理, I/O 多路復(fù)用的實(shí)現(xiàn)方式有 select、poll 和 epoll。
select/poll/epoll
在 linux下,通過(guò)文件描述符(file descriptor, 下 fd)來(lái)進(jìn)行 socket 的操作,所以下文均是對(duì) fd 操作。
首先說(shuō)最開始實(shí)現(xiàn)的 select 的問(wèn)題:
- select 打開的 fd 最大數(shù)目有限制,一般為1024,在當(dāng)前計(jì)算系統(tǒng)的并發(fā)量前顯然有點(diǎn)不適用了。
- select 在收到有 fd 可操作的通知時(shí),是無(wú)法得知具體是哪個(gè) fd 的,需要線性掃描 fd 表,效率較低。
- 當(dāng)有 fd 可操作時(shí),fd 會(huì)將 fd 表復(fù)制到內(nèi)核來(lái)遍歷,消耗也較大。
隨著網(wǎng)絡(luò)技術(shù)的發(fā)展,出現(xiàn)了 poll:poll 相對(duì)于 select,使用 pollfd 表(鏈表實(shí)現(xiàn)) 來(lái)代替 fd,它沒(méi)有上限,但受系統(tǒng)內(nèi)存的限制,它同樣使用 fd 遍歷的方式,在并發(fā)高時(shí)效率仍然是一個(gè)問(wèn)題。
最終,epoll 在 Linux 2.6 的內(nèi)核面世,它使用事件機(jī)制,在每一個(gè) fd 上添加事件,當(dāng)fd 的事件被觸發(fā)時(shí),會(huì)調(diào)用回調(diào)函數(shù)來(lái)處理對(duì)應(yīng)的事件,epoll 的優(yōu)勢(shì)總之如下:
- 只關(guān)心活躍的 fd,精確定位,改變了poll的時(shí)間效率 O(n) 到 O(1);
- fd 數(shù)量限制是系統(tǒng)能打開的最大文件數(shù),會(huì)受系統(tǒng)內(nèi)存和每個(gè) fd 消耗內(nèi)存的影響,以當(dāng)前的系統(tǒng)硬件配置,并發(fā)數(shù)量絕對(duì)不是問(wèn)題。
- 內(nèi)核使用內(nèi)存映射,大量 fd 向內(nèi)核態(tài)的傳輸不再是問(wèn)題。
為了一步到位,也是為了學(xué)習(xí)最先進(jìn)的I/O多路復(fù)用模型,直接使用了 epoll 機(jī)制,接下來(lái)介紹一下 epoll 相關(guān)基礎(chǔ)和自己服務(wù)器的實(shí)現(xiàn)過(guò)程。
epoll介紹
epoll 需要引入<sys/epoll.h>文件,首先介紹一下 epoll 系列函數(shù):
epoll_create
int epoll_create(int size);
創(chuàng)建一個(gè) epoll 實(shí)例,返回一個(gè)指向此 epoll 實(shí)例的文件描述符,當(dāng) epoll 實(shí)例不再使用時(shí),需要使用close()方法來(lái)關(guān)閉它。
在最初的實(shí)現(xiàn)中, size 作為期望打開的最大 fd 數(shù)傳入,以便系統(tǒng)分配足夠大的空間。在最新版本的內(nèi)核中,系統(tǒng)內(nèi)核動(dòng)態(tài)分配內(nèi)存,已不再需要此參數(shù)了,但為了避免程序運(yùn)行在舊內(nèi)核中會(huì)有問(wèn)題,還是要求此值必須大于0;
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- epfd 是通過(guò) epoll_create 返回的文件描述符
- op 則是文件描述符監(jiān)聽(tīng)事件的操作方式,EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL 分別表示添加、修改和刪除一個(gè)監(jiān)聽(tīng)事件。
- fd 為要監(jiān)聽(tīng)的文件描述符。
- event 為要監(jiān)聽(tīng)的事件,可選事件和行為會(huì)在下面描述
它的結(jié)構(gòu)如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* epoll事件 */
epoll_data_t data; /* 事件相關(guān)數(shù)據(jù) */
};
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 監(jiān)聽(tīng) epoll 事件:
- events 是 epoll 事件數(shù)組,epoll 事件的結(jié)構(gòu)上面已經(jīng)介紹過(guò)。
- maxevents 是一次監(jiān)聽(tīng)獲取到的最大事件數(shù)目。
- timeout 是一次監(jiān)聽(tīng)中獲取不到事件的最長(zhǎng)等待時(shí)間,設(shè)置成 -1 會(huì)一直阻塞等待,0 則會(huì)立即返回。
epoll行為
在 epoll_ctl 的 event 參數(shù)中,事件 events 有如下可選項(xiàng):
EPOLLIN(可讀)、EPOLLOUT(可寫)、EPOLLRDHUP(連接關(guān)閉)、EPOLLPRI(緊急數(shù)據(jù)可讀),此外 EPOLLERR(錯(cuò)誤),EPOLLHUP(連接掛斷)事件會(huì)被 epoll 默認(rèn)一直監(jiān)聽(tīng)。
除了設(shè)置事件外,還可以對(duì)監(jiān)聽(tīng)的行為設(shè)置:
- level trigger:此行為被 epoll 默認(rèn)支持,不必設(shè)置。在 epoll_wait 得到一個(gè)事件時(shí),如果應(yīng)用程序不處理此事件,在 level trigger 模式下,epoll_wait 會(huì)持續(xù)觸發(fā)此事件,直到事件被程序處理;
- EPOLLET(edge trigger):在 edge trigger 模式下,事件只會(huì)被 epoll_wait 觸發(fā)一次,如果用戶不處理此事件,不會(huì)在下次 epoll_wait 再次觸發(fā)。在處理得當(dāng)?shù)那闆r下,此模式無(wú)疑是高效的。需要注意的是此模式需求 socket 處理非阻塞模式,下面會(huì)實(shí)現(xiàn)此模式。
- EPOLLONESHOT:在單次命中模式下,對(duì)同一個(gè)文件描述符來(lái)說(shuō),同類型的事件只會(huì)被觸發(fā)一次,若想重復(fù)觸發(fā),需要重新給文件描述符注冊(cè)事件。
- EPOLLWAKEUP:3.5版本加入,如果設(shè)置了單次命中和ET模式,而且進(jìn)程有休眠喚醒能力,當(dāng)事件被掛起和處理時(shí),此選項(xiàng)確保系統(tǒng)不進(jìn)入暫?;蛐菝郀顟B(tài)。 事件被 epoll_wait 調(diào)起后,直到下次 epoll_wait 再次調(diào)起此事件、文件描述符被關(guān)閉,事件被注銷或修改,都會(huì)被認(rèn)為是處于處理中狀態(tài)。
- EPOLLEXCLUSIVE:4.5版本加入,為一個(gè)關(guān)聯(lián)到目標(biāo)文件描述符的 epoll 句柄設(shè)置獨(dú)占喚醒模式。如果目標(biāo)文件描述符被關(guān)聯(lián)到多個(gè) epoll 句柄,當(dāng)有喚醒事件發(fā)生時(shí),默認(rèn)所有 epoll 句柄都會(huì)被喚醒。而都設(shè)置此標(biāo)識(shí)后,epoll 句柄之一被喚醒,以避免“驚群”現(xiàn)象。
當(dāng)監(jiān)聽(tīng)事件和行為需求同時(shí)設(shè)置時(shí),使用運(yùn)算符 |即可。
代碼實(shí)現(xiàn)
整體處理邏輯
使用 epoll 時(shí)的服務(wù)器受理客戶端請(qǐng)求邏輯如下:
1.創(chuàng)建服務(wù)器 socket,注冊(cè)服務(wù)器 socket 讀事件;
2.客戶端連接服務(wù)器,觸發(fā)服務(wù)器 socket 可讀,服務(wù)器創(chuàng)建客戶端 socket,注冊(cè)客戶端socket 讀事件;
3.客戶端發(fā)送數(shù)據(jù),觸發(fā)客戶端 socket 可讀,服務(wù)器讀取客戶端信息,將響應(yīng)寫入 socket;
4.客戶端關(guān)閉連接,觸發(fā)客戶端 socket 可讀,服務(wù)器讀取客戶端信息為空,注銷客戶端 socket 讀事件;
erver_fd = server_start();
epoll_fd = epoll_create(FD_SIZE);
epoll_register(epoll_fd, server_fd, EPOLLIN|EPOLLET);// 這里注冊(cè)socketEPOLL事件為ET模式
while (1) {
event_num = epoll_wait(epoll_fd, events, MAX_EVENTS, 0);
for (i = 0; i < event_num; i++) {
fd = events[i].data.fd;
// 如果是服務(wù)器socket可讀,則處理連接請(qǐng)求
if ((fd == server_fd) && (events[i].events == EPOLLIN)){
accept_client(server_fd, epoll_fd);
// 如果是客戶端socket可讀,則獲取請(qǐng)求信息,響應(yīng)客戶端
} else if (events[i].events == EPOLLIN){
deal_client(fd, epoll_fd);
} else if (events[i].events == EPOLLOUT)
// todo 數(shù)據(jù)過(guò)大,緩沖區(qū)不足的情況待處理
continue;
}
}
需要注意的是,客戶端socket在可讀之后也是立刻可寫的,我這里直接讀取一次請(qǐng)求,然后將響應(yīng)信息 write 進(jìn)去,沒(méi)有考慮讀數(shù)據(jù)時(shí)緩沖區(qū)滿的問(wèn)題。
這里提出的解決方案為:
1.設(shè)置一個(gè)客戶端 socket 和 buffer 的哈希表;
2.在讀入一次信息緩沖區(qū)滿時(shí) recv 會(huì)返回 EAGIN 錯(cuò)誤,這時(shí)將數(shù)據(jù)放入 buffer,暫時(shí)不響應(yīng)。
3.后續(xù)讀事件中讀取到數(shù)據(jù)尾后,再注冊(cè) socket 可寫事件。
4.在處理可寫事件時(shí),讀取 buffer 內(nèi)的全部請(qǐng)求內(nèi)容,處理完畢后響應(yīng)給客戶端。
5.最后注銷 socket 寫事件。
設(shè)置epoll ET(edge trigger)模式
上文說(shuō)過(guò),ET模式是 epoll 的高效模式,事件只會(huì)通知一次,但處理良好的情況下會(huì)更適用于高并發(fā)。它需要 socket 在非阻塞模式下才可用,這里我們實(shí)現(xiàn)它。
sock_fd = socket(AF_INET, SOCK_STREAM, 0); // 獲取服務(wù)器socket的設(shè)置,并添加"不阻塞"選項(xiàng) flags = fcntl(sock_fd, F_GETFL, 0); fcntl(sock_fd, F_SETFL, flags|O_NONBLOCK); ..... // 這里注冊(cè)服務(wù)器socket EPOLL事件為ET模式 epoll_register(epoll_fd, server_fd, EPOLLIN|EPOLLET);
我將處理事件注掉后使用一次客戶端連接請(qǐng)求進(jìn)行了測(cè)試,很清晰地說(shuō)明了 ET模式下,事件只觸發(fā)一次的現(xiàn)象,前后對(duì)比圖如下:


小結(jié)
Mac OS X 操作系統(tǒng)的某些部分是基于 FreeBSD 的,F(xiàn)reeBSD 不支持,MAC 也不支持(不過(guò)有相似的 kqueue),跑到開發(fā)機(jī)上開發(fā)的,作為一個(gè)最基礎(chǔ)的 C learner, 靠著printf()和fflush()兩個(gè)函數(shù)來(lái)調(diào)試的,不過(guò)搞了很久總算是完成了,有用 C 的前輩推薦一下調(diào)試方式就最好了
以上就是如何用C寫一個(gè)web服務(wù)器之I/O多路復(fù)用的詳細(xì)內(nèi)容,更多關(guān)于用C寫一個(gè)web服務(wù)器之I/O多路復(fù)用的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
C語(yǔ)言超詳細(xì)講解隊(duì)列的實(shí)現(xiàn)及代碼
隊(duì)列(Queue)與棧一樣,是一種線性存儲(chǔ)結(jié)構(gòu),它具有如下特點(diǎn):隊(duì)列中的數(shù)據(jù)元素遵循“先進(jìn)先出”(First?In?First?Out)的原則,簡(jiǎn)稱FIFO結(jié)構(gòu)。在隊(duì)尾添加元素,在隊(duì)頭刪除元素2022-04-04
C++實(shí)現(xiàn)圖片轉(zhuǎn)base64的示例代碼
Base64就是一種 基于64個(gè)可打印字符來(lái)表示二進(jìn)制數(shù)據(jù)的表示方法,本文主要為大家詳細(xì)介紹了如何使用C++實(shí)現(xiàn)圖片轉(zhuǎn)base64,需要的可以參考下2024-04-04
c++將字符串轉(zhuǎn)數(shù)字的實(shí)例方法
在本篇文章里小編給大家整理的是關(guān)于c++將字符串轉(zhuǎn)數(shù)字的實(shí)例方法,有需要的朋友們可以參考下。2020-02-02
C語(yǔ)言實(shí)現(xiàn)通訊錄系統(tǒng)程序
這篇文章主要為大家詳細(xì)介紹了C語(yǔ)言實(shí)現(xiàn)通訊錄系統(tǒng)程序,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-06-06
c語(yǔ)言實(shí)現(xiàn)把文件中數(shù)據(jù)讀取并存到數(shù)組中
下面小編就為大家?guī)?lái)一篇c語(yǔ)言實(shí)現(xiàn)把文件中數(shù)據(jù)讀取并存到數(shù)組中。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2016-12-12
C語(yǔ)言中volatile關(guān)鍵字的作用及說(shuō)明
文中主要介紹了C語(yǔ)言中volatile關(guān)鍵字的含義和使用場(chǎng)景,volatile是一個(gè)類型修飾符,主要用來(lái)修飾被不同線程訪問(wèn)和修改的變量,它的作用是防止編譯器對(duì)代碼進(jìn)行優(yōu)化,確保每次直接讀取原始內(nèi)存地址的值2024-10-10

