零拷貝是老生常談的問題啦,大廠非常喜歡問。比如Kafka為什麼快,RocketMQ為什麼快等,都涉及到零拷貝知識點。最近技術讨論群幾個夥伴分享了阿裡、蝦皮的面試真題,也都涉及到零拷貝。是以本文将跟大家一起來學習零拷貝原理。
1. 什麼是零拷貝2. 傳統的IO執行流程3. 零拷貝相關的知識點回顧4. 零拷貝實作的幾種方式5. java提供的零拷貝方式
1.什麼是零拷貝
零拷貝字面上的意思包括兩個,“零”和“拷貝”:
- “拷貝”:就是指資料從一個存儲區域轉移到另一個存儲區域。
- “零” :表示次數為0,它表示拷貝資料的次數為0。
合起來,那零拷貝就是不需要将資料從一個存儲區域複制到另一個存儲區域咯。
零拷貝是指計算機執行IO操作時,CPU不需要将資料從一個存儲區域複制到另一個存儲區域,進而可以減少上下文切換以及CPU的拷貝時間。它是一種 I/O
操作優化技術。
2. 傳統 IO 的執行流程
做服務端開發的小夥伴,檔案下載下傳功能應該實作過不少了吧。如果你實作的是一個web程式,前端請求過來,服務端的任務就是:将服務端主機磁盤中的檔案從已連接配接的socket發出去。關鍵實作代碼如下:
while((n = read(diskfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf , n);
傳統的IO流程,包括read和write的過程。
-
:把資料從磁盤讀取到核心緩沖區,再拷貝到使用者緩沖區read
-
:先把資料寫入到socket緩沖區,最後寫入網卡裝置。write
流程圖如下:
- 使用者應用程序調用read函數,向作業系統發起IO調用,上下文從使用者态轉為核心态(切換1)
- DMA控制器把資料從磁盤中,讀取到核心緩沖區。
- CPU把核心緩沖區資料,拷貝到使用者應用緩沖區,上下文從核心态轉為使用者态(切換2),read函數傳回
- 使用者應用程序通過write函數,發起IO調用,上下文從使用者态轉為核心态(切換3)
- CPU将使用者緩沖區中的資料,拷貝到socket緩沖區
- DMA控制器把資料從socket緩沖區,拷貝到網卡裝置,上下文從核心态切換回使用者态(切換4),write函數傳回
從流程圖可以看出,傳統IO的讀寫流程,包括了4次上下文切換(4次使用者态和核心态的切換),4次資料拷貝(兩次CPU拷貝以及兩次的DMA拷貝),什麼是DMA拷貝呢?我們一起來回顧下,零拷貝涉及的作業系統知識點哈。
3. 零拷貝相關的知識點回顧
3.1 核心空間和使用者空間
我們電腦上跑着的應用程式,其實是需要經過作業系統,才能做一些特殊操作,如磁盤檔案讀寫、記憶體的讀寫等等。因為這些都是比較危險的操作,不可以由應用程式亂來,隻能交給底層作業系統來。
是以,作業系統為每個程序都配置設定了記憶體空間,一部分是使用者空間,一部分是核心空間。核心空間是作業系統核心通路的區域,是受保護的記憶體空間,而使用者空間是使用者應用程式通路的記憶體區域。 以32位作業系統為例,它會為每一個程序都配置設定了4G(2的32次方)的記憶體空間。
- 核心空間:主要提供程序排程、記憶體配置設定、連接配接硬體資源等功能
- 使用者空間:提供給各個程式程序的空間,它不具有通路核心空間資源的權限,如果應用程式需要使用到核心空間的資源,則需要通過系統調用來完成。程序從使用者空間切換到核心空間,完成相關操作後,再從核心空間切換回使用者空間。
3.2 什麼是使用者态、核心态
- 如果程序運作于核心空間,被稱為程序的核心态
- 如果程序運作于使用者空間,被稱為程序的使用者态。
3.3 什麼是上下文切換
- 什麼是CPU上下文?
CPU 寄存器,是CPU内置的容量小、但速度極快的記憶體。而程式計數器,則是用來存儲 CPU 正在執行的指令位置、或者即将執行的下一條指令位置。它們都是 CPU 在運作任何任務前,必須的依賴環境,是以叫做CPU上下文。
- 什麼是CPU上下文切換?
它是指,先把前一個任務的CPU上下文(也就是CPU寄存器和程式計數器)儲存起來,然後加載新任務的上下文到這些寄存器和程式計數器,最後再跳轉到程式計數器所指的新位置,運作新任務。
一般我們說的上下文切換,就是指核心(作業系統的核心)在CPU上對程序或者線程進行切換。程序從使用者态到核心态的轉變,需要通過系統調用來完成。系統調用的過程,會發生CPU上下文的切換。
CPU 寄存器裡原來使用者态的指令位置,需要先儲存起來。接着,為了執行核心态代碼,CPU 寄存器需要更新為核心态指令的新位置。最後才是跳轉到核心态運作核心任務。
3.4 虛拟記憶體
現代作業系統使用虛拟記憶體,即虛拟位址取代實體位址,使用虛拟記憶體可以有2個好處:
- 虛拟記憶體空間可以遠遠大于實體記憶體空間
- 多個虛拟記憶體可以指向同一個實體位址
正是多個虛拟記憶體可以指向同一個實體位址,可以把核心空間和使用者空間的虛拟位址映射到同一個實體位址,這樣的話,就可以減少IO的資料拷貝次數啦,示意圖如下
3.5 DMA技術
DMA,英文全稱是Direct Memory Access,即直接記憶體通路。DMA本質上是一塊主機闆上獨立的晶片,允許外設裝置和記憶體存儲器之間直接進行IO資料傳輸,其過程不需要CPU的參與。
我們一起來看下IO流程,DMA幫忙做了什麼事情.
- 使用者應用程序調用read函數,向作業系統發起IO調用,進入阻塞狀态,等待資料傳回。
- CPU收到指令後,對DMA控制器發起指令排程。
- DMA收到IO請求後,将請求發送給磁盤;
- 磁盤将資料放入磁盤控制緩沖區,并通知DMA
- DMA将資料從磁盤控制器緩沖區拷貝到核心緩沖區。
- DMA向CPU發出資料讀完的信号,把工作交換給CPU,由CPU負責将資料從核心緩沖區拷貝到使用者緩沖區。
- 使用者應用程序由核心态切換回使用者态,解除阻塞狀态
可以發現,DMA做的事情很清晰啦,它主要就是幫忙CPU轉發一下IO請求,以及拷貝資料。為什麼需要它的?
主要就是效率,它幫忙CPU做事情,這時候,CPU就可以閑下來去做别的事情,提高了CPU的利用效率。大白話解釋就是,CPU老哥太忙太累啦,是以他找了個小弟(名叫DMA) ,替他完成一部分的拷貝工作,這樣CPU老哥就能着手去做其他事情。
4. 零拷貝實作的幾種方式
零拷貝并不是沒有拷貝資料,而是減少使用者态/核心态的切換次數以及CPU拷貝的次數。零拷貝實作有多種方式,分别是
- mmap+write
- sendfile
- 帶有DMA收集拷貝功能的sendfile
4.1 mmap+write實作的零拷貝
mmap 的函數原型如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
- addr:指定映射的虛拟記憶體位址
- length:映射的長度
- prot:映射記憶體的保護模式
- flags:指定映射的類型
- fd:進行映射的檔案句柄
- offset:檔案偏移量
前面一小節,零拷貝相關的知識點回顧,我們介紹了虛拟記憶體,可以把核心空間和使用者空間的虛拟位址映射到同一個實體位址,進而減少資料拷貝次數!mmap就是用了虛拟記憶體這個特點,它将核心中的讀緩沖區與使用者空間的緩沖區進行映射,所有的IO都在核心中完成。
mmap+write
實作的零拷貝流程如下:
- 使用者程序通過
向作業系統核心發起IO調用,上下文從使用者态切換為核心态。mmap方法
- CPU利用DMA控制器,把資料從硬碟中拷貝到核心緩沖區。
- 上下文從核心态切換回使用者态,mmap方法傳回。
- 使用者程序通過
方法向作業系統核心發起IO調用,上下文從使用者态切換為核心态。write
- CPU将核心緩沖區的資料拷貝到的socket緩沖區。
- CPU利用DMA控制器,把資料從socket緩沖區拷貝到網卡,上下文從核心态切換回使用者态,write調用傳回。
可以發現,
mmap+write
實作的零拷貝,I/O發生了4次使用者空間與核心空間的上下文切換,以及3次資料拷貝。其中3次資料拷貝中,包括了2次DMA拷貝和1次CPU拷貝。
mmap
是将讀緩沖區的位址和使用者緩沖區的位址進行映射,核心緩沖區和應用緩沖區共享,是以節省了一次CPU拷貝‘’并且使用者程序記憶體是虛拟的,隻是映射到核心的讀緩沖區,可以節省一半的記憶體空間。
4.2 sendfile實作的零拷貝
sendfile
是Linux2.1核心版本後引入的一個系統調用函數,API如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
- out_fd:為待寫入内容的檔案描述符,一個socket描述符。,
- in_fd:為待讀出内容的檔案描述符,必須是真實的檔案,不能是socket和管道。
- offset:指定從讀入檔案的哪個位置開始讀,如果為NULL,表示檔案的預設起始位置。
- count:指定在fdout和fdin之間傳輸的位元組數。
sendfile表示在兩個檔案描述符之間傳輸資料,它是在作業系統核心中操作的,避免了資料從核心緩沖區和使用者緩沖區之間的拷貝操作,是以可以使用它來實作零拷貝。
sendfile實作的零拷貝流程如下:
sendfile實作的零拷貝
- 使用者程序發起sendfile系統調用,上下文(切換1)從使用者态轉向核心态
- DMA控制器,把資料從硬碟中拷貝到核心緩沖區。
- CPU将讀緩沖區中資料拷貝到socket緩沖區
- DMA控制器,異步把資料從socket緩沖區拷貝到網卡,
- 上下文(切換2)從核心态切換回使用者态,sendfile調用傳回。
可以發現,
sendfile
實作的零拷貝,I/O發生了2次使用者空間與核心空間的上下文切換,以及3次資料拷貝。其中3次資料拷貝中,包括了2次DMA拷貝和1次CPU拷貝。那能不能把CPU拷貝的次數減少到0次呢?有的,即
帶有DMA收集拷貝功能的sendfile
!
4.3 sendfile+DMA scatter/gather實作的零拷貝
linux 2.4版本之後,對
sendfile
做了優化更新,引入SG-DMA技術,其實就是對DMA拷貝加入了
scatter/gather
操作,它可以直接從核心空間緩沖區中将資料讀取到網卡。使用這個特點搞零拷貝,即還可以多省去一次CPU拷貝。
sendfile+DMA scatter/gather實作的零拷貝流程如下:
- 使用者程序發起sendfile系統調用,上下文(切換1)從使用者态轉向核心态
- DMA控制器,把資料從硬碟中拷貝到核心緩沖區。
- CPU把核心緩沖區中的檔案描述符資訊(包括核心緩沖區的記憶體位址和偏移量)發送到socket緩沖區
- DMA控制器根據檔案描述符資訊,直接把資料從核心緩沖區拷貝到網卡
- 上下文(切換2)從核心态切換回使用者态,sendfile調用傳回。
可以發現,
sendfile+DMA scatter/gather
實作的零拷貝,I/O發生了2次使用者空間與核心空間的上下文切換,以及2次資料拷貝。其中2次資料拷貝都是包DMA拷貝。這就是真正的 零拷貝(Zero-copy) 技術,全程都沒有通過CPU來搬運資料,所有的資料都是通過DMA來進行傳輸的。
5. java提供的零拷貝方式
- Java NIO對mmap的支援
- Java NIO對sendfile的支援
5.1 Java NIO對mmap的支援
Java NIO有一個
MappedByteBuffer
的類,可以用來實作記憶體映射。它的底層是調用了Linux核心的mmap的API。
public class MmapTest {
public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//資料傳輸
writeChannel.write(data);
readChannel.close();
writeChannel.close();
}catch (Exception e){
System.out.println(e.getMessage());
}
}
}
5.2 Java NIO對sendfile的支援
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
public class SendFileTest {
public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
long len = readChannel.size();
long position = readChannel.position();
FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//資料傳輸
readChannel.transferTo(position, len, writeChannel);
readChannel.close();
writeChannel.close();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}