什么是零拷貝
維基上是這么描述零拷貝的:零拷貝描述的是CPU不執行拷貝數據從一個存儲區域到另一個存儲區域的任務,這通常用于通過網絡傳輸一個文件時以減少CPU周期和內存帶寬。
零拷貝給我們帶來的好處:
- 減少甚至完全避免不必要的CPU拷貝,從而讓CPU解脫出來去執行其他的任務
- 減少內存帶寬的占用
- 通常零拷貝技術還能夠減少用戶空間和操作系統內核空間之間的上下文切換
Linux系統的“用戶空間”和“內核空間”
從Linux系統上看,除了引導系統的BIN區,整個內存空間主要被分成兩個部分:內核空間(Kernel space)、用戶空間(User space)。“用戶空間”和“內核空間”的空間、操作權限以及作用都是不一樣的。內核空間是Linux自身使用的內存空間,主要提供給程序調度、內存分配、連接硬件資源等程序邏輯使用;用戶空間則是提供給各個進程的主要空間。用戶空間不具有訪問內核空間資源的權限,因此如果應用程序需要使用到內核空間的資源,則需要通過系統調用來完成:從用戶空間切換到內核空間,然后在完成相關操作后再從內核空間切換回用戶空間。
Linux 中零拷貝技術的實現方向
① 直接 I/O:對于這種數據傳輸方式來說,應用程序可以直接訪問硬件存儲,操作系統內核只是輔助數據傳輸。這種方式依舊存在用戶空間和內核空間的上下文切換,但是硬件上的數據不會拷貝一份到內核空間,而是直接拷貝至了用戶空間,因此直接I/O不存在內核空間緩沖區和用戶空間緩沖區之間的數據拷貝。
② 在數據傳輸過程中,避免數據在用戶空間緩沖區和系統內核空間緩沖區之間的CPU拷貝,以及數據在系統內核空間內的CPU拷貝。本文主要討論的就是該方式下的零拷貝機制。
③ copy-on-write(寫時復制技術):在某些情況下,Linux操作系統的內核空間緩沖區可能被多個應用程序所共享,操作系統有可能會將用戶空間緩沖區地址映射到內核空間緩存區中。當應用程序需要對共享的數據進行修改的時候,才需要真正地拷貝數據到應用程序的用戶空間緩沖區中,并且對自己用戶空間的緩沖區的數據進行修改不會影響到其他共享數據的應用程序。所以,如果應用程序不需要對數據進行任何修改的話,就不會存在數據從系統內核空間緩沖區拷貝到用戶空間緩沖區的操作。
注意,對于各種零拷貝機制是否能夠實現都是依賴于操作系統底層是否提供相應的支持。
零拷貝機制的原理
下面我們通過一個Java非常常見的應用場景:將系統中的文件發送到遠端(該流程涉及:磁盤上文件 ——> 內存(字節數組) ——> 傳輸給用戶/網絡)來詳細展開傳統I/O操作和通過零拷貝來實現的I/O操作。
傳統I/O
① 發出read系統調用:導致用戶空間到內核空間的上下文切換(第一次上下文切換)。通過DMA引擎將文件中的數據從磁盤上讀取到內核空間緩沖區(第一次拷貝: hard drive ——> kernel buffer)。
② 將內核空間緩沖區的數據拷貝到用戶空間緩沖區(第二次拷貝: kernel buffer ——> user buffer),然后read系統調用返回。而系統調用的返回又會導致一次內核空間到用戶空間的上下文切換(第二次上下文切換)。
③ 發出write系統調用:導致用戶空間到內核空間的上下文切換(第三次上下文切換)。將用戶空間緩沖區中的數據拷貝到內核空間中與socket相關聯的緩沖區中(即,第②步中從內核空間緩沖區拷貝而來的數據原封不動的再次拷貝到內核空間的socket緩沖區中。)(第三次拷貝: user buffer ——> socket buffer)。
④ write系統調用返回,導致內核空間到用戶空間的再次上下文切換(第四次上下文切換)。通過DMA引擎將內核緩沖區中的數據傳遞到協議引擎(第四次拷貝: socket buffer ——> protocol engine),這次拷貝是一個獨立且異步的過程。
Q:你可能會問獨立和異步這是什么意思?難道是調用會在數據被傳輸前返回?
A:事實上調用的返回并不保證數據被傳輸;它甚至不保證傳輸的開始。它只是意味著將我們要發送的數據放入到了一個待發送的隊列中,在我們之前可能有許多數據包在排隊。除非驅動器或硬件實現優先級環或隊列,否則數據是以先進先出的方式傳輸的。
總的來說,傳統的I/O操作進行了4次用戶空間與內核空間的上下文切換,以及4次數據拷貝。其中4次數據拷貝中包括了2次DMA拷貝和2次CPU拷貝。
Q: 傳統I/O模式為什么將數據從磁盤讀取到內核空間緩沖區,然后再將數據從內核空間緩沖區拷貝到用戶空間緩沖區了?為什么不直接將數據從磁盤讀取到用戶空間緩沖區就好?
A: 傳統I/O模式之所以將數據從磁盤讀取到內核空間緩沖區而不是直接讀取到用戶空間緩沖區,是為了減少磁盤I/O操作以此來提高性能。因為OS會根據局部性原理在一次read()系統調用的時候預讀取更多的文件數據到內核空間緩沖區中,這樣當下一次read()系統調用的時候發現要讀取的數據已經存在于內核空間緩沖區中的時候只要直接拷貝數據到用戶空間緩沖區中即可,無需再進行一次低效的磁盤I/O操作(注意:磁盤I/O操作的速度比直接訪問內存慢了好幾個數量級)。
Q: 既然系統內核緩沖區能夠減少磁盤I/O操作,那么我們經常使用的BufferedInputStream緩沖區又是用來干啥的?
A: BufferedInputStream的作用是會根據情況自動為我們預取更多的數據到它自己維護的一個內部字節數據緩沖區中,這樣做能夠減少系統調用的次數以此來提供性能。
總的來說內核空間緩沖區的一大用處是為了減少磁盤I/O操作,因為它會從磁盤中預讀更多的數據到緩沖區中。而BufferedInputStream的用處是減少“系統調用”。
DMA
DMA(Direct Memory Access) ———— 直接內存訪問 :DMA是允許外設組件將I/O數據直接傳送到主存儲器中并且傳輸不需要CPU的參與,以此將CPU解放出來去完成其他的事情。
而用戶空間與內核空間之間的數據傳輸并沒有類似DMA這種可以不需要CPU參與的傳輸工具,因此用戶空間與內核空間之間的數據傳輸是需要CPU全程參與的。所有也就有了通過零拷貝技術來減少和避免不必要的CPU數據拷貝過程。
通過sendfile實現的零拷貝I/O
① 發出sendfile系統調用,導致用戶空間到內核空間的上下文切換(第一次上下文切換)。通過DMA引擎將磁盤文件中的內容拷貝到內核空間緩沖區中(第一次拷貝: hard drive ——> kernel buffer)。然后再將數據從內核空間緩沖區拷貝到內核中與socket相關的緩沖區中(第二次拷貝: kernel buffer ——> socket buffer)。
② sendfile系統調用返回,導致內核空間到用戶空間的上下文切換(第二次上下文切換)。通過DMA引擎將內核空間socket緩沖區中的數據傳遞到協議引擎(第三次拷貝: socket buffer ——> protocol engine)
總的來說,通過sendfile實現的零拷貝I/O只使用了2次用戶空間與內核空間的上下文切換,以及3次數據的拷貝。其中3次數據拷貝中包括了2次DMA拷貝和1次CPU拷貝。
Q:但通過是這里還是存在著一次CPU拷貝操作,即,kernel buffer ——> socket buffer。是否有辦法將該拷貝操作也取消掉了?
A:有的。但這需要底層操作系統的支持。從Linux 2.4版本開始,操作系統底層提供了scatter/gather這種DMA的方式來從內核空間緩沖區中將數據直接讀取到協議引擎中,而無需將內核空間緩沖區中的數據再拷貝一份到內核空間socket相關聯的緩沖區中。
帶有DMA收集拷貝功能的sendfile實現的I/O
從Linux 2.4版本開始,操作系統底層提供了帶有scatter/gather的DMA來從內核空間緩沖區中將數據讀取到協議引擎中。這樣一來待傳輸的數據可以分散在存儲的不同位置上,而不需要在連續存儲中存放。那么從文件中讀出的數據就根本不需要被拷貝到socket緩沖區中去,只是需要將緩沖區描述符添加到socket緩沖區中去,DMA收集操作會根據緩沖區描述符中的信息將內核空間中的數據直接拷貝到協議引擎中。
① 發出sendfile系統調用,導致用戶空間到內核空間的上下文切換(第一次上下文切換)。通過DMA引擎將磁盤文件中的內容拷貝到內核空間緩沖區中(第一次拷貝: hard drive ——> kernel buffer)。
② 沒有數據拷貝到socket緩沖區。取而代之的是只有相應的描述符信息會被拷貝到相應的socket緩沖區當中。該描述符包含了兩方面的信息:a)kernel buffer的內存地址;b)kernel buffer的偏移量。
③ sendfile系統調用返回,導致內核空間到用戶空間的上下文切換(第二次上下文切換)。DMA gather copy根據socket緩沖區中描述符提供的位置和偏移量信息直接將內核空間緩沖區中的數據拷貝到協議引擎上(第二次拷貝: kernel buffer ——> protocol engine),這樣就避免了最后一次CPU數據拷貝。
總的來說,帶有DMA收集拷貝功能的sendfile實現的I/O只使用了2次用戶空間與內核空間的上下文切換,以及2次數據的拷貝,而且這2次的數據拷貝都是非CPU拷貝。這樣一來我們就實現了最理想的零拷貝I/O傳輸了,不需要任何一次的CPU拷貝,以及最少的上下文切換。
關于sendfile:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
在linux2.6.33版本之前 sendfile指支持文件到套接字之間傳輸數據,即in_fd相當于一個支持mmap的文件,out_fd必須是一個socket。但從linux2.6.33版本開始,out_fd可以是任意類型文件描述符。所以從linux2.6.33版本開始sendfile可以支持“文件到文件”和“文件到套接字”之間的數據傳輸。
"傳統I/O” VS “sendfile零拷貝I/O”
- 傳統I/O通過兩條系統指令read、write來完成數據的讀取和傳輸操作,以至于產生了4次用戶空間與內核空間的上下文切換的開銷;而sendfile只使用了一條指令就完成了數據的讀寫操作,所以只產生了2次用戶空間與內核空間的上下文切換。
- 傳統I/O產生了2次無用的CPU拷貝,即內核空間緩存中數據與用戶空間緩沖區間數據的拷貝;而sendfile最多只產出了一次CPU拷貝,即內核空間內之間的數據拷貝,甚至在底層操作體系支持的情況下,sendfile可以實現零CPU拷貝的I/O。
- 因傳統I/O用戶空間緩沖區中存有數據,因此應用程序能夠對此數據進行修改等操作;而sendfile零拷貝消除了所有內核空間緩沖區與用戶空間緩沖區之間的數據拷貝過程,因此sendfile零拷貝I/O的實現是完成在內核空間中完成的,這對于應用程序來說就無法對數據進行操作了。
Q:對于上面的第三點,如果我們需要對數據進行操作該怎么辦了?
A:Linux提供了mmap零拷貝來實現我們的需求。
通過mmap實現的零拷貝I/O
mmap(內存映射)是一個比sendfile昂貴但優于傳統I/O的方法。
① 發出mmap系統調用,導致用戶空間到內核空間的上下文切換(第一次上下文切換)。通過DMA引擎將磁盤文件中的內容拷貝到內核空間緩沖區中(第一次拷貝: hard drive ——> kernel buffer)。
② mmap系統調用返回,導致內核空間到用戶空間的上下文切換(第二次上下文切換)。接著用戶空間和內核空間共享這個緩沖區,而不需要將數據從內核空間拷貝到用戶空間。因為用戶空間和內核空間共享了這個緩沖區數據,所以用戶空間就可以像在操作自己緩沖區中數據一般操作這個由內核空間共享的緩沖區數據。
③ 發出write系統調用,導致用戶空間到內核空間的上下文切換(第三次上下文切換)。將數據從內核空間緩沖區拷貝到內核空間socket相關聯的緩沖區(第二次拷貝: kernel buffer ——> socket buffer)。
④ write系統調用返回,導致內核空間到用戶空間的上下文切換(第四次上下文切換)。通過DMA引擎將內核空間socket緩沖區中的數據傳遞到協議引擎(第三次拷貝: socket buffer ——> protocol engine)
總的來說,通過mmap實現的零拷貝I/O進行了4次用戶空間與內核空間的上下文切換,以及3次數據拷貝。其中3次數據拷貝中包括了2次DMA拷貝和1次CPU拷貝。
FileChannel與零拷貝
FileChannel中大量使用了我們上面所提及的零拷貝技術。
FileChannel的map方法會返回一個MappedByteBuffer。MappedByteBuffer是一個直接字節緩沖器,該緩沖器的內存是一個文件的內存映射區域。map方法底層是通過mmap實現的,因此將文件內存從磁盤讀取到內核緩沖區后,用戶空間和內核空間共享該緩沖區。
MappedByteBuffer內存映射文件是一種允許Java程序直接從內存訪問的一種特殊的文件。我們可以將整個文件或者整個文件的一部分映射到內存當中,那么接下來是由操作系統來進行相關的頁面請求并將內存的修改寫入到文件當中。我們的應用程序只需要處理內存的數據,這樣可以實現非常迅速的I/O操作。
FileChannel map的三種模式
- 只讀模式
/**
* Mode for a read-only mapping.
*/
public static final MapMode READ_ONLY = new MapMode("READ_ONLY");
只讀模式來說,如果程序試圖進行寫操作,則會拋出ReadOnlyBufferException異常
- 讀寫模式
/**
* Mode for a read/write mapping.
*/
public static final MapMode READ_WRITE = new MapMode("READ_WRITE");
讀寫模式表明,對結果對緩沖區所做的修改將最終廣播到文件。但這個修改可能會也可能不會被其他映射了相同文件程序可見。
- 專用模式
/**
* Mode for a private (copy-on-write) mapping.
*/
public static final MapMode PRIVATE = new MapMode("PRIVATE");
私有模式來說,對結果緩沖區的修改將不會被廣播到文件并且也不會對其他映射了相同文件的程序可見。取而代之的是,它將導致被修改部分緩沖區獨自拷貝一份到用戶空間。這便是OS的“copy on write”原則。
FileChannel的transferTo、transferFrom
如果操作系統底層支持的話transferTo、transferFrom也會使用相關的零拷貝技術來實現數據的傳輸。所以,這里是否使用零拷貝必須依賴于底層的系統實現。
后記
本文是通過視頻學習以及大量資料查詢后對零拷貝機制進行的一個非常膚淺的知識梳理,至少個人是這么覺得。通過這次的學習,對Linux操作系統又多了一丟丟的了解,也希望在之后的學習中能對Linux系統有更近一步的深入的理解。非常歡迎大家對文中的不足和錯誤進行指點~
參考:
It's all about buffers: zero-copy, mmap and Java NIO
Zero Copy I: User-Mode Perspective
Linux Programmer's Manual SENDFILE(2)
Linux 中的零拷貝技術,第 1 部分
Linux 中的零拷貝技術,第 2 部分
圣思園《精通并發與Netty》