IO多路複用詳解

忞翛發表於2021-08-04

假如你想了解IO多路複用,那本文或許可以幫助你
本文的最大目的就是想要把select、epoll在執行過程中幹了什麼敘述出來,所以具體的程式碼不會涉及,畢竟不同語言的介面有所區別。

基礎知識

IO多路複用涉及硬體、作業系統、應用程式三個層面,瞭解這些知識是很有幫助的。
假如已經瞭解,可直接跳過

Linux系統中斷

中斷是指計算機在執行期間,系統內發生任何非尋常的或非預期的急需處理事件,使得CPU暫時中斷當前正在執行的程式而轉去執行相應的事件處理程式,待處理完畢後又返回原來被中斷處繼續執行或排程新的程式執行的過程。

硬中斷

通過硬體產生相應的中斷請求,稱為硬中斷。
我們的硬體裝置如:滑鼠、鍵盤、網路卡、磁碟等,假如想要讓CPU處理它們的資料(如按下鍵盤、移動滑鼠、處理網路卡緩衝區的報文資料等)都需要通過中斷控制器(一個硬體裝置)向資料匯流排中傳送中斷請求(IRQ Interrupt ReQuest的縮寫),CPU收到IRQ後會將當前程式資訊儲存到程式描述符中,然後在中斷向量表中找到對應中斷處理程式的地址,然後執行中斷處理程式,在執行完處理程式後,從程式描述符中恢復原程式。

簡化以上過程:外設 ==> 中斷控制器 ==> CPU ==> 掛起當前程式 ==> 中斷向量表 ==> 中斷處理程式 ==> 恢復原程式。

軟中斷

軟中斷是在通訊程式之間通過模擬硬中斷而實現的一種通訊方式。軟中斷僅在當前執行的程式中產生。
我們經常用到的系統呼叫就是一個軟中斷,因為中斷向量號為0x80故又稱80中斷。

下面會解釋系統呼叫到底做了什麼

延伸閱讀:
詳解作業系統中斷,該文章介紹了8259A中斷控制器以及中斷觸發和處理的過程。

系統呼叫

上面說過,系統呼叫是一種軟中斷。那麼作業系統為什麼要給我們提供系統呼叫呢?以及系統呼叫的實現過程又是如何的?

使用者態和核心態

我們知道作業系統本身也是一個程式,我們平時寫的程式都跑在作業系統之上,計算機的硬體資源都是由作業系統核心進行管理的。假如我們需要使用某一硬體的資源,是不能直接訪問的。因為為了提高作業系統的穩定性和安全性,應用程式需要和系統程式分開。CPU將程式執行的狀態分為了不同的級別,從0到3,數字越小,訪問級別越高。0代表核心態,在該特權級別下,所有記憶體上的資料都是可見的,可訪問的。3代表使用者態,在這個特權級下,程式只能訪問一部分的記憶體區域,只能執行一些限定的指令。這就把記憶體分為了使用者態和核心態。
由於記憶體分為使用者態和核心態,當我們需要訪問作業系統的內部函式時,就需要使用系統呼叫了,為了規範作業系統提供的系統呼叫,IEEE制定了一個標準介面族,被稱為POSIX(Portable Operating System Interface of Unix)。比如一些常用的介面:fork、pthread_create、open等。

系統呼叫過程

