零拷贝技术及在Java中应用

前言

前一段时间参与定位Tomcat某一问题,涉及到sendfile系统调用。忽然想到之前一些使用经验,知道Java领域中有不少开源软件,都使用零拷贝来提升期性能,于是有了本文。先看看我们这些耳熟能详的软件吧,你是否有了解过他们背后使用的技术点:

  • Tomcat: 使用sendfile直接把大文件写入Socket,提升静态文件高效的数据传输
  • Netty: 统一的ByteBuf机制,通过DirectBuffer封装,使用堆外内存进行Socket读写;也提供使用Sendfile来把文件缓冲区的数据发送到目标Channel
  • RecketMQ:基于mmap内存映射文件方式对CommitLog文件读写文件,当客户端消费消息时直接把内容写到目标Socket

本文是对网上知识的收集与整理,以便分享给大家。本文中【OS层章节】中介绍零拷贝技术的部分内容与图片来源于 看过就懂的java零拷贝及实现方式详解 ,在此先致谢。

什么是零拷贝

零拷贝(Zero Copy)是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另外一个特定区域。这种技术通常通过网络传输文件时节省CPU周期和带宽。

OS层

传统I/O

搞清楚零拷贝之前,先抛开Java语境,要了解在Linux下的I/O体系中几个核心知识点:

  • 内核空间 :Linux内核运行的空间
  • 用户空间 :用户程序运行的空间,为了安全,内核空间与用户空间是隔离的,即使用户程序崩溃了,不会导致内核受到影响。所有在内存管理,操作权限等都隔离。在内核空间内可以调用系统的一切资源,向用户空间的程序提供系统接口。而用户空间的程序不能直接调用资源系统,只能通过系统接口来间接向内核发起请求,这又称系统调用。
  • 磁盘/网卡 :磁盘/网卡相对于内存来说,是慢速I/O,他们之间数据传输主要有两种方式:PIO,经过CPU :磁盘/网卡与内存的数据交换,数据要经过CPU存储转发DMA,不经过CPU :而是直接进行磁盘/网卡和内存的数据交换,CPU只需要向DMA控制器下达指令,由DMA控制器通过系统总线来传输数据,传送完毕再通知CPU
  • CPU上下文切换 :先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

传统I/O的工作方式是:数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取或写入。

零拷贝技术及在Java中应用

代码会涉及两个系统调用,在Linux下,file与socket都抽象为文件,下面的函数第一参数其实都是文件描述符:

  • read(file, tmp_buf, len);
  • write(socket, tmp_buf, len);

整个过程发生了2次系统调用,4次用户态与内核态的上下文切换,而上下文切换问题:

  • 调度 :每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态
  • 时延 :需要耗时几十纳秒到几微秒,CPU调度也会有时延,在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能

存在用户空间<->内核空间<->磁盘/网卡之间的数据交换,发生了4次数据拷贝,其中2次是DMA拷贝,另外2次则是通过CPU拷贝:

  • 拷贝1( DMA拷贝 ):把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过DMA搬运
  • 拷贝2( CPU拷贝 ):把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由CPU完成
  • 拷贝3( CPU拷贝 ):把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的socket的缓冲区里,这个过程依然还是由CPU搬运
  • 拷贝4( DMA拷贝 ):把内核的socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由DMA搬运

零拷贝技术

mmap

Linux采用虚拟内存,多个虚拟内存可以指向同一个物理地址。利用这个特性,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样在 I/O操作时就不需要来回复制。将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的IO都在内核中完成。

mmap是内核提供一个系统调用,其函数原型如下:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
复制代码

mmap+write利用了虚拟内存的特性来实现的零拷贝,其流程如下:

零拷贝技术及在Java中应用

上述流程就是少了1次CPU拷贝,提升了I/O的速度。不过上下文的切换还是4次并没有减少,这是因为还是要应用程序发起write操作。mmap是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,所以节省了1次CPU拷贝并且用户进程内存是虚拟的,只是映射到内核的读缓冲区,应用层内存也会减少一半。

sendfile

sendfile是内核提供另一个系统调用,其函数原型如下:

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
复制代码

sendfile表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。其流程如下:

零拷贝技术及在Java中应用

sendfile方式有3次数据拷贝,包括了2次DMA拷贝和1次CPU拷贝,以及2次上下文切换。

