1. 程式人生 > >關於非阻塞I/O、多路複用、epoll的雜談

關於非阻塞I/O、多路複用、epoll的雜談

本文主要是想解答一下這樣幾個問題:

- 什麼是非阻塞I/O
- 非阻塞I/O和非同步I/O的區別
- epoll的工作原理

檔案描述符

檔案描述符在本文有多次出現,難免有的朋友不太熟悉,有必要簡單說明一下。
檔案描述符是一個非負整數,用於標識一個開啟的檔案。
這裡“檔案”一詞是更寬泛的概念,可以是程序中使用的任何型別的I\O資源,例如常規檔案,管道,Socket等。
通常,開啟I\O流的系統呼叫都會返回一個int型別的檔案描述符。
例如下面分別是開啟一個檔案和建立一個Socket的系統呼叫。

int open(const char *pathname, int flags);  
int socket(int domain, int type, int protocol);

檔案描述符File descriptor,在程式中一般簡寫為fd

阻塞模式(Blocking I/O)

解釋非阻塞I/O之前先,先聊下阻塞I/O 。
預設的,Unix系統的所有檔案描述符都是阻塞模式。這意味著有關I/O的操作(如read,write,open)都是阻塞的。
以從Linux終端視窗讀取資料為例,如果你在程式中呼叫了read(),程式會一直阻塞,直到你確實敲鍵盤輸入了字元。
這裡的程式阻塞,更準確地講,是系統核心把該程序設定成了睡眠狀態,直到有資料輸入才被喚醒。
TCP Socket通訊也是一樣的道理,如果你嘗試著從socket中讀取資料,read()會被阻塞,直到socket的另一端傳送了資料。
可見,在阻塞模式下程式是不能併發操作的,因為程序\執行緒都被睡眠了。
對於併發I/O,我們將討論三個解決辦法:

  • 非阻塞模式
  • I/O多路複用系統呼叫,select和epoll
  • 多執行緒/多程序

下面詳細聊聊這三個方法。

非阻塞模式(Nonblocking I/O)

在開啟一個檔案時,設定flag引數為O_NONBLOCK,便告訴了作業系統對這個檔案的操作應該是非阻塞的,或者說該檔案描述符處於非阻塞模式。
這意味著兩點:

  1. 如果這個檔案不能立即開啟,open()系統呼叫會返回一個錯誤碼,而不是阻塞open()。
  2. 檔案開啟成功後,後續的I/O操作應該也是非阻塞的,例如如果不能立即完成read或write,返回錯誤碼。
    不能立即完成讀寫的原因可能是沒有資料可讀,或者快取已滿沒有更多空間可以寫。
    (注意,非阻塞模式對常規檔案無效,因為系統核心總能保證有足夠的快取讓常規檔案I/O不阻塞)

非阻塞模式僅僅是Unix系統中一個原始特性,只有它還不能讓程式併發執行。還需要我們在應用程式中有一個死迴圈,不停的檢測檔案描述符的狀態。
下面以併發訪問兩個檔案描述符為例寫一段虛擬碼。
先解釋一下read()系統呼叫的幾個引數:
int read(int fd, void *buffer, size_t count);

  • fd 即讀取的檔案描述符
  • buffer 用於接收本次讀取的資料
  • count 本次讀取的最大位元組數
  • 返回值 實際讀取的位元組數,發生錯誤返回-1
ssize_t nbytes;
for (;;) {
   //檔案描述符1
    if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {
        if (errno != EWOULDBLOCK) {
            //發生錯誤,且錯誤碼為EWOULDBLOCK
           //說明檔案描述符fd1沒有準備好read
        }
    } else {
        handle_data(buf); //處理資料
    }
    //檔案描述符2
    if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {
        if (errno != EWOULDBLOCK) {
        //fd2不能read
        }
    } else {
        handle_data(buf);
    }
    nanosleep(sleep_interval, NULL); //再次檢測前睡眠片刻
}

以上實現了對兩個檔案併發I\O,但有很明顯的缺點:

  • 迴圈的頻次太低,會導致I/O的響應延遲。
  • 迴圈的頻次太高,當需要併發處理的檔案很多時,每次迴圈都要檢測所有檔案,效能會變得很差(read()是系統呼叫)。

