采用單線程的方式處理高并發(fā)
在單線程模型中,可以采用I/O復用來提高單線程處理多個請求的能力,然后再采用事件驅(qū)動模型,基于異步回調(diào)來處理事件來。
I/O 多路復用技術(shù)是為了解決進程或線程阻塞到某個 I/O 系統(tǒng)調(diào)用而出現(xiàn)的技術(shù),使進程不阻塞于某個特定的 I/O 系統(tǒng)調(diào)用。
select(),poll(),epoll()都是I/O多路復用的機制。I/O多路復用通過一種機制,可以監(jiān)視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒,就是這個文件描述符進行讀寫操作之前),能夠通知程序進行相應(yīng)的讀寫操作。
他們本質(zhì)上都是同步I/O,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實現(xiàn)會負責把數(shù)據(jù)從內(nèi)核拷貝到用戶空間。
與多線程和多進程相比,I/O 多路復用的最大優(yōu)勢是系統(tǒng)開銷小,系統(tǒng)不需要建立新的進程或者線程,也不必維護這些線程和進程。
select():
監(jiān)視并等待多個文件描述符的屬性變化(可讀、可寫或錯誤異常)。
select()函數(shù)監(jiān)視的文件描述符分 3 類,分別是writefds、readfds、和 exceptfds。
調(diào)用后 select() 函數(shù)會阻塞,直到有描述符就緒(有數(shù)據(jù)可讀、可寫、或者有錯誤異常),或者超時( timeout 指定等待時間),函數(shù)才返回。
當 select()函數(shù)返回后,可以通過遍歷 fdset,來找到就緒的描述符。
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
//成功:就緒描述符的數(shù)目,超時返回 0,
//出錯:-1
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
//nfds: 要監(jiān)視的文件描述符的范圍,一般取監(jiān)視的描述符數(shù)的最大值+1,如這里寫 10, 這樣的話,描述符
//0,1, 2 …… 9 都會被監(jiān)視,在 Linux 上最大值一般為1024。
//readfd: 監(jiān)視的可讀描述符集合,只要有文件描述符即將進行讀操作,這個文件描述符就存儲到這。
//writefds: 監(jiān)視的可寫描述符集合。
//exceptfds: 監(jiān)視的錯誤異常描述符集合
缺點:
- 每次調(diào)用 select(),都需要把 fd 集合從用戶態(tài)拷貝到內(nèi)核態(tài),這個開銷在 fd 很多時會很大,同時每次調(diào)用 select() 都需要在內(nèi)核遍歷傳遞進來的所有 fd,這個開銷在 fd 很多時也很大。
- 單個進程能夠監(jiān)視的文件描述符的數(shù)量存在最大限制,在 Linux 上一般為 1024,可以通過修改宏定義甚至重新編譯內(nèi)核的方式提升這一限制,但是這樣也會造成效率的降低。
poll():
select() 和 poll() 系統(tǒng)調(diào)用的本質(zhì)一樣,管理多個描述符也是進行輪詢,根據(jù)描述符的狀態(tài)進行處理。
但是 poll() 沒有最大文件描述符數(shù)量的限制(但是數(shù)量過大后性能也是會下降)。
缺點與select()一樣:包含大量文件描述符的數(shù)組被整體復制于用戶態(tài)和內(nèi)核的地址空間之間,而不論這些文件描述符是否就緒,它的開銷隨著文件描述符數(shù)量的增加而線性增大。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);//監(jiān)視并等待多個文件描述符的屬性變化。
//fds: 不同與 select() 使用三個位圖來表示三個 fdset 的方式,poll() 使用一個 pollfd 的指針實現(xiàn)。
//一個 pollfd 結(jié)構(gòu)體數(shù)組,其中包括了你想測試的文件描述符和事件, 事件由結(jié)構(gòu)中事件域 events 來確定,
//調(diào)用后實際發(fā)生的時間將被填寫在結(jié)構(gòu)體的 revents 域。
//nfds: 用來指定第一個參數(shù)數(shù)組元素個數(shù)。
//timeout: 指定等待的毫秒數(shù),無論 I/O 是否準備好,poll() 都會返回。
struct pollfd{
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 實際發(fā)生了的事件 */
};
poll() 的實現(xiàn)和 select() 非常相似,只是描述 fd 集合的方式不同,poll() 使用 pollfd 結(jié)構(gòu)而不是 select() 的 fd_set 結(jié)構(gòu),其他的都差不多。
epoll():
epoll 使用一個文件描述符管理多個描述符,將用戶關(guān)系的文件描述符的事件存放到內(nèi)核的一個事件表中,這樣在用戶空間和內(nèi)核空間的 copy 只需一次。
#include <sys/epoll.h>
//成功:epoll 專用的文件描述符
//失?。?1
int epoll_create(int size);//生成一個 epoll 專用的文件描述符(創(chuàng)建一個 epoll 的句柄)。會占用一個 fd 值。
//size:參數(shù) size 并不是限制了 epoll 所能監(jiān)聽的描述符最大個數(shù),只是對內(nèi)核初始分配內(nèi)部數(shù)據(jù)結(jié)構(gòu)的一個建議。
//linux 2.6.8 之后,size 參數(shù)是被忽略的,也就是說可以填只有大于 0 的任意值。
//成功:0
//失?。?1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//功能:epoll 的事件注冊函數(shù),它不同于 select() 是在監(jiān)聽事件時告訴內(nèi)核要監(jiān)聽什么類型的事件,而是在這里先注冊要監(jiān)聽的事件類型。
//epfd: epoll 專用的文件描述符,epoll_create()的返回值
//op: 表示動作,用三個宏來表示:EPOLL_CTL_ADD(注冊新的 fd 到 epfd 中)、EPOLL_CTL_MOD(修改fd監(jiān)聽事件)、EPOLL_CTL_DEL(刪除fd)
//fd: 需要監(jiān)聽的文件描述符
//event: 告訴內(nèi)核要監(jiān)聽什么事件,也是幾個宏的集合
//成功:返回需要處理的事件數(shù)目,如返回 0 表示已超時。
//失敗:-1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
//功能:等待事件的產(chǎn)生,收集在 epoll 監(jiān)控的事件中已經(jīng)發(fā)送的事件,類似于 select() 調(diào)用。
//epfd: epoll 專用的文件描述符,epoll_create()的返回值
//events: 分配好的 epoll_event 結(jié)構(gòu)體數(shù)組,epoll 將會把發(fā)生的事件賦值到events 數(shù)組中
//(events 不可以是空指針,內(nèi)核只負責把數(shù)據(jù)復制到這個 events 數(shù)組中,不會去幫助我們在用戶態(tài)中分配內(nèi)存)。
//maxevents: maxevents 告之內(nèi)核這個 events 有多大 。
//timeout: 超時時間,單位為毫秒,為 -1 時,函數(shù)為阻塞
epoll 對文件描述符的操作有兩種模式:LT(level trigger)和 ET(edge trigger)。
LT 模式:
.socket接收緩沖區(qū)不為空 有數(shù)據(jù)可讀 讀事件一直觸發(fā)
.socket發(fā)送緩沖區(qū)不滿 可以繼續(xù)寫入數(shù)據(jù) 寫事件一直觸發(fā)
符合思維習慣,epoll_wait返回的事件就是socket的狀態(tài)
只要可讀,就一直觸發(fā)讀事件,只要可寫,就一直觸發(fā)寫事件。事件循環(huán)處理比較簡單,無需關(guān)注應(yīng)用層是否有緩沖或緩沖區(qū)是否滿,只管上報事件。缺點是:可能經(jīng)常上報,可能影響性能。
ET 模式:
.socket的接收緩沖區(qū)狀態(tài)變化時觸發(fā)讀事件,即空的接收緩沖區(qū)剛接收到數(shù)據(jù)時觸發(fā)讀事件
.socket的發(fā)送緩沖區(qū)狀態(tài)變化時觸發(fā)寫事件,即滿的緩沖區(qū)剛空出空間時觸發(fā)讀事件
僅在狀態(tài)變化時觸發(fā)事件。
從不可讀變?yōu)榭勺x,從可讀變?yōu)椴豢勺x,從不可寫變?yōu)榭蓪?,從可寫變?yōu)椴豢蓪?,都只觸發(fā)一次。
LT的編程與poll/select接近,符合一直以來的習慣,不易出錯
ET的編程可以做到更加簡潔,某些場景下更加高效,但另一方面容易遺漏事件,容易產(chǎn)生bug
優(yōu)點:
-
監(jiān)視的描述符數(shù)量不受限制。
它所支持的 FD 上限是最大可以打開文件的數(shù)目,這個數(shù)字一般遠大于 2048,舉個例子,在 1GB 內(nèi)存的機器上大約是 10 萬左右。雖然也可以選擇多進程的解決方案( Apache 就是這樣實現(xiàn)的),不過雖然 Linux 上面創(chuàng)建進程的代價比較小,但仍舊是不可忽視的,加上進程間數(shù)據(jù)同步遠比不上線程間同步的高效,所以也不是一種完美的方案。 -
I/O 的效率不會隨著監(jiān)視 fd 的數(shù)量的增長而下降。
輪詢就緒期間可能要睡眠和喚醒多次交替。但是 select() 和 poll() 在“醒著”的時候要遍歷整個 fd 集合,而 epoll 在“醒著”的時候只要判斷一下就緒鏈表是否為空就行了,這節(jié)省了大量的 CPU 時間。這就是回調(diào)機制帶來的性能提升。 -
節(jié)省拷貝開銷。
select(),poll() 每次調(diào)用都要把 fd 集合從用戶態(tài)往內(nèi)核態(tài)拷貝一次,而 epoll 只要一次拷貝,這也能節(jié)省不少的開銷。
epoll怎么實現(xiàn)的
Linux epoll機制是通過紅黑樹和雙向鏈表實現(xiàn)的。 首先通過epoll_create()系統(tǒng)調(diào)用在內(nèi)核中創(chuàng)建一個eventpoll類型的句柄,其中包括紅黑樹根節(jié)點和雙向鏈表頭節(jié)點。然后通過epoll_ctl()系統(tǒng)調(diào)用,向epoll對象的紅黑樹結(jié)構(gòu)中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。最后通過epoll_wait()系統(tǒng)調(diào)用判斷雙向鏈表是否為空,如果為空則阻塞。當文件描述符狀態(tài)改變,fd上的回調(diào)函數(shù)被調(diào)用,該函數(shù)將fd加入到雙向鏈表中,此時epoll_wait函數(shù)被喚醒,返回就緒好的事件。
具體步驟:
利用sys_epoll_create()創(chuàng)建內(nèi)核事件表,在sys_epoll_creat()里面創(chuàng)建了struct eventpoll結(jié)構(gòu)體,其中包括兩個成員:
- 就緒雙端隊列struct list_head rdlist,用來存放有就緒事件的描述符;
- 紅黑樹struct rb_root rbr,作為內(nèi)核事件表,用來收集描述符;
每一個epoll對象都有一個獨立的eventpoll結(jié)構(gòu)體,用于存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會通過ep_instert掛載到紅黑樹上,這樣重復添加的事件就可以通過紅黑樹而高效的識別出來;
而所有添加到epoll中的事件都會與驅(qū)動程序建立回調(diào)關(guān)系,當相應(yīng)的事件發(fā)生時,會調(diào)用ep_poll_callback這個回調(diào)方法,它會將發(fā)生的事件添加到rdlist雙端隊列中;
在epoll中,對于每一個事件,都會建立一個epitem結(jié)構(gòu)體,它里面包括:
- 紅黑樹節(jié)點
- Rdlist節(jié)點
- 事件句柄信息
- 一個指向其所屬的eventpoll對象的指針
- 期待發(fā)生的事件類型
當調(diào)用epoll_wait檢查是否有事件發(fā)生時,只需要檢查eventpoll對象中的雙端隊列rdlist中是否有epitem元素即可。如果rdlist不為空,則把事件復制到用戶態(tài),同時將事件數(shù)量返回給用戶;如果為空,就等待直到超時。