Linux中傳統的I/O操作是一種緩存I/O,I/O過程中產生的數據傳輸通常需要在緩沖區中進行多次拷貝。當應用程序需要訪問某個數據(read()操作)時,操作系統會先判斷這塊數據是否在內核緩沖區中,如果在內核緩沖區中找不到這塊數據,內核會先將這塊數據從磁盤中讀出來放到內核緩沖區中,應用程序再從緩沖區中讀取。當應用程序需要將數據輸出(write())時,同樣需要先將數據拷貝到輸出堆棧相關的內核緩沖區,再從內核緩沖區拷貝到輸出設備中。


while((n = read(diskfd, buf, BUF_SIZE)) > 0)

? ?write(sockfd, buf , n);

以一次網絡請求為例,如下圖。對于一次數據讀取,用戶應用程序只需要調用read()及write()兩個系統調用就可以完成一次數據傳輸,但這個過程中數據經過了四次拷貝,且數據拷貝需要由CPU來調控。在某些情況下,這些數據拷貝會極大地降低系統數據傳輸的性能,比如文件服務器中,一個文件從磁盤讀取后不加修改地回傳給調用方,那么這占用CPU時間去處理這四次數據拷貝的性價比是極低的。


一次處理網絡調用的系統I/O的流程:


發出read()系統調用,導致應用程序空間到內核空間的上下文切換,將文件數據從磁盤上讀取到內核空間緩沖區。

將內核空間緩沖區的數據拷貝到應用程序空間緩沖區,read()系統調用返回,導致內核空間到應用程序空間的上下文切換。

發出write()系統調用,導致應用程序空間到內核空間的上下文切換,將應用程序緩沖區的數據拷貝到網絡堆棧相關的內核緩沖區(socket緩沖區)。

write()系統調用返回,導致內核空間到應用程序空間的上下文切換,數據被拷貝到網卡子系統進行打包發送。

以上可以發現,傳統的Linux系統I/O 操作要進行4次內核空間與應用程序空間的上下文切換,以及4次數據拷貝。



image.png

硬件系統的支持


DMA


直接內存訪問(Direct Memory Access,DMA)是計算機科學中的一種內存訪問技術,允許某些電腦內部的硬件子系統獨立地讀取系統內存,而不需要中央處理器(CPU)的介入。在同等程度的處理器負擔下,DMA是一種快速的數據傳送方式。這類子系統包括硬盤控制器、顯卡、網卡和聲卡。


文件cache(buffer cache/page cache)


在Linux系統中,當應用程序需要讀取文件中的數據時,操作系統先分配一些內存,將數據從存儲設備讀入到這些內存中,然后再將數據傳遞應用進程;當需要往文件中寫數據時,操作系統先分配內存接收用戶數據,然后再將數據從內存寫入磁盤。文件cache管理就是對這些由操作系統分配并用開存儲文件數據的內存的管理。


在Linux系統中,文件cache分為兩個層面,page cache 與 Buffer cache,每個page cache包含若干個buffer cache。操作系統中,磁盤文件都是由一系列的數據塊(Block)組成,buffer cache也叫塊緩存,是對磁盤一個數據塊的緩存,目的是為了在程序多次訪問同一個磁盤塊時減少訪問時間;而文件系統對數據的組織形式為頁,page cache為頁緩存,是由多個塊緩存構成,其對應的緩存數據塊在磁盤上不一定是連續的。也就是說buffer cache緩存文件的具體內容--物理磁盤上的磁盤塊,加速對磁盤的訪問,而page cache緩存文件的邏輯內容,加速對文件內容的訪問。


buffer cache的大小一般為1k,page cache在32位系統上一般為4k,在64位系統上一般為8k。磁盤數據塊、buffer cache、page cache及文件的關系如下圖:



image.png

文件cache的目的是加快對數據文件的訪問,同時會有一個預讀過程。對于每個文件的第一次讀請求,系統會讀入所請求的頁面并讀入緊隨其后的幾個頁面;對于第二次讀請求,如果所讀頁面在cache中,則會直接返回,同時又一個異步預讀的過程(將讀取頁面的下幾頁讀入cache中),如果不在cache中,說明讀請求不是順序讀,則會從磁盤中讀取文件內容并刷新cache。因此在順序讀取情況下,讀取數據的性能近乎內存讀取。


