真正的零拷贝有两种方式:

  • mmap+write
  • Sendfile

mmap 是一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系。

这样就可以省掉原来内核 Read 缓冲区 Copy 数据到用户缓冲区,但是还是需要内核 Read 缓冲区将数据 Copy 到内核 Socket 缓冲区。所以Copy次数减少到了三次。

 

 

Sendfile 系统调用在内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。

Sendfile 系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数,(mmap中还是要有用户态的参与,参与虚拟地址映射,sendfile则彻底不需要用户态的参与,所以减少了上下文切换的次数)

数据传送只发生在内核空间,所以减少了一次上下文切换;但是还是存在一次 Copy,也可以把这一次 Copy 也省略掉,

Linux2.4 内核中做了改进,将 Kernel buffer 中对应的数据描述信息(内存地址,偏移量)记录到相应的 Socket 缓冲区当中,这样连内核空间中的一次 CPU Copy 也省掉了。

Java中提供了mmap+write的实现,

MappedByteBuffer

Java NIO 提供的 FileChannel 提供了 map() 方法,该方法可以在一个打开的文件和 MappedByteBuffer 之间建立一个虚拟内存映射。

MappedByteBuffer 继承于 ByteBuffer,类似于一个基于内存的缓冲区,只不过该对象的数据元素存储在磁盘的一个文件中。

调用 get() 方法会从磁盘中获取数据,此数据反映该文件当前的内容,调用 put() 方法会更新磁盘上的文件,并且对文件做的修改对其他阅读者也是可见的。

map()方法如下:

public abstract MappedByteBuffer map(MapMode mode, 
                                         long position, long size) 
        throws IOException; 

分别提供了三个参数,MapMode,Position 和 Size,分别表示:

  • MapMode:映射的模式,可选项包括:READ_ONLY,READ_WRITE,PRIVATE。
  • Position:从哪个位置开始映射,字节数的位置。
  • Size:从 Position 开始向后多少个字节。

重点看一下 MapMode,前两个分别表示只读和可读可写,当然请求的映射模式受到 Filechannel 对象的访问权限限制,如果在一个没有读权限的文件上启用 READ_ONLY,将抛出 NonReadableChannelException。

PRIVATE 模式表示写时拷贝的映射,意味着通过 put() 方法所做的任何修改都会导致产生一个私有的数据拷贝并且该拷贝中的数据只有 MappedByteBuffer 实例可以看到。

该过程不会对底层文件做任何修改,而且一旦缓冲区被施以垃圾收集动作(garbage collected),那些修改都会丢失。

大致浏览一下 map() 方法的源码:

public MappedByteBuffer map(MapMode mode, long position, long size) 
       throws IOException 
   { 
           ...... 
           int pagePosition = (int)(position % allocationGranularity); 
           long mapPosition = position - pagePosition; 
           long mapSize = size + pagePosition; 
           try { 
               // If no exception was thrown from map0, the address is valid 
               addr = map0(imode, mapPosition, mapSize); 
           } catch (OutOfMemoryError x) { 
               // An OutOfMemoryError may indicate that we've exhausted memory 
               // so force gc and re-attempt map 
               System.gc(); 
               try { 
                   Thread.sleep(100); 
               } catch (InterruptedException y) { 
                   Thread.currentThread().interrupt(); 
               } 
               try { 
                   addr = map0(imode, mapPosition, mapSize); 
               } catch (OutOfMemoryError y) { 
                   // After a second OOME, fail 
                   throw new IOException("Map failed", y); 
               } 
           } 
 
           // On Windows, and potentially other platforms, we need an open 
           // file descriptor for some mapping operations. 
           FileDescriptor mfd; 
           try { 
               mfd = nd.duplicateForMapping(fd); 
           } catch (IOException ioe) { 
               unmap0(addr, mapSize); 
               throw ioe; 
           } 
 
           assert (IOStatus.checkAll(addr)); 
           assert (addr % allocationGranularity == 0); 
           int isize = (int)size; 
           Unmapper um = new Unmapper(addr, mapSize, isize, mfd); 
           if ((!writable) || (imode == MAP_RO)) { 
               return Util.newMappedByteBufferR(isize, 
                                                addr + pagePosition, 
                                                mfd, 
                                                um); 
           } else { 
               return Util.newMappedByteBuffer(isize, 
                                               addr + pagePosition, 
                                               mfd, 
                                               um); 
           } 
    } 

大致意思就是通过 Native 方法获取内存映射的地址,如果失败,手动 GC 再次映射。

最后通过内存映射的地址实例化出 MappedByteBuffer,MappedByteBuffer 本身是一个抽象类,其实这里真正实例化出来的是 DirectByteBuffer。

DirectByteBuffer 继承于 MappedByteBuffer,从名字就可以猜测出开辟了一段直接的内存,并不会占用 JVM 的内存空间。所以别名叫堆外内存,这也是netty中的那个DirectByteBuffer。

Netty 提供了零拷贝的 Buffer,除了DirectByteBuffer这种mmap的方式,其它的宣称提供的零拷贝操作不能算是真正意义上的零拷贝,叫”少拷贝“更为合适,比如因为在传输数据时,最终处理的数据会需要对单个传输的报文,进行组合和拆分,NIO 原生的 ByteBuffer 无法做到,Netty 通过提供的 Composite(组合)和 Slice(拆分)两种 Buffer 来实现”少拷贝“。

如果在TCP 层 HTTP 报文被分成了两个 ChannelBuffer(分别对应HTTP报文的Header和Body),这两个 Buffer 对我们上层的逻辑(HTTP 处理)是没有意义的。

但是两个 ChannelBuffer 被组合起来,就成为了一个有意义的 HTTP 报文,这个报文对应的 ChannelBuffer,才是能称之为“Message”的东西,这里用到了一个词“Virtual Buffer”。

可以看一下 Netty 提供的 CompositeChannelBuffer 源码:

public class CompositeChannelBuffer extends AbstractChannelBuffer { 
 
    private final ByteOrder order; 
    private ChannelBuffer[] components; 
    private int[] indices; 
    private int lastAccessedComponentId; 
    private final boolean gathering; 
 
    public byte getByte(int index) { 
        int componentId = componentId(index); 
        return components[componentId].getByte(index - indices[componentId]); 
    } 
    ......

Components 用来保存的就是所有接收到的 Buffer,Indices 记录每个 buffer 的起始位置,lastAccessedComponentId 记录上一次访问的 ComponentId。

CompositeChannelBuffer 并不会开辟新的内存并直接复制所有 ChannelBuffer 内容,而是直接保存了所有 ChannelBuffer 的引用,并在子 ChannelBuffer 里进行读写,实现了零拷贝(少拷贝)。

其它零拷贝:

RocketMQ 的消息采用顺序写到 commitlog 文件,然后利用 consume queue 文件作为索引,RocketMQ 采用零拷贝 mmap+write 的方式来回应 Consumer 的请求。

同样 Kafka 中存在大量的网络数据持久化到磁盘和磁盘文件通过网络发送的过程,Kafka使用了 Sendfile 零拷贝方式。

 

posted on 2020-05-20 17:49  Moonshoterr  阅读(925)  评论(0编辑  收藏  举报