首先,本人沒有l(wèi)inux、驅(qū)動(dòng)開發(fā)經(jīng)驗(yàn)。平時(shí)的工作語(yǔ)言是java、python,c、c++的知識(shí)也停留在大學(xué)時(shí)代。所以關(guān)于網(wǎng)絡(luò)IO,我查閱了很多的文章才基本明白。文中可能會(huì)有錯(cuò)誤,或者不足的地方,希望諒解。所以參考的文章在末尾都一一列出,大家也可以查閱。
網(wǎng)絡(luò)io種類
- blocking IO
- nonblocking IO
- IO multiplexing
- signal driven IO
- asynchronous IO
由signal driven IO在實(shí)際中并不常用,所以主要介紹其余四種IO Model。
對(duì)于一個(gè)network IO (這里我們以read舉例),它會(huì)涉及到兩個(gè)系統(tǒng)對(duì)象,一個(gè)是調(diào)用這個(gè)IO的process (or thread),另一個(gè)就是系統(tǒng)內(nèi)核(kernel)。當(dāng)一個(gè)read操作發(fā)生時(shí),它會(huì)經(jīng)歷兩個(gè)階段:
1)等待數(shù)據(jù)準(zhǔn)備 (Waiting for the data to be ready)
2)將數(shù)據(jù)從內(nèi)核拷貝到進(jìn)程中(Copying the data from the kernel to the process)
記住這兩點(diǎn)很重要,因?yàn)檫@些IO模型的區(qū)別就是在兩個(gè)階段上各有不同的情況。
阻塞IO(blocking IO)

blocking IO的特點(diǎn)就是在IO執(zhí)行的兩個(gè)階段(等待數(shù)據(jù)和拷貝數(shù)據(jù)兩個(gè)階段)都被block了。
非阻塞IO(non-blocking IO)

non-blocking IO 在等待數(shù)據(jù)階段是非阻塞的,但是需要一直輪訓(xùn)。但是在數(shù)據(jù)拷貝階段同樣也是阻塞的
多路復(fù)用IO(IO multiplexing)

當(dāng)用戶進(jìn)程調(diào)用了select,那么整個(gè)進(jìn)程會(huì)被block,而同時(shí),kernel會(huì)“監(jiān)視”所有select負(fù)責(zé)的socket,當(dāng)任何一個(gè)socket中的數(shù)據(jù)準(zhǔn)備好了,select就會(huì)返回。這個(gè)時(shí)候用戶進(jìn)程再調(diào)用read操作,將數(shù)據(jù)從kernel拷貝到用戶進(jìn)程。
這個(gè)圖和blocking IO的圖其實(shí)并沒有太大的不同,事實(shí)上還更差一些。因?yàn)檫@里需要使用兩個(gè)系統(tǒng)調(diào)用(select和recvfrom),而blocking IO只調(diào)用了一個(gè)系統(tǒng)調(diào)用(recvfrom)。但是,用select的優(yōu)勢(shì)在于它可以同時(shí)處理多個(gè)connection。(多說一句:所以,如果處理的連接數(shù)不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優(yōu)勢(shì)并不是對(duì)于單個(gè)連接能處理得更快,而是在于能處理更多的連接。)
在多路復(fù)用模型中,對(duì)于每一個(gè)socket,一般都設(shè)置成為non-blocking,但是,如上圖所示,整個(gè)用戶的process其實(shí)是一直被block的。只不過process是被select這個(gè)函數(shù)block,而不是被socket IO給block。因此select()與非阻塞IO類似。
異步IO(Asynchronous I/O)

用戶進(jìn)程發(fā)起read操作之后,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當(dāng)它受到一個(gè)asynchronous read之后,首先它會(huì)立刻返回,所以不會(huì)對(duì)用戶進(jìn)程產(chǎn)生任何block。然后,kernel會(huì)等待數(shù)據(jù)準(zhǔn)備完成,然后將數(shù)據(jù)拷貝到用戶內(nèi)存,當(dāng)這一切都完成之后,kernel會(huì)給用戶進(jìn)程發(fā)送一個(gè)signal,告訴它read操作完成了。注意這里不用用戶進(jìn)程主動(dòng)的去將數(shù)據(jù)從內(nèi)核態(tài)拷貝到用戶態(tài)
-
blocking與non-blocking區(qū)別
1.調(diào)用blocking IO會(huì)一直block住對(duì)應(yīng)的進(jìn)程直到操作完成
2.non-blocking IO在kernel還在準(zhǔn)備數(shù)據(jù)的情況下會(huì)立刻返回 -
synchronous IO和asynchronous IO的區(qū)別
Stevens給出的定義(其實(shí)是POSIX的定義)如下:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;
其中關(guān)鍵就在于是否將用戶process阻塞。asynchronous IO則不一樣,當(dāng)進(jìn)程發(fā)起IO操作之后,就直接返回再也不理睬了,直到kernel發(fā)送一個(gè)信號(hào),告訴進(jìn)程說IO完成。在這整個(gè)過程中,進(jìn)程完全沒有被block。按照這個(gè)定義,之前所述的blocking IO,non-blocking IO,IO multiplexing都屬于synchronous IO
網(wǎng)卡接收數(shù)據(jù)的過程
文章開頭提到,不同io的主要區(qū)別是如何處理(1)等待數(shù)據(jù)準(zhǔn)備(2)數(shù)據(jù)從內(nèi)核態(tài)拷貝到進(jìn)制中,這兩個(gè)階段的。那對(duì)于階段一,操作系統(tǒng)是如何準(zhǔn)備的呢?如下圖

