徹底理解高級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,那么之前依賴該系統調用的所有程序將無法正常運行。