為了講多路復(fù)用,當(dāng)然還是要跟風(fēng),采用鞭尸的思路,先講講傳統(tǒng)的網(wǎng)絡(luò) IO 的弊端,用拉踩的方式捧起多路復(fù)用 IO 的優(yōu)勢。
為了方便理解,以下所有代碼都是偽代碼,知道其表達的意思即可。
阻塞 IO
服務(wù)端為了處理客戶端的連接和請求的數(shù)據(jù),寫了如下代碼。
listenfd = socket(); // 打開一個網(wǎng)絡(luò)通信端口
bind(listenfd); // 綁定
listen(listenfd); // 監(jiān)聽while(1) {
connfd = accept(listenfd); // 阻塞建立連接
int n = read(connfd, buf); // 阻塞讀數(shù)據(jù)
doSomeThing(buf); // 利用讀到的數(shù)據(jù)做些什么
close(connfd); // 關(guān)閉連接,循環(huán)等待下一個連接
}
這段代碼會執(zhí)行得磕磕絆絆,就像這樣。
可以看到,服務(wù)端的線程阻塞在了兩個地方,一個是 accept 函數(shù),一個是 read 函數(shù)。
如果再把 read 函數(shù)的細節(jié)展開,我們會發(fā)現(xiàn)其阻塞在了兩個階段。
這就是傳統(tǒng)的阻塞 IO。
整體流程如下圖。
所以,如果這個連接的客戶端一直不發(fā)數(shù)據(jù),那么服務(wù)端線程將會一直阻塞在 read 函數(shù)上不返回,也無法接受其他客戶端連接。
這肯定是不行的。
非阻塞 IO
為了解決上面的問題,其關(guān)鍵在于改造這個 read 函數(shù)。
有一種聰明的辦法是,每次都創(chuàng)建一個新的進程或線程,去調(diào)用 read 函數(shù),并做業(yè)務(wù)處理。
while(1) {
connfd = accept(listenfd); // 阻塞建立連接
pthread_create(doWork); // 創(chuàng)建一個新的線程
}
void doWork() {
int n = read(connfd, buf); // 阻塞讀數(shù)據(jù)
doSomeThing(buf); // 利用讀到的數(shù)據(jù)做些什么
close(connfd); // 關(guān)閉連接,循環(huán)等待下一個連接
}
這樣,當(dāng)給一個客戶端建立好連接后,就可以立刻等待新的客戶端連接,而不用阻塞在原客戶端的 read 請求上。
不過,這不叫非阻塞 IO,只不過用了多線程的手段使得主線程沒有卡在 read 函數(shù)上不往下走罷了。操作系統(tǒng)為我們提供的 read 函數(shù)仍然是阻塞的。
所以真正的非阻塞 IO,不能是通過我們用戶層的小把戲,而是要懇請操作系統(tǒng)為我們提供一個非阻塞的 read 函數(shù)。
這個 read 函數(shù)的效果是,如果沒有數(shù)據(jù)到達時(到達網(wǎng)卡并拷貝到了內(nèi)核緩沖區(qū)),立刻返回一個錯誤值(-1),而不是阻塞地等待。
操作系統(tǒng)提供了這樣的功能,只需要在調(diào)用 read 前,將文件描述符設(shè)置為非阻塞即可。
fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);
這樣,就需要用戶線程循環(huán)調(diào)用 read,直到返回值不為 -1,再開始處理業(yè)務(wù)。
這里我們注意到一個細節(jié)。
非阻塞的 read,指的是在數(shù)據(jù)到達前,即數(shù)據(jù)還未到達網(wǎng)卡,或者到達網(wǎng)卡但還沒有拷貝到內(nèi)核緩沖區(qū)之前,這個階段是非阻塞的。
當(dāng)數(shù)據(jù)已到達內(nèi)核緩沖區(qū),此時調(diào)用 read 函數(shù)仍然是阻塞的,需要等待數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū),才能返回。
整體流程如下圖
IO 多路復(fù)用
為每個客戶端創(chuàng)建一個線程,服務(wù)器端的線程資源很容易被耗光。
當(dāng)然還有個聰明的辦法,我們可以每 accept 一個客戶端連接后,將這個文件描述符(connfd)放到一個數(shù)組里。
fdlist.add(connfd);
然后弄一個新的線程去不斷遍歷這個數(shù)組,調(diào)用每一個元素的非阻塞 read 方法。
while(1) {
for(fd 《-- fdlist) {
if(read(fd) != -1) {
doSomeThing();
}
}
}
這樣,我們就成功用一個線程處理了多個客戶端連接。
你是不是覺得這有些多路復(fù)用的意思?
但這和我們用多線程去將阻塞 IO 改造成看起來是非阻塞 IO 一樣,這種遍歷方式也只是我們用戶自己想出的小把戲,每次遍歷遇到 read 返回 -1 時仍然是一次浪費資源的系統(tǒng)調(diào)用。
在 while 循環(huán)里做系統(tǒng)調(diào)用,就好比你做分布式項目時在 while 里做 rpc 請求一樣,是不劃算的。
所以,還是得懇請操作系統(tǒng)老大,提供給我們一個有這樣效果的函數(shù),我們將一批文件描述符通過一次系統(tǒng)調(diào)用傳給內(nèi)核,由內(nèi)核層去遍歷,才能真正解決這個問題。
select
select 是操作系統(tǒng)提供的系統(tǒng)調(diào)用函數(shù),通過它,我們可以把一個文件描述符的數(shù)組發(fā)給操作系統(tǒng), 讓操作系統(tǒng)去遍歷,確定哪個文件描述符可以讀寫, 然后告訴我們?nèi)ヌ幚恚?/p>
select系統(tǒng)調(diào)用的函數(shù)定義如下。
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
// nfds:監(jiān)控的文件描述符集里最大文件描述符加1// readfds:監(jiān)控有讀數(shù)據(jù)到達文件描述符集合,傳入傳出參數(shù)// writefds:監(jiān)控寫數(shù)據(jù)到達文件描述符集合,傳入傳出參數(shù)// exceptfds:監(jiān)控異常發(fā)生達文件描述符集合, 傳入傳出參數(shù)// timeout:定時阻塞監(jiān)控時間,3種情況// 1.NULL,永遠等下去// 2.設(shè)置timeval,等待固定時間// 3.設(shè)置timeval里時間均為0,檢查描述字后立即返回,輪詢
服務(wù)端代碼,這樣來寫。
首先一個線程不斷接受客戶端連接,并把 socket 文件描述符放到一個 list 里。
while(1) {
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
然后,另一個線程不再自己遍歷,而是調(diào)用 select,將這批文件描述符 list 交給操作系統(tǒng)去遍歷。
while(1) {
// 把一堆文件描述符 list 傳給 select 函數(shù)
// 有已就緒的文件描述符就返回,nready 表示有多少個就緒的
nready = select(list);
。..
}
不過,當(dāng) select 函數(shù)返回后,用戶依然需要遍歷剛剛提交給操作系統(tǒng)的 list。
只不過,操作系統(tǒng)會將準(zhǔn)備就緒的文件描述符做上標(biāo)識,用戶層將不會再有無意義的系統(tǒng)調(diào)用開銷。
while(1) {
nready = select(list);
// 用戶層依然要遍歷,只不過少了很多無效的系統(tǒng)調(diào)用
for(fd 《-- fdlist) {
if(fd != -1) {
// 只讀已就緒的文件描述符
read(fd, buf);
// 總共只有 nready 個已就緒描述符,不用過多遍歷
if(--nready == 0) break;
}
}
}
正如剛剛的動圖中所描述的,其直觀效果如下。(同一個動圖消耗了你兩次流量,氣不氣?)
可以看出幾個細節(jié):
1. select 調(diào)用需要傳入 fd 數(shù)組,需要拷貝一份到內(nèi)核,高并發(fā)場景下這樣的拷貝消耗的資源是驚人的。(可優(yōu)化為不復(fù)制)2. select 在內(nèi)核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態(tài),是個同步過程,只不過無系統(tǒng)調(diào)用切換上下文的開銷。(內(nèi)核層可優(yōu)化為異步事件通知)3. select 僅僅返回可讀文件描述符的個數(shù),具體哪個可讀還是要用戶自己遍歷。(可優(yōu)化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
可以看到,這種方式,既做到了一個線程處理多個客戶端連接(文件描述符),又減少了系統(tǒng)調(diào)用的開銷(多個文件描述符只有一次 select 的系統(tǒng)調(diào)用 + n 次就緒狀態(tài)的文件描述符的 read 系統(tǒng)調(diào)用)。
poll
poll 也是操作系統(tǒng)提供的系統(tǒng)調(diào)用函數(shù)。
int poll(struct pollfd *fds, nfds_tnfds, int timeout);
struct pollfd {
intfd; /*文件描述符*/
shortevents; /*監(jiān)控的事件*/
shortrevents; /*監(jiān)控事件中滿足條件返回的事件*/
};
它和 select 的主要區(qū)別就是,去掉了 select 只能監(jiān)聽 1024 個文件描述符的限制。
epoll
epoll 是最終的大 boss,它解決了 select 和 poll 的一些問題。
還記得上面說的 select 的三個細節(jié)么?
1. select 調(diào)用需要傳入 fd 數(shù)組,需要拷貝一份到內(nèi)核,高并發(fā)場景下這樣的拷貝消耗的資源是驚人的。(可優(yōu)化為不復(fù)制)2. select 在內(nèi)核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態(tài),是個同步過程,只不過無系統(tǒng)調(diào)用切換上下文的開銷。(內(nèi)核層可優(yōu)化為異步事件通知)3. select 僅僅返回可讀文件描述符的個數(shù),具體哪個可讀還是要用戶自己遍歷。(可優(yōu)化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
所以 epoll 主要就是針對這三點進行了改進。
1. 內(nèi)核中保存一份文件描述符集合,無需用戶每次都重新傳入,只需告訴內(nèi)核修改的部分即可。2. 內(nèi)核不再通過輪詢的方式找到就緒的文件描述符,而是通過異步 IO 事件喚醒。3. 內(nèi)核僅會將有 IO 事件的文件描述符返回給用戶,用戶也無需遍歷整個文件描述符集合。
具體,操作系統(tǒng)提供了這三個函數(shù)。
第一步,創(chuàng)建一個 epoll 句柄
int epoll_create(int size);
第二步,向內(nèi)核添加、修改或刪除要監(jiān)控的文件描述符。
int epoll_ctl(
int epfd, int op, int fd, struct epoll_event *event);
第三步,類似發(fā)起了 select() 調(diào)用
int epoll_wait(
int epfd, struct epoll_event *events, int max events, int timeout);
使用起來,其內(nèi)部原理就像如下一般絲滑。
如果你想繼續(xù)深入了解 epoll 的底層原理,推薦閱讀飛哥的《圖解 | 深入揭秘 epoll 是如何實現(xiàn) IO 多路復(fù)用的!》,從 linux 源碼級別,一行一行非常硬核地解讀 epoll 的實現(xiàn)原理,且配有大量方便理解的圖片,非常適合源碼控的小伙伴閱讀。
后記
大白話總結(jié)一下。
一切的開始,都起源于這個 read 函數(shù)是操作系統(tǒng)提供的,而且是阻塞的,我們叫它 阻塞 IO。為了破這個局,程序員在用戶態(tài)通過多線程來防止主線程卡死。后來操作系統(tǒng)發(fā)現(xiàn)這個需求比較大,于是在操作系統(tǒng)層面提供了非阻塞的 read 函數(shù),這樣程序員就可以在一個線程內(nèi)完成多個文件描述符的讀取,這就是 非阻塞 IO。
但多個文件描述符的讀取就需要遍歷,當(dāng)高并發(fā)場景越來越多時,用戶態(tài)遍歷的文件描述符也越來越多,相當(dāng)于在 while 循環(huán)里進行了越來越多的系統(tǒng)調(diào)用。后來操作系統(tǒng)又發(fā)現(xiàn)這個場景需求量較大,于是又在操作系統(tǒng)層面提供了這樣的遍歷文件描述符的機制,這就是 IO 多路復(fù)用。多路復(fù)用有三個函數(shù),最開始是 select,然后又發(fā)明了 poll 解決了 select 文件描述符的限制,然后又發(fā)明了 epoll 解決 select 的三個不足。
所以,IO 模型的演進,其實就是時代的變化,倒逼著操作系統(tǒng)將更多的功能加到自己的內(nèi)核而已。如果你建立了這樣的思維,很容易發(fā)現(xiàn)網(wǎng)上的一些錯誤。比如好多文章說,多路復(fù)用之所以效率高,是因為用一個線程就可以監(jiān)控多個文件描述符。這顯然是知其然而不知其所以然,多路復(fù)用產(chǎn)生的效果,完全可以由用戶態(tài)去遍歷文件描述符并調(diào)用其非阻塞的 read 函數(shù)實現(xiàn)。而多路復(fù)用快的原因在于,操作系統(tǒng)提供了這樣的系統(tǒng)調(diào)用,使得原來的 while 循環(huán)里多次系統(tǒng)調(diào)用,變成了一次系統(tǒng)調(diào)用 + 內(nèi)核層遍歷這些文件描述符。
就好比我們平時寫業(yè)務(wù)代碼,把原來 while 循環(huán)里調(diào) http 接口進行批量,改成了讓對方提供一個批量添加的 http 接口,然后我們一次 rpc 請求就完成了批量添加。一個道理。
責(zé)任編輯:haq
-
數(shù)據(jù)
+關(guān)注
關(guān)注
8文章
7139瀏覽量
89577 -
代碼
+關(guān)注
關(guān)注
30文章
4825瀏覽量
69049
原文標(biāo)題:你管這破玩意叫 IO 多路復(fù)用?
文章出處:【微信號:gh_3980db2283cd,微信公眾號:開關(guān)電源芯片】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
Linux--IO多路復(fù)用(select,poll,epoll)
在多路復(fù)用模擬輸入應(yīng)用中使用ADS8411
![在<b class='flag-5'>多路復(fù)用</b>模擬輸入應(yīng)用中使用ADS8411](https://file.elecfans.com/web1/M00/D9/4E/pIYBAF_1ac2Ac0EEAABDkS1IP1s689.png)
多路復(fù)用器應(yīng)用中的防護
![<b class='flag-5'>多路復(fù)用</b>器應(yīng)用中的防護](https://file.elecfans.com/web1/M00/D9/4E/pIYBAF_1ac2Ac0EEAABDkS1IP1s689.png)
如何使用多路復(fù)用器處理高壓共模應(yīng)用
![如何使用<b class='flag-5'>多路復(fù)用</b>器處理高壓共模應(yīng)用](https://file.elecfans.com/web1/M00/D9/4E/pIYBAF_1ac2Ac0EEAABDkS1IP1s689.png)
多路復(fù)用器將取代繼電器應(yīng)用說明
![<b class='flag-5'>多路復(fù)用</b>器將取代繼電器應(yīng)用說明](https://file.elecfans.com/web1/M00/D9/4E/pIYBAF_1ac2Ac0EEAABDkS1IP1s689.png)
需要選擇一顆并行io擴展器件,擴展16個io連接到外設(shè)io\'上,CD74HC4067滿足要求嗎?
電源多路復(fù)用器電路設(shè)計指南
什么是多路復(fù)用器?它有哪些作用和應(yīng)用?
頻分多路復(fù)用和時分多路復(fù)用的區(qū)別有哪些
多路復(fù)用技術(shù)主要有幾種類型?它們各有什么特點?
多路復(fù)用的原理 為什么要多路復(fù)用?多路復(fù)用技術(shù)的應(yīng)用
![<b class='flag-5'>多路復(fù)用</b>的原理 為什么要<b class='flag-5'>多路復(fù)用</b>?<b class='flag-5'>多路復(fù)用</b>技術(shù)的應(yīng)用](https://file1.elecfans.com/web2/M00/C3/99/wKgaomXmuJCASr9VAAC2tkUMOKg188.jpg)
頻分多路復(fù)用的原理 頻分多路復(fù)用方式的分類
![頻分<b class='flag-5'>多路復(fù)用</b>的原理 頻分<b class='flag-5'>多路復(fù)用</b>方式的分類](https://file1.elecfans.com/web2/M00/C3/98/wKgaomXmtwGAbECoAAGsM23fXbE656.jpg)
評論