為了方便理解,盡量簡(jiǎn)化技術(shù)細(xì)節(jié),可以把接收數(shù)據(jù)的過程分為4步:
- NIC(網(wǎng)卡) 接收到數(shù)據(jù),通過 DMA 方式寫入內(nèi)存(Ring Buffer 和 sk_buff)。
- NIC 發(fā)出中斷請(qǐng)求(IRQ),告訴內(nèi)核有新的數(shù)據(jù)過來(lái)了。
- Linux 內(nèi)核響應(yīng)中斷,系統(tǒng)切換為內(nèi)核態(tài),處理 Interrupt Handler,從RingBuffer 拿出一個(gè) Packet, 并處理協(xié)議棧,填充 Socket 并交給用戶進(jìn)程。
- 系統(tǒng)切換為用戶態(tài),用戶進(jìn)程處理數(shù)據(jù)內(nèi)容。
再談多路復(fù)用
目前有哪些IO多路復(fù)用的方案
- Linux: select、poll、epoll
- MacOS/FreeBSD: kqueue
- Windows/Solaris: IOCP
本文僅討linux場(chǎng)景下的select、poll、epoll的使用和區(qū)別。上文有提到過,多路復(fù)用是用本來(lái)需要用戶進(jìn)程自已等待數(shù)據(jù)準(zhǔn)備好(一階段,會(huì)阻塞)委托給其他系統(tǒng)調(diào)用(就是這里的select、poll、epoll)。所以就減少了用戶進(jìn)程自已的等待時(shí)間和系統(tǒng)開銷,那么就可以創(chuàng)建、維護(hù)更多的鏈接,從而提高系統(tǒng)性能。
select
select函數(shù)介紹:
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
功能:輪詢掃描多個(gè)描述符中的任一描述符是否發(fā)生響應(yīng),或經(jīng)過一段時(shí)間后喚醒

其中fd_set數(shù)據(jù)結(jié)構(gòu)如下:
#define __FD_SETSIZE 1024
typedef __kernel_fd_set fd_set;
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
}
所以我們看到,fd_set實(shí)際上一個(gè)long類型數(shù)組,大小等于1024/(8*4) = 32。如果數(shù)組的大小是32,那么是不是意味著內(nèi)核最多只能監(jiān)聽32個(gè)文件標(biāo)識(shí)符呢?肯定不是的,這里用到了bitmap的思路。實(shí)際是用long類型的每一位來(lái)表示一個(gè)文件描述符,一個(gè)long32個(gè)bit,那么一個(gè)long就能監(jiān)聽32個(gè)文件描述符,32個(gè)long就能監(jiān)聽1024個(gè)文件描述符。所以這是select的缺點(diǎn),poll就是改進(jìn)了這個(gè)缺點(diǎn),采用了鏈表的方式來(lái)突破這個(gè)限制。其他和select一樣,我們下面就不說了。如何把文件描述符添加大fd_set中呢?這里需要其他幾個(gè)方法
/初始化描述符集
void FD_ZERO(fd_set *fdset);
//將一個(gè)描述符添加到描述符集
void FD_SET(int fd, fd_set *fdset);
//將一個(gè)描述符從描述符集中刪除
void FD_CLR(int fd, fd_set *fdset);
//檢測(cè)指定的描述符是否有事件發(fā)生
int FD_ISSET(int fd, fd_set *fdset);
下面我們來(lái)看一個(gè)例子(來(lái)自參考文獻(xiàn)6)
while(1)
{
fd_set rset;//創(chuàng)建一個(gè)描述符集rset
FD_ZERO(&rset);//對(duì)描述符集rset清零
FD_SET(0, &rset);//將描述符0加入到描述符集rset中
FD_SET(4, &rset);//將描述符4加入到描述符集rset中
FD_SET(5, &rset);//將描述符5加入到描述符集rset中
if(select(5+1, &rset, NULL, NULL, NULL) > 0)
{
if(FD_ISSET(0, &rset))
{
//描述符0可讀及相應(yīng)的處理代碼
}
if(FD_ISSET(4, &rset))
{
//描述符4可讀及相應(yīng)的處理代碼
}
if(FD_ISSET(5, &rset))
{
//描述符5可讀及相應(yīng)的處理代碼
}
}
}
下面是另外一個(gè)例子(來(lái)自參考文獻(xiàn)2)

