歡迎光臨
每天分享高質量文章

NIO相關基礎篇

用戶空間以及內核空間概念

我們知道現在操作系統都是採用虛擬儲存器,那麼對32位操作系統而言,它的尋址空間(虛擬儲存空間)為4G(2的32次方)。操心系統的核心是內核,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體設備的所有權限。為了保證用戶行程不能直接操作內核,保證內核的安全,操心系統將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。針對linux操作系統而言,將最高的1G位元組(從虛擬地址0xC0000000到0xFFFFFFFF),供內核使用,稱為內核空間,而將較低的3G位元組(從虛擬地址0x00000000到0xBFFFFFFF),供各個行程使用,稱為用戶空間。每個行程可以通過系統呼叫進入內核,因此,Linux內核由系統內的所有行程共享。於是,從具體行程的角度來看,每個行程可以擁有4G位元組的虛擬空間。

空間分配如下圖所示:

有了用戶空間和內核空間,整個linux內部結構可以分為三部分,從最底層到最上層依次是:硬體–>內核空間–>用戶空間。

如下圖所示:

需要註意的細節問題,從上圖可以看出內核的組成:

  1. 內核空間中存放的是內核代碼和資料,而行程的用戶空間中存放的是用戶程式的代碼和資料。不管是內核空間還是用戶空間,它們都處於虛擬空間中。

     

  2. Linux使用兩級保護機制:0級供內核使用,3級供用戶程式使用。

     

Linux 網絡 I/O模型

我們都知道,為了OS的安全性等的考慮,行程是無法直接操作I/O設備的,其必須通過系統呼叫請求內核來協助完成I/O動作,而內核會為每個I/O設備維護一個buffer。 如下圖所示:

整個請求過程為: 用戶行程發起請求,內核接受到請求後,從I/O設備中獲取資料到buffer中,再將buffer中的資料copy到用戶行程的地址空間,該用戶行程獲取到資料後再響應客戶端。

在整個請求過程中,資料輸入至buffer需要時間,而從buffer複製資料至行程也需要時間。因此根據在這兩段時間內等待方式的不同,I/O動作可以分為以下五種樣式

  • 阻塞I/O (Blocking I/O)

     

  • 非阻塞I/O (Non-Blocking I/O)

     

  • I/O復用(I/O Multiplexing)

     

  • 信號驅動的I/O (Signal Driven I/O)

     

  • 異步I/O (Asynchrnous I/O) 說明:如果像瞭解更多可能需要linux/unix方面的知識了,可自行去學習一些網絡編程原理應該有詳細說明,不過對大多數java程式員來說,不需要瞭解底層細節,知道個概念就行,知道對於系統而言,底層是支持的

     

本文最重要的參考文獻是Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2節“I/O Models ”。

記住這兩點很重要

1 等待資料準備 (Waiting for the data to be ready) 2 將資料從內核拷貝到行程中 (Copying the data from the kernel to the process)

阻塞I/O (Blocking I/O)

在linux中,預設情況下所有的socket都是blocking,一個典型的讀操作流程大概是這樣:

當用戶行程呼叫了recvfrom這個系統呼叫,內核就開始了IO的第一個階段:等待資料準備。對於network io來說,很多時候資料在一開始還沒有到達(比如,還沒有收到一個完整的UDP包),這個時候內核就要等待足夠的資料到來。而在用戶行程這邊,整個行程會被阻塞。當內核一直等到資料準備好了,它就會將資料從內核中拷貝到用戶記憶體,然後內核傳回結果,用戶行程才解除block的狀態,重新運行起來。 所以,blocking IO的特點就是在IO執行的兩個階段都被block了。

非阻塞I/O (Non-Blocking I/O)

linux下,可以通過設置socket使其變為non-blocking。當對一個non-blocking socket執行讀操作時,流程是這個樣子:

