1、摘要
零拷貝的“零”是指用戶態和內核態間copy數據的次數為零。
傳統的數據copy(文件到文件、client到server等)涉及到四次用戶態內核態切換、四次copy。四次copy中,兩次在用戶態和內核態間copy需要CPU參與、兩次在內核態與IO設備間copy為DMA方式不需要CPU參與。零拷貝避免了用戶態和內核態間的copy、減少了兩次用戶態內核態間的切換。
2、介紹
java 的zero copy多在網絡應用程序中使用。Java的libaries在linux和unix中支持zero copy,關鍵的api是java.nio.channel.FileChannel的transferTo(),transferFrom()方法。我們可以用這兩個方法來把bytes直接從調用它的channel傳輸到另一個writable byte channel,中間不會使data經過應用程序,以便提高數據轉移的效率。
許多web應用都會向用戶提供大量的靜態內容,這意味着有很多data從硬盤讀出之后,會原封不動的通過socket傳輸給用戶。這種操作看起來可能不會怎么消耗CPU,但是實際上它是低效的:kernal把數據從disk讀出來,然后把它傳輸給user級的application,然后application再次把同樣的內容再傳回給處於kernal級的socket。這種場景下,application實際上只是作為一種低效的中間介質,用來把disk file的data傳給socket。
data每次穿過user-kernel boundary,都會被copy,這會消耗cpu,並且占用RAM的帶寬。幸運的是,你可以用一種叫做Zero-Copy的技術來去掉這些無謂的 copy。應用程序用zero copy來請求kernel直接把disk的data傳輸給socket,而不是通過應用程序傳輸。Zero copy大大提高了應用程序的性能,並且減少了kernel和user模式的上下文切換
使用kernel buffer做中介(而不是直接把data傳到user buffer中)看起來比較低效(多了一次copy)。然而實際上kernel buffer是用來提高性能的。在進行讀操作的時候,kernel buffer起到了預讀cache的作用。當寫請求的data size比kernel buffer的size小的時候,這能夠顯著的提升性能。在進行寫操作時,kernel buffer的存在可以使得寫請求完全異步。
悲劇的是,當請求的data size遠大於kernel buffer size的時候,這個方法本身變成了性能的瓶頸。因為data需要在disk,kernel buffer,user buffer之間拷貝很多次(每次寫滿整個buffer)。
而Zero copy正是通過消除這些多余的data copy來提升性能。
3、傳統方式及涉及到的上下文切換
通過網絡把一個文件傳輸給另一個程序,在OS的內部,這個copy操作要經歷四次user mode和kernel mode之間的上下文切換,甚至連數據都被拷貝了四次,如下圖:
具體步驟如下:
- read() 調用導致一次從user mode到kernel mode的上下文切換。在內部調用了sys_read() 來從文件中讀取data。第一次copy由DMA (direct memory access)完成,將文件內容從disk讀出,存儲在kernel的buffer中。
- 然后請求的數據被copy到user buffer中,此時read()成功返回。調用的返回觸發了第二次context switch: 從kernel到user。至此,數據存儲在user的buffer中。
- send() Socket call 帶來了第三次context switch,這次是從user mode到kernel mode。同時,也發生了第三次copy:把data放到了kernel adress space中。當然,這次的kernel buffer和第一步的buffer是不同的buffer。
- 最終 send() system call 返回了,同時也造成了第四次context switch。同時第四次copy發生,DMA egine將data從kernel buffer拷貝到protocol engine中。第四次copy是獨立而且異步的。
4、zero copy方式及涉及的上下文轉換
在linux 2.4及以上版本的內核中(如linux 6或centos 6以上的版本),開發者修改了socket buffer descriptor,使網卡支持 gather operation,通過kernel進一步減少數據的拷貝操作。這個方法不僅減少了context switch,還消除了和CPU有關的數據拷貝。user層面的使用方法沒有變,但是內部原理卻發生了變化:
transferTo()方法使得文件內容被copy到了kernel buffer,這一動作由DMA engine完成。 沒有data被copy到socket buffer。取而代之的是socket buffer被追加了一些descriptor的信息,包括data的位置和長度。然后DMA engine直接把data從kernel buffer傳輸到protocol engine,這樣就消除了唯一的一次需要占用CPU的拷貝操作。
5、Java NIO 零拷貝示例
NIO中的FileChannel擁有transferTo和transferFrom兩個方法,可直接把FileChannel中的數據拷貝到另外一個Channel,或直接把另外一個Channel中的數據拷貝到FileChannel。該接口常被用於高效的網絡/文件的數據傳輸和大文件拷貝。在操作系統支持的情況下,通過該方法傳輸數據並不需要將源數據從內核態拷貝到用戶態,再從用戶態拷貝到目標通道的內核態,同時也避免了兩次用戶態和內核態間的上下文切換,也即使用了“零拷貝”,所以其性能一般高於Java IO中提供的方法。
5.1、通過網絡把一個文件從client傳到server:

