基本概念
在IO多路復用模型中,引入了一種新的系統(tǒng)調(diào)用,查詢IO的就緒狀態(tài)。在Linux系統(tǒng)中,對應(yīng)的系統(tǒng)調(diào)用為select/poll/epoll系統(tǒng)調(diào)用。通過該系統(tǒng)調(diào)用,一個進程可以監(jiān)視多個文件描述符,一旦某個描述符就緒(一般是內(nèi)核緩沖區(qū)可讀/可寫),內(nèi)核能夠?qū)⒕途w的狀態(tài)返回給應(yīng)用程序。隨后,應(yīng)用程序根據(jù)就緒的狀態(tài),進行相應(yīng)的IO系統(tǒng)調(diào)用。在IO多路復用模型中通過select/poll/epoll系統(tǒng)調(diào)用,單個應(yīng)用程序的線程,可以不斷地輪詢成百上千的socket連接,當某個或者某些socket網(wǎng)絡(luò)連接有IO就緒的狀態(tài),就返回對應(yīng)的可以執(zhí)行的讀寫操作。
與同步阻塞IO模型不同的是,select/poll/epoll系統(tǒng)調(diào)用可以等待多個文件描述符(套接字)上的發(fā)生IO事件,可以設(shè)置等待超時。select/poll/epoll系統(tǒng)調(diào)用只返回描述符就緒
的個數(shù)(一般可認為是IO事件的個數(shù)),用戶需要遍掃描整個描述符集處理IO時間。
select系統(tǒng)調(diào)用詳解
select函數(shù)定義
int select (
int nfds, // 監(jiān)控的文件描述符集里最大文件描述符加1,這是因為文件描述符從0開始
fd_set *readfds, // 監(jiān)控讀數(shù)據(jù)到達文件描述符集合
fd_set *writefds, // 監(jiān)控寫數(shù)據(jù)到達文件描述符集合
fd_set *exceptfds, // 監(jiān)控異常發(fā)生達文件描述符集合
struct timeval *timeout // 指定阻塞時間,超時就會立即返回
);
參數(shù)說明:
-
nfds
Linux系統(tǒng)為每一個進程維護了一個文件描述符表,表示該進程打開文件的記錄表,而文件描述符實際上就是這張表的索引。nfds參數(shù)指定所有被監(jiān)控的文件描述符的個數(shù),其最大值由FD_SETSIZE定義,想要改變其值,不僅需要修改相應(yīng)的頭文件,還需要重新編譯內(nèi)核。在centos7中按照如下路勁就能找到nfds的最大值。
在/usr/include/sys/select.h文件中,有如下定義
#define FD_SETSIZE __FD_SETSIZE
在/usr/include/bits/typesizes.h文件中,有如下定義
#define __FD_SETSIZE 1024
? 所以,nfds的最大值可以在C語言的頭文件定義中找到,Linux系統(tǒng)默認為1024。
-
readfds、writefds、errorfds
fd_set的定義如下:
typedef struct fd_set { unsigned long fds_bits[FD_SETSIZE / (sizeof(unsigned long) * 8)]; } fd_set;
fds_bits是一個數(shù)組,用于存儲各個文件描述符的狀態(tài)。數(shù)組中的每個元素都是一個unsigned long類型的整數(shù),sizeof(unsigned long)的值是8個字節(jié)。因此,fd_set類型最多可以表示(FD_SETSIZE /( 8 * 8)) * (8 * 8)個描述符的狀態(tài),也就是可以表示FD_SETSIZE個描述符的狀態(tài)。如果設(shè) fd_set 長度為 1 字節(jié),則一個 fd_set 變量最大可以表示 8 個文件描述符。所以readfds、writefds、errorfds 是三個文件描述符集合。select 會遍歷每個集合的前 nfds 個描述符,分別找到可以讀取、可以寫入、發(fā)生錯誤的描述符,統(tǒng)稱為“就緒”的文件描述符。然后用找到的子集替換這三個引用參數(shù)中的對應(yīng)集合,返回所有就緒描述符的數(shù)量。
除此之外,fd_set 的使用還涉及以下幾個 API:
#include <sys/select.h> int FD_ZERO(int fd, fd_set *fdset); // 將 fd_set 所有位 置0 int FD_CLR(int fd, fd_set *fdset); // 將 fd_set 某一位 置0 int FD_SET(int fd, fd_set *fd_set); // 將 fd_set 某一位 置1 int FD_ISSET(int fd, fd_set *fdset); // 檢測 fd_set 某一位是否為 1
-
timeout
timeout 參數(shù)表示調(diào)用 select 時的阻塞時長。如果所有 fd 文件描述符都未就緒,就阻塞調(diào)用進程,直到某個描述符就緒,或者阻塞超過設(shè)置的 timeout 后,返回。如果 timeout 參數(shù)設(shè)為 NULL,會無限阻塞直到某個描述符就緒;如果 timeout 參數(shù)設(shè)為 0,會將select監(jiān)聽的所有文件描述符遍歷一遍后立即返回,不阻塞。
注意:
readfds、writefds、errorfds這三個參數(shù)作為入?yún)⒑统鰠⒌囊饬x是不一樣的。以readfds為例,readfds參數(shù)既是入?yún)ⅲ彩浅鰠?。作為入?yún)ⅲ付舜O(jiān)視的文件描述符集合;作為出參,它指示了哪些文件描述符已經(jīng)就緒。
再說具體一點,假設(shè)fd_set 長度為 1 字節(jié),readfds作為入?yún)r其二進制表示為00001001,這表示select系統(tǒng)調(diào)用只監(jiān)視文件描述符0和3(從右往左看)。當select函數(shù)成功返回時,readfds參數(shù)會被內(nèi)核修改,以指示哪些文件描述符已經(jīng)就緒。假設(shè)readfds返回時的二進制表示為00001000,這表示文件描述符3已就緒。
select的底層原理
在描述select系統(tǒng)調(diào)用底層工作原理之前,需要有一些前置知識。首先需要深入理解套接字(socket),
套接字也是一種I/O資源,它可以通過文件描述符來進行操作。
例如,我們可以使用read和write函數(shù)來對套接字進行讀寫操作,就像對文件進行讀寫一樣。此外,套接字具有一些獨特的屬性。
常見的套接字屬性有:
協(xié)議類型
:套接字的協(xié)議類型決定了該套接字所使用的協(xié)議,例如TCP、UDP等。地址族
:套接字的地址族決定了該套接字所使用的地址類型,例如IPv4、IPv6等。地址
:套接字的地址是一個IP地址和端口號的組合,用于標識該套接字所連接的遠程主機或本地主機。狀態(tài)
:套接字的狀態(tài)反映了該套接字當前所處的狀態(tài),例如已連接、未連接、監(jiān)聽等。緩沖區(qū)
:套接字有多個緩沖區(qū),用于存儲接收到的數(shù)據(jù)和待發(fā)送的數(shù)據(jù)。緩沖區(qū)的大小、數(shù)量和使用方式都可以通過系統(tǒng)調(diào)用等接口進行配置。選項
:套接字有多個選項,用于控制套接字的行為,例如超時時間、重傳次數(shù)、廣播等。文件描述符
:套接字是一種文件,因此它也有一個文件描述符,用于在應(yīng)用程序中標識該套接字。發(fā)送隊列和接收隊列
:發(fā)送隊列是用于存儲已發(fā)送但未被對方確認接收的數(shù)據(jù)。接收隊列是用于存儲接收到的數(shù)據(jù)。除此之外,操作系統(tǒng)內(nèi)核空間還維護了一些與套接字相關(guān)的隊列。如下面的等待隊列和就緒隊列。應(yīng)用程序無法直接訪問這些隊列,只能通過系統(tǒng)調(diào)用的方式訪問。
等待隊列
:由等待該套接字的進程組成的隊列,這些進程正在等待某些事件的發(fā)生,例如數(shù)據(jù)到達。當事件發(fā)生時,內(nèi)核會從等待隊列中喚醒一個或多個進程,使它們可以繼續(xù)執(zhí)行。就緒隊列
:套接字相關(guān)聯(lián)的文件系統(tǒng)對象有一個就緒隊列,這個隊列里面放的是等待該文件系統(tǒng)對象上某個事件發(fā)生的進程。在 Linux 內(nèi)核中,套接字的就緒狀態(tài)是通過文件描述符和文件系統(tǒng)對象之間的關(guān)系來實現(xiàn)的。當套接字上發(fā)生某個事件(例如數(shù)據(jù)可讀、連接建立等)時,內(nèi)核會將該事件標記為“就緒”,并將文件描述符加入到對應(yīng)的文件系統(tǒng)對象的就緒隊列中。
有了上面的背景知識,現(xiàn)在就可以理解select系統(tǒng)調(diào)用底層工作原理。下面是詳細過程:
- 當用戶進程調(diào)用select系統(tǒng)調(diào)用,內(nèi)核創(chuàng)建socket對象。
- select系統(tǒng)調(diào)用會把要監(jiān)聽的文件描述符從用戶態(tài)空間拷貝到內(nèi)核態(tài)空間。
- 在內(nèi)核態(tài)中,操作系統(tǒng)內(nèi)核會遍歷所有要監(jiān)聽的文件描述符。再說詳細點,就是內(nèi)核會去挨個檢查每個文件描述符對應(yīng)的套接字里面的接收隊列,看有沒有數(shù)據(jù)到達。
- 遍歷完成后,如果有一個或多個文件描述符就緒,就會立即從內(nèi)核態(tài)返回到用戶態(tài),并返回已經(jīng)就緒的文件描述符的數(shù)量。但是用戶進程并不知道具體是那個文件描述符已經(jīng)就緒,所以用戶進程最后還需要去遍歷readfds,才知道具體那個文件描述符已經(jīng)就緒。
- 現(xiàn)在回到第2步。如果在內(nèi)核遍歷完成后,所有文件描述符都沒有就緒,那么調(diào)用select的用戶進程就會進入阻塞狀態(tài)(前提是timeout沒有設(shè)置為0)。
- 現(xiàn)在我們需要把注意力放在網(wǎng)絡(luò)上,當客戶端發(fā)送數(shù)據(jù)給服務(wù)器時,數(shù)據(jù)包會經(jīng)過客戶端的傳輸層、網(wǎng)絡(luò)層、鏈路層、物理層,并經(jīng)過電磁波傳輸?shù)竭_服務(wù)器的網(wǎng)卡。
- 服務(wù)器的網(wǎng)卡接收到數(shù)據(jù)后,會通過DMA的方式將數(shù)據(jù)包寫入到指定的內(nèi)存中。
- 數(shù)據(jù)寫入完成后,DMA控制器會發(fā)送一個硬中斷信號給CPU,來告訴CPU有新的數(shù)據(jù)包到達(硬中斷的優(yōu)先級較高,CPU會優(yōu)先處理該中斷)。
- CPU根據(jù)硬中斷信號查找并調(diào)用相應(yīng)的中斷處理程序,中斷處理程序的任務(wù)就是根據(jù)數(shù)據(jù)包的IP和端口號找到相應(yīng)的套接字,并將數(shù)據(jù)包拷貝到套接字的接收隊列中。然后再檢查這個套接字的等待隊列里面是否有等待該套接字的進程。如果有被阻塞的進程正在等待該套接字,那么中斷處理程序就會發(fā)送一個信號喚醒哪些被阻塞的進程。中斷處理程序的最后一步操作就是將該套接字的文件描述符加入到對應(yīng)的文件系統(tǒng)對象的就緒隊列中,也就是說等待該文件系統(tǒng)對象上某個事件發(fā)生的進程放入到就緒隊列中。至此,中斷處理程序成功返回。
- 內(nèi)核會將該套接字對應(yīng)的文件描述符設(shè)置為已就緒,并從內(nèi)核態(tài)返回到用戶態(tài)。至此,select系統(tǒng)調(diào)用的整個過程就結(jié)束了。
為了加深理解,畫了個select系統(tǒng)調(diào)用底層原理示意圖。
select的優(yōu)缺點
優(yōu)點:
- 相較于同步阻塞IO與同步非阻塞IO模型,一次select系統(tǒng)調(diào)用可以同時監(jiān)聽多個文件描述符,不需要每個文件描述符都進行系統(tǒng)調(diào)用,減少了用戶態(tài)和內(nèi)核態(tài)之間切換的次數(shù)。
缺點:
- 單進程監(jiān)聽的文件描述符的數(shù)量存在限制,默認為1024個。
- 每次select系統(tǒng)調(diào)用需要將文件描述符集合從用戶態(tài)拷貝到內(nèi)核態(tài),系統(tǒng)調(diào)用返回時還需要將文件描述符集合從內(nèi)核態(tài)拷貝到用戶態(tài)。高并發(fā)場景下這樣的拷貝會消耗極大資源。
- 當阻塞的進程被喚醒后,不知道哪些文件描述符已就緒,需要遍歷傳遞進來的文件描述符集合的每一位,不管它們是否就緒。
- select系統(tǒng)調(diào)用返回時不知道具體那個文件描述符已就緒,還需要重新把文件描述符集合遍歷一遍。
- 入?yún)⒌娜齻€文件描述符集合每次調(diào)用都需要重置。
poll系統(tǒng)調(diào)用詳解
poll函數(shù)定義
和 select 類似,只是描述 fd 集合的方式不同,poll 使用 pollfd 結(jié)構(gòu)而非 select 的 fd_set 結(jié)構(gòu)。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 要監(jiān)聽的文件描述符
short events; // 監(jiān)聽的事件
short revents; // 就緒的事件
};
poll函數(shù)支持的事件類型:
-
POLLIN
:對應(yīng)的文件描述符上有數(shù)據(jù)可讀,常量值為 1。 -
POLLOUT
:對應(yīng)的文件描述符上可以寫入數(shù)據(jù),常量值為 2。 -
POLLERR
:對應(yīng)的文件描述符發(fā)生錯誤,常量值為 4。 -
POLLHUP
:對應(yīng)的文件描述符被掛起,常量值為 16。 -
POLLNVAL
:對應(yīng)的文件描述符非法,常量值為 32。
在使用 poll() 函數(shù)時,我們可以使用位運算符 | 來組合多個事件類型。例如,如果我們想等待一個文件描述符上既有數(shù)據(jù)可讀又可以寫入數(shù)據(jù),可以這樣設(shè)置 events 字段:
fds[0].events = POLLIN | POLLOUT;
調(diào)用poll的底層原理
在使用 poll() 函數(shù)時,我們需要創(chuàng)建一個 pollfd 結(jié)構(gòu)體數(shù)組。
struct pollfd fds[MAX_CLIENTS + 1];
每個結(jié)構(gòu)體表示一個文件描述符和它所等待的事件。poll() 函數(shù)會將這些結(jié)構(gòu)體放入一個鏈表中,等待事件發(fā)生。當有事件發(fā)生時,poll() 函數(shù)會遍歷整個鏈表,找到對應(yīng)的結(jié)構(gòu)體,并將其標記為就緒狀態(tài),然后返回就緒的文件描述符。使用鏈表的好處是,可以方便地添加和刪除等待事件,而不需要重新分配數(shù)組空間。另外,鏈表的節(jié)點可以動態(tài)分配內(nèi)存,避免了數(shù)組空間浪費的問題。也正是基于鏈表存儲,所以poll系統(tǒng)調(diào)用無最大文件描述符數(shù)量的限制。
由于select 和 poll 在內(nèi)部機制方面并沒有太大的差異。相比于 select 機制,poll 只是取消了最大監(jiān)控文件描述符數(shù)限制,這里就不在過多的贅述了。
poll的優(yōu)缺點
優(yōu)點:
- 相比于select,支持等待更多類型的事件,包括讀、寫、異常和掛起等事件,可以更加靈活地處理 I/O。
- 相比于select,取消了最大監(jiān)控文件描述符數(shù)限制。
缺點:
- 每次poll系統(tǒng)調(diào)用需要將文件描述符集合從用戶態(tài)拷貝到內(nèi)核態(tài),系統(tǒng)調(diào)用返回時還需要將文件描述符集合從內(nèi)核態(tài)拷貝到用戶態(tài)。高并發(fā)場景下這樣的拷貝會消耗極大資源。
- 當阻塞的進程被喚醒后,不知道哪些文件描述符已就緒,需要遍歷傳遞進來的文件描述符集合的每一位,不管它們是否就緒。
- poll系統(tǒng)調(diào)用返回時不知道具體那個文件描述符已就緒,還需要重新把文件描述符集合遍歷一遍。
epoll系統(tǒng)調(diào)用詳解
epoll相關(guān)的函數(shù)定義
-
epoll_create函數(shù)和epoll_create1函數(shù)
int epoll_create(int size); int epoll_create1(int flags);
epoll_create 是 epoll 的初始化函數(shù),用于創(chuàng)建一個 epoll 對象。其中,size 表示最多可以監(jiān)聽的文件描述符數(shù)量。該函數(shù)返回一個整數(shù)值,表示創(chuàng)建的 epoll 對象的文件描述符。如果出錯,返回值為 -1。
epoll_create1 也用于創(chuàng)建一個epoll對象,并返回一個文件描述符,但它可以通過 flags 參數(shù)來設(shè)置一些標志位,以改變 epoll 對象的行為。常用的標志位包括:
-
EPOLL_CLOEXEC
:設(shè)置文件描述符的 close-on-exec 標志,即在執(zhí)行 exec 系統(tǒng)調(diào)用時自動關(guān)閉文件描述符。 -
EPOLL_NONBLOCK
:設(shè)置文件描述符的非阻塞標志,即對該文件描述符的操作都是非阻塞的。
總的來說,epoll_create1 函數(shù)比 epoll_create 函數(shù)更加靈活,可以通過標志位來設(shè)置一些 epoll 對象的屬性,而 epoll_create 函數(shù)則比較簡單,只能創(chuàng)建默認屬性的 epoll 對象。在實際編程中,建議優(yōu)先使用 epoll_create1 函數(shù)。
-
-
epoll_ctl函數(shù)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl 函數(shù)負責把服務(wù)端和客戶端建立的 socket 連接注冊到 eventpoll 對象里。其中,第一個參數(shù) epfd 是 epoll 對象的文件描述符。第二個參數(shù) op 表示操作類型,可以是以下三個值之一:
操作類型 描述 EPOLL_CTL_ADD 在epoll的監(jiān)視列表中添加一個文件描述符(即參數(shù)fd),指定監(jiān)視的事件類型(參數(shù)event),常量值為1。 EPOLL_CTL_DEL 將某監(jiān)視列表中已經(jīng)存在的描述符(即參數(shù)fd)刪除,參數(shù)event傳NULL,常量值為2。 EPOLL_CTL_MOD 修改監(jiān)視列表中已經(jīng)存在的描述符(即參數(shù)fd)對應(yīng)的監(jiān)視事件類型(參數(shù)event),常量值為3。 在centos這個發(fā)行版中,EPOLL_CTL_ADD、EPOLL_CTL_MOD和EPOLL_CTL_DEL的定義在/usr/include/sys/epoll.h文件下找到。
第三個參數(shù) fd 是要添加、修改或刪除的文件描述符。第四個參數(shù) event 是一個 struct epoll_event 結(jié)構(gòu)體,用于描述要監(jiān)聽的事件類型和數(shù)據(jù)。該結(jié)構(gòu)體的定義如下:
struct epoll_event { uint32_t events; /* 監(jiān)聽的事件類型 */ epoll_data_t data; /* 用戶數(shù)據(jù) */ }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
其中,events 表示要監(jiān)聽的事件類型,可以是以下幾個值之一:
-
EPOLLIN
:表示文件描述符可讀; -
EPOLLOUT
:表示文件描述符可寫; -
EPOLLERR
:表示文件描述符發(fā)生錯誤; -
EPOLLHUP
:表示文件描述符被掛起。
data 是一個 epoll_data_t 類型的聯(lián)合體,用于存儲用戶數(shù)據(jù)。它可以是一個指針、一個文件描述符、一個 32 位整數(shù)或一個 64 位整數(shù)。epoll_ctl 函數(shù)返回 0 表示成功,-1 表示失敗。
-
-
epoll_wait函數(shù)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_wait 用于等待文件描述符上的事件發(fā)生。其中,epfd 是 epoll 對象的文件描述符,events 是一個指向 struct epoll_event 數(shù)組的指針,用于存儲發(fā)生事件的文件描述符和事件類型,maxevents 表示最多等待的事件數(shù)量,timeout 表示等待的超時時間,單位是毫秒。epoll_wait 函數(shù)返回發(fā)生事件的文件描述符數(shù)量,如果出錯,返回值為 -1。
epoll的底層原理
在理解epoll底層原理之前,需要知道
struct eventpoll
(epoll對象)的作用。 epoll 對象是用于管理多個文件描述符的機制,它可以同時監(jiān)聽多個文件描述符上的事件,包括套接字的可讀、可寫、錯誤等事件。盡管 epoll 對象和套接字的功能不同,但它們之間也有聯(lián)系。在實際應(yīng)用中,通常會將套接字的文件描述符加入到 epoll 對象中,以便能夠監(jiān)聽套接字上的事件。這樣,當套接字上有數(shù)據(jù)可讀或可寫時,epoll 對象就會通知應(yīng)用程序進行相應(yīng)的操作。因此,epoll 對象和套接字是可以結(jié)合使用的。
epoll對象的定義如下:
struct eventpoll
{
wait_queue_head_t wq; // sys_epoll_wait用到的等待隊列
struct list_head rdllist; // 接收就緒的描述符都會放到這里
struct rb_root rbr; // 每個epoll對象中都有一顆紅黑樹
......
}
-
wq: 等待隊列。如果當前進程沒有數(shù)據(jù)需要處理,會把當前進程描述符和
回調(diào)函數(shù)
default_wake_functon (這個回調(diào)函數(shù)等會要考)構(gòu)造一個等待隊列項,放入當前 wq 對待隊列中,等到數(shù)據(jù)就緒的時候會通過 wq 來找到阻塞在 epoll 對象上的用戶進程。 - rdllist: 就緒的描述符的隊列。當有的連接就緒的時候(socket監(jiān)聽的事件完成的時候),內(nèi)核會把就緒的socket的文件描述符放到 rdllist 隊列里。這樣應(yīng)用進程只需要判斷隊列就能找出就緒進程,而不用去遍歷整棵樹。
- rbr: 一棵紅黑樹。為了支持對海量連接的高效查找、插入和刪除,eventpoll 內(nèi)部使用了一棵紅黑樹。通過這棵樹來管理用戶進程下添加進來的所有 socket 連接。
struct eventpoll 的結(jié)構(gòu)如下圖所示:
在創(chuàng)建好epoll對象后,接下來就是將socket的文件描述符和epoll對象關(guān)聯(lián)起來并指定需要監(jiān)聽的事件了。這就是epoll_ctl函數(shù)的作用,也就是說epoll_ctl函數(shù)可以把socket的文件描述符以及監(jiān)聽的事件添加
(也可以是刪除或更新)到epoll對象中。那具體是如何關(guān)聯(lián)的呢?實際上,epoll_ctl函數(shù)首先會創(chuàng)建一個epitem對象。epitem結(jié)構(gòu)體如下所示:
struct epitem
{
struct rb_node rbn; // 紅黑樹節(jié)點
struct epoll_filefd ffd; // socket文件描述符信息
struct eventpoll *ep; // 所歸屬的epoll對象
struct list_head pwqlist; // 等待隊列
}
這個epitem對象其實就是epoll對象里面的紅黑樹中的一個節(jié)點。epitem對象也是對socket的一種抽象概括,也就是說從epitem對象中能夠得到socket的部分關(guān)鍵信息。我們也可以把epitem對象理解為socket對象和epoll對象關(guān)聯(lián)的一個橋梁。當socket上監(jiān)聽的事件沒有發(fā)生時,socket就會變?yōu)樽枞麪顟B(tài)。總的來說,epoll_ctl函數(shù)主要做了下面這三件事情:
-
創(chuàng)建紅黑樹節(jié)點對象epitem
-
將等待事件添加到socket的等待隊列中,通過
pwqlist
(等待隊列)設(shè)置數(shù)據(jù)就緒的回調(diào)函數(shù)為ep_poll_callback。(當事件發(fā)生后,軟中斷處理程序就會調(diào)用ep_poll_callback) -
將epitem節(jié)點插入到epoll對象的紅黑樹中
接下來就開始靜靜的等待事件的發(fā)生。顯然,這就是epoll_wait函數(shù)的作用。epoll_wait 函數(shù)的動作比較簡單,檢查 epoll對象的就緒隊列
(里面放的是就緒的文件描述符)是否有數(shù)據(jù)到達,如果沒有就把當前的進程描述符添加到一個等待隊列項里,加入到 epoll對象的進程等待隊列里,設(shè)置等待項回調(diào)函數(shù)default_wake_function,然后阻塞自己,等待數(shù)據(jù)到達時通過回調(diào)函數(shù)被喚醒。
是的,當沒有 IO 事件的時候, epoll 也是會阻塞掉當前進程。這個是合理的,因為沒有事情可做了占著 CPU 也沒啥意義。網(wǎng)上的很多文章有個很不好的習慣,討論阻塞、非阻塞等概念的時候都不說主語。這會導致你看的云里霧里。拿 epoll 來說,epoll 本身是阻塞的,但一般會把 socket 設(shè)置成非阻塞。只有說了主語,這些概念才有意義。
當有數(shù)據(jù)到達時,通過下面幾個步驟喚醒對應(yīng)的進程處理數(shù)據(jù):
- (前面的過程參考select系統(tǒng)調(diào)用)。
- 中斷處理程序根據(jù)數(shù)據(jù)包里面的IP和端口號就能找到對應(yīng)的socket對象,將內(nèi)存中的數(shù)據(jù)包拷貝到socket的接收隊列中(事件被觸發(fā)) ,再調(diào)用socket的等待隊列中等待項設(shè)置的回調(diào)函數(shù)ep_poll_callback。
- ep_poll_callback 函數(shù)根據(jù)等待隊列項找到epitem。
- 由于epitem保存了已就緒的socket的文件描述符,并且epitem對象是epoll對象的一個紅黑樹節(jié)點,所以ep_poll_callback函數(shù)可以將就緒的socket的文件描述符添加到epoll對象的就緒隊列中。
- ep_poll_callback 函數(shù)檢查epoll對象的等待隊列上是否有等待項。
- 如果沒有等待項,說明用戶進程并未阻塞,此時軟中斷結(jié)束。
- 如果有等待項,則通過調(diào)用回調(diào)函數(shù) default_wake_func 喚醒這個進程。
- 當進程醒來后,繼續(xù)從epoll_wait時暫停處的代碼繼續(xù)執(zhí)行,把epoll對象就緒隊列的事件返回給用戶進程,讓用戶進程調(diào)用recv把已經(jīng)到達socket的數(shù)據(jù)拷貝到用戶空間使用。
感想:
研究了整整一天終于看懂了epoll這個系統(tǒng)調(diào)用的底層原理。真的感覺這個系統(tǒng)調(diào)用的底層原理非常復雜。過不了幾天,我感覺就能把這些東西忘的差不多了。為了更方便的回顧,畫了epoll對象、epitem對象和socket對象的關(guān)系圖。
epoll的優(yōu)缺點
優(yōu)點:
- 高效處理高并發(fā)下的大量連接,有非常有益的性能。(紅黑樹將存儲 epoll 所監(jiān)聽的 FD,高效的數(shù)據(jù)結(jié)構(gòu),本身插入和刪除性能比較好;通過epoll對象中的就緒隊列可以直接知道哪些文件描述符已就緒,減少了遍歷文件描述符集的時間開銷; mmap 的引入,將用戶空間的一塊地址和內(nèi)核空間的一塊地址同時映射到相同的一塊物理內(nèi)存地址,減少用戶態(tài)和內(nèi)核態(tài)之間的數(shù)據(jù)交換。)
缺點:
- 跨平臺性不夠好,目前只支持Linux操作系統(tǒng)。MacOS和Windows操作系統(tǒng)不支持該函數(shù)。
- 在監(jiān)聽的文件描述符或事件較少的時候,可能select和poll的性能更優(yōu)。
ET vs LT
基本概念
Edge Triggered (ET) 邊沿觸發(fā)
-
socket的接收緩沖區(qū)狀態(tài)變化時觸發(fā)讀事件,即空的接收緩沖區(qū)剛接收到數(shù)據(jù)時觸發(fā)讀事件
-
socket的發(fā)送緩沖區(qū)狀態(tài)變化時觸發(fā)寫事件,即滿的緩沖區(qū)剛空出空間時觸發(fā)讀事件
Level Triggered (LT) 水平觸發(fā)
-
socket接收緩沖區(qū)不為空,有數(shù)據(jù)可讀,則讀事件一直觸發(fā)
-
socket發(fā)送緩沖區(qū)不滿可以繼續(xù)寫入數(shù)據(jù),則寫事件一直觸發(fā)
epoll_ctl模式設(shè)置
epoll_wait 函數(shù)的觸發(fā)方式可以通過 epoll_ctl 函數(shù)的 EPOLL_CTL_ADD 操作來設(shè)置。在添加文件描述符到 epoll 對象時,可以通過設(shè)置 epoll_event 結(jié)構(gòu)體中的 events 字段來指定觸發(fā)方式。具體來說:
- 如果將 events 字段設(shè)置為
EPOLLIN | EPOLLET
或EPOLLOUT | EPOLLET
,則表示該文件描述符采用邊緣觸發(fā)方式。 - 如果將 events 字段設(shè)置為
EPOLLIN
或EPOLLOUT
,則表示該文件描述符采用水平觸發(fā)方式。
其中,EPOLLIN 表示文件描述符可讀,EPOLLOUT 表示文件描述符可寫,EPOLLET 表示邊緣觸發(fā)方式。
需要注意的是,邊緣觸發(fā)方式下,epoll_wait 函數(shù)只會在文件描述符上發(fā)生狀態(tài)變化時才返回,而水平觸發(fā)方式下,epoll_wait 函數(shù)會在文件描述符上有數(shù)據(jù)可讀或可寫時就返回。因此,邊緣觸發(fā)方式下需要更加謹慎地處理事件,否則可能會出現(xiàn)遺漏事件的情況。
應(yīng)用場景
基于IO多路復用(epoll實現(xiàn))的Web服務(wù)器
基于epoll多路復用的方式寫一個并發(fā)的Web服務(wù)器對于理解epoll多路復用很有幫助。
客戶端代碼:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define PORT 8080
int main(int argc, char const *argv[])
{
int client_fd, valread;
struct sockaddr_in server_addr;
char buffer[1024] = {0};
const char *http_request = "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nConnection: close\r\n\r\nHello World!";
client_fd = socket(AF_INET, SOCK_STREAM, 0); // 創(chuàng)建socket
server_addr.sin_family = AF_INET; // sin_family用來定義是哪種地址族,AF_INET表示使用IPv4進行通信
server_addr.sin_port = htons(PORT); // 指定端口號,htons()將短整型數(shù)據(jù)轉(zhuǎn)換成網(wǎng)絡(luò)字節(jié)順序
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); // 將IPv4地址從點分十進制轉(zhuǎn)換為二進制格式
// 連接服務(wù)器
if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
perror("connect failed");
exit(EXIT_FAILURE);
}
send(client_fd, http_request, strlen(http_request), 0); // 發(fā)送HTTP請求
return 0;
}
服務(wù)器代碼:
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#define MAX_EVENTS 10 // epoll_wait函數(shù)每次最多返回的就緒事件數(shù)量
#define BUF_SIZE 1024 // 緩沖區(qū)大小
int main(int argc, char *argv[])
{
int nfds, i, n;
char buffer[BUF_SIZE];
int server_fd, client_fd, epoll_fd;
struct epoll_event ev, events[MAX_EVENTS]; // ev是添加到epoll對象中的事件,events[]用于存儲epoll_wait函數(shù)返回的就緒事件
short port = 8080; // 服務(wù)器的監(jiān)聽端口
char listen_addr_str[] = "127.0.0.1"; // 服務(wù)器的IP地址
int server_socket, client_socket; // 定義服務(wù)端的socket和客戶端的socket
struct sockaddr_in server_addr, client_addr; // 定義服務(wù)端和客戶端的IPv4的套接字地址結(jié)構(gòu)(定長,16字節(jié))
size_t listen_addr = inet_addr(listen_addr_str); // 將點分十進制的IPv4地址轉(zhuǎn)換成網(wǎng)絡(luò)字節(jié)序列的長整型
// 對IPv4的套接字地址結(jié)構(gòu)做初始化
bzero(&server_addr, sizeof(server_addr)); // 將server_addr結(jié)構(gòu)體的前sizeof(serveraddr)個字節(jié)清零,與memset()差不多
server_addr.sin_family = AF_INET; // sin_family用來定義是哪種地址族,AF_INET表示使用IPv4進行通信
server_addr.sin_port = htons(port); // 指定端口號,htons()將短整型數(shù)據(jù)轉(zhuǎn)換成網(wǎng)絡(luò)字節(jié)順序
server_addr.sin_addr.s_addr = listen_addr; // 指定服務(wù)器的IP地址
socklen_t client_len = sizeof(client_addr);
server_fd = socket(PF_INET, SOCK_STREAM, 0); // 創(chuàng)建套接字,SOCK_STREAM表示使用TCP協(xié)議
fcntl(server_fd, F_SETFL, fcntl(server_fd, F_GETFL, 0) | O_NONBLOCK); // 設(shè)置非阻塞
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)); // 綁定端口
// 監(jiān)聽端口,SOMAXCONN默認值為128,表示TCP服務(wù)可以同時接受的連接請求的最大數(shù)量
listen(server_fd, SOMAXCONN);
// 創(chuàng)建 epoll 對象
if ((epoll_fd = epoll_create1(0)) < 0)
{
perror("epoll_create1 error");
exit(1);
}
ev.events = EPOLLIN; // 添加事件
ev.data.fd = server_fd; // 添加服務(wù)器socket到epoll對象中
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) < 0)
{
perror("epoll_ctl error");
exit(1);
}
// 開始循環(huán)監(jiān)聽
while (1)
{
// 等待事件發(fā)生,返回發(fā)生事件的文件描述符數(shù)量
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds < 0)
{
perror("epoll_wait error");
exit(1);
}
// 處理所有就緒事件
for (i = 0; i < nfds; i++)
{
if (events[i].data.fd == server_fd) // 如果是服務(wù)器socket有新連接請求
{
while ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len)) > 0)
{
// 設(shè)置客戶端socket非阻塞
fcntl(client_fd, F_SETFL, fcntl(client_fd, F_GETFL, 0) | O_NONBLOCK);
// 添加客戶端socket到epoll對象中
ev.events = EPOLLIN | EPOLLET; // ET模式,緩沖區(qū)狀態(tài)變化時觸發(fā)事件
ev.data.fd = client_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) < 0)
{
perror("epoll_ctl error");
exit(1);
}
}
}
else
{
while ((n = read(events[i].data.fd, buffer, BUF_SIZE)) > 0) // 如果是客戶端socket有數(shù)據(jù)到達
{
printf("Received: %s\n", buffer); // 輸出從客戶端接收到的數(shù)據(jù)
char *response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!";
write(events[i].data.fd, response, strlen(response)); // 回復客戶端
close(events[i].data.fd); // 關(guān)閉客戶端socket
}
}
}
}
close(server_fd); // 關(guān)閉服務(wù)器socket
exit(0);
}
運行結(jié)果如下:
以上代碼的大致流程如下:

