IO多路複用原理&場景

不曉得儂發表於2022-02-04

為了講多路複用,當然還是要跟風,採用鞭屍的思路,先講講傳統的網路 IO 的弊端,用拉踩的方式捧起多路複用 IO 的優勢。

為了方便理解,以下所有程式碼都是虛擬碼,知道其表達的意思即可。

IO多路複用的歷史

阻塞 IO

服務端為了處理客戶端的連線和請求的資料,寫了如下程式碼。

listenfd = socket();   // 開啟一個網路通訊埠
bind(listenfd);        // 繫結
listen(listenfd);      // 監聽
while(1) {
  connfd = accept(listenfd);  // 阻塞建立連線
  int n = read(connfd, buf);  // 阻塞讀資料
  doSomeThing(buf);  // 利用讀到的資料做些什麼
  close(connfd);     // 關閉連線,迴圈等待下一個連線
}

這段程式碼會執行得磕磕絆絆,就像這樣。

640

可以看到,服務端的執行緒阻塞在了兩個地方,一個是 accept 函式,一個是 read 函式。

如果再把 read 函式的細節展開,我們會發現其阻塞在了兩個階段。

20220202233518

這就是傳統的阻塞 IO。

整體流程如下圖。

20220202223908

所以,如果這個連線的客戶端一直不發資料,那麼服務端執行緒將會一直阻塞在 read 函式上不返回,也無法接受其他客戶端連線。

這肯定是不行的。

非阻塞 IO

為了解決上面的問題,其關鍵在於改造這個 read 函式。

有一種聰明的辦法是,每次都建立一個新的程式或執行緒,去呼叫 read 函式,並做業務處理。

while(1) {
  connfd = accept(listenfd);  // 阻塞建立連線
  pthread_create(doWork);  // 建立一個新的執行緒
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞讀資料
  doSomeThing(buf);  // 利用讀到的資料做些什麼
  close(connfd);     // 關閉連線,迴圈等待下一個連線
}

這樣,當給一個客戶端建立好連線後,就可以立刻等待新的客戶端連線,而不用阻塞在原客戶端的 read 請求上。

20220202224402

不過,這不叫非阻塞 IO,只不過用了多執行緒的手段使得主執行緒沒有卡在 read 函式上不往下走罷了。作業系統為我們提供的 read 函式仍然是阻塞的。

所以真正的非阻塞 IO,不能是通過我們使用者層的小把戲,而是要懇請作業系統為我們提供一個非阻塞的 read 函式

這個 read 函式的效果是,如果沒有資料到達時(到達網路卡並拷貝到了核心緩衝區),立刻返回一個錯誤值(-1),而不是阻塞地等待。

作業系統提供了這樣的功能,只需要在呼叫 read 前,將檔案描述符設定為非阻塞即可。

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

這樣,就需要使用者執行緒迴圈呼叫 read,直到返回值不為 -1,再開始處理業務。

640 (7)

這裡我們注意到一個細節。

非阻塞的 read,指的是在資料到達前,即資料還未到達網路卡,或者到達網路卡但還沒有拷貝到核心緩衝區之前,這個階段是非阻塞的。

當資料已到達核心緩衝區,此時呼叫 read 函式仍然是阻塞的,需要等待資料從核心緩衝區拷貝到使用者緩衝區,才能返回。

整體流程如下圖

20220202223923

IO 多路複用

為每個客戶端建立一個執行緒,伺服器端的執行緒資源很容易被耗光。

20220202223927

當然還有個聰明的辦法,我們可以每 accept 一個客戶端連線後,將這個檔案描述符(connfd)放到一個陣列裡。

fdlist.add(connfd);

然後弄一個新的執行緒去不斷遍歷這個陣列,呼叫每一個元素的非阻塞 read 方法。

while(1) {
  for(fd <-- fdlist) {
    if(read(fd) != -1) {
      doSomeThing();
    }
  }
}

