IO多路復用詳解


假如你想了解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 的實現原理、在實現過程中調用了哪些函數,會產生怎樣的效果。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM