前置概念
用戶空間與內核空間
CPU 將指令分為特權指令和非特權指令,對於危險指令,只允許操作系統及其相關模塊使用,普通應用程序只能使用那些不會造成災難的指令。比如 Intel 的 CPU 將特權等級分為 4 個級別:Ring0~Ring3。
其實 Linux 系統只使用了 Ring0 和 Ring3 兩個運行級別(Windows 系統也是一樣的)。當進程運行在 Ring3 級別時被稱為運行在用戶態,而運行在 Ring0 級別時被稱為運行在內核態。
簡單來說:內核空間和用戶空間本質上是要提高操作系統的穩定性及可用性,當進程運行在內核空間時就處於內核態,當進程運行在用戶空間時就處於用戶態。
DMA(直接存儲器訪問)
DMA 即Direct Memory Access ,直接存儲器訪問。DMA 控制方式是以存儲器為中心,在主存和I/O設備之間建立一條直接通路,在DMA 控制器的控制下進行設備和主存之間的數據交換。這種方式只在傳輸開始和傳輸結束時才需要CPU的干預。它非常適用於高速設備與主存之間的成批數據傳輸。
傳統I/O
下面通過一個Java 非常常見的應用場景:將系統中的文件發送到遠端(磁盤文件 -> 內存(字節數組) -> 傳輸給用戶/網絡)來詳細展開I/O操作。
如下圖所示:
- JVM 發出read() 系統調用,上下文從用戶態切換到內核態(第一次上下文切換)。通過DMA(Direct Memory Access,直接存儲器訪問)引擎將文件中的數據從磁盤上讀取到內核空間緩沖區(第一次拷貝: hard drive -> kernel buffer)。
- 將內核空間緩沖區的數據拷貝到用戶空間緩沖區(第二次拷貝:kernel buffer -> user buffer),然后read系統調用返回。而系統調用的返回又會導致一次內核態到用戶態的上下文切換(第二次上下文切換)。
- JVM 處理代碼邏輯並發送write() 系統調用,上下文從用戶態切換到內核態(第三次上下文切換),然后將用戶空間緩沖區中的數據拷貝到內核空間中與socket 相關聯的緩沖區中(即,第2步中從內核空間緩沖區拷貝而來的數據原封不動的再次拷貝到內核空間的socket緩沖區中。)(第三次拷貝:user buffer -> socket buffer)。
- write 系統調用返回,上下文再次從內核態切換到用戶態(第四次上下文切換)。通過DMA 引擎將內核緩沖區中的數據傳遞到協議引擎(第四次拷貝:socket buffer -> protocol engine),這次拷貝是一個獨立且異步的過程。
小結
傳統的I/O操作進行了4次用戶態與內核態間的上下文切換,以及4次數據拷貝(2次DMA拷貝和2次CPU拷貝)。
傳統的文件傳輸方式簡單但存在冗余的上文切換和數據拷貝,多了很多不必要的開銷,在高並發系統里會嚴重影響系統性能。
所以,要想提高文件傳輸的性能,就需要減少「用戶態與內核態的上下文切換」和「內存拷貝」的次數。
零拷貝(zero-copy)
零拷貝是站在內核的角度來說的,其目的是消除從內核空間到用戶空間的來回復制,並不是完全不會發生任何拷貝。
零拷貝不僅僅帶來了更少的數據復制,還能帶來其他的性能優勢,例如:更少的上下⽂切換,更少的CPU 緩存偽共享以及無CPU 校驗和計算。
mmap 實現
mmap 是一種內存映射文件的方法,即將一個文件或者其它對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對映關系。實現這樣的映射關系后,進程就可以采用指針的方式讀寫操作這一段內存,而系統會自動回寫臟頁面到對應的文件磁盤上,即完成了對文件的操作而不必再調用read,write等系統調用函數。相反,內核空間對這段區域的修改也直接反映用戶空間,從而可以實現不同進程間的文件共享。
基於mmap的拷貝流程如下圖:
- 發出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次數據拷貝(2次DMA 拷貝和1次CPU 拷貝)。
通過mmap實現的零拷貝I/O 與傳統 I/O 相比僅僅少了1次內核空間緩沖區和用戶空間緩沖區之間的CPU拷貝。這樣的好處是,可以將整個文件或者整個文件的一部分映射到內存當中,用戶直接對內存中對文件進行操作,然后是由操作系統來進行相關的頁面請求並將內存的修改寫入到文件當中。應用程序只需要處理內存的數據,這樣可以實現非常迅速的 I/O 操作。
sendfile 實現
- 發出sendfile 系統調用,上下文從用戶態切換到內核態(第一次上下文切換)。通過DMA 將磁盤文件中的內容拷貝到內核空間緩沖區中(第一次拷貝:hard drive -> kernel buffer)。
- 將數據從內核空間緩沖區拷貝到內核中與socket相關的緩沖區中(第二次拷貝:kernel buffer -> socket buffer)。
- sendfile 系統調用返回,上下文從內核態切換到用戶態(第二次上下文切換)。通過DMA 將內核空間socket 緩沖區中的數據傳遞到協議引擎(第三次拷貝:socket buffer -> protocol engine)。
小結
通過sendfile實現的零拷貝I/O 只進行了2次用戶態與內核態間的上下文切換,以及3次數據的拷貝(2次DMA 拷貝和1次CPU 拷貝)。
在Java中,FileChannel 的transferTo() 方法可以實現了這個過程,該方法將數據從文件通道傳輸到給定的可寫字節通道。
public void transferTo(long position, long count, WritableByteChannel target);
在 UNIX 和各種 Linux 系統中,此調用被傳遞到 sendfile()
系統調用中,最終實現將數據從一個文件描述符傳輸到了另一個文件描述符。
此時操作系統仍然需要在內核內存空間中復制數據(kernel buffer ->socket buffer)。 雖然從操作系統的角度來看,這已經是零拷貝了(因為沒有數據從內核空間復制到用戶空間, 內核需要復制的原因是因為通用硬件DMA 訪問需要連續的內存空間(因此需要緩沖區),但是,如果硬件支持scatter-and-gather ,這是可以避的。
帶有DMA 收集拷貝功能的sendfile 實現
從 Linux 2.4 版本開始,操作系統底層提供了帶有 scatter/gather 的DMA 來從內核空間緩沖區中將數據讀取到協議引擎中。這樣一來待傳輸的數據可以分散在存儲的不同位置上,而不需要在連續存儲中存放。那么從文件中讀出的數據就根本不需要被拷貝到socket 緩沖區中去,只是需要將緩沖區描述符添加到socket 緩沖區中去,DMA 收集操作會根據緩沖區描述符中的信息將內核空間中的數據直接拷貝到協議引擎中。
- 發出sendfile 系統調用,上下文從用戶態切換到內核態(第一次上下文切換)。通過DMA 將磁盤文件中的內容拷貝到內核空間緩沖區中(第一次拷貝:hard drive -> kernel buffer)。
- 沒有數據拷貝到socket緩沖區。取而代之的是只有相應的描述符信息會被拷貝到相應的socket 緩沖區當中。該描述符包含了兩方面的信息:kernel buffer 的內存地址和kernel buffer 的偏移量。
- sendfile系統調用返回,上下文從內核態切換到用戶態。DMA gather copy根據socket 緩沖區中描述符提供的位置和偏移量信息直接將內核空間緩沖區中的數據拷貝到協議引擎上(第二次拷貝:socket buffer -> protocol engine),這樣就避免了最后一次CPU數據拷貝。
小結
帶有DMA 收集拷貝功能的sendfile 實現的I/O 只進行了2次用戶態與內核態間的上下文切換,以及2次數據的拷貝,而且這2次的數據拷貝都是非CPU 拷貝。這樣一來就實現了最理想的零拷貝I/O 傳輸了,不需要任何一次的CPU 拷貝,以及最少的上下文切換。
零拷貝使用場景
- ⽂件較⼤,讀寫較慢,追求速度
- JVM 內存不夠,不能加載太⼤的數據
- 內存寬帶不夠,即存在其他程序或線程存在⼤量的IO操作
- ······
使用零拷貝的技術:
- Java NIO
- Netty
- RocketMQ
- Kafka
- ······
代碼示例
- 傳統I/O
public class OldIOserver {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(7001);
while(true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
try {
byte[] bytes = new byte[4096];
while(true) {
int readCount = dataInputStream.read(bytes);
if (-1 == readCount) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
class OldIOClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 7001);
FileInputStream fileInputStream = new FileInputStream("test1.zip");
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
byte[] bytes = new byte[4096];
long readCount = 0;
long total = 0;
long startTime = System.currentTimeMillis();
while((readCount = fileInputStream.read(bytes)) >= 0) {
total += readCount;
dataOutputStream.write(bytes);
}
System.out.println("發送的總字節數= " + total + ", 耗時: " + (System.currentTimeMillis() - startTime));
dataOutputStream.close();
socket.close();
fileInputStream.close();
}
}
輸出結果:
發送的總字節數= 192778371, 耗時: 1227
- 零拷貝:
public class NewIOServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(address);
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
int readCount = 0;
while (-1 != readCount) {
try {
readCount = socketChannel.read(byteBuffer);
} catch (Exception ex) {
ex.printStackTrace();
break;
}
// 倒帶, position = 0, mark作廢
byteBuffer.rewind();
}
}
}
}
class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "test1.zip";
FileChannel fileChannel = new FileInputStream(filename).getChannel();
long startTime = System.currentTimeMillis();
// linux下, 一個transferTo方法就可以完成傳輸
// windows下, 一次調用transferTo只能發送8m, 超過8m需要分段傳輸文件
int length = (int) fileChannel.size();
int count = length / (8 * 1024 * 1024) + 1;
long transferCount = 0;
for (int i = 0; i < count; i++) {
// transferTo 底層使用到零拷貝
transferCount += fileChannel.transferTo(transferCount, fileChannel.size(), socketChannel);
}
System.out.println("發送的總字節數= " + transferCount + ", 耗時: " + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
輸出結果:
發送的總字節數= 192778371, 耗時: 205