IO多路複用的理解/演變過程

2022-11-11 06:00:21

目錄

阻塞IO

非阻塞 IO

select

epoll

總結一下。


阻塞IO

伺服器端為了處理使用者端的連線和請求的資料,寫了如下程式碼。

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

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

圖片

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

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

圖片

這就是傳統的阻塞 IO。

整體流程如下圖。

圖片

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

這肯定是不行的。

非阻塞 IO

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

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

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

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

圖片

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

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

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

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

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

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

圖片

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

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

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

整體流程如下圖

圖片

也就是說這不是真正意義上的非阻塞IO。

IO 多路複用

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

圖片

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

fdlist.add(connfd);

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

  1. while(1) {
  2. for(fd <-- fdlist) {
  3. if(read(fd) != -1) {
  4. doSomeThing();
  5. }
  6. }
  7. }

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

圖片

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

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

所以,還是得懇請作業系統老大,提供給我們一個有這樣效果的函數,我們將一批檔案描述符通過一次系統呼叫傳給核心,由核心層去遍歷(而不是在使用者態呼叫,再陷入到核心態中去遍歷),才能真正解決這個問題。

select

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

圖片

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

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

伺服器端程式碼,這樣來寫。

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

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

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

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

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

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

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

可以看出幾個細節:

  1. select 呼叫需要傳入 fd 陣列,需要拷貝一份到核心,高並行場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)
  2. select 在核心層仍然是通過遍歷的方式檢查檔案描述符的就緒狀態,是個同步過程,只不過無系統呼叫切換上下文的開銷。(核心層可優化為非同步事件通知)
  3. select 僅僅返回可讀檔案描述符的個數,具體哪個可讀還是要使用者自己遍歷。(可優化為只返回給使用者就緒的檔案描述符,無需使用者做無效的遍歷)

整個 select 的流程圖如下。

圖片

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

epoll

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

還記得上面說的 select 的三個細節麼?epoll 主要就是針對這三點進行了改進。

  1. 核心中儲存一份檔案描述符集合,無需使用者每次都重新傳入,只需告訴核心修改的部分即可。
  2. 核心不再通過輪詢的方式找到就緒的檔案描述符,而是通過非同步 IO 事件喚醒。
  3. 核心僅會將有 IO 事件的檔案描述符返回給使用者,使用者也無需遍歷整個檔案描述符集合。

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

圖片

總結一下。

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

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

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

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

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

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

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

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

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

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

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