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的實現機制差不多是一樣的,只不過函數不同、參數不同,但是基本流程是相同的:
- 復制用戶數據到內核空間
- 估計超時時間
- 遍歷每個文件並調用f_op->poll()取得文件狀態
- 遍歷完成檢查狀態,如果有就緒的文件則跳轉至5、如果有信號產生則重新啟動select或者poll、否則掛起進程並等待超時或喚醒超時或再次遍歷每個文件狀態
- 將所有文件的就緒狀態復制到用戶空間
- 清理申請的資源
epoll函數是第三個階段,它改進了select與poll的所有缺點,epoll將select與poll分為了三個部分:
- epoll_ecreate()簡歷一個epoll對象
- epoll_ctl向epoll對象中添加socket套接字順便給內核中斷處理程序注冊一個callback,高速內核,當文件描述符上有事件到達(或者中斷)的時候就調用這個callback
- 調用epoll_wait收集發生事件的鏈接
在實現上epoll()的三個核心點是:
- 使用mmap共享內存,即用戶空間和內核空間共享的一塊物理地址,這樣當內核空間要對文件描述符上的事件進行檢查時就不需要來回拷貝數據了
- 紅黑樹,用於存儲文件描述符,當內核初始化epoll時,會開辟出一塊內核高速cache區,這塊區域用於存儲我們需要監管的所有Socket描述符,由於紅黑樹的數據結構,對文件描述符增刪查效率大為提高
- rdlist,就緒描述符鏈表區,這是一個雙向鏈表,epoll_wait()函數返回的也就是這個就緒鏈表,上面的epoll_ctl說了添加對象的時候會注冊一個callback,這個callbakc的作用實際就是將描述符放入rdlist中,所以當一個socket上的數據到達的時候內核就會把網卡上的數據復制到內核,然后把socket描述符插入到就緒鏈表rdlist中