當用戶行程呼叫recvfrom時,系統不會阻塞用戶行程,而是立刻傳回一個ewouldblock錯誤,從用戶行程角度講 ,並不需要等待,而是馬上就得到了一個結果。用戶行程判斷標誌是ewouldblock時,就知道資料還沒準備好,於是它就可以去做其他的事了,於是它可以再次發送recvfrom,一旦內核中的資料準備好了。並且又再次收到了用戶行程的system call,那麼它馬上就將資料拷貝到了用戶記憶體,然後傳回。

當一個應用程式在一個迴圈裡對一個非阻塞呼叫recvfrom,我們稱為輪詢。應用程式不斷輪詢內核,看看是否已經準備好了某些操作。這通常是浪費CPU時間,但這種樣式偶爾會遇到。

I/O復用(I/O Multiplexing)

IO multiplexing這個詞可能有點陌生,但是如果我說select,epoll,大概就都能明白了。有些地方也稱這種IO方式為event driven IO。我們都知道,select/epoll的好處就在於單個process就可以同時處理多個網絡連接的IO。它的基本原理就是select/epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有資料到達了,就通知用戶行程。它的流程如圖:

當用戶行程呼叫了select,那麼整個行程會被block,而同時,內核會“監視”所有select負責的socket,當任何一個socket中的資料準備好了,select就會傳回。這個時候用戶行程再呼叫read操作,將資料從內核拷貝到用戶行程。 這個圖和blocking IO的圖其實並沒有太大的不同,事實上,還更差一些。因為這裡需要使用兩個system call (select 和 recvfrom),而blocking IO只呼叫了一個system call (recvfrom)。但是,用select的優勢在於它可以同時處理多個connection。(多說一句。所以,如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優勢並不是對於單個連接能處理得更快,而是在於能處理更多的連接。) 在IO multiplexing Model中,實際中,對於每一個socket,一般都設置成為non-blocking,但是,如上圖所示,整個用戶的process其實是一直被block的。只不過process是被select這個函式block,而不是被socket IO給block。

檔案描述符fd

Linux的內核將所有外部設備都可以看做一個檔案來操作。那麼我們對與外部設備的操作都可以看做對檔案進行操作。我們對一個檔案的讀寫,都通過呼叫內核提供的系統呼叫;內核給我們傳回一個filede scriptor(fd,檔案描述符)。而對一個socket的讀寫也會有相應的描述符,稱為socketfd(socket描述符)。描述符就是一個數字,指向內核中一個結構體(檔案路徑,資料區,等一些屬性)。那麼我們的應用程式對檔案的讀寫就通過對描述符的讀寫完成。

select

基本原理: select 函式監視的檔案描述符分3類,分別是writefds、readfds、和exceptfds。呼叫後select函式會阻塞,直到有描述符就緒(有資料 可讀、可寫、或者有except),或者超時(timeout指定等待時間,如果立即傳回設為null即可),函式傳回。當select函式傳回後,可以通過遍歷fdset,來找到就緒的描述符。

缺點: 1、select最大的缺陷就是單個行程所打開的FD是有一定限制的,它由FDSETSIZE設置,32位機預設是1024個,64位機預設是2048。 一般來說這個數目和系統記憶體關係很大,”具體數目可以cat /proc/sys/fs/file-max察看”。32位機預設是1024個。64位機預設是2048. 2、對socket進行掃描時是線性掃描,即採用輪詢的方法,效率較低。 當套接字比較多的時候,每次select()都要通過遍歷FDSETSIZE個Socket來完成調度,不管哪個Socket是活躍的,都遍歷一遍。這會浪費很多CPU時間。”如果能給套接字註冊某個回呼函式,當他們活躍時,自動完成相關操作,那就避免了輪詢”,這正是epoll與kqueue做的。 3、需要維護一個用來存放大量fd的資料結構,這樣會使得用戶空間和內核空間在傳遞該結構時複製開銷大。

poll

