Java零拷贝
1、摘要
文件或网络间数据Copy(如文件到文件、client到server等)过程的比较:
通常的Copy过程:因为一个文件复制是两次IO调用过程,一次IO调用过程包括数据准备的DMA Copy、数据复制的CPU Copy两个copy过程,包含2次用户态内核态切换(详见 一次IO过程的内部机制-MarchOn),故一次数据复制有4次copy(2 DMA Copy、2 CPU Copy)、4次上下文切换。
零拷贝的Copy过程:将文件复制过程变为了一次IO调用,且避免了用户态和内核态间的copy(少去了2次CPU Copy)、减少了两次用户态内核态间的切换,因此数据传输效率高(4、4变2、2)。
可见,零拷贝的“零”是指用户态和内核态间copy数据的次数为零,即Zero CPU Copy。
零拷贝可以提高数据传输效率,但对于需要在用户传输过程中对数据进行加工的场景(如加密)就不适合使用零拷贝。
使用Zero 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之间的上下文切换,甚至连数据都被拷贝了四次(关于数据IO时涉及的内部过程,可参阅 IO过程的内部过程-MarchOn)。如下图:
具体步骤如下:
- 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://mp.weixin.qq.com/s/HZFbPxaEC0rwA98qp9MZcw
https://my.oschina.net/cloudcoder/blog/299944