這樣,我們就成功用一個執行緒處理了多個客戶端連線。

20220202223932

你是不是覺得這有些多路複用的意思?

但這和我們用多執行緒去將阻塞 IO 改造成看起來是非阻塞 IO 一樣,這種遍歷方式也只是我們使用者自己想出的小把戲,每次遍歷遇到 read 返回 -1 時仍然是一次浪費資源的系統呼叫。

在 while 迴圈裡做系統呼叫,就好比你做分散式專案時在 while 裡做 rpc 請求一樣,是不划算的。

所以,還是得懇請作業系統老大,提供給我們一個有這樣效果的函式,我們將一批檔案描述符通過一次系統呼叫傳給核心,由核心層去遍歷,才能真正解決這個問題。

select

select 是作業系統提供的系統呼叫函式,通過它,我們可以把一個檔案描述符的陣列發給作業系統, 讓作業系統去遍歷,確定哪個檔案描述符可以讀寫, 然後告訴我們去處理:

20220202223939

select系統呼叫的函式定義如下。

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:監控的檔案描述符集裡最大檔案描述符加1
// readfds:監控有讀資料到達檔案描述符集合,傳入傳出引數
// writefds:監控寫資料到達檔案描述符集合,傳入傳出引數
// exceptfds:監控異常發生達檔案描述符集合, 傳入傳出引數
// timeout:定時阻塞監控時間,3種情況
//  1.NULL,永遠等下去
//  2.設定timeval,等待固定時間
//  3.設定timeval裡時間均為0,檢查描述字後立即返回,輪詢

服務端程式碼,這樣來寫。

首先一個執行緒不斷接受客戶端連線,並把 socket 檔案描述符放到一個 list 裡。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

然後,另一個執行緒不再自己遍歷,而是呼叫 select,將這批檔案描述符 list 交給作業系統去遍歷。

while(1) {
  // 把一堆檔案描述符 list 傳給 select 函式
  // 有已就緒的檔案描述符就返回,nready 表示有多少個就緒的
  nready = select(list);
  ...
}

不過,當 select 函式返回後,使用者依然需要遍歷剛剛提交給作業系統的 list。

只不過,作業系統會將準備就緒的檔案描述符做上標識,使用者層將不會再有無意義的系統呼叫開銷。

while(1) {
  nready = select(list);
  // 使用者層依然要遍歷,只不過少了很多無效的系統呼叫
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只讀已就緒的檔案描述符
      read(fd, buf);
      // 總共只有 nready 個已就緒描述符,不用過多遍歷
      if(--nready == 0) break;
    }
  }
}

正如剛剛的動圖中所描述的,其直觀效果如下。(同一個動圖消耗了你兩次流量,氣不氣?)

20220202223800

可以看出幾個細節:

  1. select 呼叫需要傳入 fd 陣列,需要拷貝一份到核心,高併發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)

  2. select 在核心層仍然是通過遍歷的方式檢查檔案描述符的就緒狀態,是個同步過程,只不過無系統呼叫切換上下文的開銷。(核心層可優化為非同步事件通知)

  3. select 僅僅返回可讀檔案描述符的個數,具體哪個可讀還是要使用者自己遍歷。(可優化為只返回給使用者就緒的檔案描述符,無需使用者做無效的遍歷)

整個 select 的流程圖如下。

20220202224828

可以看到,這種方式,既做到了一個執行緒處理多個客戶端連線(檔案描述符),又減少了系統呼叫的開銷(多個檔案描述符只有一次 select 的系統呼叫 + n 次就緒狀態的檔案描述符的 read 系統呼叫)。

poll

poll 也是作業系統提供的系統呼叫函式。

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*檔案描述符*/
  shortevents; /*監控的事件*/
  shortrevents; /*監控事件中滿足條件返回的事件*/
};

它和 select 的主要區別就是,去掉了 select 只能監聽 1024 個檔案描述符的限制。