sendfile+DMA scatter/gather

那能不能把CPU拷贝减少到0?Linux 2.4内核进行了优化,提供了带有scatter/gather的sendfile操作,这个操作可以把最后一次CPU拷贝去除。其原理就是在内核空间Read BUffer和Socket Buffer不做数据复制,而是将Read Buffer的内存地址、偏移量记录到相应的Socket Buffer中,这样就不需要复制。其本质和虚拟内存的解决方法思路一致,就是内存地址的记录。其流程如下:

零拷贝技术及在Java中应用

scatter/gather的sendfile只有2次DMA拷贝,以及2次上下文切换,CUP拷贝已经完全没有。不过这种复制功能是 需要硬件及驱动程序支持 。

splice

在Linux 2.6.17版本引入了splice,splice调用和sendfile非常相似,它并不仅限于sendfile的功能。也就是说splice是sendfile的一个超集。

  • 相同点 :splice与sendfile都需要两个已经打开的文件描述符,一个表示输入,一个表示输出
  • 不同点 :splice允许任意两个文件互相连接,而并不只是文件与socket进行数据传输,splice不需要硬件支持

在Linux 2.6.23版本中,sendfile机制的实现已经没有了,但是其API及相应的功能还在,相应的功能是利用了splice机制来实现。

小结

所谓的零拷贝,都是为了减少CPU拷贝及减少了上下文的切换,汇总如下:

系统调用 CPU拷贝 DMA拷贝 上下文切换
传统I/O read+write 2 2 4
mmap mmap+write 1 2 4
sendfile sendfile 1 2 2
sendfile+gather sendfile 0 2 2
splice splice 0 2 0

引入了零拷贝之后,2次DMA拷贝是都少不了,因为两次DMA都是依赖硬件完成。

Java层

零拷贝技术

相比于OS层,由于JVM引入GC,有自己的堆内存管理。零拷贝需要考虑两个问题:

  • 从哪拷贝到哪 :用户进程需要像磁盘写数据时,需要将用户缓冲区(堆内内存)中的内容拷贝到内核缓冲区(堆外内存)中,操作系统再将内核缓冲区中的内容写进磁盘中
  • 零拷贝如何优化 :过在用户进程中,直接申请堆外内存,存储其需要写进磁盘的数据

因此,Java的零拷贝技术核心先要能使用堆外内存。

Java NIO

JVM的GC机制则会存在对内存的拷贝移动,会影响效率。当有一些高性能要求场景,需要直接使用OS原生的堆内存,DirectByteBuffer则是可出直接申请与释放JVM堆外内存。此内存不由JVM管理,不受GC影响。直接使用堆外内存从而避免了数据在JVM堆与OS用户堆的拷贝。

Java NIO API提供了OS层零拷贝的API封装,上层也非常方便的使用。

  • mmap :NIO中提供一个MappedByteBuffer类,其底层是mmap系统调用
  • sendfile : NIO中提供的FileChannel提供两个方法(transferTo/transferFrom),其底层是sendfile系统调用

Netty

Netty相比与Java内置的NIO,它从三个层次来减少数据的拷贝:

  • 避免数据流经用户空间 :Netty的FileRegion中FileChannel.tranferTo,可以实现数据如何写到目标中,可以使用mmap+write
  • 避免数据在JVM堆与OS用户堆的拷贝 :Java提供DirectByteBuffer,Netty提供对DirectByteBuffer与JVM的堆内存的统一ByteBuf接口封装
  • 避免数据在用户空间多次多次拷贝 :Netty提供ByteBuf抽象,支持引用计数与池化。并提供CompositeByteBuf组合视图来减少拷贝ByteBuf:retain/release引用计数ByteBufHolder:duplicate对于ByteBuf进行一个浅拷贝,共享同一个数据区域,但不共享read和write索引CompositeByteBuf:组合数个缓冲区为一体,并对外展现为一个缓冲区,可以将它们逻辑上当成一个完整的ByteBuf来操作,这样就免去了重新分配空间再复制数据的开销

开源分析

Tomcat

Tomcat是一个Web服务端软件,其中Web应用存在一些静态资源文件,而这些静态文件不是需要经过应用来处理,可以地接由Tomcat直接发给客户端,因而不需要经过用户空间,则可能利用前面的提到零拷贝技术。

