對于服務(wù)器的并發(fā)處理能力,我們需要的是:每一毫秒服務(wù)器都能及時處理這一毫秒內(nèi)收到的數(shù)百個不同 TCP 連接上的報文,與此同時,可能服務(wù)器上還有數(shù)以十萬計的最近幾秒沒有收發(fā)任何報文的相對不活躍連接。
同時處理多個并行發(fā)生事件的連接,簡稱為并發(fā);同時處理萬計、十萬計的連接,則是高并發(fā)。服務(wù)器的并發(fā)編程所追求的就是處理的并發(fā)連接數(shù)目無限大,同時維持著高效率使用 CPU 等資源,直至物理資源首先耗盡。
并發(fā)編程有很多種實現(xiàn)模型,最簡單的就是與“線程”捆綁,1 個線程處理 1 個連接的全部生命周期。
優(yōu)點:
這個模型足夠簡單,又可以實現(xiàn)復(fù)雜的業(yè)務(wù)場景,同時,線程個數(shù)是可以遠(yuǎn)大于 CPU 個數(shù)的。
缺點:
如果操作系統(tǒng)的線程總數(shù)很多時,來回喚醒、睡眠的代價就是昂貴的,因為這種技術(shù)性的調(diào)度損耗會影響到線程執(zhí)行業(yè)務(wù)代碼的時間。舉個例子,不活躍連接的線程就像國企,它們執(zhí)行效率太低了,總是喚醒就睡眠在做無用功,而它喚醒爭到 CPU 資源的同時,就意味著活躍連接的其他線程減少了獲得 CPU 的機(jī)會。
連接上的消息處理,可以分為兩個階段:等待消息準(zhǔn)備好、消息處理。當(dāng)使用默認(rèn)的阻塞套接字時(例如上面提到的 1 個線程捆綁處理 1 個連接),往往是把這兩個階段合二為一,這樣操作套接字的代碼所在的線程就得睡眠來等待消息準(zhǔn)備好,這導(dǎo)致了高并發(fā)下線程會頻繁的睡眠、喚醒,從而影響了 CPU 使用效率。
目前對于高并發(fā)編程只有一種模型,也是本質(zhì)上唯一有效的辦法。這就是 IO 多路復(fù)用了。
多路復(fù)用就是處理等待消息準(zhǔn)備好這件事的,但它可以同時處理多個連接。它也可能“等待”,導(dǎo)致線程睡眠,然而不要緊,因為它一對多、可以監(jiān)控所有連接。這樣,當(dāng)我們的線程被喚醒執(zhí)行時,就一定是有一些連接準(zhǔn)備好被我們的代碼執(zhí)行了,這是有效率的。沒有那么多個線程都在爭搶處理“等待消息準(zhǔn)備好”階段。
IO 復(fù)用的使用大概是這樣:
設(shè)置要監(jiān)聽的描述符以及需要監(jiān)聽的事件
↓
監(jiān)聽事件,一直阻塞直到有描述符就緒
↓
處理就緒的描述符
多路復(fù)用有很多種實現(xiàn),在 linux 上,2.4 內(nèi)核前主要是 select 和 poll,現(xiàn)在主流是 epoll。
select
函數(shù)原型:
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
參數(shù)詳解:
nfds 指定被監(jiān)聽的文件描述符的總數(shù),一般設(shè)置成最大的描述符加上 1,因為描述符是從 0 開始的
readfds 需要監(jiān)聽讀事件的描述符集
writefds 需要監(jiān)聽寫事件的描述符集
exceptfds 需要監(jiān)聽異常事件的描述符集
timeout 超時時間,如果傳遞的是 NULL,則 select 會一直阻塞
文件描述符(File descriptor)是計算機(jī)科學(xué)中的一個術(shù)語,是一個用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一個非負(fù)整數(shù)。實際上,它是一個索引值,指向內(nèi)核為每一個進(jìn)程所維護(hù)的該進(jìn)程打開文件的記錄表。當(dāng)程序打開一個現(xiàn)有文件或者創(chuàng)建一個新文件時,內(nèi)核向進(jìn)程返回一個文件描述符。在程序設(shè)計中,一些涉及底層的程序編寫往往會圍繞著文件描述符展開。但是文件描述符這一概念往往只適用于 UNIX、Linux 這樣的操作系統(tǒng)。
一般的使用步驟:
- 設(shè)置 fd_set,如果要同時監(jiān)聽多種事件,則需要使用多個描述符集合
- 調(diào)用 select,select 會通過更改描述集,只留下準(zhǔn)備好的描述符,所以需要在調(diào)用前保留一份
- 通過遍歷數(shù)組和 FD_ISSET 來判斷是否準(zhǔn)備好,若準(zhǔn)備好則去執(zhí)行相關(guān)任務(wù)
優(yōu)點:
select() 的可移植性更好,在某些 Unix 系統(tǒng)上不支持 poll();
select() 對于超時值提供了更好的精度——微秒,而 poll() 是毫秒。
缺點:
單個進(jìn)程可監(jiān)視的 fd 數(shù)量被限制,一般是 1024;
需要維護(hù)一個用來存放大量 fd 的數(shù)據(jù)結(jié)構(gòu),這樣會使得用戶空間和內(nèi)核空間在傳遞該結(jié)構(gòu)時復(fù)制開銷大;
對 fd 進(jìn)行掃描時是線性掃描。fd 劇增后,IO 效率較低;
select() 函數(shù)的超時參數(shù)在返回時是未定義的,考慮到可移植性,每次在超時之后在下一次進(jìn)入到 select() 之前都需要重新設(shè)置超時參數(shù)。
poll
poll 相對于 select 的優(yōu)點就是解決了描述符的限制問題,但是性能并不好,也是需要通過遍歷的方式來判斷描述符的準(zhǔn)備狀態(tài)。
函數(shù)原型:
int poll(struct pollfd *fds, nfd_t nfds, int timeout);
參數(shù)詳解:
struct pollfd {
int fd; //文件描述符
short events; //注冊的事件
short revents; //實際發(fā)生的事件
}
fds 需要監(jiān)聽的數(shù)組, 數(shù)組里的每個結(jié)構(gòu)體都設(shè)置好描述符和需要注冊的事件, 如果有多個, 使用或('|')操作符
nfds 數(shù)組的長度
timeout 超時值,單位是毫秒
不同于 select 使用三個位圖來表示三個 fdset 的方式,poll 使用一個 pollfd 的指針實現(xiàn)。
select() 和 poll() 將就緒的文件描述符告訴進(jìn)程后,如果進(jìn)程沒有對其進(jìn)行 IO 操作,那么下次調(diào)用 select() 和 poll() 的時候?qū)⒃俅螆蟾孢@些文件描述符,所以它們一般不會丟失就緒的消息,這種方式稱為水平觸發(fā)(Level Triggered)。
優(yōu)點:
沒有最大文件描述符數(shù)量的限制;
缺點:
描述符數(shù)組復(fù)制開銷和輪詢開銷依然很大。
epoll
epoll 能顯著減少程序在大量并發(fā)連接中只有少量活躍的情況下的系統(tǒng) CPU 利用率,因為它不會復(fù)用文件描述符集合來傳遞結(jié)果而迫使開發(fā)者每次等待事件之前都必須重新準(zhǔn)備要被偵聽的文件描述符集合。
另一點原因就是獲取事件的時候,它無須遍歷整個被偵聽的描述符集,只要遍歷那些被內(nèi)核 IO 事件異步喚醒而加入 Ready 隊列的描述符集合就行了。
還增加了一些新的特性,比如 ET(Edge Triggered,只告訴進(jìn)程哪些文件描述符剛剛變?yōu)榫途w狀態(tài),只說一遍,如果沒有采取行動,那么它將不會再次告知,這種方式稱為邊緣觸發(fā))和 LT 兩種觸發(fā)模式。
epoll 提供了三個函數(shù):
// 創(chuàng)建一個描述符, 指向內(nèi)核創(chuàng)建的描述符集
// 之后的操作都通過描述符來操作
int epoll_create(int size);
// 先注冊要監(jiān)聽的事件類型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd 指向描述符集的描述符
op 有三個宏:
EPOLL_CTL_ADD 添加 fd 上的注冊事件
EPOLL_CTL_MOD 修改 fd 上的注冊事件
EPOLL_CTL_DEL 刪除 fd 上的注冊事件
fd 需要操作的描述符
struct epoll_event {
__uint32_t events; // epoll事件
epoll_data_t data; // 用戶數(shù)據(jù)
}
*/
// 等待事件的產(chǎn)生, 類似于 select() 調(diào)用
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
/*
返回準(zhǔn)備好的描述符的個數(shù)
epfd 指向描述符集的描述符
events 作為函數(shù)傳出使用, epoll_wait 之后會設(shè)置這個數(shù)組, 之后再遍歷這個數(shù)組即可
maxevents 指定最多監(jiān)聽的描述符的個數(shù)
timeout 超時時間, 和 poll 相同
*/
底層實現(xiàn)
epoll 在底層實現(xiàn)了自己的高速緩存區(qū),并且建立了一個紅黑樹用于存放 socket,另外維護(hù)了一個鏈表用來存放準(zhǔn)備就緒的事件。