epoll

epoll 是最終的大 boss,它解決了 select 和 poll 的一些問題。

還記得上面說的 select 的三個細節麼?

\1. select 呼叫需要傳入 fd 陣列,需要拷貝一份到核心,高併發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)

\2. select 在核心層仍然是通過遍歷的方式檢查檔案描述符的就緒狀態,是個同步過程,只不過無系統呼叫切換上下文的開銷。(核心層可優化為非同步事件通知)

\3. select 僅僅返回可讀檔案描述符的個數,具體哪個可讀還是要使用者自己遍歷。(可優化為只返回給使用者就緒的檔案描述符,無需使用者做無效的遍歷)

所以 epoll 主要就是針對這三點進行了改進。

\1. 核心中儲存一份檔案描述符集合,無需使用者每次都重新傳入,只需告訴核心修改的部分即可。

\2. 核心不再通過輪詢的方式找到就緒的檔案描述符,而是通過非同步 IO 事件喚醒。

\3. 核心僅會將有 IO 事件的檔案描述符返回給使用者,使用者也無需遍歷整個檔案描述符集合。

具體,作業系統提供了這三個函式。

第一步,建立一個 epoll 控制程式碼

int epoll_create(int size);

第二步,向核心新增、修改或刪除要監控的檔案描述符。

int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

第三步,類似發起了 select() 呼叫

int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

使用起來,其內部原理就像如下一般絲滑。

20220202224951

如果你想繼續深入瞭解 epoll 的底層原理,推薦閱讀飛哥的《圖解 | 深入揭祕 epoll 是如何實現 IO 多路複用的!》,從 linux 原始碼級別,一行一行非常硬核地解讀 epoll 的實現原理,且配有大量方便理解的圖片,非常適合原始碼控的小夥伴閱讀。

後記

大白話總結一下。

一切的開始,都起源於這個 read 函式是作業系統提供的,而且是阻塞的,我們叫它 阻塞 IO

為了破這個局,程式設計師在使用者態通過多執行緒來防止主執行緒卡死。

後來作業系統發現這個需求比較大,於是在作業系統層面提供了非阻塞的 read 函式,這樣程式設計師就可以在一個執行緒內完成多個檔案描述符的讀取,這就是 非阻塞 IO

但多個檔案描述符的讀取就需要遍歷,當高併發場景越來越多時,使用者態遍歷的檔案描述符也越來越多,相當於在 while 迴圈裡進行了越來越多的系統呼叫。

後來作業系統又發現這個場景需求量較大,於是又在作業系統層面提供了這樣的遍歷檔案描述符的機制,這就是 IO 多路複用

多路複用有三個函式,最開始是 select,然後又發明了 poll 解決了 select 檔案描述符的限制,然後又發明了 epoll 解決 select 的三個不足。


所以,IO 模型的演進,其實就是時代的變化,倒逼著作業系統將更多的功能加到自己的核心而已。

如果你建立了這樣的思維,很容易發現網上的一些錯誤。

比如好多文章說,多路複用之所以效率高,是因為用一個執行緒就可以監控多個檔案描述符。

這顯然是知其然而不知其所以然,多路複用產生的效果,完全可以由使用者態去遍歷檔案描述符並呼叫其非阻塞的 read 函式實現。而多路複用快的原因在於,作業系統提供了這樣的系統呼叫,使得原來的 while 迴圈裡多次系統呼叫,變成了一次系統呼叫 + 核心層遍歷這些檔案描述符。

就好比我們平時寫業務程式碼,把原來 while 迴圈裡調 http 介面進行批量,改成了讓對方提供一個批量新增的 http 介面,然後我們一次 rpc 請求就完成了批量新增。

一個道理。

以上來源於 你管這破玩意叫 IO 多路複用?

裡面的動圖特別的形象,為了怕文章刪除,因此完全copy過來。


IO多路複用高效的原因

