Java NIO1:I/O模型概述

五月的倉頡發表於2015-12-24

I/O模型

在開始NIO的學習之前,先對I/O的模型有一個理解,這對NIO的學習是絕對有好處的。我畫一張圖,簡單表示一下資料從外部磁碟向執行中程式的記憶體區域移動的過程:

這張圖片明顯忽略了很多細節,只涉及了基本操作,下面分析一下這張圖。

 

使用者空間和核心空間

一個計算機通常有一定大小的記憶體空間,如一臺計算機有4GB的地址空間,但是程式並不能完全使用這些地址空間,因為這些地址空間是被劃分為使用者空間和核心空間的。程式只能使用使用者空間的記憶體,這裡所說的使用是指程式能夠申請的記憶體空間,並不是真正訪問的地址空間。下面看下什麼是使用者空間和核心空間:

1、使用者空間

使用者空間是常規程式所在的區域,什麼是常規程式,開啟工作管理員看到的就是常規程式:

 

JVM就是常規程式,駐守於使用者空間,使用者空間是非特權區域,比如在該區域執行的程式碼不能直接訪問硬體裝置。

2、核心空間

核心空間主要是指作業系統執行時所使用的用於程式排程、虛擬記憶體的使用或者連線硬體資源等的程式邏輯。核心程式碼有特別的權利,比如它能與裝置控制器通訊,控制著整個用於區域程式的執行狀態。和I/O相關的一點是:所有I/O都直接或間接通過核心空間

那麼,為什麼要劃分使用者空間和核心空間呢?這也是為了保證作業系統的穩定性和安全性。使用者程式不可以直接訪問硬體資源,如果使用者程式需要訪問硬體資源,必須呼叫作業系統提供的介面,這個呼叫介面的過程也就是系統呼叫。每一次系統呼叫都會存在兩個記憶體空間之間的相互切換,通常的網路傳輸也是一次系統呼叫,通過網路傳輸的資料先是從核心空間接收到遠端主機的資料,然後再從核心空間複製到使用者空間,供使用者程式使用。這種從核心空間到使用者控制元件的資料複製很費時,雖然保住了程式執行的安全性和穩定性,但是犧牲了一部分的效率。

最後,如何分配使用者空間和核心空間的比例也是一個問題,是更多地分配給使用者空間供使用者程式使用,還是首先保住核心有足夠的空間來執行,還是要平衡一下。在當前的Windows 32位作業系統中,預設使用者空間:核心空間的比例是1:1,而在32位Linux系統中的預設比例是3:1(3GB使用者空間、1GB核心空間)。

 

程式執行I/O操作的步驟

緩衝區,以及緩衝區如何工作,是所有I/O的基礎。所謂"輸入/輸出"講的無非也就是把資料移入或移出緩衝區。

程式執行I/O操作,歸結起來,就是向作業系統發出請求,讓它要麼把緩衝區裡的資料排乾淨(寫),要麼用資料把緩衝區填滿(讀)。程式利用這一機制處理所有資料進出操作,作業系統內部處理這一任務的機制,其複雜程度可能超乎想像,但就概念而言,卻非常直白易懂,從上面的圖,可以總結一下程式執行I/O操作的幾步:

1、程式使用底層函式read(),建立和執行適當的系統呼叫,要求其緩衝區被填滿,此時控制權移交給核心

2、核心隨即向磁碟控制硬體發出命令,要求其從磁碟讀取資料

3、磁碟控制器和資料直接寫入核心記憶體緩衝區,這一步通過DMA完成,無需主CPU協助。這裡多提一句,關於DMA,可以百度一下,它是現代電腦的重要特色,它允許不同速度的硬體裝置來溝通,而不需要依賴於CPU的大量中斷負載,大大提升了整個系統的效率

4、一旦磁碟控制器把緩衝區填滿,核心隨即把資料從核心空間的臨時緩衝區拷貝到程式執行read()呼叫時指定的緩衝區

5、程式從使用者空間的緩衝區中拿到資料

當然,如果核心空間裡已經有資料了,那麼該資料只需要簡單地拷貝出來即可。至於為什麼不能直接讓磁碟控制器把資料送到使用者空間的緩衝區呢?最簡單的一個理由就是,硬體通常不能直接訪問使用者空間。

 

同步和非同步、阻塞和非阻塞

有了上面對於I/O的解讀,我們來看一下同步和非同步、阻塞和非阻塞兩組概念的區別,主要二者在關注點上有所不同。

1、同步和非同步

