【網絡編程基礎】I/O 多路複用(select,poll,epoll)

網絡編程基礎

I/O 多路複用

I/O 多路複用(multiplexing)的本質是通過一種機制(系統內核緩衝 I/O 數據),讓單個進程可以監視多個文件描述符,一旦某個描述符就緒(一般是讀就緒或寫就緒),能夠通知程序進行相應的讀寫操作。

爲了解決單個應用進程能同時處理多個網絡連接的問題,通常採用 select、poll、epoll 作爲解決方案。它們的區別主要體現在以下三個方面:

  1. 系統如何知道進程需要監控哪些連接和事件(也就是fd)。
  2. 系統知道進程需要監控的連接和事件後,採用什麼方式去對fd進行狀態的監控。
  3. 系統監控到活躍事件後如何通知進程。

select

應用進程通過 select 去監控多個連接(也就是fd)的機制大概如下:

  1. 在調用 select 之前告訴 select 應用進程需要監控哪些 fd 可讀、可寫、異常事件,並保存在 fd_set 中。
  2. 應用進程調用 select 的時候把上述 3 個事件類型的 fd_set 傳給內核(產生了一次 fd_set 在用戶空間到內核空間的複製),內核收到 fd_set 後對 fd_set 進行遍歷,然後一個個去掃描對應 fd 是否滿足可讀寫事件。
  3. 如果發現了有對應的 fd 存在讀寫事件後,內核會把 fd_set 裏沒有事件狀態的 fd 句柄清除,然後把有事件的 fd 返回給應用進程(這裏又會把更新後的 fd_set 從內核空間複製用戶空間)。
  4. 最後應用進程收到了select返回的活躍事件類型的fd句柄後,再向對應的 fd 發起數據讀取或者寫入數據操作。

select 提供一種可以用一個進程監控多個網絡連接的方式,但也還遺留了一些問題,這些問題也是後來 select 面對高併發環境的性能瓶頸

  1. 每調用一次 select 就需要 3 個事件類型的 fd_set 需從用戶空間拷貝到內核空間去,返回時 select 也會把保留了活躍事件的 fd_set 返回(從內核拷貝到用戶空間)。當 fd_set 數據大的時候,這個過程消耗是很大的。
  2. select 需要逐個遍歷 fd_set 集合 ,然後去檢查對應 fd 的可讀寫狀態,如果 fd_set 數據量多,那麼遍歷 fd_set 就是一個比較耗時的過程。
  3. fd_set 是個集合類型,它的數據結構有長度限制,32位系統長度1024,62位系統長度2048,這個就限制了select 最多能同時監控 1024 個連接。

poll

隨着網絡的高速發展,高併發的網絡請求程序越來越多,吸取了 select 的教訓,poll 模式不再使用數組的方式來保存自己監控的 fd 信息了。poll 模型使用鏈表的形式來保存自己監控的fd信息,從而沒有了連接限制,可以支持高併發的請求。

select 調用返回的 fd_set 只包含了上次返回的活躍事件的 fd_set 集合,下一次調用 select 又需要把這幾個 fd_set 清空,重新添加上自己感興趣的 fd 和事件類型。poll 需要監控的 fd 信息採用的是 pollfd 的文件格式,它保存着對應 fd 需要監控的事件集合,也保存了一個返回於激活事件的 fd 集合,所以重新發請求時不需要重置感興趣的事件類型參數。

因此 poll 通過改變存儲方式,只解決了連接限制的問題,其他方面與 select 沒有太大差別。

epoll

不同於 select 和 poll 的直接調用方式,epoll 採用的是一組方法調用的方式,它的工作流程大致如下:

  1. 創建內核事件表(epoll_create)。這裏主要是向內核申請創建一個 fd 的文件描述符作爲內核事件表(B+樹結構的文件,沒有數量限制),這個描述符用來保存應用進程需要監控哪些 fd 和對應類型的事件。
  2. 添加或移出監控的 fd 和事件類型(epoll_ctl)。調用此方法可以是向內核的內核事件表動態地添加和移出 fd 和對應事件類型。
  3. epoll_wait 綁定回調事件。內核向事件表的 fd 綁定一個回調函數。當監控的 fd 活躍時,會調用 callback 函數把事件加到一個活躍事件隊列裏;最後在 epoll_wait 返回的時候內核會把活躍事件隊列裏的 fd 和事件類型返回給應用進程。