圖中左下方的紅黑樹由所有待監(jiān)控的連接構(gòu)成。左上方的鏈表,同是目前所有活躍的連接。于是,epoll_wait() 執(zhí)行時只是檢查左上方的鏈表,并返回左上方鏈表中的連接給用戶。
工作過程:
執(zhí)行 epoll_ create() 時,創(chuàng)建了紅黑樹和就緒鏈表,執(zhí)行 epoll_ ctl() 時,如果增加 socket() 句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹干上,然后向內(nèi)核注冊回調(diào)函數(shù),用于當(dāng)中斷事件來臨時向準(zhǔn)備就緒鏈表中插入數(shù)據(jù)。執(zhí)行epoll_wait時立刻返回準(zhǔn)備就緒鏈表里的數(shù)據(jù)即可。
優(yōu)點:
支持一個進(jìn)程打開大數(shù)目的描述符;
IO 效率不隨 fd 數(shù)目增加而線性下降;
使用 mmap 加速內(nèi)核與用戶空間的消息傳遞。
mmap 是一種內(nèi)存映射文件的方法,即將一個文件或者其它對象映射到進(jìn)程的地址空間,實現(xiàn)文件磁盤地址和進(jìn)程虛擬地址空間中一段虛擬地址的一一對映關(guān)系。實現(xiàn)這樣的映射關(guān)系后,進(jìn)程就可以采用指針的方式讀寫操作這一段內(nèi)存,而系統(tǒng)會自動回寫臟頁面到對應(yīng)的文件磁盤上,即完成了對文件的操作而不必再調(diào)用 read/write 等系統(tǒng)調(diào)用函數(shù)。相反,內(nèi)核空間對這段區(qū)域的修改也直接反映用戶空間,從而可以實現(xiàn)不同進(jìn)程間的文件共享。