下面敘述一下系統呼叫的過程是怎樣的,要求知道大概流程。

  1. 程式A請求系統呼叫(80中斷),此過程會將系統呼叫號放入eax暫存器,並往ebx、ecx、edx、esi,、edi暫存器放入引數

  2. 棧切換:當前棧從使用者棧切換到核心棧(使用者態和核心態使用的是不同的棧),關於Linux的棧可以看此文:Linux 中的各種棧:程式棧 執行緒棧 核心棧 中斷棧

    當前棧指的是ESP暫存器的值所指向的棧,ESP的值位於使用者棧的範圍,那程式的當前棧就是使用者棧,反之亦然。
    暫存器SS的值指向當前棧所在的頁。因此,將使用者棧切換到核心棧的過程是:

    將當前ESP、SS等暫存器的值存到核心棧上。
    將ESP、SS等值設定為核心棧的相應值。

  3. 通過中斷向量表找到system_call的地址(0x80的地址)

  4. <開始system_call>將使用者態的一些暫存器資訊儲存在自己的堆疊即核心堆疊上(system_call 中的save_all實現)

    save_all是一個巨集,它將依次壓入: %es %ds %eax %ebp %edi %esi %edx %ecx %ebx

  5. system_call根據eax暫存器的呼叫號找到特定的系統函式指標,並在暫存器中讀取引數

  6. 執行特定的系統函式

  7. 將執行結果儲存到eax暫存器中

  8. 恢復之前儲存的暫存器

  9. 執行iret,從中斷程式返回<system_all結束>

    iret是彙編指令,將原來使用者態儲存的現場恢復回來,包含程式碼段、指令指標暫存器等。這時候使用者態程式恢復執行。

  10. 棧切換:當前棧要從核心棧切換回使用者棧

  11. 執行程式A,程式A往eax暫存器中讀返回資料

system_call的部分程式碼

// system_call的開頭部分
......
SAVE_ALL	// 儲存暫存器的值到棧中,以免被覆蓋
......
cmpl $(nr_syscalls), %eax	// 比較eax暫存器中的值和系統呼叫號大1的值(驗證系統呼叫號的有效性)
jae syscall_badsys	// 如果系統呼叫無效,指向syscall_badsys


// 如果系統呼叫號有效,則會執行以下程式碼
syscall_call:
	call *sys_call_table(0, %eax, 4)	// 查詢中斷服務程式並執行, sys_call_table其實就是系統呼叫表
	.....
	RESTORE_REGS	// 恢復之前儲存的暫存器
	......
	iret	// 從中斷程式返回

在網上找來的大致流程圖:
中斷流程

Socket基礎

本文是以socket分析的,所以需要了解一下socket的基礎知識。

Socket API

以TCP為例,其一般使用模式如下:
TCP API

  • socket: 建立socket 物件。這個 socket 物件包含了輸入緩衝區輸出緩衝區等待佇列等成員。
  • bind: 繫結ip和埠
  • listen: 設定backlog,簡單來說就是設定能連多少個客戶端,想要進一步瞭解的朋友可以看此文:TCP/IP協議中backlog引數
  • accept: 等待客戶端連線(阻塞),得到一個與客戶端建立連線的socket
  • read: 從socket輸入緩衝區中讀取資料,緩衝區為空時阻塞
  • wiret: 向socket輸出緩衝區中寫入資料,緩衝區空間不夠時阻塞

    只要將全資料放到緩衝區就可以返回了,至於如何傳送及保證資料完整性,就不是它的事了。

Socket 緩衝區讀寫機制

下面詳細說一下,socket緩衝區的讀寫機制,分BIO和NIO兩種情況

每個 socket 被建立後,都會分配兩個緩衝區,輸入緩衝區和輸出緩衝區 。
我們呼叫write()/send()時,作業系統並不立即向網路中傳輸資料 ,而是先將資料拷貝到輸出緩衝區中,然後根據網路協議和阻塞模式進行處理。
我們呼叫read()/recv()時,假如對應socket的輸入緩衝區沒有資料時,會根據阻塞模式進行不同的處理。

socket收發資料

BIO

  • 資料傳送

    1. 輸出緩衝區的可用長度大於待傳送的資料,則資料將全部被拷貝到輸出緩衝區,返回。
    2. 輸出緩衝區的長度小於待傳送的資料長度,則資料能拷貝多少就先拷貝多少(分批拷貝),一直等待直到資料可以全部被拷貝到輸出緩衝區,返回
  • 資料接收

    1. 輸入緩衝區沒資料時,程式就會一直阻塞等待,直到有資料可讀為止。讀buffer大小的資料。返回值是成功讀取到的資料的長度。
    2. 輸入緩衝區有資料時,讀buffer大小的資料,返回,返回值是成功讀取到的資料的長度。

