基本概念
在學習 linux IO 模型 以前,我們先看一組概念,便於大家更好的理解。
linux IO 模型linux系統IO分為核心準備資料和將資料從核心複製到使用者空間兩個階段。
輸入圖片說明
使用者空間與核心空間作業系統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的所有許可權。
為了保證使用者程序不能直接操作核心(kernel),保證核心的安全,作業系統將虛擬空間劃分為兩部分,一部分為核心空間,一部分為使用者空間。
程序切換為了控制程序的執行,核心必須有能力掛起正在CPU上執行的程序,並恢復以前掛起的某個程序的執行。這種行為被稱為程序切換。
因此可以說,任何程序都是在作業系統核心的支援下執行的,是與核心緊密相關的。
從一個程序的執行轉到另一個程序上執行,這個過程中經過下面這些變化:
儲存處理機上下文,包括程式計數器和其他暫存器。更新PCB資訊。把程序的PCB移入相應的佇列,如就緒、在某事件阻塞等佇列。 選擇另一個程序執行,並更新其PCB。更新記憶體管理的資料結構。恢復處理機上下文。程序的阻塞正在執行的程序,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新資料尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由執行狀態變為阻塞狀態。
可見,程序的阻塞是程序自身的一種主動行為,也因此只有處於執行態的程序(獲得CPU),才可能將其轉為阻塞狀態。當程序進入阻塞狀態,是不佔用CPU資源的。
檔案描述符檔案描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向檔案的引用的抽象化概念。
檔案描述符在形式上是一個非負整數。
實際上,它是一個索引值,指向核心為每一個程序所維護的該程序開啟檔案的記錄表。
當程式開啟一個現有檔案或者建立一個新檔案時,核心向程序返回一個檔案描述符。
在程式設計中,一些涉及底層的程式編寫往往會圍繞著檔案描述符展開。但是檔案描述符這一概念往往只適用於UNIX、Linux這樣的作業系統。
Linux網路I/O模型簡介Linux的核心將所有外部裝置都看做一個檔案來操作,對一個檔案的讀寫操作會呼叫核心提供的系統命令, 返回一個filedescriptor (fd, 檔案描述符)。
而對一個socket的讀寫也會有相應的描述符,稱為socketfd(socket描述符), 描述符就是一個數字, 它指向核心中的一個結構體(檔案路徑,資料區等一些屬性)。
根據UNIX網路程式設計對I/O模型的分類,UNIX提供了5種I/O模型, 分別如下。
阻塞I/O模型:最常用的I/O模型就是阻塞I/O模型,預設情形下,所有檔案操作都是阻塞的。
我們以套接字介面為例來講解此模型:在程序空間中呼叫recvfrom, 其系統呼叫直到資料包到達且被複制到應用程序的緩衝區中或者發生錯誤時才返回,在此期間一直會等待, 程序在從呼叫recvfrom開始到它返回的整段時間內都是被阻塞的,因此被稱為阻塞I/O模型,如圖1-1所示。
輸入圖片說明
非阻塞I/O模型recv from從應用層到核心的時候,如果該緩衝區沒有資料的話,就直接返回一個EWOULDBLOCK錯誤,一般都對非阻塞I/O模型進行輪詢檢查這個狀態,看核心是不是有資料到來,如圖1-2所示。
輸入圖片說明
I/O複用模型Linux提供select/poll, 程序透過將一個或多個fd傳遞給select或poll系統呼叫, 阻塞在select操作上, 這樣select/poll可以幫我門偵測多個fd是否處於就緒狀態。
select/poll是順序掃描fd是否就緒,而且支援的fd數量有限,因此它的使用受到了一些制約。
Linux還提供了一個epoll系統呼叫,epoll使用基於事件驅動方式代替順序掃描,因此效能更高。
當有fd就緒時, 立即回撥函式rollback,如圖1-3所示。
輸入圖片說明
訊號驅動I/O模型首先開啟套介面訊號驅動I/O功能,並透過系統呼叫sigaction執行一個訊號處理函式(此係統呼叫立即返回,程序繼續工作,它是非阻塞的)。
當資料準備就緒時, 就為該程序生成一個SIGIO訊號, 透過訊號回撥通知應用程式呼叫recvfrom來讀取資料,並通知主迴圈函式處理資料,如圖1-4所示。
輸入圖片說明
非同步I/O告知核心啟動某個操作,並讓核心在整個操作完成後(包括將資料從核心複製到使用者自己的緩衝區)通知我們。這種模型與訊號驅動模型的主要區別是:訊號驅動I/O由核心通知我們何時可以開始一個I/O操作;非同步I/O模型由核心通知我們I/O操作何時已經完成,如圖1-5所示。
輸入圖片說明
對於大多數Java程式設計師來說,不需要了解網路程式設計的底層細節,大家只需要有個概念,知道對於作業系統而言,底層是支援非同步I/O通訊的。
只不過在很長一段時間Java並沒有提供非同步I/O通訊的類庫,導致很多原生的Java程式設計師對這塊兒比較陌生。
當你瞭解了網路程式設計的基礎知識後,理解Java的NIO類庫就會更加容易一些。
下面我們重點講下I/O多路複用技術, 因為Java NIO的核心類庫多路複用器Selector就是基於epoll的多路複用技術實現。
I/O多路複用技術在I/O程式設計過程中,當需要同時處理多個客戶端接入請求時,可以利用多執行緒或者I/O多路複用技術進行處理。
I/O多路複用技術透過把多個I/O的阻塞複用到同一個select的阻塞上,從而使得系統在單執行緒的情況下可以同時處理多個客戶端請求。
與傳統的多執行緒/多程序模型比,I/O多路複用的最大優勢是系統開銷小,系統不需要建立新的額外程序或者執行緒,也不需要維護這些程序和執行緒的執行,降低了系統的維護工作量,節省了系統資源。
應用場景I/O多路複用的主要應用場景如下。
伺服器需要同時處理多個處於監聽狀態或者多個連線狀態的套接字;伺服器需要同時處理多種網路協議的套接字。ps: 也就是我們常說的高併發場景。
目前支援I/O多路複用的系統呼叫有select、pselect、poll、epoll, 在Linux網路程式設計過程中, 很長一段時間都使用select做輪詢和網路事件通知, 然而select的一些固有缺陷導致了它的應用受到了很大的限制,最終Linux不得不在新的核心版本中尋找select的替代方案, 最終選擇了epoll。
核心接收網路資料全過程為了理解 epoll 究竟比 select 優秀在哪裡,我們首先要理解網路資料的接收過程。
此處的中斷程式主要有兩項功能,先將網路資料寫入到對應 socket 的接收緩衝區裡面(步驟④),再喚醒程序 A(步驟⑤),重新將程序 A 放入工作佇列中。
核心接收資料全過程核心接收資料全過程
記憶體喚醒喚醒程序
以上是核心接收資料全過程
思考的問題這裡留有兩個思考題,大家先想一想。
(1)作業系統如何知道網路資料對應於哪個socket?
因為一個socket對應著一個埠號,而網路資料包中包含了ip和埠的資訊,核心可以透過埠號找到對應的socket。
當然,為了提高處理速度,作業系統會維護埠號到socket的索引結構,以快速讀取。
(2)如何同時監視多個socket的資料?
這個問題就是本文的重點。
如何同時監視多個socket的資料?服務端需要管理多個客戶端連線,而 recv 只能監視單個 socket,這種矛盾下,人們開始尋找監視多個 socket 的方法。
先理解不太高效的 select,才能夠更好地理解 epoll 的本質。
select 的流程select 的實現思路很直接,假如程式同時監視如下圖的 sock1、sock2 和 sock3 三個 socket,那麼在呼叫 select 之後,作業系統把程序 A 分別加入這三個 socket 的等待佇列中。
作業系統把程序 A 分別加入這三個 socket 的等待佇列中select 的流程
當任何一個 socket 收到資料後,中斷程式將喚起程序。
下圖展示了 sock2 接收到了資料的處理流程:
注:recv 和 select 的中斷回撥可以設定成不同的內容。
sock2接收到了資料,中斷程式喚起程序A中斷程式喚起程序A
所謂喚起程序,就是將程序從所有的等待佇列中移除,加入到工作佇列裡面。
如下圖所示。
將程序A從所有等待佇列中移除,再加入到工作佇列裡面移除
經由這些步驟,當程序 A 被喚醒後,它知道至少有一個 socket 接收了資料。
優點程式只需遍歷一遍 socket 列表,就可以得到就緒的 socket。
這種簡單方式行之有效,在幾乎所有作業系統都有對應的實現。
缺點但是簡單的方法往往有缺點,主要是:
其一,每次呼叫 select 都需要將程序加入到所有監視 socket 的等待佇列,每次喚醒都需要從每個佇列中移除。這裡涉及了兩次遍歷,而且每次都要將整個 fds 列表傳遞給核心,有一定的開銷。正是因為遍歷操作開銷大,出於效率的考量,才會規定 select 的最大監視數量,預設只能監視 1024 個 socket。
其二,程序被喚醒後,程式並不知道哪些 socket 收到資料,還需要遍歷一次。
待改進之處那麼,有沒有減少遍歷的方法?
有沒有儲存就緒 socket 的方法?
這兩個問題便是 epoll 技術要解決的。
epoll 的設計思路epoll 是在 select 出現 N 多年後才被髮明的,是 select 和 poll(poll 和 select 基本一樣,有少量改進)的增強版本。
epoll 透過以下一些措施來改進效率:
措施一:功能分離select 低效的原因之一是將“維護等待佇列”和“阻塞程序”兩個步驟合二為一。
如下圖所示,每次呼叫 select 都需要這兩步操作,然而大多數應用場景中,需要監視的 socket 相對固定,並不需要每次都修改。
epoll 將這兩個操作分開,先用 epoll_ctl 維護等待佇列,再呼叫 epoll_wait 阻塞程序。
顯而易見地,效率就能得到提升。
相比select,epoll拆分了功能
措施二:就緒列表select 低效的另一個原因在於程式不知道哪些 socket 收到資料,只能一個個遍歷。
如果核心維護一個“就緒列表”,引用收到資料的 socket,就能避免遍歷。
如下圖所示,計算機共有三個 socket,收到資料的 sock2 和 sock3 被就緒列表 rdlist 所引用。
當程序被喚醒後,只要獲取 rdlist 的內容,就能夠知道哪些 socket 收到資料。
就緒列表示意圖
epoll 的原理與工作流程建立 epoll 物件如下圖所示,當某個程序呼叫 epoll_create 方法時,核心會建立一個 eventpoll 物件(也就是程式中 epfd 所代表的物件)。
eventpoll 物件也是檔案系統中的一員,和 socket 一樣,它也會有等待佇列。
核心建立 eventpoll 物件
建立一個代表該 epoll 的 eventpoll 物件是必須的,因為核心要維護“就緒列表”等資料,“就緒列表”可以作為 eventpoll 的成員。
以新增 socket 為例,如下圖,如果透過 epoll_ctl 新增 sock1、sock2 和 sock3 的監視,核心會將 eventpoll 新增到這三個 socket 的等待佇列中。
新增所要監聽的 socket
當 socket 收到資料後,中斷程式會操作 eventpoll 物件,而不是直接操作程序。
接收資料當socket收到資料後,中斷程式會給eventpoll的“就緒列表”新增socket引用。
如下圖展示的是sock2和sock3收到資料後,中斷程式讓rdlist引用這兩個socket。
給就緒列表新增引用
eventpoll物件相當於是socket和程序之間的中介,socket的資料接收並不直接影響程序,而是透過改變eventpoll的就緒列表來改變程序狀態。
當程式執行到epoll_wait時,如果rdlist已經引用了socket,那麼epoll_wait直接返回,如果rdlist為空,阻塞程序。
阻塞和喚醒程序假設計算機中正在執行程序A和程序B,在某時刻程序A執行到了epoll_wait語句。
如下圖所示,核心會將程序A放入eventpoll的等待佇列中,阻塞程序。
epoll_wait阻塞程序
當socket接收到資料,中斷程式一方面修改rdlist,另一方面喚醒eventpoll等待佇列中的程序,程序A再次進入執行狀態(如下圖)。
也因為rdlist的存在,程序A可以知道哪些socket發生了變化。
epoll喚醒程序
小結本文簡單介紹了 linux 最常見的 5 種 IO 模型,並對最核心的多路複用模型進行了展開講解,下一節我們將展示 java 實現 BIO/NIO/AIO 等不同的網路IO模型。
我是老馬,期待與你的下次相遇。