零拷贝机制

传统的数据传输方法

在互联网时代,从某台机器将一份数据(比如一个文件)通过网络传输到另外一台机器,是再平常不过的事情了。如果按照一般的思路,用Java语言来描述发送端的逻辑,大致如下。

Socket socket = new Socket(HOST, PORT);
InputStream inputStream = new FileInputStream(FILE_PATH);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());

byte[] buffer = new byte[4096];
while (inputStream.read(buffer) >= 0) {
    outputStream.write(buffer);
}

outputStream.close();
socket.close();
inputStream.close();

看起来当然是很简单的。但是如果我们深入到操作系统的层面,就会发现实际的微观操作要更复杂,具体来说有以下步骤:

1.JVM向OS发出read()系统调用,触发上下文切换,从用户态切换到内核态。
2.从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区。
DMA介绍:DMA(Direct Memory Access,直接存储器访问) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用。
3.将数据从内核缓冲区拷贝到用户空间缓冲区,read()系统调用返回,并从内核态切换回用户态。
4.JVM向OS发出write()系统调用,触发上下文切换,从用户态切换到内核态。
5.将数据从用户缓冲区拷贝到内核中与目的地Socket关联的缓冲区。
数据最终经由Socket通过DMA传送到硬件(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态。
如果语言描述看起来有些乱的话,通过时序图描述会更清楚一些。

在这里插入图片描述
到了这一步,你是否觉得简单的代码逻辑下隐藏着很累赘的东西了?事实也确实如此,这个过程一共发生了4次上下文切换(严格来讲是模式切换),并且数据也被来回拷贝了4次
我们都知道,上下文切换是CPU密集型的工作,数据拷贝是I/O密集型的工作。如果一次简单的传输就要像上面这样复杂的话,效率是相当低下的。零拷贝机制的终极目标,就是消除冗余的上下文切换和数据拷贝,提高效率

零拷贝的数据传输方法

“基础的”零拷贝机制
通过上面的分析可以看出,第2、3次拷贝(也就是从内核空间到用户空间的来回复制)是没有意义的,数据应该可以直接从内核缓冲区直接送入Socket缓冲区。零拷贝机制就实现了这一点。不过零拷贝需要由操作系统直接支持,不同OS有不同的实现方法。大多数Unix-like系统都是提供了一个名为sendfile()的系统调用.
下面是零拷贝机制下,数据传输的时序图。
在这里插入图片描述
可见确实是消除了从内核空间到用户空间的来回复制,因此“zero-copy”这个词实际上是站在内核的角度来说的,并不是完全不会发生任何拷贝。

在Java NIO包中提供了零拷贝机制对应的API,即FileChannel.transferTo()方法。不过FileChannel类是抽象类,transferTo()也是一个抽象方法,因此还要依赖于具体实现。FileChannel的实现类并不在JDK本身,而位于sun.nio.ch.FileChannelImpl类中,零拷贝的具体实现自然也都是native方法,看官如有兴趣可以自行查找源码来看,这里不再赘述。

将传统方式的发送端逻辑改写一下,大致如下。

SocketAddress socketAddress = new InetSocketAddress(HOST, PORT);
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(socketAddress);

File file = new File(FILE_PATH);
FileChannel fileChannel = new FileInputStream(file).getChannel();
fileChannel.transferTo(0, file.length(), socketChannel);

fileChannel.close();
socketChannel.close();

可见,不仅拷贝的次数变成了3次,上下文切换的次数也减少到了2次,效率比传统方式高了很多。但是它还并非完美状态,下面看一看让它变得更优化的方法。

对Scatter/Gather的支持

在“基础”零拷贝方式的时序图中,有一个“write data to target socket buffer”的回环,在框图中也有一个从“Read buffer”到“Socket buffer”的大箭头。这是因为在一般的Block DMA方式中,源物理地址和目标物理地址都得是连续的,所以一次只能传输物理上连续的一块数据,每传输一个块发起一次中断,直到传输完成,所以必须要在两个缓冲区之间拷贝数据。

而Scatter/Gather DMA方式则不同,会预先维护一个物理上不连续的块描述符的链表,描述符中包含有数据的起始地址和长度。传输时只需要遍历链表,按序传输数据,全部完成后发起一次中断即可,效率比Block DMA要高。也就是说,硬件可以通过Scatter/Gather DMA直接从内核缓冲区中取得全部数据,不需要再从内核缓冲区向Socket缓冲区拷贝数据。因此上面的时序图还可以进一步简化。

在这里插入图片描述

对内存映射(mmap)的支持

上面讲的机制看起来一切都很好,但它还是有个缺点:如果我想在传输时修改数据本身,就无能为力了。不过,很多操作系统也提供了内存映射机制,对应的系统调用为mmap()/munmap()。通过它可以将文件数据映射到内核地址空间,直接进行操作,操作完之后再刷回去。其对应的简要时序图如下。

在这里插入图片描述

当然,天下没有免费的午餐,上面的过程仍然会发生4次上下文切换。另外,它需要在快表(TLB)中始终维护着所有数据对应的地址空间,直到刷写完成,因此处理缺页的overhead也会更大。在使用该机制时,需要权衡效率。

NIO框架中提供了MappedByteBuffer用来支持mmap。它与常用的DirectByteBuffer一样,都是在堆外内存分配空间。相对地,HeapByteBuffer在堆内内存分配空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值