基本原理: poll本質上和select沒有區別,它將用戶傳入的陣列拷貝到內核空間,然後查詢每個fd對應的設備狀態,如果設備就緒則在設備等待佇列中加入一項並繼續遍歷,如果遍歷完所有fd後沒有發現就緒設備,則掛起當前行程,直到設備就緒或者主動超時,被喚醒後它又要再次遍歷fd。這個過程經歷了多次無謂的遍歷。

它沒有最大連接數的限制,原因是它是基於鏈表來儲存的,但是同樣有一個缺點: 1、大量的fd的陣列被整體複製於用戶態和內核地址空間之間,而不管這樣的複製是不是有意義。 2 、poll還有一個特點是“水平觸發”,如果報告了fd後,沒有被處理,那麼下次poll時會再次報告該fd。

註意: 從上面看,select和poll都需要在傳回後,通過遍歷檔案描述符來獲取已經就緒的socket。事實上,同時連接的大量客戶端在一時刻可能只有很少的處於就緒狀態,因此隨著監視的描述符數量的增長,其效率也會線性下降。

epoll

epoll是在2.6內核中提出的,是之前的select和poll的增強版本。相對於select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個檔案描述符管理多個描述符,將用戶關係的檔案描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。

基本原理: epoll支持水平觸發和邊緣觸發,最大的特點在於邊緣觸發,它只告訴行程哪些fd剛剛變為就緒態,並且只會通知一次。還有一個特點是,epoll使用“事件”的就緒通知方式,通過epollctl註冊fd,一旦該fd就緒,內核就會採用類似callback的回呼機制來激活該fd,epollwait便可以收到通知。

epoll的優點: 1、沒有最大併發連接的限制,能打開的FD的上限遠大於1024(1G的記憶體上能監聽約10萬個端口)。 2、效率提升,不是輪詢的方式,不會隨著FD數目的增加效率下降。 只有活躍可用的FD才會呼叫callback函式;即Epoll最大的優點就在於它只管你“活躍”的連接,而跟連接總數無關,因此在實際的網絡環境中,Epoll的效率就會遠遠高於select和poll。 3、記憶體拷貝,利用mmap()檔案映射記憶體加速與內核空間的訊息傳遞;即epoll使用mmap減少複製開銷。

JDK1.5_update10版本使用epoll替代了傳統的select/poll,極大的提升了NIO通信的性能。

備註: JDK NIO的BUG,例如臭名昭著的epoll bug,它會導致Selector空輪詢,最終導致CPU 100%。官方聲稱在JDK1.6版本的update18修複了該問題,但是直到JDK1.7版本該問題仍舊存在,只不過該BUG發生概率降低了一些而已,它並沒有被根本解決。這個可以在後續netty系列裡面進行說明下。

信號驅動的I/O (Signal Driven I/O)

由於signal driven IO在實際中並不常用,所以簡單提下。

很明顯可以看出用戶行程不是阻塞的。首先用戶行程建立SIGIO信號處理程式,並通過系統呼叫sigaction執行一個信號處理函式,這時用戶行程便可以做其他的事了,一旦資料準備好,系統便為該行程生成一個SIGIO信號,去通知它資料已經準備好了,於是用戶行程便呼叫recvfrom把資料從內核拷貝出來,並傳回結果。

異步I/O

一般來說,這些函式通過告訴內核啟動操作併在整個操作(包括內核的資料到緩衝區的副本)完成時通知我們。這個模型和前面的信號驅動I/O模型的主要區別是,在信號驅動的I/O中,內核告訴我們何時可以啟動I/O操作,但是異步I/O時,內核告訴我們何時I/O操作完成。

當用戶行程向內核發起某個操作後,會立刻得到傳回,並把所有的任務都交給內核去完成(包括將資料從內核拷貝到用戶自己的緩衝區),內核完成之後,只需傳回一個信號告訴用戶行程已經完成就可以了。

5中I/O模型的對比

結果表明: 前四個模型之間的主要區別是第一階段,四個模型的第二階段是一樣的:過程受阻在呼叫recvfrom當資料從內核拷貝到用戶緩衝區。然而,異步I/O處理兩個階段,與前四個不同。