同步和非同步這個概念比較廣,不僅僅是在I/O,其他的還有諸如同步呼叫/非同步呼叫、同步請求/非同步請求,都是一個意思。同步和非同步,關注的是訊息通訊機制

所謂同步,就是發出一個"呼叫請求"時,在沒有得到結果之前,該"呼叫請求"就不返回,但是一旦呼叫返回就得到返回值了。換句話說,就是由"呼叫者"主動等待"呼叫"的結果。像我們平時寫的,方法A呼叫Math.random()方法、方法B呼叫String.substring()方法都是同步呼叫,因為呼叫者主動在等待這些方法的返回。

所謂非同步,則正好相反,"呼叫"發出之後,這個呼叫就直接返回了,所有沒有返回結果。換句話說,當一個非同步呼叫請求發出之後,呼叫者不會立刻得到結果,因此非同步呼叫適用於那些對資料一致性要求不是很高的場景,比如模組A更新了快取中的某個值,模組B將某個內容分享到新浪微博,這些模組的關注點更多是"做了這件事"而不是"做了這件事是否馬上成功",用分散式的話說,就是犧牲了系統的強一致性而提高了整個系統的可用性及分割槽容錯性。如果這種場景下,我們希望獲取非同步呼叫的結果,"被呼叫者"可以通過狀態、通知來通知呼叫者,或通過回撥函式處理這個呼叫,對應Java中的有Future/FutureTask、wait/notify。

2、阻塞和非阻塞

阻塞和非阻塞關注的是程式在等待呼叫結果時的狀態

阻塞呼叫指的是呼叫結果返回之前,當前執行緒會被掛起,呼叫執行緒只有在得到結果之後才會返回。

非阻塞呼叫指的是在不能立即得到結果之前,該呼叫不會阻塞當前執行緒。

 

Linux網路I/O模型

由於絕大多數的Java應用都部署在Linux系統上,因此這裡談一下Linux網路I/O模型。

Linux的核心將所有外部裝置都看做一個檔案來操作,對一個檔案的讀寫操作會呼叫核心提供的系統命令,返回一個file descriptor(fd,檔案描述符)。而對一個Socket的讀寫也會有相應的描述符,稱為Socketfd(Socket描述符),描述符就是一個數字,它指向核心中的一個結構體(結構體,C/C++資料型別,類似Java中的類,儲存各種不同型別的資料,這裡儲存的是檔案路徑、資料區等一些屬性)。

根據UNIX網路程式設計對I/O模型的分類,UNIX提供了5種I/O模型,分別為:

1、阻塞I/O模型

阻塞I/O模型就是最常用的I/O模型,預設情況下所有的檔案操作都是阻塞的,以Socket來講解此模型:在使用者空間中呼叫recvfrom,其系統呼叫直到資料包到達且被複制到應用程式的緩衝區或者發生錯誤時才返回,在此期間會一直等待,程式在從呼叫recvfrom開始到它返回的整段時間內都是被阻塞的,因此被稱為阻塞I/O。

2、非阻塞I/O模型

recvfrom從使用者空間到核心空間的時候,如果該緩衝區沒有資料的話,就直接返回一個EWOULDBOCK錯誤,一般都對非阻塞I/O模型進行輪詢檢查這個狀態,看核心空間是不是有資料到來,有資料到來則從核心空間複製資料到使用者空間。

3、I/O複用模型

Linux提供select/poll,程式通過將一個或者多個fd傳遞給select或poll系統呼叫,阻塞在select操作上,這樣select/poll可以幫助我們偵測多個fd是否處於就緒狀態。select/poll是順序掃描fd是否就緒,而且支援的fd數量有限,因此它的使用受到了一些制約。Linux還提供了一個epoll系統呼叫,epoll使用基於事件驅動方式替代順序掃描,因此效能更高。當有fd就緒時,立即會掉函式callback。

4、訊號驅動I/O模型

首先開啟Socket訊號驅動I/O功能,並通過系統呼叫sigaction執行一個訊號處理函式(此係統呼叫立即返回,程式繼續工作,它是非阻塞的)。當資料準備就緒時,就為程式生成一個SIGIO訊號,通過訊號會掉通知應用程式呼叫recvfrom來讀取資料,並通知主迴圈函式來處理資料。

5、非同步I/O

告知核心啟動某個操作,並讓核心在整個操作完成後(包括將資料從核心複製到使用者自己的緩衝區)通知開發者。這種模型與訊號驅動I/O模型的主要區別是:訊號驅動I/O模型由核心通知開發者何時可以開始一個I/O操作,非同步I/O模型由核心通知開發者I/O操作何時已經完成

 

