網路I/O模型 解讀

ityml發表於2022-12-01

網路、核心

image-20221128213153905

網路卡能「接收所有在網路上傳輸的訊號」,但正常情況下只接受傳送到該電腦的幀和廣播幀,將其餘的幀丟棄。

所以網路 I/O 其實是網路與服務端(電腦記憶體)之間的輸入與輸出

核心

檢視核心版本 : uname -r

檢視可升級的版本: yum list kernel --showduplicates

升級核心: yum update kernel-3.10.0-1160.80.1.el7

核心的任務

  • 用於應用程式執行的流程管理。
  • 記憶體和I / O(輸入/輸出)管理。
  • 系統呼叫控制(核心的核心行為)。
  • 藉助裝置驅動程式進行裝置管理

image-20221128233214286

Cpu指令集

在說使用者態與核心態之前,有必要說一下 C P U 指令集,指令集是 C P U 實現軟體指揮硬體執行的媒介,具體來說每一條彙編語句都對應了一條 C P U 指令,而非常非常多的 C P U 指令 在一起,可以組成一個、甚至多個集合,指令的集合叫 C P U 指令集。 同時 C P U 指令集 有許可權分級,大家試想,C P U 指令集 可以直接操作硬體的,要是因為指令操作的不規範`,造成的錯誤會影響整個計算機系統的。好比你寫程式,因為對硬體操作不熟悉,導致作業系統核心、及其他所有正在執行的程式,都可能會因為操作失誤而受到不可挽回的錯誤,最後只能重啟計算機才行。 而對於硬體的操作是非常複雜的,引數眾多,出問題的機率相當大,必須謹慎的進行操作,對開發人員來說是個艱鉅的任務,還會增加負擔,同時開發人員在這方面也不被信任,所以作業系統核心直接遮蔽開發人員對硬體操作的可能,都不讓你碰到這些 C P U 指令集。

使用者態/核心態

image-20221128233516194

  • 使用者態、核心態的指令都是 CPU 都在執行,所以我們可以換個說法,實際上這個態代表的是當前 CPU 的
    狀態。那既然這些指令最終都由 CPU 執行,那對其區分的理由是什麼呢?
    那是因為,CPU 指令根據其重要的程度,也分為不同的許可權。有一些指令執行失敗了無關痛癢,而有一些>指令失敗了會導致整個作業系統崩潰,甚至需要重啟系統。如果將這些指令隨意開放給應用程式的話,整>個系統崩潰的機率將會大大的增加。

ring 0被叫做核心態,完全在作業系統核心中執行。ring 3被叫做使用者態,在應用程式中執行。 使用者態和內>核態是作業系統的兩種執行級別,兩者最大的區別就是特權級不同。使用者態擁有最低的特權級,核心態擁>有較高的特權級。執行在使用者態的程式不能直接訪問作業系統核心資料結構和程式。

  • 核心態和使用者態之間的轉換方式主要包括:系統呼叫,異常和中斷。

  • 系統呼叫

這是使用者態程式主動要求切換到核心態的一種方式,使用者態程式透過系統呼叫申請使用作業系統>提供的服務程式完成工作,比如fork()實際上就是執行了一個建立新程式的系統呼叫。而系統呼叫的機制其>核心還是使用了作業系統為使用者特別開放的一箇中斷來實現,例如Linux的int 80h中斷。

  • 異常

當CPU在執行執行在使用者態下的程式時,發生了某些事先不可知的異常,這時會觸發由當前執行程式切換到處理此異常的核心相關程式中,也就轉到了核心態,比如缺頁異常。

  • 外圍裝置中斷

當外圍裝置完成使用者請求的操作後,會向CPU發出相應的中斷訊號,這時CPU會暫停執行下>一條即
將要執行的指令轉而去執行與中斷訊號對應的處理程式,如果先前執行的指令是使用者態下的程式,
那麼這個轉換的過程自然也就發生了由使用者態到核心態的切換。比如硬碟讀寫操作完成,系統會切換到硬>盤讀寫* 處理程式中執行後續操作等。

  • 這3種方式是系統在執行時由使用者態轉到核心態的最主要方式,其中系統呼叫可以認為是使用者程式主動發起的,異常和外圍裝置中斷則是被動的。
  • 相信大家都聽過這樣的話「使用者態和核心態切換的開銷大」,但是它的開銷大在那裡呢?簡單點來說有下面幾點
  1. 保留使用者態現場(上下文、暫存器、使用者棧等)
  2. 複製使用者態引數
  3. 使用者棧切到核心棧
  4. 進入核心態額外的檢查(因為核心程式碼對使用者不信任)
  5. 執行核心態程式碼 複製核心態程式碼執行結果
  6. 回到使用者態 恢復使用者
    現場(上下文、暫存器、使用者棧等)

綜上所述,減少使用者態核心態的切換就係統效能調優的主要手段。

檔案描述符FD

Linux 系統中,把一切都看做是檔案,當程式開啟現有檔案或建立新檔案時,核心向程式返回一個檔案描述符,檔案描述符就是核心為了高效管理已被開啟的檔案所建立的索引,用來指向被開啟的檔案,所有執行I/O操作的系統呼叫都會透過檔案描述符。

image-20221129223113938

man

yum install -y man-pages

man 命令 檢視linux 系統使用手冊的工具

網路

什麼是網路IO

一般情況下,在軟體中我們常說的 I/O 是指「網路 I/O 和磁碟 I/O」,今天我們就來聊下網路 I/O

網路 I/O 就是網路中的輸入與輸出,我們再說詳細點,正常的網路通訊中,一條訊息傳送的過程中有一個很重要的媒介,叫做「網路卡」,它的作用有兩個

  • 一是將電腦的資料封裝為幀,並透過網線(對無線網路來說就是電磁波)將資料傳送到網路上去
  • 二是接收網路上其它裝置傳過來的幀,並將幀重新組合成資料,傳送到所在的電腦中。

TCP/IP

網路資料傳輸 (1)

img

  1. server 建立監聽 socket 後,執行 bind() 繫結 IP 和埠,然後呼叫 listen() 監聽,代表 server 已經準備好接收請求了,listen 的主要作用其實是初始化半連線和全連線佇列大小
  2. server 準備好後,client 也建立 socket ,然後執行 connect 向 server 發起連線請求,這一步會被阻塞,需要等待三次握手完成,第一次握手完成,服務端會建立 socket(這個 socket 是連線 socket,注意不要和第一步的監聽 socket 搞混了),將其放入半連線佇列中,第三次握手完成,系統會把 socket 從半連線佇列摘下放入全連線佇列中,然後 accept 會將其從全連線佇列中摘下,之後此 socket 就可以與客戶端 socket 正常通訊了,預設情況下如果全連線佇列裡沒有 socket,則 accept 會阻塞等待三次握手完成

Socket

image-20221129230735601

I/O 模型簡介

一般情況下,一次網路資料的傳輸會從客戶端傳送給服務端,由服務端網路卡接受,轉交給記憶體,最後由 cpu 執行相應的業務操作,只要有一點電腦知識的讀者大多數都知道,cpu、顯示卡、記憶體等電腦中你數得上名字的模組,執行效率最高的就是 cpu 了,所以「為了整個網路傳輸的提效,就誕生出了五種網路 I/O 模型」

  1. 阻塞式I/O模型
  2. 非阻塞式I/O模型
  3. I/O多路複用模型
  4. 訊號驅動式I/O模型
  5. 非同步I/O模型

阻塞式I/O模型

image-20221130213559347

image-20221130213633771

  • 說明

    Linux中,預設情況下所有的socket都是阻塞的。這裡有必要辨析以下阻塞和非阻塞這兩個概念,這兩個概念描述的是使用者執行緒呼叫核心I/O操作的方式,其中阻塞是指I/O操作需要徹底完成後才返回到使用者空間;而非阻塞則是指I/O操作被呼叫後立即返回給使用者一個狀態值,不需要等到I/O操作徹底完成。

  • 問題

    除非特別指定,幾乎所有的I/O介面都是阻塞型的,即系統呼叫時不返回撥用結果,只有當該系統呼叫獲得結果或者超時出錯才返回。這樣的機制給網路程式設計帶來了較大的影響,當執行緒因處理資料而處於阻塞狀態時,執行緒將無法執行任何運算或者相應任何網路請求。

  • 改進方案

    在伺服器端使用阻塞I/O模型時結合多程式/多執行緒技術。讓每一個連線都擁有獨立的程式/執行緒,任何一個連線的阻塞都不會影響到其他連線。(選擇多程式還是多執行緒並無統一標準,因為程式的開銷遠大於執行緒,所以在連線數較大的情況下推薦使用多執行緒。而程式相較於執行緒具有更高的安全性,所以如果單個服務執行體需要消耗較多的CPU資源,如需要進行大規模或長時間的資料運算或檔案訪問推薦使用多程式)。

    當連線數規模繼續增大,無論使用多執行緒還是多程式都會嚴重佔據系統資源,降低系統對外界的響應效率,執行緒或者程式本身也更容易陷入假死。此時可以採用“執行緒池”或“連線池”來降低建立和銷燬程式/執行緒的頻率,減少系統開銷。

非阻塞式I/O模型

I/O多路複用模型

​ I/O多路複用(也叫做事件驅動I/O)透過系統呼叫select()、poll、或者epoll()實現程式同時檢查多個檔案描述符,以找出其中任何一個是否可執行I/O操作。透過上圖可以看出I/O多路複用與阻塞I/O模型差別並不大,事實上還要差一些,因為這裡使用了兩個系統呼叫而阻塞I/O只是用了一個系統呼叫。但是I/O多路複用的優勢是其可以同時處理多個連線。因此如果處理的連線數不是特別多的情況下使用I/O多路複用模型的web server不一定比使用多執行緒技術的阻塞I/O模型好。

image-20221130215134981

select & poll

image-20221130214733020

select()和poll()的原理基本相同:

  1. 註冊待偵聽的fd(這裡的fd建立時最好使用非阻塞)
  2. 每次呼叫都去檢查這些fd的狀態,當有一個或者多個fd就緒的時候返回
  3. 返回結果中包括已就緒和未就緒的fd

select

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);

  • nfds 指集合中所有檔案描述符的範圍,即所有檔案描述符的最大值+1

  • readfds、writefds、errorfds 指向檔案描述符集合的指標,分別檢測輸入、輸出是否就緒和異常情況是否發生

    readfds、writefds、errorfds所指結構體都是儲存結果的地方,在呼叫select()之前,這些引數指向的結構體必須初始化以包含我們所感興趣的檔案描述符集合。之後select()會修改這些結構體,當其返回時他們包含的就是處於就緒態的檔案描述符集合。

  • timeout 時select()的超時時間,控制著select()的阻塞行為

    當timeout設為NULL或者其指向的結構體欄位非零時,select()將阻塞到有下列事件發生

    1. readfds、writefds、errorfds 中指定的檔案描述符中至少有一個成為就緒態(NULL)
    2. 該呼叫被訊號處理程式中斷
    3. timeout中指定的時間上限已超時
  • select()的返回值

    當select()函式返回-1表示出錯,錯誤碼包括EBADF表示存在非法檔案描述符,EINTR表示該呼叫被訊號處理程式中斷了(select不會自動恢復)。返回0表示超時,此時每個檔案描述符集合都會被清空。返回一個正整數表示準備就緒的檔案描述符個數,如果同一個檔案描述符在返回的描述符集中出現多次,select會將其統計多次。

    一個檔案描述符是否阻塞並不影響select()是否阻塞,也就是說如果希望讀一個非阻塞檔案描述符,並且以5s為超時值呼叫select(),則select()最多阻塞5s。同理若是指定超時值為NULL,則在該描述符就緒或者捕捉到一個訊號之前select()會一直阻塞。

所有關於檔案描述符集合的操作都是透過以下四個宏完成,除此之外,常量FD_SETSIZE規定了檔案描述符的最大容量。

void FD_ZERO(fd_set *fdset); //將fdset所指集合初始化為空
void FD_SET(int fd, fd_set *fdset); //將檔案描述符fd新增到由fdset指向的集合中
void FD_CLR(int fd, fd_set *fdset); //將檔案描述符fd從fdset所指集合中移出
void FD_ISSET(int fd, fd_set *fdset); //檢測fd是否是fdset所指集合成員

select:效率低,效能不太好。不能解決大量併發請求的問題。

它把1000個fd加入到fd_set(檔案描述符集合),透過select監控fd_set裡的fd是否有變化。如果有一個fd滿足讀寫事件,就會依次檢視每個檔案描述符,那些發生變化的描述符在fd_set對應位設為1,表示socket可讀或者可寫。

Select透過輪詢的方式監聽,對監聽的FD數量 t透過FD_SETSIZE限制。

兩個問題:

1、select初始化時,要告訴核心,關注1000個fd, 每次初始化都需要重新關注1000個fd。前期準備階段長。
2、select返回之後,要掃描1000個fd。 後期掃描維護成本大,CPU開銷大。

poll


int poll(struct c *fds, nfds_t nfds, int timeout);

poll和select的任務很相似,主要區別在於我們如何指定待檢查的檔案描述符(程式介面不同)。poll不為每個條件構造一個描述符集合,而是構造了一個pollfd結構的陣列,每個陣列元素指定一個描述符編號以及我們對該描述符感興趣的條件。

struct pollfd {
  int fd; //檔案描述符
  short events; //等待的事件
  short revents; //實際發生了的事件
}
  • pollfd

    每個pollfd結構體指定了一個被監視的檔案描述符,可以傳遞多個結構體,指示poll()監視多個檔案描述符。每個結構體的events域是監視該檔案描述符的事件掩碼,由使用者來設定這個域的屬性。revents域是檔案描述符的操作結果事件掩碼,核心在呼叫返回時設定這個域,並且events中請求的任何事件都可能在revents中返回。

  • timeout

    引數timeout的設定與select()中有所不同(poll的timeout引數是一個整型而select是一個結構體)。

    1. 當timeout等於-1時,表示無限超時。poll會一直阻塞到fds陣列中列出的檔案描述符有一個達到就緒態(定義在對應的events欄位中)或者捕捉到一個訊號
    2. 當timeout等於0時,poll不會阻塞——只執行一次檢檢視看哪個檔案描述符已經就緒
    3. 當timeout大於0時,poll至多阻塞timeout毫秒數,無論IO是否準備好,poll都會返回
  • poll的返回值

    當poll()函式返回-1表示出錯,錯誤碼包括EBADF表示存在非法檔案描述符,EINTR表示該呼叫被訊號處理程式中斷了(poll不會自動恢復)。返回0表示超時。返回一個正整數表示準備就緒的檔案描述符個數,與select不同,poll返回的就是就緒檔案描述符的個數每個檔案描述符只統計一次。

select()和poll()的區別

  • Linux實現層面

    select()和poll()都使用了相同的核心輪詢(poll)程式集合,與系統呼叫poll()本身不同,核心的每個poll例程都返回有關單個檔案描述符就緒的資訊,這個資訊以位掩碼的形式返回,其值同poll()系統呼叫返回的revent欄位中的位元值相關。poll()系統呼叫的實現包括為每個檔案描述符呼叫核心poll例程,並將結果資訊填入到對應的revents欄位中。對於系統呼叫select()則可以使用一組宏將核心poll例程返回的資訊轉化為由select()返回的與之對應的事件集合。

    #define POLLIN_SET (POLLIN | POLLRDNORM | POLLRDBAND | POLLHUP | POLLERR) /*讀就緒*/
    #define POLLOUT_SET (POLLOUT | POLLWRNORM | POLLWRBAND | POLLERR) /*寫就緒*/
    #define POLLEX_SET (POLLPRI) /*異常*/
    

    以上宏定義展現了select()和poll()返回資訊間的語義關係,唯一一點不同是如果被檢查的檔案描述符中有一個關閉了,poll()在revent欄位中返回POLLNVAL,而select()返回-1並把錯誤碼置為EBADF。

  • API設計層面

    1. select()使用的資料型別fd_set對於被檢查的檔案描述數量有一個上限(FD_SETSIZE)。相對也較小(1024/2048),如果要修改這個預設值需要重新編譯核心。與之相反,poll()沒有對於被檢查檔案描述符的數量限制。
    2. 由於select()的引數fd_set同時也是儲存結果的地方,在select()返回之後會發生變化,所以每當在下一次進入select()之前需要重新初始化fd_set。poll()透過兩個獨立的欄位events和revents將監控的輸入輸出分開,允許被監控的檔案陣列被複用而不需要重新初始化。
    3. select()提供的超時精度(微妙)比poll()提供的超時精度(毫秒)高。
    4. select()的超時引數在返回時也是未定義的,考慮到可移植性,每次在超時之後在下一次進入到select()之前都需要重新設定超時引數。
    5. poll()不要求開發者計算最大檔案描述符時進行+1操作
  • 效能層面

    在待檢查檔案描述符範圍較小(最大檔案描述符較低),或者有大量檔案描述符待檢查,但是其分佈比較密集時poll()和select()效能相似。
    在被檢查檔案描述符集合很稀疏的情況,poll()要優於select()。

select()和poll()的不足

  1. IO效率隨著檔案描述符的數量增加而線性下降。每次呼叫select()或poll()核心都要檢查所有的被指定的檔案描述符的狀態(但是實際上只有部分的檔案描述符會是活躍的),當有檔案描述符集合增大時,IO的效率也隨之下降。
  2. 當檢查大量檔案描述符時,使用者空間和核心空間訊息傳遞速度較慢。每次呼叫select()或poll()時,程式都必須傳遞一個表示所有需要被檢查的檔案描述符的資料結構到核心,在核心完成檢查之後,修個這個資料結構並返回給程式。(此外select()每次呼叫之前還需要初始化該資料結構)對於poll()呼叫需要將使用者傳入的pollfd陣列複製到核心空間,這是一個O(n)的操作。當事件發生後,poll()將獲得的資料傳送到使用者空間,並執行釋放記憶體和剝離等待佇列等工作同樣是O(n)的操作。因此隨著檔案描述符的增加訊息傳遞速度會逐步下降。對於select()來說,傳遞的資料結構大小固定為FD_SETSIZE,與待檢查的檔案描述符數量無關。
  3. select()或poll()呼叫完成之後,程式必須檢查返回的資料結構中每個元素,已確定那個檔案描述符處於就緒態
  4. select()對一個程式開啟的檔案描述符數目有上限值,而且較少(1024/2048)。

epoll

中斷

image-20221130231045957

中斷 是為了解決外部裝置完成某些工作後通知CPU的一種機制(譬如硬碟完成讀寫操作後透過中斷告知CPU已經完成)。早期沒有中斷機制的計算機就不得不透過輪詢來查詢外部裝置的狀態,由於輪詢是試探查詢的(也就是說裝置不一定是就緒狀態),所以往往要做很多無用的查詢,從而導致效率非常低下。由於中斷是由外部裝置主動通知CPU的,所以不需要CPU進行輪詢去查詢,效率大大提升。

從物理學的角度看,中斷是一種電訊號,由硬體裝置產生,並直接送入中斷控制器(如 8259A)的輸入引腳上,然後再由中斷控制器向處理器傳送相應的訊號。處理器一經檢測到該訊號,便中斷自己當前正在處理的工作,轉而去處理中斷。此後,處理器會通知 OS 已經產生中斷。這樣,OS 就可以對這個中斷進行適當的處理。不同的裝置對應的中斷不同,而每個中斷都透過一個唯一的數字標識,這些值通常被稱為中斷請求線。

epoll 的實現原理

image-20221130233520155

epoll API是Linux專有的特性,相較於select和poll,epoll更加靈活且沒有描述符限制。epoll設計也與select和poll不同,主要包含以下三個介面:epoll_create、epoll_ctl、epoll_wait。

  • epoll_create

     int epoll_create(int size);
    

    引數size指定核心需要監聽的檔案描述符個數,但該引數與select中的maxfdp不同,並非一個上限(Linux 2.6.8以後該引數被忽略不用)。此外函式返回代表新建立的epoll控制程式碼的檔案描述符(在Linux下檢視/proc/程式的id/fd/可看到該fd的值),因此當不再使用該檔案描述符時應該透過close()關閉,當所用與epoll控制程式碼相關的檔案描述符都關閉時,該控制程式碼被銷燬並被系統回收其資源。

  • epoll_ctl

    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev)
    

    與select()的在監聽事件時告訴核心需要監聽的事件型別不同,epoll()需要先註冊要監聽的事件型別。引數op表示要執行的動作透過三個宏表示:

    1. EPOLL_CTL_ADD註冊新的fd到epfd中;

    2. EPOLL_CTL_MOD修改已經註冊的fd的監聽事件;

    3. EPOLL_CTL_DEL從epfd中刪除一個fd。引數fd表示需要監聽的fd。

    4. 最後一個引數ev指向結構體epoll_event則是告訴核心需要監聽的事件型別,定義如下:

      struct epoll_event {
        uint32_t events; //epoll events (bit mask)
        epoll_data_t data; //user data variable
      }
      

      其中data的型別為:

      typedef union epoll_data {
        void    *ptr; //pointer to user defined data
        int     fd; //file descriptor
        uint_32 u32; //32-bit integer
        uint_64 u64; //64-bit integer
      } epoll_data_t;
      

      其中欄位event表示事件掩碼指定待監聽的檔案描述符fd上所感興趣的事件集合,除了增加了一個字首E外,這些掩碼的名稱與poll中對應名稱相同(兩個例外EPOLLET表示設定為邊緣觸發、EPOLLONESHOT表示只監聽一次)。data欄位是一個聯合體,當描述符fd就緒後,聯合體成員可以用來指定傳回給呼叫程式的資訊。data欄位是唯一可以獲知同這個事件相關的檔案描述符的途徑,因此呼叫epoll_ctl()將檔案描述符新增到興趣列表中時,應該要麼將ev.data.fd設為檔案描述符,要麼將ev.data.ptr設為指向包含該檔案描述的結構體。

  • epoll_wait

    int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
    

    等待事件的產生,引數evlist所指向的結構體陣列中返回就需檔案描述的資訊,陣列evlist的空間由呼叫者負責申請,所包含的元素個數由引數maxevents指定。

原理

在linux,一切皆檔案.所以當呼叫epoll_create時,核心給這個epoll分配一個檔案描述符,但是這個不是普通的檔案,而是隻服務於epoll.

所以當核心初始化epoll時,會開闢一塊核心高速cache區,用於安置我們監聽的socket,這些socket會以紅黑樹的形式儲存在核心的cache裡,以支援快速的查詢,插入,刪除.同時,建立了一個list連結串列,用於儲存準備就緒的事件.所以呼叫epoll_wait時,在timeout時間內,只是簡單的觀察這個list連結串列是否有資料,如果沒有,則睡眠至超時時間到返回;如果有資料,則在超時時間到,複製至使用者態events陣列中.

那麼,這個準備就緒list連結串列是怎麼維護的呢?
當我們執行epoll_ctl()時,除了把socket放到epoll檔案系統裡file物件對應的紅黑樹上之外,還會給核心中斷處理程式註冊一個回撥函式,告訴核心,如果這個控制程式碼的中斷到了,就把它放到準備就緒list連結串列裡。所以,當一個socket上有資料到了,核心在把網路卡上的資料copy到核心中後就來把socket插入到準備就緒連結串列裡了。

epoll支援兩種模式LT(水平觸發)和ET(邊緣觸發),LT模式下,主要緩衝區資料一次沒有處理完,那麼下次epoll_wait返回時,還會返回這個控制程式碼;而ET模式下,緩衝區資料一次沒處理結束,那麼下次是不會再通知了,只在第一次返回.所以在ET模式下,一般是透過while迴圈,一次性讀完全部資料.epoll預設使用的是LT.

這件事怎麼做到的呢?當一個socket控制程式碼上有事件時,核心會把該控制程式碼插入上面所說的準備就緒list連結串列,這時我們呼叫epoll_wait,會把準備就緒的socket複製到使用者態記憶體,然後清空準備就緒list連結串列,最後,epoll_wait幹了件事,就是檢查這些socket,如果不是ET模式(就是LT模式的控制程式碼了),並且這些socket上確實有未處理的事件時,又把該控制程式碼放回到剛剛清空的準備就緒連結串列了。所以,非ET的控制程式碼,只要它上面還有事件,epoll_wait每次都會返回。而ET模式的控制程式碼,除非有新中斷到,即使socket上的事件沒有處理完,也是不會次次從epoll_wait返回的.

經常看到比較ET和LT模式到底哪個效率高的問題.有一個回答是說ET模式下減少epoll系統呼叫.這話沒錯,也可以理解,但是在ET模式下,為了避免資料餓死問題,使用者態必須用一個迴圈,將所有的資料一次性處理結束.所以在ET模式下下,雖然epoll系統呼叫減少了,但是使用者態的邏輯複雜了,write/read呼叫增多了.所以這不好判斷,要看使用者的效能瓶頸在哪.

epoll 的設計特點

  • 功能分離

    socket低效的原因之一便是將“維護等待佇列”和“阻塞程式”兩個功能不加分離,每次呼叫 select 都需要這兩步操作,然而大多數應用場景中,需要監視的 socket 相對固定,並不需要每次都修改。epoll 將這兩個操作分開,先用epoll_ctl()維護等待佇列,再呼叫 epoll_wait 阻塞程式。顯而易見地,效率就能得到提升。

    而epoll則是實現了功能分離,透過epoll_create()建立一個 epoll 物件 epfd,再透過epoll_ctl()將需要監視的 socket 新增到 epfd 中,最後呼叫 epoll_wait() 等待資料使得epoll有了最佳化的可能。

  • 就緒列表

    select 低效的另一個原因在於程式不知道哪些 socket 收到資料,只能一個個遍歷。如果核心維護一個“就緒列表”,引用收到資料的 socket,就能避免遍歷。如下圖所示,計算機共有三個 socket,收到資料的 sock2 和 sock3 被就緒列表 rdlist 所引用。當程式被喚醒後,只要獲取 rdlist 的內容,就能夠知道哪些 socket 收到資料。

epoll 的優點

  1. 沒有最大開啟檔案描述符限制
    epoll支援的最大開啟檔案數與系統記憶體相關,可通
    過cat /proc/sys/fs/file-max檢視具體數目
  2. IO效率不隨檔案描述符數目增加而線性下降
    傳統的select/poll在擁有較大的一個socket集合時,不過由於網路延遲,任意時間只有部分socket是活躍的,但是select/poll每次呼叫都會線性掃描全部的集合,導致效率呈線性下降。而epoll透過在核心中實現的根據每個檔案描述符上的回撥函式callback函式實現了每次只對“活躍的”的socket進行操作,從而使epoll實現了一個偽AIO,使其效率不會隨檔案描述符的增加而先行下降。
  3. 使用mmap加速核心與使用者空間的訊息傳遞
    select、poll和epoll都需要核心把fd訊息通知給使用者空間,但是epoll採用了核心與使用者空間mmap處於同一塊記憶體來實現,具有較高的效率。

總結

image-20221201000710351

Tips

image-20221130235816692

這張圖就代表了傳統 IO 傳輸檔案的流程。讀取檔案的時候,會從使用者態切換為核心態,同時基於 DMA 引擎將磁碟檔案複製到核心緩衝區。

DMA(DirectMemoryAccess,直接記憶體存取)其實就是因為 CPU 老哥太累了,所以找了個小弟,就是 DMA 替他完成一部分的複製工作,這樣 CPU 就能去做其他事情了。

第一步我們將檔案從磁碟檔案讀到了使用者緩衝區,此時經歷了一次上下文切換和一次複製。

由核心態切換為使用者態,基於 CPU 把核心緩衝區的資料複製到使用者緩衝區。

呼叫 socket 的輸出流的 write 方法的話,此時會從使用者態切換到核心態,同時基於 CPU 把使用者緩衝區裡的資料複製到 Socket 緩衝區裡去,接著會有一個非同步化的過程,基於 DMA 引擎從 Socket 緩衝區裡把資料複製到網路協議引擎裡傳送出去。

當 IO 操作完成之後,又從核心態切換為使用者態。透過上面的步驟可以發現傳統的 IO 操作執行,有 4 次上下文的切換和 4 次複製,是不是很繁瑣。零複製的話,一般有 mmap 和 sendFile 兩種,一個一個來說。

mmap

image-20221130235735747

mmap 是一種記憶體對映技術,mmap 相比於傳統的 IO 來說,其實就是少了 1 次 CPU 複製而已。

傳統 IO 裡面從核心緩衝區到使用者緩衝區有一次 CPU 複製,從使用者緩衝區到 Socket 緩衝區又有一次 CPU 複製。mmap 則一步到位,直接基於 CPU 將核心緩衝區的資料複製到了 Socket 緩衝區。

之所以能夠減少一次複製,就是因為 mmap 直接將磁碟檔案資料對映到核心緩衝區,這個對映的過程是基於 DMA 複製的,同時使用者緩衝區是跟核心緩衝區共享一塊對映資料的,建立共享對映之後,就不需要從核心緩衝區複製到使用者緩衝區了。

雖然減少了一次複製,但是上下文切換的次數還是沒變。

RocketMQ 中就是使用的 mmap 來提升磁碟檔案的讀寫效能。

sendFile

image-20221130235839392

在 Linux 中,提供 sendFile 函式,實現了零複製。

可以看到在圖中,已經沒有了使用者緩衝區,因為使用者緩衝區是在使用者空間的,所以沒有了使用者緩衝區也就意味著不需要上下文切換了,就省略了這一步的從核心態切換為使用者態。

同時也不需要基於 CPU 將核心緩衝區的資料複製到 Socket 緩衝區了,只需要從核心緩衝區複製一些 offset 和 length 到 Socket 緩衝區。接著從核心態切換到使用者態,從核心緩衝區直接把資料複製到網路協議引擎裡去;同時從 Socket 緩衝區裡複製一些 offset 和 length 到網路協議引擎裡去,但是這個 offset 和 length 的量很少,幾乎可以忽略。

sendFile 整個過程只有兩次上下文切換和兩次 DMA 複製,很重要的一點是這裡完全不需要 CPU 來進行複製了,所以才叫做零複製,這裡的複製指的就是作業系統的層面。

那你肯定會問,那 mmap 裡面有一次 CPU 複製為啥也算零複製,只能說那不算是嚴格意義上的零複製,但是他確實是最佳化了普通 IO 的執行流程,就像老婆餅裡也沒有老婆嘛。Kafka 和 Tomcat 內部使用就是 sendFile 這種零複製。

總結

傳統 IO 執行的話需要 4 次上下文切換(使用者態 -> 核心態 -> 使用者態 -> 核心態 -> 使用者態)和 4 次複製(磁碟檔案 DMA 複製到核心緩衝區,核心緩衝區 CPU 複製到使用者緩衝區,使用者緩衝區 CPU 複製到 Socket 緩衝區,Socket 緩衝區 DMA 複製到協議引擎)。

mmap 將磁碟檔案對映到記憶體,支援讀和寫,對記憶體的操作會反映在磁碟檔案上,適合小資料量讀寫,需要 4 次上下文切換(使用者態 -> 核心態 -> 使用者態 -> 核心態 -> 使用者態)和 3 次複製(磁碟檔案 DMA 複製到核心緩衝區,核心緩衝區 CPU 複製到 Socket 緩衝區,Socket 緩衝區 DMA 複製到協議引擎)。sendfile 是將讀到核心空間的資料,轉到 socket buffer,進行網路傳送,適合大檔案傳輸,只需要 2 次上下文切換(使用者態 -> 核心態 -> 使用者態)和 2 次複製(磁碟檔案 DMA 複製到核心緩衝區,核心緩衝區 DMA 複製到協議引擎)。

相關文章