DMA允許硬件子系統直接將數據從磁盤讀取到內核緩沖區,那么在一次數據傳輸中,磁盤與內核緩沖區,輸出設備與內核緩沖區之間的兩次數據拷貝就不需要CPU進行調度,CPU只需要進行緩沖區管理、以及創建和處理DMA。而Page Cache/Buffer Cache的預讀取機制則加快了數據的訪問效率。如下圖所示,還是以文件服務器請求為例,此時CPU負責的數據拷貝次數減少了兩次,數據傳輸性能有了較大的提高。


使用DMA的系統I/O操作要進行4次內核空間與應用程序空間的上下文切換,2次CPU數據拷貝及2次DMA數據拷貝。



image.png

系統調用的豐富


Mmap


mmap()系統調用


Mmap內存映射與標準I/O操作的區別在于當應用程序需要訪問數據時,不需要進行內核緩沖區到應用程序緩沖區之間的數據拷貝。Mmap使得應用程序和操作系統共享內核緩沖區,應用程序直接對內核緩沖區進行讀寫操作,不需要進行數據拷貝。Linux系統中通過調用mmap()替代read()操作。


tmp_buf = mmap(file, len);

write(socket, tmp_buf, len);

同樣以文件服務器獲取文件(不加修改)為例,通過mmap操作的一次系統I/O過程如下:


應用程序發出mmap()系統調用,如果文件數據不在緩沖區中,通過DMA將磁盤文件中的內容拷貝到內核緩沖區(頁緩存)。

mmap系統調用返回,應用程序空間與內核空間共享這個緩沖區,應用程序可以像操作應用程序緩沖區中的數據一樣操作這個由內核空間共享的緩沖區數據。

應用程序發出write()系統調用,將數據從內核緩沖區拷貝到內核空間網絡堆棧相關聯的緩沖區(如socket緩沖區)。

write系統調用返回,通過DMA將socket緩沖區中的數據傳遞給網卡子系統進行打包發送。

通過以上流程可以看到,數據拷貝從原來的4次變為3次,2次DMA拷貝1次內核空間數據拷貝,CPU只需要調控1次內核空間之間的數據拷貝,CPU花費在數據拷貝上的時間進一步減少(4次上下文切換沒有改變)。對于大容量文件讀寫,采用mmap的方式其讀寫效率和性能都比較高。(數據頁較多,需要多次拷貝)



image.png

注:mmap()是讓應用程序空間與內核空間共享DMA從磁盤中讀取的文件緩沖,也就是應用程序能直接讀寫這部分PageCache,至于上圖中從頁緩存到socket緩沖區的數據拷貝只是文件服務器的處理,根據應用程序的不同會有不同的處理,應用程序也可以讀取數據后進行修改。重點是虛擬內存映射,內核緩存共享。


JDK NIO MappedByteBuffer 與 mmap


djk中nio包下的MappedByteBuffer,官方注釋為A direct byte buffer whose content is a memory-mapped region of a file,即直接字節緩沖區,其內容是文件的內存映射區域。FileChannel是是nio操作文件的類,其map()方法在在實現類中調用native map0()本地方法,該方法通過mmap()實現,因此是將文件從磁盤讀取到內核緩沖區,用戶應用程序空間直接操作內核空間共享的緩沖區,Java程序通過MappedByteBuffer的get()方法獲取內存數據。


MappedByteBuffer允許Java程序直接從內存訪問文件,可以將整個文件或文件的一部分映射到內存中,由操作系統進行相關的請求并將內存中的修改寫入到磁盤中。


FileChannel map有三種模式


// 只讀模式,不能進行寫操作

public static final MapMode READ_ONLY = new MapMode("READ_ONLY");


// 可讀可寫模式,對緩沖區的修改最終會同步到文件中

public static final MapMode READ_WRITE = new MapMode("READ_WRITE");


// 私有模式,對緩沖區數據進行修改時,被修改部分緩沖區會拷貝一份到應用程序空間,copy-on-write

public static final MapMode PRIVATE = new MapMode("PRIVATE");

