什么是零拷貝
關(guān)于零拷貝,WIKI 上給出的定義如下:
「Zero-copy」 describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
所謂「零拷貝」描述的是計(jì)算機(jī)操作系統(tǒng)當(dāng)中,CPU不執(zhí)行將數(shù)據(jù)從一個(gè)內(nèi)存區(qū)域,拷貝到另外一個(gè)內(nèi)存區(qū)域的任務(wù)。通過(guò)網(wǎng)絡(luò)傳輸文件時(shí),這樣通??梢怨?jié)省 CPU 周期和內(nèi)存帶寬。
從描述中已經(jīng)了解到零拷貝技術(shù)給我們帶來(lái)的好處:
1、節(jié)省了 CPU 周期,空出的 CPU 可以完成更多其他的任務(wù)
2、減少了內(nèi)存區(qū)域之間數(shù)據(jù)拷貝,節(jié)省內(nèi)存帶寬
3、減少用戶(hù)態(tài)和內(nèi)核態(tài)之間數(shù)據(jù)拷貝,提升數(shù)據(jù)傳輸效率
4、應(yīng)用零拷貝技術(shù),減少用戶(hù)態(tài)和內(nèi)核態(tài)之間的上下文切換
傳統(tǒng) IO 數(shù)據(jù)拷貝原理
在正式分析零拷貝機(jī)制原理之前,我們先來(lái)看下傳統(tǒng) IO 在數(shù)據(jù)拷貝的基本原理,從數(shù)據(jù)拷貝 (I/O 拷貝) 的次數(shù)以及上下文切換的次數(shù)進(jìn)行對(duì)比分析。
傳統(tǒng) IO:
1、JVM 進(jìn)程內(nèi)發(fā)起 read() 系統(tǒng)調(diào)用,操作系統(tǒng)由用戶(hù)態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過(guò) DMA 引擎建數(shù)據(jù)從磁盤(pán)拷貝到內(nèi)核態(tài)空間的輸入的 socket 緩沖區(qū)中(第一次拷貝)
3、將內(nèi)核態(tài)空間緩沖區(qū)的數(shù)據(jù)原封不動(dòng)的拷貝到用戶(hù)態(tài)空間的緩存區(qū)中(第二次拷貝),同時(shí)內(nèi)核態(tài)空間切換到用戶(hù)態(tài)空間(第二次上下文切換),read() 系統(tǒng)調(diào)用結(jié)束
4、JVM 進(jìn)程內(nèi)業(yè)務(wù)邏輯代碼執(zhí)行
5、JVM 進(jìn)程內(nèi)發(fā)起 write() 系統(tǒng)調(diào)用
6、操作系統(tǒng)由用戶(hù)態(tài)空間切換到內(nèi)核態(tài)空間(第三次上下文切換),將用戶(hù)態(tài)空間的緩存區(qū)數(shù)據(jù)原封不動(dòng)的拷貝到內(nèi)核態(tài)空間輸出的 socket 緩存區(qū)中(第三次拷貝)
7、write() 系統(tǒng)調(diào)用返回,操作系統(tǒng)由內(nèi)核態(tài)空間切換到用戶(hù)態(tài)空間(第四次上下文切換),通過(guò) DMA 引擎將數(shù)據(jù)從內(nèi)核態(tài)空間的 socket 緩存區(qū)數(shù)據(jù)拷貝到協(xié)議引擎中(第四次拷貝)
傳統(tǒng) IO 方式,一共在用戶(hù)態(tài)空間與內(nèi)核態(tài)空間之間發(fā)生了 4 次上下文的切換,4 次數(shù)據(jù)的拷貝過(guò)程,其中包括 2 次 DMA 拷貝和 2 次 I/O 拷貝(內(nèi)核態(tài)與用戶(hù)應(yīng)用程序之間發(fā)生的拷貝)。
內(nèi)核空間緩沖區(qū)的一大用處是為了減少磁盤(pán)I/O操作,因?yàn)樗鼤?huì)從磁盤(pán)中預(yù)讀更多的數(shù)據(jù)到緩沖區(qū)中。而使用 BufferedInputStream 的用處是減少 「系統(tǒng)調(diào)用」。
什么是DMA
DMA(Direct Memory Access)—直接內(nèi)存訪問(wèn) :DMA是允許外設(shè)組件將 I/O 數(shù)據(jù)直接傳送到主存儲(chǔ)器中并且傳輸不需要 CPU 的參與,以此將 CPU 解放出來(lái)去完成其他的事情。
sendfile 數(shù)據(jù)零拷貝原理
sendfile 數(shù)據(jù)零拷貝:
顯然,在傳統(tǒng) IO 中,用戶(hù)態(tài)空間與內(nèi)核態(tài)空間之間的復(fù)制是完全不必要的,因?yàn)橛脩?hù)態(tài)空間僅僅起到了一種數(shù)據(jù)轉(zhuǎn)存媒介的作用,除此之外沒(méi)有做任何事情。
Linux 提供了 sendfile() 用來(lái)減少我們前面提到的數(shù)據(jù)拷貝和的上下文切換次數(shù)。
如下圖所示:
1、發(fā)起 sendfile() 系統(tǒng)調(diào)用,操作系統(tǒng)由用戶(hù)態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過(guò) DMA 引擎將數(shù)據(jù)從磁盤(pán)拷貝到內(nèi)核態(tài)空間的輸入的 socket 緩沖區(qū)中(第一次拷貝)
3、將數(shù)據(jù)從內(nèi)核空間拷貝到與之關(guān)聯(lián)的 socket 緩沖區(qū)(第二次拷貝)
4、將 socket 緩沖區(qū)的數(shù)據(jù)拷貝到協(xié)議引擎中(第三次拷貝)
5、sendfile() 系統(tǒng)調(diào)用結(jié)束,操作系統(tǒng)由用戶(hù)態(tài)空間切換到內(nèi)核態(tài)空間(第二次上下文切換)
根據(jù)以上過(guò)程,一共有 2 次的上下文切換,3 次的 I/O 拷貝。我們看到從用戶(hù)空間到內(nèi)核空間并沒(méi)有出現(xiàn)數(shù)據(jù)拷貝,從操作系統(tǒng)角度來(lái)看,這個(gè)就是零拷貝。內(nèi)核空間出現(xiàn)了復(fù)制的原因: 通常的硬件在通過(guò)DMA訪問(wèn)時(shí)期望的是連續(xù)的內(nèi)存空間。
支持 scatter-gather 特性的 sendfile 數(shù)據(jù)零拷貝:
這次相比 sendfile() 數(shù)據(jù)零拷貝,減少了一次從內(nèi)核空間到與之相關(guān)的 socket 緩沖區(qū)的數(shù)據(jù)拷貝。
基本流程:
1、發(fā)起 sendfile() 系統(tǒng)調(diào)用,操作系統(tǒng)由用戶(hù)態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過(guò) DMA 引擎將數(shù)據(jù)從磁盤(pán)拷貝到內(nèi)核態(tài)空間的輸入的 socket 緩沖區(qū)中(第一次拷貝)
3、將描述符信息會(huì)拷貝到相應(yīng)的 socket 緩沖區(qū)當(dāng)中,該描述符包含了兩方面的信息:
a)?kernel buffer的內(nèi)存地址;
b)?kernel buffer的偏移量。
4、DMA gather copy 根據(jù) socket 緩沖區(qū)中描述符提供的位置和偏移量信息直接將內(nèi)核空間緩沖區(qū)中的數(shù)據(jù)拷貝到協(xié)議引擎上(第二次拷貝),這樣就避免了最后一次 I/O 數(shù)據(jù)拷貝。
5、sendfile() 系統(tǒng)調(diào)用結(jié)束,操作系統(tǒng)由用戶(hù)態(tài)空間切換到內(nèi)核態(tài)空間(第二次上下文切換)
下面這個(gè)圖更進(jìn)一步理解:
Linux/Unix 操作系統(tǒng)下可以通過(guò)下面命令查看是否支持 scatter-gather 特性。
ethtool -k eth0 | grep scatter-gatherscatter-gather: on
許多的 web server 都已經(jīng)支持了零拷貝技術(shù),比如 Apache、Tomcat。
sendfile 零拷貝消除了所有內(nèi)核空間緩沖區(qū)與用戶(hù)空間緩沖區(qū)之間的數(shù)據(jù)拷貝過(guò)程,因此 sendfile 零拷貝 I/O 的實(shí)現(xiàn)是完成在內(nèi)核空間中完成的,這對(duì)于應(yīng)用程序來(lái)說(shuō)就無(wú)法對(duì)數(shù)據(jù)進(jìn)行操作了。
mmap 數(shù)據(jù)零拷貝原理
如果需要對(duì)數(shù)據(jù)做操作,Linux 提供了mmap 零拷貝來(lái)實(shí)現(xiàn)。
mmap 零拷貝:
通過(guò)上圖看到,一共發(fā)生了 4 次的上下文切換,3 次的 I/O 拷貝,包括 2 次 DMA 拷貝和 1 次的 I/O 拷貝,相比于傳統(tǒng) IO 減少了一次 I/O 拷貝。使用 mmap() 讀取文件時(shí),只會(huì)發(fā)生第一次從磁盤(pán)數(shù)據(jù)拷貝到 OS 文件系統(tǒng)緩沖區(qū)的操作。
1)在什么場(chǎng)景下使用 mmap() 去訪問(wèn)文件會(huì)更高效?
對(duì)文件執(zhí)行隨機(jī)訪問(wèn)時(shí),如果使用 read() 或 write(),則意味著較低的 cache 命中率。這種情況下使用 mmap() 通常將更高效。
多個(gè)進(jìn)程同時(shí)訪問(wèn)同一個(gè)文件時(shí)(無(wú)論是順序訪問(wèn)還是隨機(jī)訪問(wèn)),如果使用mmap(),那么操作系統(tǒng)緩沖區(qū)的文件內(nèi)容可以在多個(gè)進(jìn)程之間共享,從操作系統(tǒng)角度來(lái)看,使用 mmap() 可以大大節(jié)省內(nèi)存。
2)什么場(chǎng)景下沒(méi)有使用 mmap() 的必要?
訪問(wèn)小文件時(shí),直接使用 read() 或 write() 將更加高效。
單個(gè)進(jìn)程對(duì)文件執(zhí)行順序訪問(wèn)時(shí) (sequential access),使用 mmap() 幾乎不會(huì)帶來(lái)性能上的提升。譬如說(shuō),使用 read() 順序讀取文件時(shí),文件系統(tǒng)會(huì)使用 read-ahead 的方式提前將文件內(nèi)容緩存到文件系統(tǒng)的緩沖區(qū),因此使用 read() 將很大程度上可以命中緩存。
Java 中 NIO 零拷貝實(shí)現(xiàn)
Java NIO 中的通道(Channel)相當(dāng)于操作系統(tǒng)的內(nèi)核空間(kernel space)的緩沖區(qū),而緩沖區(qū)(Buffer)對(duì)應(yīng)的相當(dāng)于操作系統(tǒng)的用戶(hù)空間(user space)中的用戶(hù)緩沖區(qū)(user buffer)。
通道(Channel)是全雙工的(雙向傳輸),它既可能是讀緩沖區(qū)(read buffer),也可能是網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
緩沖區(qū)(Buffer)分為堆內(nèi)存(HeapBuffer)和堆外內(nèi)存(DirectBuffer),這是通過(guò) malloc() 分配出來(lái)的用戶(hù)態(tài)內(nèi)存。
Java NIO 引入了用于通道的緩沖區(qū)的 ByteBuffer。
ByteBuffer有三個(gè)主要的實(shí)現(xiàn):
1、HeapByteBuffer
調(diào)用 ByteBuffer.allocate() 方法時(shí)使用到 HeapByteBuffer。這個(gè)緩存區(qū)域是在 JVM 進(jìn)程的堆上分配的,可以獲得如GC支持和緩存優(yōu)化的優(yōu)勢(shì)。
但它不是頁(yè)面對(duì)齊的,這意味著若需通過(guò)JNI與本地代碼交談,JVM將不得不復(fù)制到對(duì)齊的緩沖區(qū)空間。
2、DirectByteBuffer
調(diào)用 ByteBuffer.allocateDirect() 方法時(shí)使用。 JVM 會(huì)使用 malloc() 在堆空間之外分配內(nèi)存空間。 由于它的內(nèi)存空間不由 JVM 管理,所以你的內(nèi)存空間是頁(yè)面對(duì)齊的,不受GC影響。但需要自己管理這個(gè)內(nèi)存,注意分配和釋放內(nèi)存來(lái)防止內(nèi)存泄漏。
3、MappedByteBuffer
調(diào)用 FileChannel.map() 時(shí)使用。與DirectByteBuffer類(lèi)似,這也是 JVM 堆外部分配內(nèi)存空間。它基本上作為操作系統(tǒng) mmap() 系統(tǒng)調(diào)用的包裝函數(shù),以便代碼直接操作映射的物理內(nèi)存數(shù)據(jù)。
Java IO 與 NIO 實(shí)戰(zhàn)案例分析
下面我們通過(guò)代碼示例來(lái)對(duì)比下傳統(tǒng) IO 與使用了零拷貝技術(shù)的 NIO 之間的差異。
我們通過(guò)服務(wù)端開(kāi)啟 socket 監(jiān)聽(tīng),然后客戶(hù)端連接的服務(wù)端進(jìn)行數(shù)據(jù)的傳輸,數(shù)據(jù)傳輸文件大小為 237M。
零拷貝技術(shù)的 NIO,這里咱們通過(guò)剛剛介紹的 HeapByteBuffer 來(lái)實(shí)戰(zhàn)對(duì)比一下。
1、構(gòu)建傳統(tǒng)IO的socket服務(wù)端,監(jiān)聽(tīng)8898端口。
public class OldIOServer {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8898)) {
while (true) {
Socket socket = serverSocket.accept();
DataInputStream inputStream = new DataInputStream(socket.getInputStream());
byte[] bytes = new byte[4096];
// 從socket中讀取字節(jié)數(shù)據(jù)
while (true) {
// 讀取的字節(jié)數(shù)大小,-1則表示數(shù)據(jù)已被讀完
int readCount = inputStream.read(bytes, 0, bytes.length);
if (-1 == readCount) {
break;
}
}
}
}
}
}
2、構(gòu)建傳統(tǒng) IO 的客戶(hù)端,連接服務(wù)端的 8898 端口,并從磁盤(pán)讀取 237M 的數(shù)據(jù)文件向服務(wù)端 socket 中發(fā)起寫(xiě)請(qǐng)求。
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(“l(fā)ocalhost”, 8898)); // 連接服務(wù)端socket 8899端口
// 設(shè)置一個(gè)大的文件, 237M
try (FileInputStream fileInputStream = new FileInputStream(new File(“/Users/david/Downloads/jdk-8u144-macosx-x64.dmg”));
// 定義一個(gè)輸出流
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) {
// 讀取文件數(shù)據(jù)
// 定義byte緩存
byte[] buffer = new byte[4096];
int readCount; // 每一次讀取的字節(jié)數(shù)
int total = 0; // 讀取的總字節(jié)數(shù)
long startTime = System.currentTimeMillis();
while ((readCount = fileInputStream.read(buffer)) 》 0) {
total += readCount; //累加字節(jié)數(shù)
dataOutputStream.write(buffer); // 寫(xiě)入到輸出流中
}
System.out.println(“發(fā)送的總字節(jié)數(shù):” + total + “, 耗時(shí):” + (System.currentTimeMillis() - startTime));
}
}
}
運(yùn)行結(jié)果:發(fā)送的總字節(jié)數(shù):237607747,耗時(shí):450 (400~600毫秒之間)
接下來(lái),我們通過(guò)使用 JDK 提供的 NIO 的方式實(shí)現(xiàn)數(shù)據(jù)傳輸與上述傳統(tǒng) IO 做對(duì)比。
1、構(gòu)建基于 NIO 的服務(wù)端,監(jiān)聽(tīng) 8899 端口。
public class NewIOServer {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8899));
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false); // 這里設(shè)置為阻塞模式
int readCount = socketChannel.read(byteBuffer);
while (-1 != readCount) {
readCount = socketChannel.read(byteBuffer);
// 這里一定要調(diào)用下rewind方法,將position重置為0開(kāi)始位置
byteBuffer.rewind();
}
}
}
}
2、構(gòu)建基于 NIO 的客戶(hù)端,連接NIO的服務(wù)端 8899 端口,通過(guò)
FileChannel.transferTo 傳輸 237M 的數(shù)據(jù)文件。
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(“l(fā)ocalhost”, 8899));
socketChannel.configureBlocking(true);
String fileName = “/Users/david/Downloads/jdk-8u144-macosx-x64.dmg”;
FileInputStream fileInputStream = new FileInputStream(fileName);
FileChannel fileChannel = fileInputStream.getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目標(biāo)channel
System.out.println(“發(fā)送的總字節(jié)數(shù):” + transferCount + “,耗時(shí):” + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
運(yùn)行結(jié)果:發(fā)送的總字節(jié)數(shù):237607747,耗時(shí):161(100到300毫秒之間)
結(jié)合運(yùn)行結(jié)果,基于 NIO 零拷貝技術(shù)要比傳統(tǒng) IO 傳輸效率高 3倍多。所以,后續(xù)當(dāng)設(shè)計(jì)大文件數(shù)據(jù)傳輸時(shí)可以?xún)?yōu)先采用類(lèi)似 NIO 的方式實(shí)現(xiàn)。
這里我們使用了 FileChannel,其中調(diào)用的 transferTo() 方法將數(shù)據(jù)從 FileChannel傳輸?shù)狡渌?channel 中,如果操作系統(tǒng)底層支持的話(huà) transferTo、transferFrom 會(huì)使用相關(guān)的零拷貝技術(shù)來(lái)實(shí)現(xiàn)數(shù)據(jù)的傳輸。所以,這里是否使用零拷貝必須依賴(lài)于底層的系統(tǒng)實(shí)現(xiàn)。
FileChannel.transferTo 方法:
public abstract long transferTo(long position,
long count,
WritableByteChannel target) throws IOException
將字節(jié)從此通道的文件傳輸?shù)浇o定的可寫(xiě)入字節(jié)通道。
試圖讀取從此通道的文件中給定 position 處開(kāi)始的 count 個(gè)字節(jié),并將其寫(xiě)入目標(biāo)通道。
此方法的調(diào)用不一定傳輸所有請(qǐng)求的字節(jié);
是否傳輸取決于通道的性質(zhì)和狀態(tài)。
如果此通道的文件從給定的 position 處開(kāi)始所包含的字節(jié)數(shù)小于 count 個(gè)字節(jié),或者如果目標(biāo)通道是非阻塞的并且其輸出緩沖區(qū)中的自由空間少于 count 個(gè)字節(jié),則所傳輸?shù)淖止?jié)數(shù)要小于請(qǐng)求的字節(jié)數(shù)。
此方法不修改此通道的位置。
如果給定的位置大于該文件的當(dāng)前大小,則不傳輸任何字節(jié)。
如果目標(biāo)通道中有該位置,則從該位置開(kāi)始寫(xiě)入各字節(jié),然后將該位置增加寫(xiě)入的字節(jié)數(shù)。
與從此通道讀取并將內(nèi)容寫(xiě)入目標(biāo)通道的簡(jiǎn)單循環(huán)語(yǔ)句相比,此方法可能高效得多。
很多操作系統(tǒng)可將字節(jié)直接從文件系統(tǒng)緩存?zhèn)鬏數(shù)侥繕?biāo)通道,而無(wú)需實(shí)際復(fù)制各字節(jié)。
position - 文件中的位置,從此位置開(kāi)始傳輸;
必須為非負(fù)數(shù)
count - 要傳輸?shù)淖畲笞止?jié)數(shù);
必須為非負(fù)數(shù)
target - 目標(biāo)通道
返回:實(shí)際已傳輸?shù)淖止?jié)數(shù),可能為零
FileChannel.transferFrom 方法:
public abstract long transferFrom(ReadableByteChannel src,
long position,
long count) throws IOException
將字節(jié)從給定的可讀取字節(jié)通道傳輸?shù)酱送ǖ赖奈募小?/p>
試著從源通道中最多讀取 count 個(gè)字節(jié),并將其寫(xiě)入到此通道的文件中從給定 position 處開(kāi)始的位置。
此方法的調(diào)用不一定傳輸所有請(qǐng)求的字節(jié);
是否傳輸取決于通道的性質(zhì)和狀態(tài)。
如果源通道的剩余空間小于 count 個(gè)字節(jié),或者如果源通道是非阻塞的并且其輸入緩沖區(qū)中直接可用的空間小于 count 個(gè)字節(jié),則所傳輸?shù)淖止?jié)數(shù)要小于請(qǐng)求的字節(jié)數(shù)。
此方法不修改此通道的位置。
如果給定的位置大于該文件的當(dāng)前大小,則不傳輸任何字節(jié)。
如果該位置在源通道中,則從該位置開(kāi)始讀取各字節(jié),然后將該位置增加讀取的字節(jié)數(shù)。
與從源通道讀
取并將內(nèi)容寫(xiě)入此通道的簡(jiǎn)單循環(huán)語(yǔ)句相比,此方法可能高效得多。
很多操作系統(tǒng)可將字節(jié)直接從源通道傳輸?shù)轿募到y(tǒng)緩存,而無(wú)需實(shí)際復(fù)制各字節(jié)。
參數(shù):
src - 源通道
position - 文件中的位置,從此位置開(kāi)始傳輸;
必須為非負(fù)數(shù)
count - 要傳輸?shù)淖畲笞止?jié)數(shù);
必須為非負(fù)數(shù)
返回:實(shí)際已傳輸?shù)淖止?jié)數(shù),可能為零
發(fā)生相應(yīng)的異常的情況:
異常拋出:
IllegalArgumentException - 如果關(guān)于參數(shù)的前提不成立
NonReadableChannelException - 如果不允許從此通道進(jìn)行讀取操作
NonWritableChannelException - 如果目標(biāo)通道不允許進(jìn)行寫(xiě)入操作
ClosedChannelException - 如果此通道或目標(biāo)通道已關(guān)閉
AsynchronousCloseException - 如果正在進(jìn)行傳輸時(shí)另一個(gè)線(xiàn)程關(guān)閉了任一通道
ClosedByInterruptException - 如果正在進(jìn)行傳輸時(shí)另一個(gè)線(xiàn)程中斷了當(dāng)前線(xiàn)程,因此關(guān)閉了兩個(gè)通道并將當(dāng)前線(xiàn)程設(shè)置為中斷
IOException - 如果發(fā)生其他 I/O 錯(cuò)誤
評(píng)論
查看更多