Redis的IO多路復用模型簡單分析
Redis采用IO多路復用技術(shù),其底層實現(xiàn)原理如下:
-
事件驅(qū)動:Redis采用事件驅(qū)動的方式處理客戶端請求。當有客戶端請求到達時,Redis會將請求放入一個事件隊列中,然后通過IO多路復用技術(shù)來監(jiān)聽事件。當有事件到達時,Redis會從事件隊列中取出事件,并根據(jù)事件類型進行處理。
-
IO多路復用:Redis采用IO多路復用技術(shù)來處理客戶端請求。通過IO多路復用技術(shù),Redis可以同時監(jiān)聽多個客戶端請求,從而實現(xiàn)高并發(fā)的讀寫操作。
-
非阻塞IO:Redis采用非阻塞IO來處理客戶端請求。通過非阻塞IO,Redis可以在等待客戶端請求的同時,繼續(xù)處理其他請求,從而提高系統(tǒng)的吞吐量。
-
事件循環(huán):Redis采用事件循環(huán)的方式處理客戶端請求。事件循環(huán)是指Redis在等待客戶端請求的同時,不斷地進行事件處理。通過事件循環(huán),Redis可以在保證高并發(fā)的同時,保持低延遲和高吞吐量。
Redis的IO多路復用模型是基于epoll實現(xiàn)的。在Linux系統(tǒng)中,有多種IO多路復用模型,包括select、poll和epoll等。Redis最初是基于select模型實現(xiàn)的,但由于select模型在大量連接的情況下性能不佳,因此Redis從2.6版本開始采用epoll模型。
綜上所述,Redis的IO多路復用模型是Redis能夠?qū)崿F(xiàn)高性能、高并發(fā)、低延遲的關(guān)鍵。通過IO多路復用技術(shù)的應(yīng)用,Redis能夠同時監(jiān)聽多個客戶端請求,從而實現(xiàn)高并發(fā)的讀寫操作。同時采用非阻塞IO和事件循環(huán)的方式處理客戶端請求,在保證高并發(fā)的同時,保持低延遲和高吞吐量。
問:redis的IO多路復用模型是基于epoll實現(xiàn),由于epoll系統(tǒng)調(diào)用只持支Linux操作系統(tǒng),為什么windows也能使用redis的IO多路復用模型?
Redis是使用epoll系統(tǒng)調(diào)用作為IO多路復用模型的底層實現(xiàn),而Windows操作系統(tǒng)不支持epoll系統(tǒng)調(diào)用。因此,在Windows上使用Redis時,Redis不能直接使用epoll作為底層I/O模型。Redis在Windows上會使用類似epoll的技術(shù)實現(xiàn)I/O多路復用。
具體來說,Redis在Windows上使用了IOCP(Input/Output Completion Ports)技術(shù)來實現(xiàn)I/O多路復用。IOCP是Windows專有的技術(shù),它是一種高效的I/O調(diào)度機制,它可以支持一組I/O操作的異步完成通知。通過IOCP,Redis可以在Windows平臺上實現(xiàn)高效的異步I/O,并且可以避免使用epoll等Linux專有的API。
需要注意的是,由于Windows和Linux的底層實現(xiàn)機制不同,導致在Windows上使用IOCP和在Linux上使用epoll并不完全相同,因此,在跨平臺開發(fā)時需要注意這種差異。文章來源:http://www.zghlxwxcb.cn/news/detail-472976.html
參考鏈接:文章來源地址http://www.zghlxwxcb.cn/news/detail-472976.html
- 深入學習IO多路復用 select/poll/epoll 實現(xiàn)原理
- 圖解 | 深入揭秘 epoll 是如何實現(xiàn) IO 多路復用的
- 大話 Select、Poll、Epoll-騰訊云開發(fā)者社區(qū)-騰訊云
- Epoll原理解析 - 博客園
- Linux內(nèi)核API default_wake_function - 極客筆記
- epoll工作原理 - 嗶哩嗶哩
- 小白也看得懂的 I/O 多路復用解析
- C語言網(wǎng)絡(luò)編程-tcp服務(wù)器實現(xiàn)
到了這里,關(guān)于IO多路復用詳解的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!