IO模型 Select、Poll、Epoll,你知道哪個?
什么是IO?
IO中的I就是input,O就是output,IO模型即輸入輸出模型,而比較常聽說的便是磁盤IO,網絡IO。
什么是操作系統的IO?
我們如果需要對磁盤進行讀取或者寫入數據的時候必須得有主體去操作,這個主體就是應用程序。應用程序是不能直接進行一些讀寫操作(IO)的,因為用戶可能會利用此程序直接或者間接的對計算機造成破壞,只能交給底層軟件—操作系統.也就是說應用程序想要對磁盤進行讀取或者寫入數據,只能通過操作系統對上層開放的API來進行。在任何一個應用程序里面,都會有進程地址空間,該空間分為兩部分,一部分稱為用戶空間(允許應用程序進行訪問的空間),另一部分稱為內核空間(只能給操作系統進行訪問的空間,它受到保護)。
應用程序想要進行一次IO操作分為兩個階段:
?IO調用:應用程序進程向操作系統內核發起調用【1】。
?IO執行:操作系統內核完成IO操作【2】。
操作系統完成一次IO操作包括兩個過程:
?數據準備階段:內核等待I/O設備準備好數據(從網卡copy到內核緩沖區【3】。
?數據copy階段:將數據從內核緩沖區copy到用戶進程緩沖區【4】。
應用程序一次I/O流程如下:
圖片
一個完整的IO過程包括以下幾個步驟:
1.應用程序進程向操作系統發起IO調用請求。
2.操作系統準備數據,外部設備的數據通過網卡加載到內核緩沖區。
3.操作系統拷貝數據,即將內核緩沖區的數據copy到用戶進程緩沖區。
而一次IO的本質其實就是: 等待 + 拷貝
IO模型有哪些?
1.阻塞式 IO:
服務端為了處理客戶端的連接和數據處理:
偽代碼具體如下:
listenfd = socket(); // 打開一個網絡通信套接字
bind(listenfd); // 綁定
listen(listenfd); // 監聽
while(true) {
buf = new buf[1024]; // 讀取數據容器
connfd = accept(listenfd); // 阻塞 等待建立連接
int n = read(connfd, buf); // 阻塞 讀數據
doSomeThing(buf); // 處理數據
close(connfd); // 關閉連接
}
上面的偽代碼中我們可以看出,服務端處理客戶端的請求阻塞在兩個地方,一個是 accept、一個是 read ,我們這里主要研究 read 的過程,可以分為兩個階段:等待讀就緒(等待數據到達網卡 & 將網卡的數據拷貝到內核緩沖區)、讀數據。
阻塞IO流程如下:
圖片
2.非阻塞式 IO:
非阻塞式 IO 我們應該讓操作系統提供一個非阻塞的 read() 函數,當第一階段讀未就緒時返回 -1 ,當讀已就緒時才進行數據的讀取。
非阻塞IO往往需要程序員循環的方式反復嘗試讀寫文件描述符, 這個過程稱為輪詢(for(connfd : arr)). 這對CPU來說是較大的浪費, 一 般只有特定場景下才使用.
偽代碼具體如下:
arr = new Arr[];
listenfd = socket(); // 打開一個網絡通信套接字
bind(listenfd); // 綁定
listen(listenfd); // 監聽
while(true) {
connfd = accept(listenfd); // 阻塞 等待建立連接
arr.add(connfd);
}
// 異步線程檢測 連接是否可讀
new Tread(){
for(connfd : arr){
buf = new buf[1024]; // 讀取數據容器
// 非阻塞 read 最重要的是提供了我們在一個線程內管理多個文件描述符的能力
int n = read(connfd, buf); // 檢測 connfd 是否可讀
if(n != -1){
newThreadDeal(buf); // 創建新線程處理
close(connfd); // 關閉連接
arr.remove(connfd); // 移除已處理的連接
}
}
}
newTheadDeal(buf){
doSomeThing(buf); // 處理數據
}
所謂非阻塞 IO 只是將第一階段的等待讀就緒改為非阻塞,但是第二階段的數據讀取還是阻塞的,非阻塞 read 最重要的是提供了我們在一個線程內管理多個文件描述符的能力
非阻塞具體流程如下:
圖片
3. IO多路復用(select、poll、epoll):
上面的實現看著很不錯,但是卻存在一個很大的問題,我們需要不斷的調用 read() 進行系統調用,這里的系統調用我們可以理解為分布式系統的 RPC 調用,性能損耗十分嚴重,因為這依然是用戶層的一些小把戲。
多路復用就是系統提供了一種函數可以同時監控多個文件描述符的操作,這個函數就是我們常說到的select、poll、epoll函數,可以通過它們同時監控多個文件描述符,只要有任何一個數據狀態準備就緒了,就返回可讀狀態,這時詢問線程再去通知處理數據的線程,對應線程此時再發起read()請求去讀取數據。實際上最核心之處在于IO多路轉接能夠同時等待多個文件描述符的就緒狀態,來達到不必為每個文件描述符創建一個對應的監控線程,從而減少線程資源創建的目的。
select:
select 是操作系統提供的系統函數,通過它我們可以將文件描述符發送給系統,讓系統內核幫我們遍歷檢測是否可讀,并告訴我們進行讀取數據。
偽代碼如下:
arr = new Arr[];
listenfd = socket(); // 打開一個網絡通信套接字
bind(listenfd); // 綁定
listen(listenfd); // 監聽
while(true) {
connfd = accept(listenfd); // 阻塞 等待建立連接
arr.add(connfd);
}
// 異步線程檢測 通過 select 判斷是否有連接可讀
new Tread(){
while(select(arr) > 0){
for(connfd : arr){
if(connfd can read){
// 如果套接字可讀 創建新線程處理
newTheadDeal(connfd);
arr.remove(connfd); // 移除已處理的連接
}
}
}
}
newTheadDeal(connfd){
buf = new buf[1024]; // 讀取數據容器
int n = read(connfd, buf); // 阻塞讀取數據
doSomeThing(buf); // 處理數據
close(connfd); // 關閉連接
}
流程簡圖:
圖片
優點:
1.減少大量系統調用。
2.系統內核幫我們遍歷檢測是否可讀。
存在一些問題:
? 每次調用需要在用戶態和內核態之間拷貝文件描述符數組,但高并發場景下這個拷貝的消耗是很大的。
? 內核檢測文件描述符可讀還是通過遍歷實現,當文件描述符數組很長時,遍歷操作耗時也很長。
? 內核檢測完文件描述符數組后,當存在可讀的文件描述符數組時,用戶態需要再遍歷檢測一遍。
poll:
? poll 和 select 原理基本一致,最大的區別是去掉了最大 1024 個文件描述符的限制。
? select 使用固定長度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的個數是有限制的,在 Linux 系統中,由內核中的 FD_SETSIZE 限制, 默認最大值為 1024,只能監聽 0~1023 的文件描述符。
? poll 不再用 BitsMap 來存儲所關注的文件描述符,取而代之用動態數組,以鏈表形式來組織,突破了 select 的文件描述符個數限制,當然還會受到系統文件描述符限制。
epoll:
epoll 主要優化了上面三個問題實現:
1.每次調用需要在用戶態和內核態之間拷貝文件描述符數組,但高并發場景下這個拷貝的消耗是很大的。
方案:內核中保存一份文件描述符,無需用戶每次傳入,而是僅同步修改部分。
2.內核檢測文件描述符可讀還是通過遍歷實現,當文件描述符數組很長時,遍歷操作耗時也很長。
方案:通過事件喚醒機制喚醒替代遍歷。
3.內核檢測完文件描述符數組后,當存在可讀的文件描述符數組時,用戶態需要再遍歷檢測一遍。
方案:僅將可讀部分文件描述符同步給用戶態,不需要用戶態再次遍歷。
epoll 基于高效的紅黑樹結構,提供了三個核心操作:epoll_create、epoll_ctl、epoll_wait。
epoll_create:
用于創建epoll文件描述符,該文件描述符用于后續的epoll操作,參數size目前還沒有實際用處,我們只要填一個大于0的數就行。
圖片
epoll_ctl:
epoll_ctl函數用于增加,刪除,修改epoll事件,epoll事件會存儲于內核epoll結構體紅黑樹中.
圖片
epoll_wait函數:
epoll_wait用于監聽套接字事件,可以通過設置超時時間timeout來控制監聽的行為為阻塞模式還是超時模式。
圖片
整體運轉如下:
圖片
偽代碼如下:
listenfd = socket(); // 打開一個網絡通信套接字
bind(listenfd); // 綁定
listen(listenfd); // 監聽
int epfd = epoll_create(...); // 創建 epoll 對象
while(1) {
connfd = accept(listenfd); // 阻塞 等待建立連接
epoll_ctl(connfd, ...); // 將新連接加入到 epoll 對象
}
// 異步線程檢測 通過 epoll_wait 阻塞獲取可讀的套接字
new Tread(){
while(arr = epoll_wait()){
for(connfd : arr){
// 僅返回可讀套接字
newTheadDeal(connfd);
}
}
}
newTheadDeal(connfd){
buf = new buf[1024]; // 讀取數據容器
int n = read(connfd, buf); // 阻塞讀取數據
doSomeThing(buf); // 處理數據
close(connfd); // 關閉連接
}
LT模式和ET模式:
LT模式:水平觸發:
1.socket讀觸發:socket接收緩沖區有數據,會一直觸發epoll_wait EPOLLIN事件,直到數據被用戶讀取完。
2.socket寫觸發:socket可寫,會一直觸發epoll_wait EPOLLOUT事件。
ET模式:邊緣觸發:
1.socket讀觸發:當被監控的 Socket 描述符上有可讀事件發生時,服務器端只會從 epoll_wait 中蘇醒一次,即使進程沒有調用 read 函數從內核讀取數據,也依然只蘇醒一次,因此我們程序要保證一次性將內核緩沖區的數據讀取完。
2.socket寫觸發:socket可寫,會觸發一次epoll_wait EPOLLOUT事件。
epoll為什么高效:
1.紅黑樹紅黑樹提高epoll事件增刪查改效率。
2.回調通知機制:當epoll監聽套接字有數據讀或者寫時,會通過注冊到socket的回調函數通知epoll,epoll檢測到事件后,將事件存儲在就緒隊列(rdllist)。
3.就緒隊列:epoll_wait返回成功后,會將所有就緒事件存儲在事件數組,用戶不需要進行無效的輪詢,從而提高了效率。
信號驅動IO:
多路轉接解決了一個線程可以監控多個fd的問題,但是select采用無腦的輪詢就顯得有點暴力,因為大部分情況下的輪詢都是無效的,所以有人就想,別讓我總去問數據是否準備就緒,而是等你準備就緒后主動通知我,這邊是信號驅動IO。
信號驅動IO是在調用sigaction時候建立一個SIGIO的信號聯系,當內核準備好數據之后再通過SIGIO信號通知線程,此fd準備就緒,當線程收到可讀信號后,此時再向內核發起recvfrom讀取數據的請求,因為信號驅動IO的模型下,應用線程在發出信號監控后即可返回,不會阻塞,所以一個應用線程也可以同時監控多個fd。
異步 IO:
應用只需要向內核發送一個讀取請求,告訴內核它要讀取數據后即刻返回;內核收到請求后會建立一個信號聯系,當數據準備就緒,內核會主動把數據從內核復制到用戶空間,等所有操作都完成之后,內核會發起一個通知告訴應用,我們稱這種模式為異步IO模型。
異步IO的優化思路是解決應用程序需要先后發送詢問請求、接收數據請求兩個階段的模式,在異步IO的模式下,只需要向內核發送一次請求就可以完成狀態詢問和數拷貝的所有操作。
同步和異步區別:
同步和異步關注的是消息通信機制.
同步:就是在發出一個調用時,自己需要參與等待結果的過程,則為同步,前面四個IO都自己參與了,所以也稱為同步IO。
異步:則指出發出調用以后,到數據準備完成,自己都未參與,則為異步IO。