我們知道現在作業系統都是採用虛擬儲存器,那麼對32位作業系統而言,它的定址空間(虛擬儲存空間)為4G(2的32次方)。操心繫統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的所有許可權。為了保證使用者行程不能直接操作核心,保證內核的安全,操心繫統將虛擬空間劃分為兩部分,一部分為核心空間,一部分為使用者空間。針對linux作業系統而言,將最高的1G位元組(從虛擬地址0xC0000000到0xFFFFFFFF),供核心使用,稱為核心空間,而將較低的3G位元組(從虛擬地址0x00000000到0xBFFFFFFF),供各個行程使用,稱為使用者空間。每個行程可以透過系統呼叫進入核心,因此,Linux核心由系統內的所有行程共享。於是,從具體行程的角度來看,每個行程可以擁有4G位元組的虛擬空間。
空間分配如下圖所示:
有了使用者空間和核心空間,整個linux內部結構可以分為三部分,從最底層到最上層依次是:硬體–>核心空間–>使用者空間。
如下圖所示:
需要註意的細節問題,從上圖可以看出內核的組成:
核心空間中存放的是核心程式碼和資料,而行程的使用者空間中存放的是使用者程式的程式碼和資料。不管是核心空間還是使用者空間,它們都處於虛擬空間中。
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呼叫:
-
buf = mmap(diskfd, len);
-
write(sockfd, buf, len);
應用程式呼叫mmap(),磁碟上的資料會透過DMA被複製的核心緩衝區,接著作業系統會把這段核心緩衝區與應用程式共享,這樣就不需要把核心緩衝區的內容往使用者空間複製。應用程式再呼叫write(),作業系統直接將核心緩衝區的內容複製到socket緩衝區中,這一切都發生在核心態,最後,socket緩衝區再把資料發到網絡卡去。
同樣的,看圖很簡單:
使用mmap替代read很明顯減少了一次複製,當複製資料量很大時,無疑提升了效率。但是使用mmap是有代價的。當你使用mmap時,你可能會遇到一些隱藏的陷阱。例如,當你的程式map了一個檔案,但是當這個檔案被另一個行程截斷(truncate)時, write系統呼叫會因為訪問非法地址而被SIGBUS訊號終止。SIGBUS訊號預設會殺死你的行程並產生一個coredump,如果你的伺服器這樣被中止了,那會產生一筆損失。
通常我們使用以下解決方案避免這種問題:
-
為SIGBUS訊號建立訊號處理程式 當遇到 SIGBUS訊號時,訊號處理程式簡單地傳回, write系統呼叫在被中斷之前會傳回已經寫入的位元組數,並且 errno會被設定成success,但是這是一種糟糕的處理辦法,因為你並沒有解決問題的實質核心。
-
使用檔案租借鎖 通常我們使用這種方法,在檔案描述符上使用租借鎖,我們為檔案向內核申請一個租借鎖,當其它行程想要截斷這個檔案時,核心會向我們傳送一個實時的 RTSIGNALLEASE訊號,告訴我們核心正在破壞你加持在檔案上的讀寫鎖。這樣在程式訪問非法記憶體並且被 SIGBUS殺死之前,你的 write系統呼叫會被中斷。 write會傳回已經寫入的位元組數,並且置 errno為success。 我們應該在 mmap檔案之前加鎖,並且在操作完檔案後解鎖:
1. if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
2. perror("kernel lease set signal");
3. return -1;
4. }
5. /* l_type can be F_RDLCK F_WRLCK 加鎖*/
6. /* l_type can be F_UNLCK 解鎖*/
7. if(fcntl(diskfd, F_SETLEASE, l_type)){
8. perror("kernel lease set type");
9. return -1;
10. }