NIO

  • 資料傳送

    1. 輸出緩衝區剩餘大小大於待傳送的資料大小,那資料將完整拷貝到輸出緩衝區,返回。
    2. 輸出緩衝區剩餘大小小於待傳送的資料大小,那本次write()/send()則為儘可能拷貝,有多少空間就拷貝多少資料,返回,而且返回值為成功拷貝到輸出緩衝區的資料長度。
  • 資料接收

    1. 輸入緩衝區沒資料時,馬上返回,此時的返回值為0。
    2. 輸入緩衝區有資料時,讀buffer大小的資料,返回,返回值是成功讀取到的資料的長度。

補充:
socket關閉時,若輸出緩衝區中的資料仍有資料,這些資料依然會被系統傳送過去;若輸入緩衝區中的資料仍有資料,這部分資料將被丟棄。

延伸閱讀:
談談socket緩衝區,該文章介紹了TCP、UDP在阻塞和非阻塞下的收發情況,以及在收發過程中的一些常見情景。

BIO時socket接收資料過程

下面通過敘述socket等待recv過程,將前面的內容串聯一下。

上面說過,呼叫socket會在核心態建立socket 物件。這個 socket 物件包含了輸入緩衝區輸出緩衝區等待佇列等成員,如下圖。
建立socket

當我們呼叫recv讀取輸入緩衝區中的資料時,由於緩衝區中沒有資料,程式A就會從工作佇列中移除,也就是說程式A處於阻塞態了。同時,程式A建立的socket的等待佇列加入程式A的地址,用於喚醒程式A。如圖:

呼叫recv

之後的流程是這樣的:

  1. 程式A會一直阻塞,直到網路卡收到對端發來的資料,由網路卡的DMA裝置接收資料,將資料放到記憶體中的網路卡緩衝區
  2. 然後網路卡向中斷控制器傳送訊號,而中斷控制器會在條件允許的情況下傳送中斷請求(IRQ)
  3. CPU收到IRQ後,掛起當前程式,執行中斷
  4. CPU根據中斷向量表找到網路卡的中斷處理程式,CPU執行該中斷處理程式
  5. 中斷處理程式根據報文資料的埠,將資料從網路卡緩衝區複製到程式A的socket的輸入緩衝區中
  6. 然後根據socket的等待佇列喚醒程式A,將程式A加入到工作佇列中,即程式A變為就緒態。

整個過程如圖:

呼叫recv 網路卡收到資料

呼叫recv 網路卡發起中斷請求

呼叫recv 執行網路卡中斷處理程式

將上述過程簡化一下,就大概是下圖了

簡化流程

這是BIO的情況,一個程式只能監聽一個socket,即使使用多程式或多執行緒也很難解決c10k的問題,因此需要IO多路複用技術。

補充:網路卡DMA裝置
DMA是指外部裝置不通過CPU而直接與系統記憶體交換資料的介面技術。
網路卡DMA裝置的處理流程:

  1. 網路卡收到對端socket發來的資料
  2. 網路卡的DMA裝置取資料
  3. 將DMA中讀到的資料放到RAM中的網路卡緩衝區
    更多關於DMA裝置的內容,可檢視:DMA(直接儲存器存取)

IO多路複用

正文開始

I/O多路複用就是通過一種機制,讓一個程式可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。常用的IO多路複用的實現有:select、poll、epoll。select、poll、epoll是系統呼叫,在呼叫的過程中會阻塞,在讀資料的時候也會阻塞,但它可以同時監聽多個檔案描述符。

select

基本使用

select函式原型:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  1. 引數
    • nfds:有效位(見下面解釋)
    • 要監聽的檔案描述符,以讀、寫、異常的順序傳
    • timeout,設定為大於0的數,等待多少秒後返回;設定為0,立即返回;設定為NULL,阻塞直到可用;
  2. 返回
    • 就緒檔案描述符個數

      返回前,原監聽的檔案描述符會被標記,然後從核心態覆蓋到使用者態(下面流程部分的第9步)

原始碼刨析:Linux select核心原始碼剖析

關於fd_set

fd_set在Linux下是bitmap,長度大小為1024(在linux原始碼中定義的)。

linux提供了一組巨集,可以對fd_set進行操作,這裡不過多介紹了,想要了解的可以看這裡:select機制核心原始碼剖析-fd_set部分