epoll
epoll是用來(lái)改進(jìn)select的缺點(diǎn),缺點(diǎn)我下面會(huì)總結(jié)一下,epoll設(shè)計(jì)三個(gè)系統(tǒng)調(diào)用
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//epfd: 由 epoll_create 生成的epoll專用的文件描述符;
//op: 要進(jìn)行的操作例如注冊(cè)事件,可能的取值EPOLL_CTL_ADD 注冊(cè)、EPOLL_CTL_MOD 修 改、EPOLL_CTL_DEL 刪除
//fd: 關(guān)聯(lián)的文件描述符
//event: 指向epoll_event的指針;
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
具體的含義如下:
(1)epoll_create調(diào)用后,內(nèi)核幫我們做了兩件事情
- 在內(nèi)核cache里建了個(gè)紅黑樹用于存儲(chǔ)以后epoll_ctl傳來(lái)的socket;
- 建立一個(gè)list鏈表,用于存儲(chǔ)準(zhǔn)備就緒的事件
(2)epoll_ctl
- 把socket放到epoll文件系統(tǒng)里file對(duì)象對(duì)應(yīng)的紅黑樹上
- 給內(nèi)核中斷處理程序注冊(cè)一個(gè)回調(diào)函數(shù),告訴內(nèi)核,如果這個(gè)句柄的中斷到了,就把它放到準(zhǔn)備就緒list鏈表里
(3)epoll_wait
- 察list鏈表里有沒有數(shù)據(jù)。有數(shù)據(jù)就返回,沒有數(shù)據(jù)就sleep,等到timeout時(shí)間到后即使鏈表沒數(shù)據(jù)也返回。而且,通常情況下即使我們要監(jiān)控百萬(wàn)計(jì)的句柄,大多一次也只返回很少量的準(zhǔn)備就緒句柄而已,所以,epoll_wait僅需要從內(nèi)核態(tài)copy少量的句柄到用戶態(tài)而已。
總結(jié)如下:
一顆紅黑樹,一張準(zhǔn)備就緒句柄鏈表,少量的內(nèi)核cache,解決了大并發(fā)下的socket處理問題。
- 執(zhí)行epoll_create時(shí),創(chuàng)建了紅黑樹和就緒鏈表;
- 執(zhí)行epoll_ctl時(shí),如果增加socket句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹干上,然后向內(nèi)核注冊(cè)回調(diào)函數(shù),用于當(dāng)中斷事件來(lái)臨時(shí)向準(zhǔn)備就緒鏈表中插入數(shù)據(jù);
- 執(zhí)行epoll_wait時(shí)立刻返回準(zhǔn)備就緒鏈表里的數(shù)據(jù)即可。
select、epoll對(duì)比
- select 監(jiān)聽描述符有數(shù)量限制,epoll沒有
- select 將fd_set 傳給內(nèi)核,內(nèi)核需要變量來(lái)將數(shù)據(jù)準(zhǔn)備就緒的對(duì)應(yīng)位置set為1,同樣的,由于內(nèi)核共用的fd_set, 用戶進(jìn)程同樣也需要掃描fd_set來(lái)看那個(gè)socket準(zhǔn)備好了,這里有兩個(gè)問題
1.時(shí)間復(fù)雜度O(n)
2.每次都需要將fd_set 內(nèi)存拷貝,如果連接幾百萬(wàn)個(gè)(雖然一個(gè)fd_set是1024個(gè),我們可以用多個(gè)fd_set嘛),那么空間消耗大 ;
但是epoll是用的紅黑樹,時(shí)間復(fù)雜度低O(1),用了回調(diào)函數(shù)將準(zhǔn)備就緒的文件描述符放到了一個(gè)鏈表中,一般情況下,這個(gè)鏈表比較小。所以空間復(fù)雜度也比較低
所以epoll無(wú)論在時(shí)間復(fù)雜度還是空間復(fù)雜度上是都是比select優(yōu)越的。