IO多路複用之所以高效的原因是用一個執行緒監控多個檔案描述符(socket控制程式碼)的狀態,根本原因是作業系統提供了系統呼叫(select、epollo),使得原來使用者程式碼內的while迴圈內的多次系統呼叫變成了一次系統呼叫+核心層遍歷這些檔案描述符。

select的三個缺點:

1.連線數受限

2.採用遍歷檔案控制程式碼集合方式獲取就緒的控制程式碼,在檔案連線數多的情況下效率低

3.資料由核心copy到使用者態

poll只是改善了select第一個缺點,連線數不再受限。

epoll改變了select三個缺點。

select和poll獲取就緒狀態控制程式碼事件複雜度O(n),epoll獲取就緒狀態控制程式碼事件複雜度O(1)。

epoll會把哪個channel發生了什麼IO就緒通知給使用者,採用的是事件驅動模型,因此複雜度是O(1)。。

表面上看epoll的效能最好,但是在連線數少並且連線都十分活躍的情況下,select和poll的效能可能比epoll好,畢竟epoll的通知機制需要很多函式回撥。

select低效是因為每次它都需要輪詢。但低效也是相對的,視情況而定。

IO多路複用解決的什麼問題

IO多路複用解決的是阻塞IO中1連線1執行緒模式在高併發場景下執行緒過多導致的切換效率問題,採用多路複用減少執行緒數量,從而減少執行緒切換次數,提高cpu利用率,但並不能提高IO。因此只有當伺服器瓶頸是大量連線的執行緒切換時,才會提高效率,比如web就非常適合採用IO多路複用。相反連線數少的情況下,沒必要使用多路複用。

web server的特點是:並不能限定某個時間段有多少個使用者對伺服器發起請求,即不能讓連線數成為你服務的瓶頸。而且高併發web應用有一個特點就是fast fail:快速響應,如果在指定時間內響應不了,直接返回失敗。所以每個連線處理的業務邏輯,不會過於複雜——否則就放到非同步任務中。大量連線、每個連線的負載較輕的情況,是nio的常用場景。所以web server非常適用nio。

epoll比selector效能一定更好嗎

從IO多路複用分析來看,selector採用輪詢socket控制程式碼集合來判斷是否有事件就緒,epoll是通過回撥機制來通知使用者來喚醒socket就緒,epoll比selector要高效,但是為什麼實際中使用的中介軟體dubbbo、rocketmq的通訊netty都是使用的NIO selector方式,而非epoll呢?

以前時候我也一直以為epoll效率比select高(畢竟select和poll都是輪詢,即每次呼叫都掃描整個檔案描述符集合,將其中就緒的檔案描述符返回給使用者程式,因此它們檢測就緒事件演算法複雜度是o(n),epoll採用回撥方式,核心檢測到就緒的檔案描述符,觸發回撥,回撥將該檔案描述符對應的事件插入核心就緒佇列,核心最後在適當的時間將該就緒佇列中的內容拷貝到使用者空間。因此epoll無須輪詢整個檔案描述符集合來檢測哪些事件就緒,其演算法複雜度是o(1)),在linux上netty要選擇epoll,但是通過看dubbo、rocketmq等中介軟體的通訊,發現都使用的select,經過查詢資料,認為這些中介軟體選擇選擇select的原因肯定是有過實際選型呼叫和實踐。

epoll引入了新的資料結構,帶來了複雜性,如果連線都是活躍連線,那麼select直接對整個socket控制程式碼進行遍歷就可以獲取到整個連線就緒控制程式碼,對於epoll來說,每次都要進行回撥,回撥太頻繁了(通常回撥都是通過遍歷監聽器),這樣效率反而不如select。

對於dubbo、rocketmq採用selector是因為連線都是活動連線,且連線並不是特別多,那麼使用selector直接對整個socket控制程式碼集合進行輪詢效率很高。而epoll更適合巨量的連線數,活動連線較少的情況,比如IM通訊等。