有效位解釋:
假如現在要監聽5、6、7檔案描述符,那麼其bitmap應該為000001110000.....,但為了提高效率,可以把後面沒用的0去掉,把有效位設為8(最大的檔案描述符加1)變為:00000111。至於是如何實現的,這裡可以列出原始碼:

/ *  do_select函式,作用是遍歷所有監聽的檔案描述符,呼叫對應驅動程式的poll函式 */


/ * ..... */


/ * 此為監聽檔案檔案描述符過程,n為有效位 */

for (i = 0; i < n; ++rinp, ++routp, ++rexp)

/ * ..... */

例子

使用select比較簡單,如下面這段虛擬碼:

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...);
listen(s, ...);
int fds[] =  存放需要監聽的socket;

&rset = 根據fds構建出的點陣圖; // 讀監聽
while(1){
    int n = select(..., &rset, ...)
    for(int i=0; i < fds.length; i++){
		// FD_ISSET是fd_set的巨集,可以判斷該位置上的bitmap是否為1
        if(FD_ISSET(fds[i], &rset)){
            // fds[i]的資料處理
			read(fds[i], buffer);
			doSomething(buffer);
        }
}}

下面以這段虛擬碼為例子,描述select的流程。

流程

  1. 程式A建立多個socket物件(呼叫socket或accpet函式)
  2. 呼叫select,進行系統呼叫(80中斷),將bitmap即描述符資料複製到核心態
  3. 程式A從執行佇列中移除
  4. select:
    • ①. 如果 fds 中的所有 socket 都沒有資料,select 會阻塞
    • ②. 遍歷監聽每個socket
    • ③.將程式A加入到socket等待佇列中
  5. 網路卡收到對端socket的資料
  6. 網路卡通過DMA將報文儲存到RAM中的網路卡緩衝區
  7. 網路卡發起硬中斷IRQ
    • ①. 修改CPU暫存器,將堆疊指標指向核心態堆疊
    • ②. 儲存程式使用者態堆疊資訊到程式描述符
    • ③. 根據IRQ到中斷向量表找到中斷處理程式
    • ④. 執行網路卡的中斷處理程式
      • a. 將資料從網路卡緩衝區轉移到對應的socket的讀緩衝區(根據socket埠)
      • b. 將程式A從socket等待佇列出隊,並將程式A放到執行佇列中
  8. CPU根據排程演算法執行程式A
  9. select:
    • ①. select遍歷所有socket,找到就緒的,並設定標記( 把bitmap中已經就緒的不變為1,未就緒的變為0)

      如:監聽描述符為3、4、5,那麼它的bitmap資料應該是000111,假如描述符3和5已經就緒,那麼bitmap變為000101

    • ②. 將核心空的bitmap覆蓋到程式A中的bitmap,並返回就緒的socket數

  10. 程式A拿到就緒的socket數,遍歷bitmap資料,找到就緒的描述符
  11. 進行讀寫等操作

整個過程的前部分和使用BIO監聽一個socket時一樣,其核心部分就是select把不同的socket的等待佇列都指向了程式A,所以當執行中斷處理程式時,程式A就會被喚醒(如圖),從而實現了可以監聽多個檔案描述符(socket)的效果。

select核心部分

缺點

根據上面的流程的敘述,我們很容易就可以發現select的缺點

  1. 傳參時,bitmap需要從使用者態複製到核心態
  2. 返回時,修改(標記)後的bitmap需要從核心態複製到使用者態
  3. 由於每次返回都會修改原bitmap,所以每次都要把bitmap重新置位,不能複用
  4. 有三次遍歷(監聽時、標記時、程式找就緒socket時),十分浪費資源
  5. 在linux下,bitmap的長度不能超過1024,可以修改linux原始碼並重新編譯核心解決問題,但是由於bitmap需要在使用者態與核心態之間傳來傳去,而且需要遍歷,效果可能不太理想。

poll

poll的機制與select類似,與select在本質上沒有多大差別,所以在這裡只做簡單介紹。poll和select一樣,管理多個描述符也是進行輪詢,根據描述符的狀態進行處理。poll以連結串列的形式儲存檔案描述符,而且最大檔案描述符數量沒有限制。但poll和select同樣存在一個缺點:包含大量檔案描述符的陣列被整體複製於使用者態和核心的地址空間之間,而不論這些檔案描述符是否就緒,它的開銷隨著檔案描述符數量的增加而線性增大。所以,監聽fd很多的時候建議用epoll。