多程序/執行緒方式

併發處理多個I/O流還有一種更原始的方法,使用多程序或多執行緒,每個I/O流獨佔一個程序或執行緒,很容易理解。
也有很多缺點:

  • 在任何時刻,都可能有大量的執行緒處於空閒狀態,造成資源浪費。
  • 執行緒是佔記憶體的,不可能建立太多的工作執行緒。
  • 執行緒/程序的上下文切換也會帶來很大的效能開銷。

在網際網路早期流量比較小,很多服務採用的這種方式,但是它只適用於低併發的伺服器。
上面討論了非阻塞模式和多程序兩個方式,在高併發場景都不太好用。
這時,我們就需要Unix的I/O多路複用了。

I/O多路複用(I/O Multiplexing)

類Unix系統有多個實現了I/O多路複用的系統呼叫,Unix系統的selectpoll,Linux的epoll,以及BSD的kqueue
他們的底層工作原理相似:首先告訴系統核心你想監控哪些檔案描述符的哪些事件(典型的read和write事件),然後使用者程式被阻塞,直到你感興趣的事件發生。
例如你可能告訴系統核心,“當檔案描述符X可以read時,通知我。”
Unix的I/O多路複用機制不關心檔案描述符是否處於非阻塞模式,你可以把所有檔案描述符設定為阻塞模式,epoll和select不會受影響。
這一點很重要!非阻塞模式和I/O多路複用,這兩個方法都可以實現併發I/O,但是本質上他們是相互獨立的兩個解決問題思路,互不依賴。
多路複用實現的併發I/O,有時被稱為非同步I/O(asynchronous I/O)。但是有人也把這種方式稱為非阻塞I/O,這是錯誤的,應該是對非阻塞模式有什麼誤解。

epoll的工作原理

epoll是event poll的簡寫,是Linux核心提供的一種由事件驅動的I/O通知機制。(注意epoll是Linux特有的,其他類Unix系統可能沒有實現。)
另外,epoll本身並不是一個系統呼叫,而是一組系統呼叫的統稱。
這組系統呼叫包括:

  • epoll_create()系統呼叫
  • epoll_ctl()系統呼叫
  • epoll_wait()系統呼叫

epoll_create()

方法簽名:int epoll_create(int size)
該系統呼叫用於建立一個epoll例項,該例項存在於系統核心空間。
epoll例項會初始化一個列表,用於儲存使用者程式感興趣的檔案描述符,下文簡稱interest list,引數size即為這個列表的初始大小(可以動態擴充套件)。返回值是epoll例項的檔案描述符。

epoll_ctl()

使用epoll_ctl()可以對interest list增刪改(ctl應該是control的縮寫)
方法簽名:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev)
引數說明:

  • epfd: 即epoll_create()建立的epoll例項的檔案描述符
  • op: 列舉值,指定操作型別,例如EPOLL_CTL_ADD新增一個檔案描述符
  • ev: 結構體型別,是對該檔案描述符的設定。
    ev引數最重要的是ev.events欄位可以新增感興趣的事件,例如添加了read事件,表示該檔案可以read的時候,通知應用程式。

    epoll_wait()

    使用者程式呼叫該方法獲取ready的檔案描述符。
    繼續之前先解釋一下檔案描述符什麼時候ready
    即使在檔案描述符處於阻塞模式(沒有設定O_NONBLOCK)的情況下,對該檔案描述符的read\write等操作扔不會阻塞,就說該檔案描述符ready。
    方法簽名:int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
    引數說明:
  • epfd: epoll例項的檔案描述符
  • evlist: 用於接收ready的檔案描述符,該陣列由使用者程式分配。
  • maxevents: 一次呼叫返回的最大事件數量
  • timeout: epoll_wait系統呼叫的超時時間,0表示不阻塞,無事件發生立即返回。-1一直阻塞,直到有事件發生。大於0表示超時返回。

