徹底理解高級 I-O:零拷貝
大家好,我是小風哥,今天和大家簡單聊聊零拷貝。
計算機處理的任務大體可以分爲兩類:CPU 密集型與 IO 密集型。
當前流行的互聯網應用更多的屬於 IO 密集型,傳統的 IO 標準接口都是基於數據拷貝的,這篇文章我們主要關注該怎樣從數據拷貝的角度來優化 IO 性能。
爲什麼 IO 接口要基於數據拷貝?
爲了讓廣大碼農們更好的沉迷於自己的一畝三分地,防止 ta 們分心去關心計算機中的硬件資源分配問題,操作系統誕生了。
操作系統本質上就是一個管家,目的就是更加公平合理的給各個進程分配硬件資源,在操作系統出現之前,程序員需要直面各類硬件,就像這樣:
在這一時期程序員真可謂掌控全局,掌控全局帶來的後果就是你需要掌控所有細節,這顯然不利於生產力的釋放。
操作系統應用而生。
計算機系統就變成這樣了:
現在應用程序不需要和硬件直接交互了,僅從 IO 的角度上看,操作系統變成了一個類似路由器的角色,把應用程序遞交過來的數據分發到具體的硬件上去,或者從硬件接收數據並分發給相應的進程。
數據傳遞是通過什麼呢?就是我們常說的 buffer,所謂 buffer 就是一塊可用的內存空間,用來暫存數據。
操作系統這一中間商導致的問題就是:你需要首先把東西交給操作系統,操作系統再轉手交給硬件,這就必然涉及到數據拷貝。
這就是爲什麼傳統的 IO 操作必然需要進行數據拷貝的原因所在。關於操作系統系統完整的闡述請參見博主的《深入理解操作系統》。
然而數據拷貝是有性能損耗的,接下來我們用一個實例來讓大家對該問題有一個更直觀的認知。
網絡服務器
瀏覽器打開一個網頁需要很多數據,包括看到的圖片、html 文件、css 文件、js 文件等等,當瀏覽器請求這類文件時服務器端的工作其實是非常簡單的:服務器只需要從磁盤中抓出該文件然後丟給網絡發送出去。
代碼基本上類似這樣:
read(fileDesc, buf, len);
write(socket, buf, len);
這兩段代碼非常簡單,第一行代碼從文件中讀取數據存放在 buf 中,然後將 buf 中的數據通過網絡發送出去。
注意觀察 buf,服務器全程沒有對 buf 中的數據進行任何修改,buf 裏的數據在用戶態逛了一圈後揮一揮衣袖沒有帶走半點雲彩就回到了內核態。
這兩行看似簡單的代碼實際上在底層發生了什麼呢?
答案是這樣的:
在程序看來簡單的兩行代碼在底層是比較複雜的,看到這張圖你應該真心感激操作系統,操作系統就像一個無比稱職的管家,替你把所有髒活累活都承擔下來,好讓你悠閒的在用戶態指點江山。
這簡單的兩行代碼涉及:四次數據拷貝以及四次上下文切換:
-
read 函數會涉及一次用戶態到內核態的切換,操作系統會向磁盤發起一次 IO 請求,當數據準備好後通過 DMA 技術把數據拷貝到內核的 buffer 中,注意本次數據拷貝無需 CPU 參與。
-
此後操作系統開始把這塊數據從內核拷貝到用戶態的 buffer 中,此時 read() 函數返回,並從內核態切換回用戶態,到這時 read(fileDesc, buf, len); 這行代碼就返回了,buf 中裝好了新鮮出爐的數據。
-
接下來 send 函數再次導致用戶態與內核態的切換,此時數據需要從用戶態 buf 拷貝到網絡協議子系統的 buf 中,具體點該 buf 屬於在代碼中使用的這個 socket。
-
此後 send 函數返回,再次由內核態返回到用戶態;此時在程序員看來數據已經成功發出去了,但實際上數據可能依然停留在內核中,此後第四次數據 copy 開始,利用 DMA 技術把數據從 socket buf 拷貝給網卡,然後真正的發送出去。
這就是看似簡單的這兩行代碼在底層的完整過程。
你覺得這個過程有什麼問題嗎?
發現問題
有的同學肯定已經注意到了,既然在用戶態沒有對數據進行任何修改,那爲什麼要這麼麻煩的讓數據在用戶態來個一日遊呢?直接在內核態從磁盤給到網卡不就可以了嗎?
恭喜你,答對了!
這種優化思路就是所謂的零拷貝技術,Zero Copy。
總體上來看,優化數據拷貝會有以下三個方向:
-
用戶態不需要真正的去訪問數據,就像上面這個示例,用戶態根本不需要知道 buf 裏面裝的是什麼。在這種情況下無需把數據從內核態拷貝到用戶態然後再把數據從用戶態拷貝回內核態。
數據無需用戶態感知,數據拷貝完全發生在內核態。
-
內核態不要真正的去訪問數據,用戶態程序可以繞過內核直接和硬件交互,這樣就避免了內核的參與,從而減少數據拷貝的可能。
內核無需感知數據。
-
如果內核態和用戶態不得不進行數據交互,則優化用戶態與內核態數據的交互方式。
知道了解決問題的思路,我們來看下爲了實現零拷貝,計算機系統中都有哪些巧妙的設計。
mmap
是的,就是 mmap,在《mmap 可以讓程序員實現哪些騷操作》一文中我們對其進行了詳細講解,你能想到 mmap 還可以實現零拷貝嗎?
對於本文提到的網絡服務器我們可以這樣修改代碼:
buf = mmap(file, len);
write(socket, buf, len);
你可能會想僅僅將 read 替換爲 mmap 會有什麼優化嗎?
如果你真的理解了 mmap 就會知道,mmap 僅僅將文件內容映射到了進程地址空間中,並沒有真正的拷貝到進程地址空間,這節省了一次從內核態到用戶態的數據拷貝。
同樣的,當調用 write 時數據直接從內核 buf 拷貝給了 socket buf,而不是像 read/write 方法中把用戶態數據拷貝給 socket buf。
我們可以看到,利用 mmap 我們節省了一次數據拷貝,上下文切換依然是四次。
儘管 mmap 可以節省數據拷貝,但維護文件與地址空間的映射關係也是有代價的,除非 CPU 拷貝數據的時間超過維繫映射關係的代價,否則基於 mmap 的程序性能可能不及傳統的 read/write。
此外,如果映射的文件被其它進程截斷,在 Linux 系統下你的進程將立即接收到 SIGBUS 信號,因此這種異常情況也需要正確處理。
除了 mmap 之外,還有其它辦法也可以實現零拷貝。
sendfile
你沒有看錯,在 Linux 系統下爲了解決數據拷貝問題專門設計了這一系統調用:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
Windows 下也有一個作用類似的 API:TransmitFile。
這一系統調用的目的是在兩個文件描述之間拷貝數據,但值得注意的是,數據拷貝的過程完全是在內核態完成,因此在網絡服務器的這個例子中我們將把那兩行代碼簡化爲一行,也就是調用這裏的 sendfile。
使用 sendfile 將節省兩次數據拷貝,因爲數據無需傳輸到用戶態:
調用 sendfile 後,首先 DMA 機制會把數據從磁盤拷貝到內核 buf 中,接下來把數據從內核 buf 拷貝到相應的 socket buf 中,最後利用 DMA 機制將數據從 socket buf 拷貝到網卡中。
我們可以看到,同使用傳統的 read/write 相比少了一次數據拷貝,而且內核態和用戶態的切換隻有兩次。
有的同學可能已經看出了,這好像不是零拷貝吧,在內核中這不是還有一次從內核態 buf 到 socket buf 的數據拷貝嗎?這次拷貝看上去也是沒有必要的。
的確如此,爲解決這一問題,單純的軟件機制已經不夠用了,我們需要硬件來幫一點忙,這就是 DMA Gather Copy。
sendfile 與 DMA Gather Copy
傳統的 DMA 機制必須從一段連續的空間中傳輸數據,就像這樣:
很顯然,你需要在源頭上把所有需要的數據都拷貝到一段連續的空間中:
現在肯定有同學會問,爲什麼不直接讓 DMA 可以從多個源頭收集數據呢?
這就是所謂的 DMA Gather Copy。
有了這一特性,無需再將內核文件 buf 中的數據拷貝到 socket buf,而是網卡利用 DMA Gather Copy 機制將消息頭以及需要傳輸的數據等直接組裝在一起發送出去。
在這一機制的加持下,CPU 甚至完全不需要接觸到需要傳輸的數據,而且程序利用 sendfile 編寫的代碼也無需任何改動,這進一步提升了程序性能。
當前流行的消息中間件 kafka 就基於 sendfile 來高效傳輸文件。
其實你應該已經看出來了,高效 IO 的祕訣其實很簡單:儘量少讓 CPU 參與進來。
實際上 sendfile 的使用場景是比較受限的,大前提是用戶態無需看到操作的數據,並且只能從文件描述符往 socket 中傳輸數據,而且 DMA Gather Copy 也需要硬件支持,那麼有沒有一種不依賴硬件特性同時又能在任意兩個文件描述符之間以零拷貝方式高效傳遞數據的方法呢?
答案是肯定的!這就要說到 Linux 下的另一個系統調用了:splice。
Splice
這裏還要再次強調一下不管是 sendfile 還是這裏的 splice 系統調用,使用的大前提都是無需在用戶態看到要傳遞的數據。
讓我們再來看一下傳統的 read/write 方法。
在這一方法下必須將數據從內核態拷貝的用戶態,然後在從用戶態拷貝回內核態,既然用戶態無需對該數據有任何操作,那麼爲什麼不讓數據傳輸直接在內核態中進行呢?
現在目標有了,實現方法呢?
答案是藉助 Linux 世界中用於進程間通信的管道,pipe。
還是以網絡服務器爲例,DMA 把數據從磁盤拷貝到文件 buf,然後將數據寫入管道,當在再次調用 splice 後將數據從管道讀入 socket buf 中,然後通過 DMA 發送出去,值得注意的是向管道寫數據以及從管道讀數據並沒有真正的拷貝數據,而僅僅傳遞的是該數據相關的必要信息。
你會看到,splice 和 sendfile 是很像的,實際上後來 sendfile 系統調用經過改造後就是基於 splice 實現的,既然有 splice 那麼爲什麼還要保留 sendfile 呢?答案很簡單,如果直接去掉 sendfile,那麼之前依賴該系統調用的所有程序將無法正常運行。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/oATQEI2DQcrhAFJlc_Zf8Q