MappedByteBuffer的應用,以rocketMQ為例(簡單介紹)。


producer端發送消息最終會被寫入到commitLog文件中,consumer端消費時先從訂閱的consumeQueue中讀取持久化消息的commitLogOffset、size等內容,隨后再根據offset、size從commitLog中讀取消息的真正實體內容。其中,commitLog是混合部署的,所有topic下的消息隊列共用一個commitLog日志數據文件,consumeQueue類似于索引,同時區分開不同topic下不同MessageQueue的消息。


rocketMQ利用MappedByteBuffer及PageCache加速對持久化文件的讀寫操作。rocketMQ通過MappedByteBuffer將日志數據文件映射到OS的虛擬內存中(PageCache),寫消息時首先寫入PageCache,通過刷盤方式(異步或同步)將消息批量持久化到磁盤;consumer消費消息時,讀取consumeQueue是順序讀取的,雖然有多個消費者操作不同的consumeQueue,對混合部署的commitLog的訪問時隨機的,但整體上是從舊到新的有序讀,加上PageCache的預讀機制,大部分情況下消息還是從PageCache中讀取,不會產生太多的缺頁中斷(要讀取的消息不在pageCache中)而從磁盤中讀取。


rocketMQ利用mmap()使程序與內核空間共享內核緩沖區,直接對PageCache中的文件進行讀寫操作,加速對消息的讀寫請求,這是其高吞吐量的重要手段。



image.png

Mmap的問題


使用mmap能減少CPU數據拷貝的次數,但也存在一些問題。


當對文件進行內存映射,然后調用write(),如果此時有其他進程截斷(truncate)了這個文件,write()調用會因為訪問非法地址而被SIGBUS信號終止,SIGBUS信號默認會殺掉進程。解決方法一個是設置新的信號處理器,當發生上述情況時不結束進程;另一個是在對文件進行內存映射前使用文件租借鎖。

mmap內存映射的大小是有限制的,其大小受OS虛擬內存大小的限制,一般只能映射1.5-2G的文件至用戶態的虛擬內存空間。對于MappedByteBuffer來說,虛擬內存是不屬于jvm堆內存的,所以大小不受JVM的-Xmx參數限制,且Java應用程序無法占用全部的虛擬內存(其他進程使用)。這也是rocketMQ默認設置單個commitLog日志數據文件大小為1G的原因。

sendfile


sendfile()流程


從Linux2.1開始,Linux引入sendfile()簡化操作。取消read()/write(),mmap()/write()。


ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

調用sendfile的流程如下:


應用程序調用sendfile(),導致應用程序空間到內核空間的上下文切換,數據如果不在緩存中,則通過DMA將數據從磁盤拷貝到內核緩沖區,然后再將數據從內核緩沖區拷貝到內核空間socket緩沖區中。

sendfile()返回,導致內核空間到應用程序空間的上下文切換。通過DMA將內核空間socket緩沖區的數據發送到網卡子系統進行打包發送。


image.png

通過sendfile()的I/O進行了2次應用程序空間與內核空間的上下文切換,以及3次數據拷貝,其中2次是DMA拷貝,1次是CPU拷貝。sendfile相比起mmap,數據信息沒有進入到應用程序空間,所以能減少2次上下文切換的開銷,而數據拷貝次數是一樣的。


上述流程也可以看出,sendfile()適合對文件不加修改的I/O操作。


帶有DMA收集拷貝功能的sendfile


sendfile()只是減少應用程序空間與內核空間的上下文切換,并沒有減少CPU數據拷貝的次數,還存在一次內核空間的兩個緩沖區的數據拷貝。要實現CPU零數據拷貝,需要引入一些硬件上的支持。在上一小節的sendfile流程中,數據需要從內核緩沖區拷貝到內核空間socket緩沖區,數據都是在內核空間,如果socket緩沖區到網卡的這次DMA數據傳輸操作能直接讀取到內核緩沖區中的數據,那么這一次的CPU數據拷貝也就能避免。要達到這個目的,DMA需要知道存有文件位置和長度信息的緩沖區描述符,即socket緩沖區需要從內核緩沖區接收這部分信息,DMA需要支持數據收集功能。