再談BIO與NIO

上面講了五種IO模型,其實重點就是1和3,1為BIO(Blocking IO),3為NIO(Nonblocking IO),所以再用圖加深一下理解,首先是BIO的:

接著是NIO的:

 

從圖中可以看出,NIO的單執行緒能處理的連線數量比BIO要高出很多,為什麼呢?原因就是NIO的Selector。

當一個連線建立之後,有兩個步驟要做:

  • 接受完客戶端發過來的所有資料
  • 服務端處理完請求業務之後返回Response給客戶端

NIO與BIO的主要區別就在第一步:

  • 在BIO中,等待客戶端傳送資料這個過程是阻塞的,這就造成了一個執行緒只能處理一個請求的情況,而機器能支援的最大執行緒數是有限的,這就是為什麼BIO不能支援高併發的原因
  • 在NIO中,當一個Socket建立好之後,Thread並不會去阻塞接收這個Socket,而是將這個請求交給Selector,Selector會判斷哪個Socket建立完成,然後通知對應執行緒,對應執行緒處理完資料再返回給客戶端,這樣就可以讓一個執行緒處理更多的請求了

在NIO上,我們看到了主要是使用Selector使得一條執行緒可以處理多個Socket,接著我們來理解一下Selector。

 

Selector原理

在上圖中,我們可以看到NIO的核心就是Selector,Selector做的事情就是:

以單條執行緒監視多Socket I/O的狀態,空閒時阻塞當前執行緒,當有一個或者多個Socket有I/O事件時就從阻塞狀態中醒來

Selector就是這種思想的實現,其發展大致經歷了select、poll、epoll三個階段的發展(Linux作業系統,Windows作業系統是其他函式實現)。

第一階段為select階段,select有如下缺點:

  • 單個程式能夠監視的檔案描述符數量存在最大限制,通常是1024,當然可以更改數量,但是數量越多效能越差
  • 核心/使用者空間的記憶體拷貝問題,select需要複製大量控制程式碼資料結構從而產生巨大開銷
  • select返回的是含有整個控制程式碼的陣列,應用程式需要遍歷整個陣列才能發現哪些控制程式碼發生了事件
  • select的觸發方式是水平觸發,應用程式如果沒有完成對一個已經就緒的檔案描述符進行I/O操作,那麼之後每次select呼叫還是會將這些檔案描述符通知程式

相比select,poll使用連結串列儲存檔案描述符,因此沒有了監視檔案數量的限制,但是其它三個缺點依舊存在。

綜上,再總結一下,select和poll的實現機制差不多是一樣的,只不過函式不同、引數不同,但是基本流程是相同的:

  1. 複製使用者資料到核心空間
  2. 估計超時時間
  3. 遍歷每個檔案並呼叫f_op->poll()取得檔案狀態
  4. 遍歷完成檢查狀態,如果有就緒的檔案則跳轉至5、如果有訊號產生則重新啟動select或者poll、否則掛起程式並等待超時或喚醒超時或再次遍歷每個檔案狀態
  5. 將所有檔案的就緒狀態複製到使用者空間
  6. 清理申請的資源

epoll函式是第三個階段,它改進了select與poll的所有缺點,epoll將select與poll分為了三個部分:

  1. epoll_ecreate()簡歷一個epoll物件
  2. epoll_ctl向epoll物件中新增socket套接字順便給核心中斷處理程式註冊一個callback,高速核心,當檔案描述符上有事件到達(或者中斷)的時候就呼叫這個callback
  3. 呼叫epoll_wait收集發生事件的連結

在實現上epoll()的三個核心點是:

  1. 使用mmap共享記憶體,即使用者空間和核心空間共享的一塊實體地址,這樣當核心空間要對檔案描述符上的事件進行檢查時就不需要來回拷貝資料了
  2. 紅黑樹,用於儲存檔案描述符,當核心初始化epoll時,會開闢出一塊核心高速cache區,這塊區域用於儲存我們需要監管的所有Socket描述符,由於紅黑樹的資料結構,對檔案描述符增刪查效率大為提高
  3. rdlist,就緒描述符連結串列區,這是一個雙向連結串列,epoll_wait()函式返回的也就是這個就緒連結串列,上面的epoll_ctl說了新增物件的時候會註冊一個callback,這個callbakc的作用實際就是將描述符放入rdlist中,所以當一個socket上的資料到達的時候核心就會把網路卡上的資料複製到核心,然後把socket描述符插入到就緒連結串列rdlist中

相關文章