为了提高性能,节省带宽,Tomcat提供一种内建机制来对静态资源文件压缩,但压缩节省带宽了却提高了CPU。Tomcat又提供sendfile的功能。当默认大于48Kb的静态文件,会直接使用sendfile功能进行传送,而不再启用压缩。

相关的配置可以参见: Default Servlet Reference 与 Advanced IO and Tomcat

Tomcat提供三种IO:

  • BIO :阻塞IO,现在应该很少使用
  • NIO :非阻塞IO技术,使用Java提供NIO API,Tomcat提供了NIO与NIO2两种实现
  • APR :基于JNI调用操作系统相关API,性能相对NIO有提升,但需要下载APR需要的库

Tomcat定义了三种类型的Endpoint,分别对应上面三种IO模式,其中 NioEndpoint.java 中可以找到如下代码:

    if (sd.fchannel == null) {
        // Setup the file channel
        File f = new File(sd.fileName);
        @SuppressWarnings("resource") // Closed when channel is closed
        FileInputStream fis = new FileInputStream(f);
        sd.fchannel = fis.getChannel(); // 生成静态文件的FileChannel
    }

    // Configure output channel
    sc = socketWrapper.getSocket();
    // TLS/SSL channel is slightly different
    WritableByteChannel wc = ((sc instanceof SecureNioChannel) ? sc : sc.getIOChannel());

    // We still have data in the buffer
    if (sc.getOutboundRemaining() > 0) {
        ...
    } else {
        long written = sd.fchannel.transferTo(sd.pos, sd.length, wc); // 底层sendfile系统调用
        if (written > 0) {
复制代码

RocketMQ

RocketMQ支持消息持久化,所有的消息接收之后都是顺序追加写入到CommitLog中,CommitLog是磁盘上的文件。消费者连接服务端会创建 CosumerQueue,CommitLog文件中的消息与CosumerQueue建立索引关系。当消费者是通过CosumerQueue得到消息的真实物理地址再去 CommitLog上获取到对应的消息。

RocketMQ是采用mmap+write来实现CommitLog文件的内容发送,它避免了JVM的堆内存的拷贝,也减少一次CPU拷贝。但没有没有采用sendfile。

我们可以在 MappedFile.java 中找到文件初始化,好理解消息消费和删除需要支持随机读写:

try {
    this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
    this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
    TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
    TOTAL_MAPPED_FILES.incrementAndGet();
    ok = true;
} catch (FileNotFoundException e) {
    log.error("Failed to create file " + this.fileName, e);
    throw e;
} catch (IOException e) {
复制代码

当客户端来拉取消息时,我们可以在 PullMessageProcessor.java 中找到如下代码:

  • 如果是transferMsgByHeap,而会把消息读到堆中,再写到Body中
  • 如果不是transferMsgByHeap,则会创建MessageTransfer,而它实现了Netty的FileRegion接口,在 tranferTo 方法把内容写到目标channel中。
if (this.brokerController.getBrokerConfig().isTransferMsgByHeap()) {
    final byte[] r = this.readGetMessageResult(getMessageResult, requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId());
    this.brokerController.getBrokerStatsManager().incGroupGetLatency(requestHeader.getConsumerGroup(),
        requestHeader.getTopic(), requestHeader.getQueueId(),
        (int) (this.brokerController.getMessageStore().now() - beginTimeMills));
    response.setBody(r);
} else {
    try {
        FileRegion fileRegion =
            new ManyMessageTransfer(response.encodeHeader(getMessageResult.getBufferTotalSize()), getMessageResult);
        channel.writeAndFlush(fileRegion).addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                getMessageResult.release();
                if (!future.isSuccess()) {
                    log.error("transfer many message by pagecache failed, {}", channel.remoteAddress(), future.cause());
                }
            }
        });
    } catch (Throwable e) {
        log.error("transfer many message by pagecache exception", e);
        getMessageResult.release();
    }
复制代码

结语

本文搜集整理了零拷贝的知识点,并简单打开两个开源软件的源码来看看,可以看到零拷贝技术并不是什么复杂高深的技术,在Java层使用也非常简单。希望本文能给大家带来一些启发,在后续的网络编程中对有零拷贝技术所应用。

猜你喜欢

转载自juejin.im/post/7110568992049692703