1 /** 2 * disk-nic零拷貝 3 */ 4 class ZerocopyServer { 5 ServerSocketChannel listener = null; 6 7 protected void mySetup() { 8 InetSocketAddress listenAddr = new InetSocketAddress(9026); 9 10 try { 11 listener = ServerSocketChannel.open(); 12 ServerSocket ss = listener.socket(); 13 ss.setReuseAddress(true); 14 ss.bind(listenAddr); 15 System.out.println("監聽的端口:" + listenAddr.toString()); 16 } catch (IOException e) { 17 System.out.println("端口綁定失敗 : " + listenAddr.toString() + " 端口可能已經被使用,出錯原因: " + e.getMessage()); 18 e.printStackTrace(); 19 } 20 21 } 22 23 public static void main(String[] args) { 24 ZerocopyServer dns = new ZerocopyServer(); 25 dns.mySetup(); 26 dns.readData(); 27 } 28 29 private void readData() { 30 ByteBuffer dst = ByteBuffer.allocate(4096); 31 try { 32 while (true) { 33 SocketChannel conn = listener.accept(); 34 System.out.println("創建的連接: " + conn); 35 conn.configureBlocking(true); 36 int nread = 0; 37 while (nread != -1) { 38 try { 39 nread = conn.read(dst); 40 } catch (IOException e) { 41 e.printStackTrace(); 42 nread = -1; 43 } 44 dst.rewind(); 45 } 46 } 47 } catch (IOException e) { 48 e.printStackTrace(); 49 } 50 } 51 } 52 53 class ZerocopyClient { 54 public static void main(String[] args) throws IOException { 55 ZerocopyClient sfc = new ZerocopyClient(); 56 sfc.testSendfile(); 57 } 58 59 public void testSendfile() throws IOException { 60 String host = "localhost"; 61 int port = 9026; 62 SocketAddress sad = new InetSocketAddress(host, port); 63 SocketChannel sc = SocketChannel.open(); 64 sc.connect(sad); 65 sc.configureBlocking(true); 66 67 String fname = "src/main/java/zerocopy/test.data"; 68 FileChannel fc = new FileInputStream(fname).getChannel(); 69 long start = System.nanoTime(); 70 long nsent = 0, curnset = 0; 71 curnset = fc.transferTo(0, fc.size(), sc); 72 System.out.println("發送的總字節數:" + curnset + " 耗時(ns):" + (System.nanoTime() - start)); 73 try { 74 sc.close(); 75 fc.close(); 76 } catch (IOException e) { 77 System.out.println(e); 78 } 79 } 80 }
5.2、文件到文件的零拷貝:

1 /** 2 * disk-disk零拷貝 3 */ 4 class ZerocopyFile { 5 @SuppressWarnings("resource") 6 public static void transferToDemo(String from, String to) throws IOException { 7 FileChannel fromChannel = new RandomAccessFile(from, "rw").getChannel(); 8 FileChannel toChannel = new RandomAccessFile(to, "rw").getChannel(); 9 10 long position = 0; 11 long count = fromChannel.size(); 12 13 fromChannel.transferTo(position, count, toChannel); 14 15 fromChannel.close(); 16 toChannel.close(); 17 } 18 19 @SuppressWarnings("resource") 20 public static void transferFromDemo(String from, String to) throws IOException { 21 FileChannel fromChannel = new FileInputStream(from).getChannel(); 22 FileChannel toChannel = new FileOutputStream(to).getChannel(); 23 24 long position = 0; 25 long count = fromChannel.size(); 26 27 toChannel.transferFrom(fromChannel, position, count); 28 29 fromChannel.close(); 30 toChannel.close(); 31 } 32 33 public static void main(String[] args) throws IOException { 34 String from = "src/main/java/zerocopy/1.data"; 35 String to = "src/main/java/zerocopy/2.data"; 36 // transferToDemo(from,to); 37 transferFromDemo(from, to); 38 } 39 }
6、參考資料
https://my.oschina.net/cloudcoder/blog/299944