這篇文章翻譯自Efficient data transfer through zero copy。由于譯者水平有限,有的地方翻譯的可能不正確,所以讀者應當先查看原文。
Web應用程序為了提供靜態內容,需要先從磁盤中讀出數據,然后將這些數據寫入到Socket中。雖然這個過程不會占用太多CPU資源,但是它確實效率不高。內核從磁盤讀取數據,需要在內核態和用戶態之間切換,然后將它寫入到Socket時,又需要從用戶態切換到內核態。
當數據在用戶態和內核態之間切換時,會消耗CPU資源以及內存帶寬。我們可以通過一種叫做Zero-copy的技術來消除這些開銷。zero-copy會將數據直接從磁盤拷貝到Socket,這就避免在內核態和用戶態之間的切換。在Java中,java.nio.channels.FileChannel的transferTo()函數就是通過zero-copy來實現的。你可以通過transferTo()方法,直接將數據從一個Channel傳輸到另一個Channel。
在這篇文章中,首先通過一個使用傳統方式實現的文件傳輸的例子,來查看其開銷。然后通過一個用zero-copy實現的例子,來驗證其性能優勢。
使用傳統的方式實現的文件傳輸例子
假設我們要開發一個用于從特定文件中讀取數據,然后通過網絡將它傳輸給其他的程序的例子。這個程序的核心,就是這么兩個調用:
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
這很容易理解。但是,在其內部,卻有著四次用戶態和內核態之間的切換,并且,數據被拷貝了四次。下面這張圖,展示了數據傳輸的過程:
而下面這張圖,則展示了內核態和用戶態之間切換的過程:
這四次用戶態和內核態之間切換分別是:
- 當調用read()時,會導致一次從用戶態到內核態的切換。read()的內部是通過sys_read()來實現的。第一次數據復制,是數據通過DMA被從文件讀出并且放在了內核的buffer中。
- 數據從內核的讀緩沖區被復制到應用程序的緩沖區,然后read()調用返回。這就會導致一次從內核態到用戶態的切換。現在,數據被存儲到了應用程序的緩沖區中。
- send()調用又會導致一次從用戶態到內核態的切換。這里發生了第三次數據復制,數據從應用程序的緩沖區被復制到內核的寫緩沖區。
- send()調用返回,這導致了第四次上下文切換。并且數據通過DMA從內核寫緩沖區被發送到了協議棧。
即使中間的內核緩沖區(而不是直接將數據傳輸到應用程序的緩沖區中)似乎看起來效率很低。但是實際上,中間的內核緩沖區是為了提升性能而存在的。對于內核讀緩沖區來說,它可以實現預先讀,即,緩沖區中預先加載一些磁盤中的數據,下次應用程序在讀數據的時候,就不需要再去磁盤讀取,而是直接從內核的讀緩沖區讀取就好了,這是因為,有研究表明,應用程序總是傾向于讀取連續的數據,即如果磁盤上的一塊數據被訪問到了,那么,在磁盤上,與這塊數據相鄰的其他數據,也很快就會被訪問到。這就提高了讀性能。而對于寫緩沖區來說,它可以實現異步的寫操作。即,對于寫操作,將數據復制到內核寫緩沖區,基本上就可以認為寫入成功,而不需要一直阻塞到TCP協議棧真的發送了數據并收到了響應。
然而,內核緩沖區也有一些缺點。想象這樣一種場景,應用程序請求10KB的數據,而內核緩沖區僅有4KB,那么,就需要分三次來讀取磁盤數據,并復制到應用程序的緩沖區。對于內核的緩沖區也是這樣。
通過zero-copy,就可以規避這些問題。
使用zero-copy實現的文件傳輸的例子
我們可以發現,在上面的例子中,實際上,第二次和第三次數據復制并不是必須的。應用程序僅僅是作為一個橋梁,將數據從內核讀緩沖區傳輸到內核寫緩沖區。實際上,數據可以直接從內核的讀緩沖區寫入到socket的緩沖區。Java中的transferTo()就實現了這個操作。
transferTo()方法的聲明如下:
public void transferTo(long position, long count, WritableByteChannel target);
transferTo方法會將數據從FileChannel傳輸到WritableByteChannel。但是,這個方法依賴于操作系統的底層實現。只有當操作系統支持zero-copy時,這個方法才有用。在UNIX以及Linux的不同發行版中,這個方法最終會調用sendfile()系統調用。
sendfile()的聲明如下:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
下圖展示了transferTo()方法的流程:
下圖展示了使用transferTo()方法時,上下文切換的情況:
兩次上下文切換分別發生在:
- 在調用transferTo()方法時,需要從用戶態轉換到內核態。然后數據通過DMA被從文件讀到內核讀緩沖區中。然后數據被從內核讀緩沖區復制到Socket的緩沖區中。最后,數據通過DMA被放到了NIC buffer中.
- tranferTo()方法返回時,需要從內核態切換到用戶態。
即使這相對于用傳統方式實現的例子有了一些提升,比如,上下文切換次數從四次降到了兩次,數據需要被復制三次,而不是四次了。但是,這還不是zero-copy。要進一步降低開銷,需要網絡接口直接一些特定的操作。從Linux內核2.4之后,socket buffer descriptor就被修改了,來適應這種需求。這個修改,不僅降低了上下文切換的次數,而且消除了數據在被復制時,涉及到的CPU消耗:
- transferTo()方法讓數據通過DMA被復制內核讀緩沖區
- 沒有數據會被復制到socket buffer。socket buffer只會收到一個descriptor,它包含了數據的位置和長度信息。DMA直接將數據從內核讀緩沖區復制到NIC Buffer中,因此,消除掉了CPU消耗。
性能比較
我們在一個內核為2.6的Linux操作系統上,運行了一些測試,最終得出了下面的結果:
我們可以看到,使用transferTo()實現的這種方式,相對于用傳統方式實現,只需要大約65%的時間。