從同步、異步,以及阻塞、非阻塞兩個維度來劃分來看:

零拷貝

CPU不執行拷貝資料從一個儲存區域到另一個儲存區域的任務,這通常用於在網絡上傳輸檔案時節省CPU周期和記憶體帶寬。

快取 IO

快取 IO 又被稱作標準 IO,大多數檔案系統的預設 IO 操作都是快取 IO。在 Linux 的快取 IO 機制中,操作系統會將 IO 的資料快取在檔案系統的頁快取( page cache )中,也就是說,資料會先被拷貝到操作系統內核的緩衝區中,然後才會從操作系統內核的緩衝區拷貝到應用程式的地址空間。

快取 IO 的缺點:資料在傳輸過程中需要在應用程式地址空間和內核進行多次資料拷貝操作,這些資料拷貝操作所帶來的 CPU 以及記憶體開銷是非常大的。

零拷貝技術分類

零拷貝技術的發展很多樣化,現有的零拷貝技術種類也非常多,而當前並沒有一個適合於所有場景的零拷貝技術的出現。對於 Linux 來說,現存的零拷貝技術也比較多,這些零拷貝技術大部分存在於不同的 Linux 內核版本,有些舊的技術在不同的 Linux 內核版本間得到了很大的發展或者已經漸漸被新的技術所代替。本文針對這些零拷貝技術所適用的不同場景對它們進行了劃分。概括起來,Linux 中的零拷貝技術主要有下麵這幾種:

  • 直接 I/O:對於這種資料傳輸方式來說,應用程式可以直接訪問硬體儲存,操作系統內核只是輔助資料傳輸:這類零拷貝技術針對的是操作系統內核並不需要對資料進行直接處理的情況,資料可以在應用程式地址空間的緩衝區和磁盤之間直接進行傳輸,完全不需要 Linux 操作系統內核提供的頁快取的支持。
  • 在資料傳輸的過程中,避免資料在操作系統內核地址空間的緩衝區和用戶應用程式地址空間的緩衝區之間進行拷貝。有的時候,應用程式在資料進行傳輸的過程中不需要對資料進行訪問,那麼,將資料從 Linux 的頁快取拷貝到用戶行程的緩衝區中就可以完全避免,傳輸的資料在頁快取中就可以得到處理。在某些特殊的情況下,這種零拷貝技術可以獲得較好的性能。Linux 中提供類似的系統呼叫主要有 mmap(),sendfile() 以及 splice()。
  • 對資料在 Linux 的頁快取和用戶行程的緩衝區之間的傳輸過程進行優化。該零拷貝技術側重於靈活地處理資料在用戶行程的緩衝區和操作系統的頁快取之間的拷貝操作。這種方法延續了傳統的通信方式,但是更加靈活。在Linux 中,該方法主要利用了寫時複製技術。

前兩類方法的目的主要是為了避免應用程式地址空間和操作系統內核地址空間這兩者之間的緩衝區拷貝操作。這兩類零拷貝技術通常適用在某些特殊的情況下,比如要傳送的資料不需要經過操作系統內核的處理或者不需要經過應用程式的處理。第三類方法則繼承了傳統的應用程式地址空間和操作系統內核地址空間之間資料傳輸的概念,進而針對資料傳輸本身進行優化。我們知道,硬體和軟體之間的資料傳輸可以通過使用 DMA 來進行,DMA 進行資料傳輸的過程中幾乎不需要CPU參與,這樣就可以把 CPU 解放出來去做更多其他的事情,但是當資料需要在用戶地址空間的緩衝區和 Linux 操作系統內核的頁快取之間進行傳輸的時候,並沒有類似DMA 這種工具可以使用,CPU 需要全程參與到這種資料拷貝操作中,所以這第三類方法的目的是可以有效地改善資料在用戶地址空間和操作系統內核地址空間之間傳遞的效率。

