IO原理理解與IO模型
基本概念
Linux核心將所有外部裝置都看做一個檔案來操作。那麼我們對與外部裝置的操作都可以看做是對檔案進行操作。我們對一個檔案的讀寫,都通過呼叫核心提供的系統呼叫;核心給我們返回一個file descriptor(fd,檔案描述符),對一個socket的讀寫也會有相應的描述符,稱為socketfd(socket描述符)。描述符就是一個數字(可以理解為一個索引),指向核心中一個結構體(檔案路徑,資料區,等一些屬性)。應用程式對檔案的讀寫就通過對描述符的讀寫完成。
一個基本的IO,它會涉及到兩個系統物件,一個是呼叫這個IO的程序物件(使用者程序),另一個就是系統核心(kernel)。當一個read操作發生時,它會經歷兩個階段:- 通過read系統呼叫向核心發起讀請求。
- 核心向硬體傳送讀指令,並等待讀就緒。
- DMA把將要讀取的資料複製到描述符所指向的核心快取區中。
- 核心將資料從核心快取區拷貝到使用者程序空間中。
IO操作的幾種方式
1)同步IO:當用戶發出IO請求操作之後,核心會去檢視要讀取的資料是否就緒,如果資料沒有就緒,就一直等待。需要通過使用者執行緒或者核心不斷地去輪詢資料是否就緒,當資料就緒時,再將資料從核心拷貝到使用者空間。
2)非同步IO:只有IO請求操作的發出是由使用者執行緒來進行的,IO操作的兩個階段都是由核心自動完成,然後傳送通知告知使用者執行緒IO操作已經完成。也就是說在非同步IO中,不會對使用者執行緒產生任何阻塞。
3)阻塞IO:當用戶執行緒發起一個IO請求操作(以讀請求操作為例),核心檢視要讀取的資料還沒就緒,當前執行緒被掛起,阻塞等待結果返回。
4)非阻塞IO:如果資料沒有就緒,則會返回一個標誌資訊告知使用者執行緒當前要讀的資料沒有就緒。當前執行緒在拿到此次請求結果的過程中,可以做其它事情。
I/O 模型
Linux系統下根據阻塞與非阻塞,同步與非同步實現了五種IO模型
同步阻塞I/O模型
最常見的I/O模型是阻塞I/O模型,預設情形下,所有檔案操作都是阻塞的。我們以套介面為例來講解此模型。在程序空間中呼叫recvfrom,其系統呼叫直到資料報到達且被拷貝到應用程序的緩衝區中或者發生錯誤才返回,期間一直在等待。我們就說程序在從呼叫recvfrom開始到它返回的整段時間內是被阻塞的。
同步非阻塞I/O模型
程序把一個套介面設定成非阻塞是在通知核心:當所請求的I/O操作不能滿足要求時候,不把本程序投入睡眠,而是返回一個錯誤。也就是說當資料沒有到達時並不等待,而是以一個錯誤返回。
I/O複用模型
linux提供select/poll,程序通過將一個或多個fd傳遞給select或poll系統呼叫,阻塞在select;這樣select/poll可以幫我們偵測許多fd是否就緒。但是select/poll是順序掃描fd是否就緒,而且支援的fd數量有限。linux還提供了一個epoll系統呼叫,epoll是基於事件驅動方式,而不是順序掃描,當有fd就緒時,立即回撥函式rollback;
訊號驅動非同步I/O模型
首先開啟套介面訊號驅動I/O功能, 並通過系統呼叫sigaction安裝一個訊號處理函式(此係統呼叫立即返回,程序繼續工作,它是非阻塞的)。當資料報準備好被讀時,就為該程序生成一個SIGIO訊號。隨即可以在訊號處理程式中呼叫recvfrom來讀資料報,井通知主迴圈資料已準備好被處理中。也可以通知主迴圈,讓它來讀資料報。
非同步I/O模型
告知核心啟動某個操作,並讓核心在整個操作完成後(包括將資料從核心拷貝到使用者自己的緩衝區)通知我們。這種模型與訊號驅動模型的主要區別是:訊號驅動I/O:由核心通知我們何時可以啟動一個I/O操作;非同步I/O模型:由核心通知我們I/O操作何時完成。
總結
前四種都是同步IO,在核心資料copy到使用者空間時都是阻塞的。
最後一種是非同步IO,通過API把IO操作交由作業系統處理,當前程序不關心具體IO的實現,通過回撥函式,或者訊號量通知當前程序直接對IO返回結果進行處理。
AIO,BIO,NIO
AIO非同步非阻塞IO,AIO方式適用於連線數目多且連線比較長(重操作)的架構,比如相簿伺服器,充分呼叫OS參與併發操作,程式設計比較複雜,JDK7開始支援。
NIO同步非阻塞IO,適用於連線數目多且連線比較短(輕操作)的架構,比如聊天伺服器,併發侷限於應用中,程式設計比較複雜,JDK1.4開始支援。
BIO同步阻塞IO,適用於連線數目比較小且固定的架構,這種方式對伺服器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,但程式直觀簡單易理解。
Java對BIO、NIO、AIO的支援:
Java BIO : 同步並阻塞,伺服器實現模式為一個連線一個執行緒,即客戶端有連線請求時伺服器端就需要啟動一個執行緒進行處理,如果這個連線不做任何事情會造成不必要的執行緒開銷,當然可以通過執行緒池機制改善。
Java NIO : 同步非阻塞,伺服器實現模式為一個請求一個執行緒,即客戶端傳送的連線請求都會註冊到多路複用器上,多路複用器輪詢到連線有I/O請求時才啟動一個執行緒進行處理。(底層是epoll)
Java AIO(NIO.2) : 非同步非阻塞,伺服器實現模式為一個有效請求一個執行緒,客戶端的I/O請求都是由OS先完成了再通知伺服器應用去啟動執行緒進行處理。
epoll,select/poll
都是IO複用,I/O多路複用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而非同步I/O則無需自己負責進行讀寫,非同步I/O的實現會負責把資料從核心拷貝到使用者空間。
epoll的效率更高,優化了select的輪詢操作,通過callback事件響應方式。
epoll除了提供select/poll那種IO事件的水平觸發(Level Triggered)外,還提供了邊緣觸發(Edge Triggered),這就使得使用者空間程式有可能快取IO狀態,減少epoll_wait/epoll_pwait的呼叫,提高應用程式效率。
select的幾大缺點:
(1)每次呼叫select,都需要把fd集合從使用者態拷貝到核心態,這個開銷在fd很多時會很大
(2)同時每次呼叫select都需要在核心遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
(3)select支援的檔案描述符數量太小了,預設是1024
poll實現
poll的實現和select非常相似,只是描述fd集合的方式不同,poll使用pollfd結構而不是select的fd_set結構.
epoll
epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎麼解決的呢?在此之前,我們先看一下epoll和select和poll的呼叫介面上的不同,select和poll都只提供了一個函式——select或者poll函式。而epoll提供了三個函式,epoll_create,epoll_ctl和epoll_wait,epoll_create是建立一個epoll控制代碼;epoll_ctl是註冊要監聽的事件型別;epoll_wait則是等待事件的產生。
對於第一個缺點,epoll的解決方案在epoll_ctl函式中。每次註冊新的事件到epoll控制代碼中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把所有的fd拷貝進核心,而不是在epoll_wait的時候重複拷貝。epoll保證了每個fd在整個過程中只會拷貝一次。
對於第二個缺點,epoll的解決方案不像select或poll一樣每次都把current輪流加入fd對應的裝置等待佇列中,而只在epoll_ctl時把current掛一遍(這一遍必不可少)併為每個fd指定一個回撥函式,當裝置就緒,喚醒等待佇列上的等待者時,就會呼叫這個回撥函式,而這個回撥函式會把就緒的fd加入一個就緒連結串列)。epoll_wait的工作實際上就是在這個就緒連結串列中檢視有沒有就緒的fd(利用schedule_timeout()實現睡一會,判斷一會的效果,和select實現中的第7步是類似的)。
對於第三個缺點,epoll沒有這個限制,它所支援的FD上限是最大可以開啟檔案的數目,這個數字一般遠大於2048,舉個例子,在1GB記憶體的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統記憶體關係很大。
使用mmap加速核心與使用者空間的訊息傳遞。無論是select,poll還是epoll都需要核心把FD訊息通知給使用者空間,如何避免不必要的記憶體拷貝就很重要,在這點上,epoll是通過核心於使用者空間mmap同一塊記憶體實現的。
總結
select,poll實現需要自己不斷輪詢所有fd集合,直到裝置就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要呼叫epoll_wait不斷輪詢就緒連結串列,期間也可能多次睡眠和喚醒交替,但是它是裝置就緒時,呼叫回撥函式,把就緒fd放入就緒連結串列中,並喚醒在epoll_wait中進入睡眠的程序。雖然都要睡眠和交替,但是select和poll在“醒著”的時候要遍歷整個fd集合,而epoll在“醒著”的時候只要判斷一下就緒連結串列是否為空就行了,這節省了大量的CPU時間。這就是回撥機制帶來的效能提升。
select,poll每次呼叫都要把fd集合從使用者態往核心態拷貝一次,並且要把current往裝置等待佇列中掛一次,而epoll只要一次拷貝,而且把current往等待佇列上掛也只掛一次(在epoll_wait的開始,注意這裡的等待佇列並不是裝置等待佇列,只是一個epoll內部定義的等待佇列)。這也能節省不少的開銷。