從原理到實踐:完全掌握零拷貝技術
零拷貝技術是一種優化數據傳輸的技術,旨在減少數據在內存之間的拷貝次數,從而提高數據傳輸性能和降低 CPU 的負載。傳統的數據傳輸方式涉及多次拷貝操作:首先將數據從磁盤讀取到內核緩衝區,然後再將數據從內核緩衝區複製到應用程序緩衝區。
而零拷貝技術通過避免不必要的數據拷貝,直接將數據從磁盤讀取或網絡接收到用戶空間應用程序所使用的內存中。它利用了文件描述符、DMA(直接內存訪問)等機制,在內核態和用戶態之間實現了數據傳輸的零拷貝。這樣可以減少不必要的 CPU 時間和系統開銷,並提升整體系統性能。
零拷貝技術廣泛應用於高性能網絡通信、大規模存儲系統以及多媒體處理等領域。它能夠顯著提高數據傳輸效率,降低延遲,並在大規模併發場景下發揮重要作用。
一、基本概念
零拷貝(zero-copy)基本思想是:數據報從網絡設備到用戶程序空間傳遞的過程中,減少數據拷貝次數,減少系統調用,實現 CPU 的零參與,徹底消除 CPU 在這方面的負載。實現零拷貝用到的最主要技術是 DMA 數據傳輸技術和內存區域映射技術。如圖 1 所示,傳統的網絡數據報處理,需要經過網絡設備到操作系統內存空間,系統內存空間到用戶應用程序空間這兩次拷貝,同時還需要經歷用戶向系統發出的系統調用。而零拷貝技術則首先利用 DMA 技術將網絡數據報直接傳遞到系統內核預先分配的地址空間中,避免 CPU 的參與;同時,將系統內核中存儲數據報的內存區域映射到檢測程序的應用程序空間(還有一種方式是在用戶空間建立一緩存,並將其映射到內核空間,類似於 linux 系統下的 kiobuf 技術),檢測程序直接對這塊內存進行訪問,從而減少了系統內核向用戶空間的內存拷貝,同時減少了系統調用的開銷,實現了真正的 “零拷貝”。
什麼是零拷貝?
簡單一點來說,零拷貝就是一種避免 CPU 將數據從一塊存儲拷貝到另外一塊存儲的技術。針對操作系統中的設備驅動程序、文件系統以及網絡協議堆棧而出現的各種零拷貝技術極大地提升了特定應用程序的性能,並且使得這些應用程序可以更加有效地利用系統資源。這種性能的提升就是通過在數據拷貝進行的同時,允許 CPU 執行其他的任務來實現的。零拷貝技術可以減少數據拷貝和共享總線操作的次數,消除傳輸數據在存儲器之間不必要的中間拷貝次數,從而有效地提高數據傳輸效率。而且,零拷貝技術減少了用戶應用程序地址空間和操作系統內核地址空間之間因爲上下文切換而帶來的開銷。進行大量的數據拷貝操作其實是一件簡單的任務,從操作系統的角度來說,如果 CPU 一直被佔用着去執行這項簡單的任務,那麼這將會是很浪費資源的;如果有其他比較簡單的系統部件可以代勞這件事情,從而使得 CPU 解脫出來可以做別的事情,那麼系統資源的利用則會更加有效。
避免數據拷貝:
-
避免操作系統內核緩衝區之間進行數據拷貝操作。
-
避免操作系統內核和用戶應用程序地址空間這兩者之間進行數據拷貝操作。
-
用戶應用程序可以避開操作系統直接訪問硬件存儲。
-
數據傳輸儘量讓 DMA 來做。
將多種操作結合在一起
-
避免不必要的系統調用和上下文切換。
-
需要拷貝的數據可以先被緩存起來。
-
對數據進行處理儘量讓硬件來做。
前文提到過,對於高速網絡來說,零拷貝技術是非常重要的。這是因爲高速網絡的網絡鏈接能力與 CPU 的處理能力接近,甚至會超過 CPU 的處理能力。
如果是這樣的話,那麼 CPU 就有可能需要花費幾乎所有的時間去拷貝要傳輸的數據,而沒有能力再去做別的事情,這就產生了性能瓶頸,限制了通訊速率,從而降低了網絡連接的能力。一般來說,一個 CPU 時鐘週期可以處理一位的數據。舉例來說,一個 1 GHz 的處理器可以對 1Gbit/s 的網絡鏈接進行傳統的數據拷貝操作,但是如果是 10 Gbit/s 的網絡,那麼對於相同的處理器來說,零拷貝技術就變得非常重要了。
對於超過 1 Gbit/s 的網絡鏈接來說,零拷貝技術在超級計算機集羣以及大型的商業數據中心中都有所應用。然而,隨着信息技術的發展,1 Gbit/s,10 Gbit/s 以及 100 Gbit/s 的網絡會越來越普及,那麼零拷貝技術也會變得越來越普及,這是因爲網絡鏈接的處理能力比 CPU 的處理能力的增長要快得多。傳統的數據拷貝受限於傳統的操作系統或者通信協議,這就限制了數據傳輸性能。零拷貝技術通過減少數據拷貝次數,簡化協議處理的層次,在應用程序和網絡之間提供更快的數據傳輸方法,從而可以有效地降低通信延遲,提高網絡吞吐率。零拷貝技術是實現主機或者路由器等設備高速網絡接口的主要技術之一。
現代的 CPU 和存儲體系結構提供了很多相關的功能來減少或避免 I/O 操作過程中產生的不必要的 CPU 數據拷貝操作,但是,CPU 和存儲體系結構的這種優勢經常被過高估計。存儲體系結構的複雜性以及網絡協議中必需的數據傳輸可能會產生問題,有時甚至會導致零拷貝這種技術的優點完全喪失。在下一章中,我們會介紹幾種 Linux 操作系統中出現的零拷貝技術,簡單描述一下它們的實現方法,並對它們的弱點進行分析。
二、零拷貝技術分類
零拷貝技術的發展很多樣化,現有的零拷貝技術種類也非常多,而當前並沒有一個適合於所有場景的零拷貝技術的出現。對於 Linux 來說,現存的零拷貝技術也比較多,這些零拷貝技術大部分存在於不同的 Linux 內核版本,有些舊的技術在不同的 Linux 內核版本間得到了很大的發展或者已經漸漸被新的技術所代替。本文針對這些零拷貝技術所適用的不同場景對它們進行了劃分。概括起來,Linux 中的零拷貝技術主要有下面這幾種:
直接 I/O:對於這種數據傳輸方式來說,應用程序可以直接訪問硬件存儲,操作系統內核只是輔助數據傳輸:這類零拷貝技術針對的是操作系統內核並不需要對數據進行直接處理的情況,數據可以在應用程序地址空間的緩衝區和磁盤之間直接進行傳輸,完全不需要 Linux 操作系統內核提供的頁緩存的支持。
在數據傳輸的過程中,避免數據在操作系統內核地址空間的緩衝區和用戶應用程序地址空間的緩衝區之間進行拷貝。有的時候,應用程序在數據進行傳輸的過程中不需要對數據進行訪問,那麼,將數據從 Linux 的頁緩存拷貝到用戶進程的緩衝區中就可以完全避免,傳輸的數據在頁緩存中就可以得到處理。在某些特殊的情況下,這種零拷貝技術可以獲得較好的性能。Linux 中提供類似的系統調用主要有 mmap(),sendfile() 以及 splice()。
對數據在 Linux 的頁緩存和用戶進程的緩衝區之間的傳輸過程進行優化。該零拷貝技術側重於靈活地處理數據在用戶進程的緩衝區和操作系統的頁緩存之間的拷貝操作。這種方法延續了傳統的通信方式,但是更加靈活。在 Linux 中,該方法主要利用了寫時複製技術。
前兩類方法的目的主要是爲了避免應用程序地址空間和操作系統內核地址空間這兩者之間的緩衝區拷貝操作。這兩類零拷貝技術通常適用在某些特殊的情況下,比如要傳送的數據不需要經過操作系統內核的處理或者不需要經過應用程序的處理。第三類方法則繼承了傳統的應用程序地址空間和操作系統內核地址空間之間數據傳輸的概念,進而針對數據傳輸本身進行優化。我們知道,硬件和軟件之間的數據傳輸可以通過使用 DMA 來進行,DMA 進行數據傳輸的過程中幾乎不需要 CPU 參與,這樣就可以把 CPU 解放出來去做更多其他的事情,但是當數據需要在用戶地址空間的緩衝區和 Linux 操作系統內核的頁緩存之間進行傳輸的時候,並沒有類似 DMA 這種工具可以使用,CPU 需要全程參與到這種數據拷貝操作中,所以這第三類方法的目的是可以有效地改善數據在用戶地址空間和操作系統內核地址空間之間傳遞的效率。
三、零拷貝的定義
Zero-copy, 就是在操作數據時, 不需要將數據 buffer 從一個內存區域拷貝到另一個內存區域. 因爲少了一次內存的拷貝, 因此 CPU 的效率就得到的提升.
在 OS 層面上的 Zero-copy 通常指避免在 用戶態 (User-space) 與 內核態 (Kernel-space) 之間來回拷貝數據。
Netty 中的 Zero-copy 與 OS 的 Zero-copy 不太一樣, Netty 的 Zero-coyp 完全是在用戶態 (Java 層面) 的, 它的 Zero-copy 的更多的是偏向於優化數據操作。
3.1Netty 的 “零拷貝”
主要體現在如下三個方面:
-
Netty 的接收和發送 ByteBuffer 採用 DIRECT BUFFERS,使用堆外直接內存進行 Socket 讀寫,不需要進行字節緩衝區的二次拷貝。如果使用傳統的堆內存(HEAP BUFFERS)進行 Socket 讀寫,JVM 會將堆內存 Buffer 拷貝一份到直接內存中,然後才寫入 Socket 中。相比於堆外直接內存,消息在發送過程中多了一次緩衝區的內存拷貝。
-
Netty 提供了組合 Buffer 對象,可以聚合多個 ByteBuffer 對象,用戶可以像操作一個 Buffer 那樣方便得對組合 Buffer 進行操作,避免了傳統通過內存拷貝的方式將幾個小 Buffer 合併成一個大的 Buffer。
-
Netty 的文件傳輸採用了 transferTo 方法,它可以直接將文件緩衝區的數據發送到目標 Channel,避免了傳統通過循環 write 方式導致的內存拷貝問題。
3.2 傳統 IO 方式
在 java 開發中,從某臺機器將一份數據通過網絡傳輸到另外一臺機器,大致的代碼如下:
Socket socket = new Socket(HOST, PORT);
InputStream inputStream = new FileInputStream(FILE_PATH);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
while (inputStream.read(buffer) >= 0) {
outputStream.write(buffer);
}
outputStream.close();
socket.close();
inputStream.close();
看起來代碼很簡單,但如果我們深入到操作系統層面,就會發現實際的微觀操作更復雜。具體操作如下圖:
-
- 用戶進程向 OS 發出 read() 系統調用,觸發上下文切換,從用戶態轉換到內核態。
-
2.CPU 發起 IO 請求,通過直接內存訪問(DMA)從磁盤讀取文件內容,複製到內核緩衝區 PageCache 中
-
- 將內核緩衝區數據,拷貝到用戶空間緩衝區,觸發上下文切換,從內核態轉換到用戶態。
-
- 用戶進程向 OS 發起 write 系統調用,觸發上下文切換,從用戶態切換到內核態。
-
- 將數據從用戶緩衝區拷貝到內核中與目的地 Socket 關聯的緩衝區。
-
- 數據最終經由 Socket 通過 DMA 傳送到硬件(網卡)緩衝區,write() 系統調用返回,並從內核態切換回用戶態。
四、零拷貝(Zero-copy)
4.1 數據拷貝基礎過程
在 Linux 系統內部緩存和內存容量都是有限的,更多的數據都是存儲在磁盤中。對於 Web 服務器來說,經常需要從磁盤中讀取數據到內存,然後再通過網卡傳輸給用戶:
上述數據流轉只是大框,接下來看看幾種模式。
(1) 僅 CPU 方式
-
當應用程序需要讀取磁盤數據時,調用 read() 從用戶態陷入內核態,read() 這個系統調用最終由 CPU 來完成;
-
CPU 向磁盤發起 I/O 請求,磁盤收到之後開始準備數據;
-
磁盤將數據放到磁盤緩衝區之後,向 CPU 發起 I/O 中斷,報告 CPU 數據已經 Ready 了;
-
CPU 收到磁盤控制器的 I/O 中斷之後,開始拷貝數據,完成之後 read() 返回,再從內核態切換到用戶態;
(2)CPU&DMA 方式
CPU 的時間寶貴,讓它做雜活就是浪費資源。
直接內存訪問(Direct Memory Access),是一種硬件設備繞開 CPU 獨立直接訪問內存的機制。所以 DMA 在一定程度上解放了 CPU,把之前 CPU 的雜活讓硬件直接自己做了,提高了 CPU 效率。
目前支持 DMA 的硬件包括:網卡、聲卡、顯卡、磁盤控制器等。
有了 DMA 的參與之後的流程發生了一些變化:
主要的變化是,CPU 不再和磁盤直接交互,而是 DMA 和磁盤交互並且將數據從磁盤緩衝區拷貝到內核緩衝區,之後的過程類似。
“【敲黑板】無論從僅 CPU 方式和 DMA&CPU 方式,都存在多次冗餘數據拷貝和內核態 & 用戶態的切換。”
我們繼續思考 Web 服務器讀取本地磁盤文件數據再通過網絡傳輸給用戶的詳細過程。
4.2 普通模式數據交互
一次完成的數據交互包括幾個部分:系統調用 syscall、CPU、DMA、網卡、磁盤等。
系統調用 syscall 是應用程序和內核交互的橋樑,每次進行調用 / 返回就會產生兩次切換:
-
調用 syscall 從用戶態切換到內核態
-
syscall 返回 從內核態切換到用戶態
來看下完整的數據拷貝過程簡圖:
讀數據過程:
應用程序要讀取磁盤數據,調用 read() 函數從而實現用戶態切換內核態,這是第 1 次狀態切換;
DMA 控制器將數據從磁盤拷貝到內核緩衝區,這是第 1 次 DMA 拷貝;
CPU 將數據從內核緩衝區複製到用戶緩衝區,這是第 1 次 CPU 拷貝;
CPU 完成拷貝之後,read() 函數返回實現用戶態切換用戶態,這是第 2 次狀態切換;
寫數據過程:
應用程序要向網卡寫數據,調用 write() 函數實現用戶態切換內核態,這是第 1 次切換;
CPU 將用戶緩衝區數據拷貝到內核緩衝區,這是第 1 次 CPU 拷貝;
DMA 控制器將數據從內核緩衝區複製到 socket 緩衝區,這是第 1 次 DMA 拷貝;
完成拷貝之後,write() 函數返回實現內核態切換用戶態,這是第 2 次切換;
綜上所述:
-
讀過程涉及 2 次空間切換、1 次 DMA 拷貝、1 次 CPU 拷貝;
-
寫過程涉及 2 次空間切換、1 次 DMA 拷貝、1 次 CPU 拷貝;
可見傳統模式下,涉及多次空間切換和數據冗餘拷貝,效率並不高,接下來就該零拷貝技術出場了。
4.3 零拷貝技術
(1) 出現原因
我們可以看到,如果應用程序不對數據做修改,從內核緩衝區到用戶緩衝區,再從用戶緩衝區到內核緩衝區。兩次數據拷貝都需要 CPU 的參與,並且涉及用戶態與內核態的多次切換,加重了 CPU 負擔。
我們需要降低冗餘數據拷貝、解放 CPU,這也就是零拷貝 Zero-Copy 技術。
(2)解決思路
目前來看,零拷貝技術的幾個實現手段包括:mmap+write、sendfile、sendfile+DMA 收集、splice 等。
mmap 方式
mmap 是 Linux 提供的一種內存映射文件的機制,它實現了將內核中讀緩衝區地址與用戶空間緩衝區地址進行映射,從而實現內核緩衝區與用戶緩衝區的共享。這樣就減少了一次用戶態和內核態的 CPU 拷貝,但是在內核空間內仍然有一次 CPU 拷貝。
mmap 對大文件傳輸有一定優勢,但是小文件可能出現碎片,並且在多個進程同時操作文件時可能產生引發 coredump 的 signal。
sendfile 方式
mmap+write 方式有一定改進,但是由系統調用引起的狀態切換並沒有減少。
sendfile 系統調用是在 Linux 內核 2.1 版本中被引入,它建立了兩個文件之間的傳輸通道。
sendfile 方式只使用一個函數就可以完成之前的 read+write 和 mmap+write 的功能,這樣就少了 2 次狀態切換,由於數據不經過用戶緩衝區,因此該數據無法被修改。
splice 系統調用可以在內核緩衝區和 socket 緩衝區之間建立管道來傳輸數據,避免了兩者之間的 CPU 拷貝操作。
splice 也有一些侷限,它的兩個文件描述符參數中有一個必須是管道設備。
以下使用 FileChannel.transferTo 方法,實現 zero-copy:
SocketAddress socketAddress = new InetSocketAddress(HOST, PORT);
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(socketAddress);
File file = new File(FILE_PATH);
FileChannel fileChannel = new FileInputStream(file).getChannel();
fileChannel.transferTo(0, file.length(), socketChannel);
fileChannel.close();
socketChannel.close();
相比傳統方式,零拷貝的執行流程如下圖:
可以看到,相比傳統方式,零拷貝不走數據緩衝區減少了一些不必要的操作。
4.4 零拷貝的應用
零拷貝在很多框架中得到了廣泛使用,常見的比如 Netty、Kafka 等等。
在 kafka 中使用了很多設計思想,比如分區並行、順序寫入、頁緩存、高效序列化、零拷貝等等。
上邊博客分析了 Kafka 的大概架構,知道了 kafka 中的文件都是以. log 文件存儲,每個日誌文件對應兩個索引文件. index 與. timeindex。
kafka 在傳輸數據時利用索引,使用 fileChannel.transferTo (position, count, socketChannel) 指定數據位置與大小實現零拷貝。
kafka 底層傳輸源碼:(TransportLayer)
/**
* Transfers bytes from `fileChannel` to this `TransportLayer`.
*
* This method will delegate to {@link FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)},
* but it will unwrap the destination channel, if possible, in order to benefit from zero copy. This is required
* because the fast path of `transferTo` is only executed if the destination buffer inherits from an internal JDK
* class.
*
* @param fileChannel The source channel
* @param position The position within the file at which the transfer is to begin; must be non-negative
* @param count The maximum number of bytes to be transferred; must be non-negative
* @return The number of bytes, possibly zero, that were actually transferred
* @see FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)
*/
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException;
實現類(PlaintextTransportLayer):
@OverRide
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
該方法的功能是將 FileChannel 中的數據傳輸到 TransportLayer,也就是 SocketChannel。在實現類 PlaintextTransportLayer 的對應方法中,就是直接調用了 FileChannel.transferTo () 方法。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/nmfpWi1-NEaOF_ftJ-RsXQ