前言
零拷贝是网络编程的关键,很多性能优化都离不开零拷贝,很多优秀的开源框架底层都用的零拷贝,如Netty、RocketMQ、Spark等
正文
1. 再看IO
在深入零拷贝机制之前,先来了解下传统BIO通信底层发生了什么,为什么会这么“消耗资源”。Linux服务器是现在绝大多数系统的首选,它的优点就不再赘述,下面的分析都基于Linux环境来进行。作为一台服务器,最常见的功能就是
获取客户端发送过来的请求,然后再去查数据库DB获取到想要的数据,再将数据以一定的业务逻辑处理后传回给客户端,这一过程主要会调用Linux内核的以下两个函数:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
一个客户端向服务器发送一个查询商品信息的请求,数据在服务器流转流程大致如下:
- 服务器网卡接受到客户端发送过来的请求,其中遵循TCP/IP五层协议准则,请求从物理层、数据链路层、网络层、传输层,应用层,服务器程序最终拿到了请求的请求头、请求体等信息;
- 程序“解析”完请求内容后,进行对应的业务代码处理,然后通过持久层框架去读取数据库DB里的数据;
- 数据库里的数据是落在磁盘里的,而程序要读磁盘里的数据需要调用Linux内核kernel的read方法,此时CPU就会从用户态切换到内核态,由于CPU运行速度极快,不可能拉低速度去进行读操作,所以会委托DMA去磁盘里读数据到内核缓冲区;
- 内核缓冲区中的数据进行拷贝,复制到用户态缓冲区中,在内存中进行了一次数据拷贝,整个过程由CPU来参与,拷贝结束后CPU会从内核态切换为用户态;
- 数据从持久层返回,然后由应用程序接口(Controller、RPC、WebService)返回;但用户空间的数据想要发送给客户端,是需要通过Linux内核的write函数调用的,这个时候又需要进行用户态到内核态的切换,然后接口返回的数据需要通过DMA从用户态缓冲区拷贝到内核态的socket发送缓冲区;
- 最后socket缓冲区中的数据需要赋值到网卡的发送缓冲区中,最终由网卡将数据发送给客户端,然后CPU从内核态切换回到用户态;
由上面的流程分析可以发现,这个过程发生了4次拷贝(两次DMA拷贝,两次CPU拷贝),4次CPU状态切换。这个操作对于应用服务器来说很频繁,因此带来的开销也是非常大的。
2. DMA
DMA,即绕开CPU进行数据读写。在计算机中,相比CPU来说,外部设备访问速度是非常缓慢的,因而,"memory到memory“ 或者 “memory到device”或者“divice到memory”之间搬运数据是非常浪费CPU时间的!造成CPU无法及时处理实时事件…怎么办?因此工程师设计出来一种专门协助CPU搬运数据的硬件“DMA控制器”,协助CPU完成数据搬运。
3. 内存分析
Java攻城狮都知道,JVM里堆主要是用来存放对象的,栈是用来存放变量以及对象引用地址。本节以JVM内存的角度来分析数据的流向。
在第一节中的流程,操作系统会自动在内核中分配一块内存空间,这块内存空间的创建和销毁完全由内核操作系统来控制。程序对磁盘数据进行读或者向网络发送数据都由操作系统分配的这块内存来进行的,所以读数据时会进行DMA拷贝将数据从磁盘读取到内核的这块内存空间中,然后再经过CPU拷贝将数据拷贝到堆中。而向网络发送数据时,通过CPU拷贝,数据会从堆拷贝到内核的这块内存空间中,然后再通过DMA将数据拷贝到网卡中,进行数据的发送。由此可以发现,传统的IO不仅会有多次拷贝、内核状态的切换,还会受到堆内存OOM、GC时STW的影响,聪明的前辈们早已想好了对策,即将数据存放在内核分配的一个内存空间中,这样就省去了内核态和用户态之间两次无意义的拷贝了。就这样,零拷贝的概念诞生了。
4. 零拷贝
通过上面的分析可以看出,内核态到用户态数据的来回拷贝是没有意义的,数据应该可以直接从内核缓冲区直接送入socket缓冲区。零拷贝机制就实现了这一点。
操作系统层面减少数据拷贝次数主要是指用户空间和内核空间的数据拷贝,因为只有他们的拷贝是大量消耗CPU时间片的,而DMA控制器拷贝数据CPU参与的工作较少,只是辅助作用,所以减少CPU拷贝意义更大。
现实中对零拷贝的概念有“广义”和“狭义”之分,广义上是指只要减少了数据拷贝的次数,就称之为零拷贝;狭义上是指真正的零拷贝,就是避免了内核缓冲区和用户空间内存之间的两次CPU拷贝,
零拷贝实现方式:
4.1 Linux实现零拷贝—— mmap内存映射
既然是内存映射,首先来了解解下虚拟内存和物理内存的映射关系,虚拟内存是操作系统为了方便操作而对物理内存做的抽象,他们之间是靠页表(Page Table)进行关联的,关系如下:
每个进程都有自己的PageTable,进程的虚拟内存地址通过PageTable对应于物理内存,内存分配具有惰性,它的过程一般是这样的:进程创建后新建与进程对应的PageTable,当进程需要内存时会通过PageTable寻找物理内存,如果没有找到对应的页帧就会发生缺页中断,从而创建PageTable与物理内存的对应关系。虚拟内存不仅可以对物理内存进行扩展,还可以更方便地灵活分配,并对编程提供更友好的操作。
内存映射(mmap)是指用户空间和内核空间的虚拟内存地址同时映射到同一块物理内存,用户态进程可以直接操作物理内存,避免用户空间和内核空间之间的数据拷贝。
整个流程是这样的:
- 用户进程通过系统调用mmap函数进入内核态,发生第1次上下文切换,并建立内核缓冲区;
- 发生缺页中断,CPU通知DMA读取数据;
- DMA拷贝数据到物理内存,并建立内核缓冲区和物理内存的映射关系;
- 建立用户空间的进程缓冲区和同一块物理内存的映射关系,由内核态转变为用户态,发生第2次上下文切换;
- 用户进程进行逻辑处理后,通过系统调用Socket send,用户态进入内核态,发生第3次上下文切换;
- 系统调用Send创建网络缓冲区,并拷贝内核读缓冲区数据;
- DMA控制器将网络缓冲区的数据发送网卡,并返回,由内核态进入用户态,发生第4次上下文切换;
小结:
- 避免了内核空间和用户空间的2次CPU拷贝,但增加了1次内核空间的CPU拷贝,整体上相当于只减少了1次CPU拷贝;
- 针对大文件比较适合mmap,小文件则会造成较多的内存碎片,得不偿失;
- 不能很好的利用DMA方式,会比sendfile多消耗CPU,内存安全性控制复杂,需要避免JVM Crash问题。
4.2 Linux实现零拷贝—— sendfile
sendfile是在Linux2.1引入的,它只需要2次上下文切换和1次内核CPU拷贝、2次DMA拷贝,函数定义如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
out_fd为文件描述符,in_fd为网络缓冲区描述符,offset偏移量(默认NULL),count文件大小。
sendfile零拷贝的执行流程是这样的:
- 用户进程系统调用senfile,由用户态进入内核态,发生第1次上下文切换;
- CPU通知DMA控制器把文件数据拷贝到内核缓冲区;
- 内核空间自动调用网络发送功能并拷贝数据到网络缓冲区;
- CPU通知DMA控制器发送数据;
- sendfile系统调用结束并返回,进程由内核态进入用户态,发生第2次上下文切换;
小结:
- 数据处理完全是由内核操作,减少了2次上下文切换,整个过程2次上下文切换、1次CPU拷贝,2次DMA拷贝;
- 优点上下文切换少,消耗 CPU 较少,大块文件传输效率高,无内存安全问题;
- 缺点是小块文件效率低亍 mmap 方式;
那么有没有什么办法彻底减少CPU拷贝次数,让数据不在内存缓冲区和网络缓冲区之间进行拷贝呢?答案就是sendfile + DMA gatter
4.3 Linux实现零拷贝—— sendfile + DMA gatter
Linux2.4对sendfile进行了优化,为DMA控制器引入了gather功能,就是在不拷贝数据到网络缓冲区,而是将待发送数据的内存地址和偏移量等描述信息存在网络缓冲区,DMA根据描述信息从内核的读缓冲区截取数据并发送。它的流程是如下:
- 用户进程系统调用senfile,由用户态进入内核态,发生第1次上下文切换;
- CPU通知DMA控制器把文件数据拷贝到内核缓冲区;
- 把内核缓冲区地址和sendfile的相关参数作为数据描述信息存在网络缓冲区中;
- CPU通知DMA控制器,DMA根据网络缓冲区中的数据描述截取数据并发送;
- sendfile系统调用结束并返回,进程由内核态进入用户态,发生第2次上下文切换;
小结:
- 需要硬件支持,如DMA;
- 整个过程2次上下文切换,0次CPU拷贝,2次DMA拷贝,实现真正意义上的零拷贝;
- 依然不能修改数据;
但那时的sendfile有个致命的缺陷,如果你查看Sendfild手册,你会发现如下描述
in_fd不仅仅不能是socket,而且在2.6.33之前Sendfile的out_fd必须是socket,因此sendfile几乎成了专为网络传输而设计的,限制了其使用范围比较狭窄。2.6.33之后out_fd才可以是任何file,于是乎出现了splice。
4.5 Linux实现的零拷贝—— splice
鉴于Sendfile的缺点,在Linux2.6.17中引入了Splice,它在读缓冲区和网络操作缓冲区之间建立管道避免CPU拷贝:先将文件读入到内核缓冲区,然后再与内核网络缓冲区建立管道。它的函数定义:
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
它的执行流程如下
- 用户进程系统调用splice,由用户态进入内核态,发生第1次上下文切换;
- CPU通知DMA控制器把文件数据拷贝到内核缓冲区;
- 建立内核缓冲区和网络缓冲区的管道;
- CPU通知DMA控制器,DMA从管道读取数据并发送;
- splice系统调用结束并返回,进程由内核态进入用户态,发生第2次上下文切换;
小结:
- 整个过程2次上下文切换,0次CPU拷贝,2次DMA拷贝,实现真正意义上的零拷贝;
- 依然不能修改数据;
- fd_in和fd_out必须有一个是管道;
4.6 Java实现的零拷贝—— mmap的实现(MappedByteBuffer)
在Java中,目前主要通过了NIO实现了mmap和sendfile。
JDK NIO提供的MappedByteBuffer底层就是调用mmap来实现的,FileChannel.map用来建立内存映射关系:把用户空间和内存空间的虚拟内存地址映射到同一块物理内存。mmap对大文件比较合适,对小文件则容易造成内存碎片,反而不是最佳使用场景。
4.7 Java实现的零拷贝—— sendfile的实现(transferTo)
NIO提供的FileChannel.transferTo方法可以直接将一个channel传递给另一个channel,结合上一篇推文看,channel像极了内核缓冲区。
5. DirectByteBuffer底层分析
MappedByteBuffer是个抽象类,其实例化后的对象是DirectByteBuffer。
这里FileChannel的实现类是FileChannelImpl,查看FileChannelImpl源码:
讲解DirectByteBuffer之前,先来回顾下JVM内存的一些知识。JVM运行时数据区里包含了方法区、堆区、栈、寄存器等,而其中堆区主要存放对象的区域,并且堆区存在GC,可以在JVM内存不足时进行GC,回收垃圾对象。但是DirectByteBuffer是堆外内存,不受GC控制。NIO通过DirectByteBuffer来实现的mmap零拷贝,那么有一个问题是,DirectByteBuffer的内存该如何回收呢?
再次之前,来介绍下Java的引用类型:强引用、弱引用、软引用、虚引用
JVM运行时数据区里的栈,栈中存在一个栈帧的区域(栈帧的入栈和出栈对应着Java类方法的调用和调用结束),栈帧中存放着的引用地址指向了堆中的对象。
5.1 强引用
强引用是我们日常开发见到最多的了,就是简单的 A a = new A(),a引用指向了A对象。
5.2 软引用
软引用的特点就是,当引用指向的对象置为null时,gc时软引用不一定被回收。
5.3 弱引用
软引用的特点就是,当引用指向的对象置为null时,gc时弱引用一定会被回收。
5.4 虚引用
虚引用的特点就是,当虚引用指向一个对象时,会把原本该对象的引用存到自己的一个引用队列里,当对象置为null,被GC调后,仍然可以通过引用队列获取到虚引用对象。
那么,到此讲了Java引用的这么多概念,到底和Java零拷贝有啥关系?答案就是:
DirectByteBuffer堆外内存的回收,就用到了虚引用。
下面看下DirectByteBuffer对象的构造方法
接着看下Deallocator类
这样,我们已经找到了释放堆外内存DirectByteBuffer内存的核心代码了,问题是此处的Deallocator是怎么调用,怎么运行起来的呢? 下面就要引入虚引用这个概念了。
Cleanner#clean方法真正的入口在Reference里
那么,NIO使用DirectByteBuffer实现零拷贝的流程是怎样的呢?
如上图,通过DirectByteBuffer对象创建了一块堆外的内存,通过这块堆外的内存来实现mmap方式的零拷贝,当零拷贝结束,堆外内存不再需要被用到时,就会通过Cleaner对象进行内存回收,整个回收机制如下:
- 通过DirectByteBuffer构造方法创建出DirectByteBuffer的实例对象,在构造方法中创建了Cleaner这个虚引用对象,然后将DirectByteBuffer的引用传给了Cleaner,Deallocator对象传入Cleaner,而Cleaner就指向了DirectByteBuffer对象,与此同时Cleaner会将自己作为引用传入pending引用队列里;
- 当零拷贝结束,堆外内存不再使用时,可以把DirectByteBuffer对象置空,此时Cleaner对象不可达,在下一次GC时会被回收掉。
- 由于Cleaner对象的产生,让父类Reference的一个ReferenceHandler线程得以死循环的方式进行,线程执行逻辑就是去遍历Cleaner之前创建的引用队列pending;
- 当读到pending队列里cleaner的引用时,会调用cleaner引用的clean方法,并最终调用Deallocator的run方法,调用Unsafe#freeMemory来进行真正的堆内存释放;
至此,DirectByteBuffer创建的堆外内存就被真正的释放掉了。