總結:select時候連線大多數是活躍狀態,epoll適合連線數量多,但是活動連線較少的情況。

下圖解釋了select和epoll的壓測情況

1419485-20210412231112777-1215259567

IO多路複用在中介軟體的使用場景

IO多路複用即一個select/epollo管理多個channe,多個channel共用一個IO(這個IO認為是一個reactor執行緒,執行緒和select/epoll繫結)。

以下框架和中介軟體使用了IO多路複用,如netty,nginx、redis

為什麼nginx使用IO多路複用是多程式(單執行緒)

nginx通訊也採用了IO多路複用,不同的是它採用的是多程式(單執行緒)形式。

netty使用IO多路複用是多執行緒形式,即多個IO執行緒,但是nginx是一個master程式用於accept(等同netty的boss執行緒),多個worker程式(每個worker程式只有一個IO執行緒)用於IO操作(等同netty的work執行緒,即IO執行緒),nginx這樣做的原因是為了高可用,如果Nginx 使用了多執行緒的模式,由於執行緒之間是共享同一個地址空間的,當某一個第三方模組引發了一個地址空間導致的斷錯時 (eg: 地址越界), 會導致整個Nginx全部掛掉; 當採用多程式來實現時, 往往不會出現這個問題。nginx開放了外掛機制,為了高可用,因此設定為多程式(單執行緒)模式。

參考

https://blog.csdn.net/qq422431474/article/details/108244352

redis的網路模型

redis在6.0之前採用的是單reactor模型,利用 select/epolle 等多路複用技術,在單執行緒(一個redis程式只有一個執行緒)的事件迴圈中不斷去處理事件(客戶端請求),操作記憶體,最後回寫響應資料到客戶端:因此6.0之前為了在多核伺服器發揮redis效能,通常是一個伺服器部署多個redis例項。redis採用單執行緒的原因是避免上下文切換,且因為操作的是記憶體,不會導致阻塞,因此cpu不是瓶頸,網路IO才是瓶頸因此採用了單reactor模型。

隨著網際網路的高速發展,網際網路業務系統所要處理的線上流量越來越大,Redis 的單執行緒模式會導致系統消耗很多 CPU 時間在網路 I/O 上從而降低吞吐量,為了提升 Redis 的效能因此需要優化網路IO模型,因此redis6.0開始redis網路模型採用的主從reactor模型,和netty的執行緒模型相同。

參考

https://strikefreedom.top/multiple-threaded-network-model-in-redis

https://javamana.com/2021/12/202112270226346085.html

netty為什麼選擇NIO而非AIO

NIO模型

我們常說的NIO指的是同步非阻塞,非阻塞是因為select檢測到socket控制程式碼沒有就緒事件(該socket網路卡到核心沒有資料),直接返回;同步指的是讀就緒(資料到了核心),select呼叫,把資料從核心讀取到使用者空間。

AIO模型

AIO非同步非阻塞,客戶端的I/O請求都是由核心先完成了再通知(回撥)使用者執行緒進行處理,AIO又稱為NIO2.0,在JDK7才開始支援。

看起來AIO要比NIO高效的多,但是netty為什麼選擇NIO而非AIO呢?

從netty issue上檢視到netty作者的原話,主要原因總結如下:

Netty.4.Final 刪除了AIO的原因如下:

1.Netty不看重Windows上的使用,在Linux系統上,AIO的底層實現仍使用EPOLL,沒有很好實現AIO,因此在效能上沒有明顯的優勢,而且被JDK封裝了一層不容易深度優化。

2.Netty整體架構是reactor模型, 而AIO是proactor模型, 混合在一起會非常混亂,把AIO也改造成reactor模型看起來是把epoll繞個彎又繞回來。

3.AIO還有個缺點是接收資料需要預先分配快取, 而不是NIO那種需要接收時才需要分配快取, 所以對連線數量非常大但流量小的情況, 記憶體浪費很多。