註意,對於各種零拷貝機制是否能夠實現都是依賴於操作系統底層是否提供相應的支持。

當應用程式訪問某塊資料時,操作系統首先會檢查,是不是最近訪問過此檔案,檔案內容是否快取在內核緩衝區,如果是,操作系統則直接根據read系統呼叫提供的buf地址,將內核緩衝區的內容拷貝到buf所指定的用戶空間緩衝區中去。如果不是,操作系統則首先將磁盤上的資料拷貝的內核緩衝區,這一步目前主要依靠DMA來傳輸,然後再把內核緩衝區上的內容拷貝到用戶緩衝區中。 接下來,write系統呼叫再把用戶緩衝區的內容拷貝到網絡堆棧相關的內核緩衝區中,最後socket再把內核緩衝區的內容發送到網卡上。

從上圖中可以看出,共產生了四次資料拷貝,即使使用了DMA來處理了與硬體的通訊,CPU仍然需要處理兩次資料拷貝,與此同時,在用戶態與內核態也發生了多次背景關係切換,無疑也加重了CPU負擔。 在此過程中,我們沒有對檔案內容做任何修改,那麼在內核空間和用戶空間來回拷貝資料無疑就是一種浪費,而零拷貝主要就是為瞭解決這種低效性。

讓資料傳輸不需要經過user space,使用mmap 我們減少拷貝次數的一種方法是呼叫mmap()來代替read呼叫:

  1. buf = mmap(diskfd, len);

     

  2. write(sockfd, buf, len);

     

應用程式呼叫mmap(),磁盤上的資料會通過DMA被拷貝的內核緩衝區,接著操作系統會把這段內核緩衝區與應用程式共享,這樣就不需要把內核緩衝區的內容往用戶空間拷貝。應用程式再呼叫write(),操作系統直接將內核緩衝區的內容拷貝到socket緩衝區中,這一切都發生在內核態,最後,socket緩衝區再把資料發到網卡去。

同樣的,看圖很簡單:

使用mmap替代read很明顯減少了一次拷貝,當拷貝資料量很大時,無疑提升了效率。但是使用mmap是有代價的。當你使用mmap時,你可能會遇到一些隱藏的陷阱。例如,當你的程式map了一個檔案,但是當這個檔案被另一個行程截斷(truncate)時, write系統呼叫會因為訪問非法地址而被SIGBUS信號終止。SIGBUS信號預設會殺死你的行程並產生一個coredump,如果你的服務器這樣被中止了,那會產生一筆損失。

通常我們使用以下解決方案避免這種問題:

  1. 為SIGBUS信號建立信號處理程式 當遇到 SIGBUS信號時,信號處理程式簡單地傳回, write系統呼叫在被中斷之前會傳回已經寫入的位元組數,並且 errno會被設置成success,但是這是一種糟糕的處理辦法,因為你並沒有解決問題的實質核心。

     

  2. 使用檔案租借鎖 通常我們使用這種方法,在檔案描述符上使用租借鎖,我們為檔案向內核申請一個租借鎖,當其它行程想要截斷這個檔案時,內核會向我們發送一個實時的 RTSIGNALLEASE信號,告訴我們內核正在破壞你加持在檔案上的讀寫鎖。這樣在程式訪問非法記憶體並且被 SIGBUS殺死之前,你的 write系統呼叫會被中斷。 write會傳回已經寫入的位元組數,並且置 errno為success。 我們應該在 mmap檔案之前加鎖,並且在操作完檔案後解鎖:

     

  1. 1. if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
  2. 2.    perror("kernel lease set signal");
  3. 3.    return -1;
  4. 4. }
  5. 5. /* l_type can be F_RDLCK F_WRLCK  加鎖*/
  6. 6. /* l_type can be  F_UNLCK 解鎖*/
  7. 7. if(fcntl(diskfd, F_SETLEASE, l_type)){
  8. 8.    perror("kernel lease set type");
  9. 9.    return -1;
  10. 10. }

赞(0)

分享創造快樂