從epoll整體思路上來看,採用事先就在內核創建一個事件監聽表,後面只需要往裏面添加移出對應事件。因爲本身事件表就在內核空間,所以就避免了像 select、poll 一樣每次都要把自己需要監聽的事件列表來回傳輸,這也就避免了事件信息需要在用戶空間和內核空間相互拷貝的問題。

然後 epoll 並不是像 select 一樣去遍歷事件列表,逐個輪詢地監控 fd 的事件狀態,而是事先就建立了 fd 與之對應的回調函數,當事件激活後主動回調 callback 函數,這也就避免了遍歷事件列表的這個操作,所以 epoll 並不會像 select 和 poll 一樣隨着監控的 fd 變多而效率降低,這種事件機制也是 epoll 要比 select 和 poll 高效的主要原因。

水平觸發 (LT 模式)

默認工作模式,即當 epoll_wait 檢測到某描述符事件就緒並通知應用程序時,應用程序可以不立即處理該事件;下次調用 epoll_wait 時,會再次通知此事件。

邊沿觸發(ET 模式)

當 epoll_wait 檢測到某描述符事件就緒並通知應用程序時,應用程序必須立即處理該事件。如果不處理,下次調用 epoll_wait 時,不會再次通知此事件,直到做了某些操作導致該描述符變成未就緒狀態了,也就是說邊緣觸發只在狀態由未就緒變爲就緒時通知一次。

ET 模式很大程度上減少了 epoll 事件的觸發次數,因此效率比 LT 模式下高。

epoll 工作在 ET 模式的時候,必須使用非阻塞套接口,以避免由於一個文件句柄的阻塞讀作者阻塞寫操作把處理多個文件描述符的任務餓死。

在 socket 中的表現

Linux 中基於 socket 的通信本質也是一種 I/O,使用 socket() 函數創建的套接字默認都是阻塞的,這意味着當 sockets API 的調用不能立即完成時,線程一直處於等待狀態,直到操作完成獲得結果或者超時出錯。會引起阻塞的 socket API 分爲以下四種:

  • 輸入操作: recv()、recvfrom()。以阻塞套接字爲參數調用該函數接收數據時,如果套接字緩衝區內沒有數據可讀,則調用線程在數據到來前一直睡眠。
  • 輸出操作: send()、sendto()。以阻塞套接字爲參數調用該函數發送數據時,如果套接字緩衝區沒有可用空間,線程會一直睡眠,直到有空間。
  • 接受連接:accept()。以阻塞套接字爲參數調用該函數,等待接受對方的連接請求。如果此時沒有連接請求,線程就會進入睡眠狀態。
  • 外出連接:connect()。對於TCP連接,客戶端以阻塞套接字爲參數,調用該函數向服務器發起連接。該函數在收到服務器的應答前,不會返回。這意味着TCP連接總會等待至少服務器的一次往返時間。

select的設計思想很直接,假設預先傳入一個socket列表,如果列表中的 socket 都沒有數據,掛起進程,直到有一個 socket 收到數據,喚醒進程。通常會準備一個數組來存放所有需要監視的 socket。然後調用select,如果所有的 socket 都沒有數據,那麼 select 會阻塞,當任何一個socket收到數據後,中斷程序將喚起進程,將進程從所有的等待隊列中移除,加入到工作隊列裏面。用戶此時再通過遍歷數組,通過 FD_ISSET 判斷具體哪個 socket 收到數據,然後做出處理。

select 這樣的處理方式通常有兩個缺點:

  1. 每次調用 select 都需要將進程加入到所有監視 socket 的等待隊列,每次喚醒都需要從每個隊列中移除。這裏涉及了兩次遍歷,而且每次都要將整個 fd 列表傳遞給內核,有一定的開銷。正是因爲遍歷操作開銷大,出於效率的考量,纔會規定select的最大監視數量,默認只能監視1024個socket。
  2. 進程被喚醒後,程序並不知道哪些 socket 收到數據,還需要遍歷一次。

epoll 的實際思路則是:

  1. 功能分離

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

  2. 就緒列表

    select 低效的另一個原因在於程序不知道哪些 socket 收到數據,只能一個個遍歷。如果內核維護一個“就緒列表”,引用收到數據的 socket,就能避免遍歷。當進程被喚醒後,只要獲取 rdlist 的內容,就能夠知道哪些 socket 收到數據。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章