epoll

epoll是select和poll出現後被開發出來的,既然是新的東西,那當然不能走select和poll的老路子。在上文也說過select和poll的主要問題是每次檔案描述符都要從使用者態複製到核心態,然後監聽的已經就緒後再複製回來。在這個過程中,沒有就緒的描述符也會返回,所有需要在程式中輪詢檢視每個描述符的狀態,浪費資源。為此,epoll會在核心空間中開闢一片空間,用於存放檔案描述符等資料,返回時只需要返回就緒的檔案描述符即可。這樣未就緒的檔案描述符可以繼續監聽,程式也不需要遍歷檢視哪個檔案描述符就緒了。

基本使用

epoll常用的有三個介面,epoll_createepoll_ctlepoll_wait

  1. int epoll_create(int size);
    在核心區建立一個eventpoll結構(該結構包含:監聽事件列表就緒佇列等待佇列 等),並且將一個控制程式碼fd返回給使用者態。

    監聽事件列表是用紅黑樹實現的。epoll 通過 socket 控制程式碼來作為 key,把 socket 儲存在紅黑樹中。
    紅黑樹是一種自平衡二叉查詢樹,搜尋、插入和刪除時間複雜度都是O(log(N)),效率較好。
    而就緒佇列是雙向連結串列

  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    程式通過之前返回的fd,新增/修改/刪除檔案的監聽事件,這個介面操作的是監聽事件列表

  3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    等待事件的產生,比較像呼叫select(),不過返回的是就緒佇列

關於三個介面的引數詳情,以及呼叫它們後,是如何用程式碼實現的,可以看此文章:epoll核心原始碼分析

例子

同樣用一段虛擬碼說明一下epoll的大概的使用流程。

int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)
 
int epfd = epoll_create(...);

//將所有需要監聽的socket新增到epfd中
epoll_ctl(epfd, ...); 
 
while(1){
    int n = epoll_wait(...)
    for(接收到資料的socket){
        //處理
    }
}


流程

  1. 程式A建立多個socket物件(呼叫socket或accpet函式)
  2. 呼叫epoll_create,在核心中建立eventpoll結構,返回fd
  3. 呼叫epoll_ctl將socket加入到eventpoll的監聽事件列表中(通過引數指定監聽讀/寫就緒、水平/邊緣觸發等)
  4. 呼叫epoll_wait,假如eventpoll的就緒佇列中有資料,則返回,否則阻塞(可以指定timeout引數不讓其一直阻塞,但這裡不展開)
  5. 程式A從執行佇列中移除
  6. epoll:
    • ①. 將程式A的地址加入到eventpoll的等待佇列
    • ②. 將eventpoll的地址加入每個socket的等待佇列中
      示意圖
  7. 網路卡收到對端socket的資料
  8. 網路卡通過DMA將報文儲存到RAM中的網路卡緩衝區
  9. 網路卡發起硬中斷IRQ
    • ①. 修改CPU暫存器,將堆疊指標指向核心態堆疊
    • ②. 儲存程式使用者態堆疊資訊到程式描述符
    • ③. 根據IRQ到中斷向量表找到中斷處理程式
    • ④. 執行網路卡的中斷處理程式
      • a. 將資料從網路卡緩衝區轉移到對應的socket的讀緩衝區(根據socket埠)
      • b. 從socket等待佇列中找到eventpoll,呼叫ep_poll_callback函式處理。
        接收到資料
  10. epoll的ep_poll_callback函式:
  • ①. 將當前socket新增到eventpoll的就緒佇列
  • ②. 喚醒等待佇列中的程式,即程式A
    eventpoll處理
  1. CPU根據排程演算法執行程式A
  2. epoll_wait將eventpoll中的就緒列表從核心態複製到使用者態
    複製到使用者態
  3. 程式A拿到就緒列表
  4. 進行讀寫等操作

延伸閱讀:
Epoll 如何工作的?
該文章講解了epoll 的實現原理、在實現過程中呼叫了哪些函式,會產生怎樣的效果。

相關文章