為了對epoll的併發I/O程式設計有個感性的認識,我們來寫一段虛擬碼

  epfd = epoll_create(EPOLL_QUEUE_LEN); //建立epoll例項
  static struct epoll_event ev;
  int client_sock;
  ev.events = EPOLLIN | EPOLLPRI | EPOLLERR | EPOLLHUP; //宣告感興趣的事件
  ev.data.fd = client_sock; //檔案描述符指向一個Socket連線
  //新增要監控的檔案描述符和事件型別到interest list
  //真實環境中,可能需要新增成百上千個這種事件。
  int res = epoll_ctl(epfd, EPOLL_CTL_ADD, client_sock, &ev); 
 while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS_PER_RUN, TIMEOUT);//獲取ready的事件,可能有多個
    if (nfds < 0) die("Error in epoll_wait!"); //發生錯誤,退出程式
    for(int i = 0; i < nfds; i++) {   //遍歷處理每一個已ready的socket
      int fd = events[i].data.fd; 
      handle_io_on_socket(fd);
    }
  }

從虛擬碼中可以看到,基於epoll的I/O多路複用的時間複雜度只有O(n),其中n是ready的事件數量。
時間複雜度不會隨著interest list的增加而線性增長,這使得epoll有很好的擴充套件性。
試想一個高併發伺服器同一時刻可能需要監控成千上萬甚至更多的socket連線,
如果使用文章開始介紹的非阻塞模式,每一次迴圈都要對全部的socket進行測試,這是很恐怖的。
但是epoll的壓力只與socket通訊的繁忙程度有關。
另外提一下,Unix中的poll()和select()有著更差的時間複雜度和空間複雜度,篇幅受限這裡不展開說了。

最後,結合圖片瞭解一下epoll的內部結構(圖片出處見文章最後)

1.程序483通過epoll_create()建立一個epoll例項,該例項存在於核心空間。

2.程序483通過epoll_ctl()系統呼叫,新增五個感興趣的檔案描述符到interest list。

3.當有檔案描述符ready時,系統核心會把該檔案描述符新增到ready list,ready list是interest list的子集。

事件發生後新增到ready list,這個過程是核心完成的。

4. 使用者程式呼叫epoll_wait()時,核心會把ready list返回給使用者程式。

寫在最後

博主的主力語言是Java,對C一知半解,如文章有理解錯誤的地方,感謝指正。
我在學習Java NIO的原理時,遇到了很多Jdk原始碼解決不了的困惑。於是我決定從Linux程式設計介面著手,想了解一下Linux系統怎麼做到的併發I/O。
途中翻閱了很多資料,其中對我幫助最大的是《The Linux Programming Interface》,作者是Linux man-pages的維護者,具有一定的權威性。
這本書並沒有像書名一樣停留在Linux API層面,而是穿插著講解了很多原理性的知識,有幾個知識點講的可謂醍醐灌頂。
其他資料就比較雜亂了,像維基百科、man手冊、個人部落格都有。
這篇文章,更多得是對這幾天學習的知識梳理和總結,沒什麼原創內容。
希望對你有所幫助吧。

參考內容

  1. 《The Linux Programming Interface》英文版 4.3章節、5.9章節、 56.2章節、63.1章節、63.2.3章節 (核心參考)
  2. man7關於O_NONBLOCK的權威說明:http://man7.org/linux/man-pages/man2/open.2.html ( 裡面有提到,設定O_NONBLOCK與否對select和epoll沒有影響。)
  3. 很清晰的解釋了非阻塞模式和多路複用的區別和聯絡:https://eklitzke.org/blocking-io-nonblocking-io-and-epoll
  4. 介紹了epoll的工作原理和內部資料結構(本文中的圖片來源):https://medium.com/@copyconstruct/the-method-to-epolls-madness-d9d2d6378642
  5. 對《The Linux Programming Interface》,epoll章節的讀書筆記:https://jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/
  6. 一個epoll程式設計的小demo : https://kovyrin.net/2006/04/13/epoll-asynchronous-network-programming/
  7. 關於epoll和poll效能的討論: https://www.win.tue.nl/~aeb/linux/lk/lk-12.html