上一篇《Linux網絡I/O模型》提到了多路復用是目前實現高並發網絡模型的主流方式。那么今天我們就來了解下I/O多路復用的實現原理。
在正式講解之前,我們必須先來了解一下什么是文件描述符。
什么是文件描述符
在Linux系統中,把所有I/O設備都被抽象為了文件這個概念,一切皆文件。磁盤、網絡、終端,甚至進程間通信工具管道pipe等都被當作文件對待。
通過這一抽象,任何I/O操作,都可以通過簡單的幾個接口來實現。
- open,打開文件
- seek,改變讀寫位置
- read,write,讀寫文件
- close,關閉文件
當進程打開現有文件或創建新文件時,內核向進程返回一個文件描述符,簡稱 fd
(file descriptor)。文件描述符就是內核為了高效管理已被打開的文件所創建的索引,用來指向被打開的文件。有了文件描述符,進程可以對文件一無所知,比如文件在磁盤的什么位置、加載到內存中又是怎樣管理的等等,這些信息統統交由操作系統打理,進程無需關心,進程只需要通過文件描述符調用簡單的 API 進行操作即可。
因此,當我們打開一個磁盤上的文件進行讀取時,過程是這樣的:
int fd = open(file_name); // 返回文件描述符
read(fd, buff);
當我們建立一個網絡連接接收客戶端發送來的數據時,過程是這樣的:
int conn_fd = accept(...); // 返回網絡連接文件描述符
if (read(conn_fd, request_buff) > 0) {
do_something(request_buff);
}
高並發網絡服務實現
了解了文件描述符,繼續回到我們本節我們要討論的問題來,如果讓我們自己去實現一個高性能支持高並發的網絡服務器,我們該怎么設計呢。
我們能想到的最簡單的方式,有可能就是多線程了。采用多線程+阻塞I/O模型,每來一個客戶端連接,就創建一個線程進行通信,有數據就讀寫,沒數據就阻塞。
但是多線程方式存在兩個最大問題:
- 大量的線程頻繁創建和銷毀,非常消耗資源。當然可以通過線程池的方式解決。
- 線程之間頻繁的切換引起CPU上下文的切換,非常消耗CPU資源。
顯然多線程存在的這些固有缺陷導致了它並不是實現高並發服務的最優選擇,那么我們只能得把目光轉移到單線程。
如果使用單線程,我們就得首先排除阻塞I/O模式:
int iResult = recv(s, buffer,1024);
在阻塞模式下,如果沒有數據到來,recv
會一直阻塞在那里,從而導致整個程序都鎖死了,什么也干不了。
那非阻塞的模式是不是就能完全解決這個問題了呢?
int iResult = ioctlsocket(s, FIOBIO, (unsigned long *)&ul);
iResult = recv(s, buffer,1024);
上面的 ioctlsocket
把socket設置為了非阻塞模式,所以當調用 recv
時,如果沒有數據,也會馬上返回,並返回一個錯誤 EWOULDBLOCK
,表示請求操作沒有成功完成,需要再次發起 recv
操作,循環往復。
但這種通過輪詢判斷有沒有數據的方式,太消耗CPU資源,效率很低。
如果有一種方式,不需要我們去頻繁詢問,而是有數據准備就緒后,主動來通知我們,那該多好啊。就像設計模式中的好萊塢原則,“不要給我們打電話,我們會給你打電話(Don't call me, I'll call you.)”。這就是我們這節要講的,I/O多路復用(I/O multiplexing)。
select
select
是I/O多路復用的一種實現方式。它的實現過程大概是這樣的:
- 每個連接都會返回一個
fd
,我們需要一個數組fds
記錄所有連接的fd
。 - 我們需要一個叫
fd_set
的數據結構,它是一個默認 1024 bit大小的一個位圖bitmap結構,每個bit用來標識一個fd
。 - 用戶進程通過
select
系統調用把fd_set
結構的數據拷貝到內核,由內核來監視並判斷哪些連接有數據到來,如果有連接准備好數據,select
系統調用就返回。 select
返回后,用戶進程只知道某個或某幾個連接有數據,但並不知道是哪個連接。所以需要遍歷fds
中的每個fd
, 當該fd
被置位時,代表 該fd
表示的連接有數據需要被讀取。然后我們讀取該fd
的數據並進行業務操作。- 重新置位
fd_set
,然后跳轉到步驟 3 循環執行。
下面是用 select
實現的服務端部分demo代碼
/* 創建socket監聽 */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sockfd, 5);
/* 接受5個客戶端連接 */
int fds[5]; // fd 數組
for (i=0; i<5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen);
if (fds[i] > max)
max = fds[i]; // 記錄最大fd數值
}
fd_set rset;
while (1)
{
FD_ZERO(&rset); // 重置fd_set
for (i=0; i<5; i++)
{
FD_SET(fds[i], &rset);
}
puts("round again");
select(max+1, &rset, NULL, NULL, NULL); // 主角select
for (i=0; i<5; i++)
{
if (FD_ISSET(fds[i], &rset))
{
memset(buffer, 0, MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer); // 業務處理邏輯
}
}
}
fd_set
結構體如下:
#include <sys/select.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
void FD_SET(int fd, fd_set *fdset) //將fd添加到fdset
void FD_CLR(int fd, fd_set *fdset) //從fdset中刪除fd
void FD_ISSET(int fd, fd_set *fdset) //判斷fd是否已存在fdset
void FD_ZERO(fd_set *fdset) //初始化fdset內容全為0
select
函數的定義
// nfds:fds中最大fd的值加1
// readfds: 讀數據文件描述符集合
// writefds: 寫數據文件描述符集合
// exceptfds: 異常情況的文件描述符集合
// timeout: 該方法阻塞的超時時間
int select (int nfds, fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
struct timeval {
long tv_sec; //秒
long tv_usec; //毫秒
}
上述過程有幾個關鍵點需要特別做說明:
fd_set
是文件描述符fd
的集合,由於每個進程可打開的文件描述符默認值為1024,fd_set
可記錄的fd
個數上限也是1024個。fd_set
采用位圖 bitmap 結構,是一個大小為32的 long 型數組,每一個 bit 代表一個描述符是否被監視。select
第一個參數需要傳入最大fd
值加1的數值,目的是為了用戶能自定義監視的fd
范圍,防止不必要資源消耗。- 為什么每次 while 循環開始都要重置
fd_set
,是因為操作系統會復用用戶進程傳入的fd_set
變量,來作為出參,所以我們傳入的fd_set
返回時已經被內核修改過了。
相比於非阻塞模型中用戶進程不斷輪詢每個 fd
是否有數據,select
的方式選擇讓內核來幫我們監視這些 fd
,當有數據可讀時就通知我們。這種方式在效率方面得到了極大的提高,但仍有不太完美的地方,主要有以下幾個原因:
- 可監控的文件描述符數量最大為 1024 個,就代表最大能支持的並發為1024,這個是操作系統內核決定的。
- 用戶進程的文件描述符集合
fd_set
每次都需要從用戶進程拷貝到內核,有一定的性能開銷。 select
函數返回,我們只知道有文件描述符滿足要求,但不知道是哪個,所以需要遍歷所有文件描述符,復雜度為O(n)。
因此我們可以看到,select
機制的這些特性在高並發網絡服務器動輒幾萬幾十萬並發連接的場景下無疑是低效的。
poll
pool
是另一種I/O多路復用的實現方式,它解決了 select
1024個文件描述符的限制問題。
下面我們先看demo代碼
/* 接受5個客戶端連接 */
for (i=0; i<5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN; // 關注輸入事件
}
while (1)
{
puts("round again");
poll(pollfds, 5, 50000); // 主角poll
for(i=0; i<5; i++)
{
if (pollfds[i].revents & POLLIN) // 輸入事件
{
pollfds[i].revents = 0; // 重置標志位
memset(buffer, 0, MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer); // 業務處理邏輯
}
}
}
從代碼中可以看出,poll
是使用了 pollfd
結構來替代了 select
的 fd_set
位圖,以解決 1024 的文件描述符個數限制。
pollfd
的結構定義如下:
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
第一個變量 fd
表示要監視的文件描述符;
第二個變量 events
表示要監視的事件,比如輸入、輸出或異常;
第三個變量 revents
表示返回的標志位,標識哪個事件有信息到來,處理完成后記得重置標志位。
poll
函數的定義
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
函數的第一個參數傳入了一個自定義的 pollfd
的數組,原則上已經沒有了個數的限制。
但 poll
除了解決了 select
存在的文件描述符個數的限制,並沒有解決 select
存在的其他問題。
select
和 poll
都會隨着監控的文件描述符數量增加而性能下降,因此也不太適合高並發場景。
epoll
epoll
是 select
和 poll
的增強版本,它更加靈活,沒有描述符數量的限制。epoll
使用一個文件描述符管理多個描述符,省去了大量文件描述符頻繁在用戶態和內核態之間拷貝的資源消耗。
epoll
的實現代碼大概是這樣的:
struct epoll_event events[5];
int epfd = epoll_create(10);
// ...
for (i=0; i<5; i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while (1)
{
puts("round again");
nfds = epoll_wait(epfd, events, 5, 10000);
for (i=0; i<nfds; i++)
{
memset(buffer, 0, MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
epoll
操作過程有三個非常重要的接口,分別是:
#include <sys/epoll.h>
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()
方法生成一個 epoll
專用的文件描述符(創建一個 epoll
的句柄)。參數 size
在新版本中沒有具體意義,填一個大於0的任意值即可。
epoll_ctl()
方法是 epoll
的事件注冊函數,告訴內核要監視的文件描述符和事件類型。
epfd
:epoll
專用的文件描述符,epoll_create()
的返回值。
op
:表示添加、修改、刪除的動作,用三個宏來表示:
EPOLL_CTL_ADD
:注冊新的fd
到epfd
中;
EPOLL_CTL_MOD
:修改已經注冊的fd
的監聽事件;
EPOLL_CTL_DEL
:從epfd
中刪除一個fd
;
fd
:需要監聽的文件描述符。
event
:告訴內核要監聽的事件。
epoll_wait()
方法等待事件的產生,類似 select
調用。
epfd
:epoll
專用的文件描述符,epoll_create()
的返回值。
events
:分配好的epoll_event
結構體數組,epoll
將會把發生的事件賦值到events
數組中。
maxevents
:告訴內核events
數組的大小。
timeout
:超時時間,單位毫秒,為 -1 時,方法為阻塞。
epoll
底層使用了 RB-Tree
紅黑樹和 list
鏈表實現。內核創建了紅黑樹用於存儲 epoll_ctl
傳來的 socket,另外創建了一個 list 鏈表,用於存儲准備就緒的事件。
當 epoll_wait
調用時,僅僅觀察這個 list 鏈表里有沒有數據即可。有數據就返回,沒有數據就阻塞。所以,epoll_wait
非常高效,通常情況下即使我們要監控百萬計的連接,大多一次也只返回很少量准備就緒的文件描述符而已,所以,epoll_wait
僅需要從內核態拷貝很少的文件描述符到用戶態。
epoll
相比於 select
和 poll
,它更高效的本質在於:
- 減少了用戶態和內核態文件描述符狀態的拷貝,
epoll
只需要一個專用的文件句柄即可; - 減少了文件描述符的遍歷,
select
和poll
每次都要遍歷所有的文件描述符,用來判斷哪個連接准備就緒;epoll
返回的是准備就緒的文件描述符,效率大大提高; - 沒有並發數量的限制,性能不會隨文件描述符數量的增加而下降。
I/O多路復用總結
本節講解了三種I/O多路復用的實現方式,以及它們各自的優缺點。
select
是較早實現的一種I/O多路復用技術,但它最明顯的缺點就是有 1024 個文件描述符數量的限制,也就導致它無法滿足高並發的需求。
poll
一定程度上解決了 select
文件描述符數量的限制,但和 select
一樣,仍然存在文件描述符狀態在用戶態和內核態的頻繁拷貝,和遍歷所有文件描述符的問題,這導致了在面對高並發的實現需求時,它的性能不會很高。
epoll
高效地解決了以上問題,首先使用一個特殊的文件描述符,解決了用戶態和內核態頻繁拷貝的問題;其次 epoll_wait
返回的是准備就緒的文件描述符,省去了無效的遍歷;再次,底層使用紅黑樹和鏈表的數據結構,更加高效地實現連接的監視。
我們工作中常用的 redis、nginx 都是使用了 epoll
這種I/O復用模型,通過單線程就實現了10萬以上的並發訪問。
另外有很多資料會說 epoll
使用了共享內存(mmap)的方式,其實是不正確的,它並沒有用到mmap。
通過上面的講解,你是不是感覺 epoll
任何情況下一定比 select
高效呢,其實也不一定,需要根據具體場景。比如你的並發不是很高,且大部分都是活躍的 socket,那么也許 select
會比 epoll
更加高效,因為 epoll
會有更多次的系統調用,用戶態和內核態會有更加頻繁的切換。
所有,沒有銀彈,一切都是 trade-off。
歡迎關注我的微信公眾號【架構小菜】