4.Linux上AIO不夠成熟。

image-20220204000615082

BIO 和 NIO 在應用場景上的區別?它們各有什麼優勢劣勢?

BIO 方式適用於連線數目比較小且固定的架構,這種方式對伺服器資源要求比較高,對訪問響應速度沒有太高要求的架構中可以考慮,優點開發簡單,易上手,但是不適合連線數多且高併發的場景。小連線數,追求極快響應的場景比較適合 BIO。在檔案傳輸方面,也適合使用bio,沒有執行緒切換。

NIO常說的是同步非阻塞IO,適合於連線數多且高併發IO場景,比如web伺服器、rpc等場景。NIO(netty實現)不適合大檔案傳輸,會導致IO執行緒一直處理這個socket的讀取從而導致其它socket的讀取阻塞。缺點是:用NIO同時保持連線數多了,會導致單個連線的網路響應時間下降,因為總頻寬不變,且TCP本來就是非保證頻寬的技術實現。

典型場景:

BIO: 資料庫網路引擎

NIO: web 服務/rpc服務

為什麼資料庫的網路模型不選擇IO多路複用

工作中通常對db(mysql)都有連線數的監控,如果連線數達到了閾值(比如3000)會報警給dba,從而dba督促連線此db的專案組進行整改。而且測試環境還會對db進行定時kill連線。我們都知道mysql有連線數的限制,過高的連線會導致mysql效能下降(mysql是bio方式,為每個連線分配一個執行緒),那麼為什麼mysql不選用IO多路複用呢,不就沒有這個連線數限制問題了嗎?

要從IO多路複用和BIO的場景說起

bio為需要為每個連線分配個執行緒,在連線數多的情況下,導致頻繁執行緒上下文切換,cpu得不到充分利用。

IO多路複用解決的是阻塞IO中1連線1執行緒模式在高併發場景下執行緒過多導致的切換效率問題,採用多路複用減少執行緒數量,從而減少執行緒切換次數,提高cpu利用率,但並不能提高IO。因此只有當伺服器瓶頸是大量連線的執行緒切換時,才會提高效率,比如web就非常適合採用IO多路複用。相反連線數少的情況下,沒必要使用多路複用。比如DB是IO密集型,瓶頸通常在磁碟IO上,不在連線數上。

因此原因如下:

1.jdbc規範釋出的早,那會只有bio,nio出現的晚,因此資料庫驅動都是針對BIO設計的。且 jdbc介面是同步化的。資料庫廠商只提供基於bio的jdbc實現。

2.對於DB而言,DB的瓶頸實際上是硬碟IO,用NIO引入更多的客戶端session最後的結果是session都停留在等待磁碟IO上,並沒法帶來業務實質優化。反而因為每個連線響應時間都變長,從而造成業務響應變壞,而且資料庫的一些實現,比如等鎖,搶鎖等,因為同時重入的session變多,更加容易造成連線等待時間變長,綜上,NIO對DB沒有什麼特別的好處。

3.DB訪問一般採用連線池這種現象是生態造成的。歷史上的BIO+連線池的做法經過多年的發展,已經解決了主要的問題。在Java的大環境下,這個方案是非常靠譜的,成熟的。而基於IO多路複用的方式儘管在效能上可能有優勢,但是其對整個程式的程式碼結構要求過多,過於複雜。當然,如果有特定的需要,希望使用IO多路複用管理DB連線,是完全可行的。

當然採用IO多路複用的DB也有,比如redis。只是傳統的RDBMS資料庫由於歷史生態和收益(優勢)問題,通常還是採用的是BIO+執行緒池模式。

redis採用IO多路複用的原因是redis是基於記憶體操作,IO上不是瓶頸,瓶頸是網路IO,因此採用IO多路複用。

參考https://www.zhihu.com/question/23084473

相關文章