里之行,始于足下
什么是“零拷贝”技术
要想要了解“零拷贝”机制,首先要了解在什么地方会用到这个东西。试想一个场景:我们需要从磁盘读取一个文件,然后通过网络输出到一个软件客户端。
一般的文件传输过程
考虑这样一种常用的情形:开发者需要将静态内容(类似图片、数据表、文件)发送给远程的用户。那么这个情形就意味着开发者需要先将静态内容从磁盘中拷贝出来放到一个内存buf中,然后将这个buf通过socket传输给用户,进而用户或者静态内容的展示。这看起来再正常不过了,但是实际上这是很低效的流程,我们把上面的这种情形抽象成下面的过程:
read(file, tmp_buf, len); write(socket, tmp_buf, len);
首先调用read将静态内容,这里假设为数据文件A,读取到tmp_buf, 然后调用write将tmp_buf写入到socket中,如图:
DMA :direct memory copy 直接内存拷贝(不使用CPU)
在这个过程中数据文件A的经历了4次复制的过程:
2 CPU控制将kernel buffer内的数据复制到用户空间buffer。
3 再将用户空间buffer里的内容复制到内核的socket buffer区。
4 将内核的socket buffer区的内容复制给协议引擎(这里指网卡驱动)
从上面,我们可以看到数据白白从kernel模式到user模式走了一圈,浪费了2次copy(第一次从kernel模式拷贝到user模式;第二次从user模式再拷贝回kernel模式,即上面4次过程的第2和3步骤)。而且上面的过程中kernel和user模式的上下文的切换也是4次。
第1步数据从磁盘使用DMA拷贝到内存缓冲是必须的,需要系统读取数据给网卡嘛。但是为啥还要从内核复制一份到用户空间呢?应用程序直接使用内核缓冲区的数据不就行了吗?
这是因为对于操作系统来说,可能有多个应用程序同时使用这些数据,并且有可能进行修改,如果大家都使用同一份内核的数据就会发生冲突,因此,就设计为每个应用程式在使用时就得先拷贝到自己的用户空间。但是这个机制在不需要做修改的场景就产生了浪费,数据本来可以呆在内核缓冲区不动,没必要再多此一举拷贝一次到用户空间
为了避免这种浪费,人们一开始采用了mmap调用的方式来进行优化。即应用程序不再调用read,而是采用mmap,mmap会从磁盘复制数据到内核缓冲区,然后与用户进程共享该内核缓冲区,这样就不再需要从内核缓冲区复制到用户缓冲区了,也就比之前少了一次数据复制过程。
mmap内存映射
例如,在 Linux 中,减少拷贝次数的一种方法是调用 mmap() 来代替调用 read,比如:
tmp_buf=mmap(file,len); write(socket,tmp_buf,len);
首先,应用程序调用了mmap()以后,数据会先通过DMA(直接存储器访问) 被复制到操作系统内核的缓冲区中去。
接着,应用程序跟操作系统共享这个缓冲区,这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据复制操作。应用程序调用了 write() 之后,操作系统内核将数据从原来的内核缓冲区中复制到与 socket 相关的内核缓冲区中。接下来,数据从内核 socket 缓冲区复制到协议引擎中去,这是第三次数据拷贝操作。如下图所示:
现在,你只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。
虽然mmap这种方式减少了一次拷贝,但是,这种改进也是需要代价的。当对文件进行了内存映射,然后调用 write() 系统调用,如果此时其他的进程截断了这个文件,那么 write() 系统调用将会被总线错误信号 SIGBUS 中断,因为此时正在执行的是一个错误的存储访问。这个信号将会导致进程被杀死,解决这个问题可以通过以下这两种方法:
为 SIGBUS 安装一个新的信号处理器,这样,write() 系统调用在它被中断之前就返回已经写入的字节数目,errno 会被设置成 success。但是这种方法也有其缺点,它不能反映出产生这个问题的根源所在,因为 BIGBUS 信号只是显示某进程发生了一些很严重的错误。
是通过文件租借锁来解决这个问题的,这种方法相对来说更好一些。我们可以通过内核对文件加读或者写的租借锁,当另外一个进程尝试对用户正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会告诉用户内核破坏了用户加在那个文件上的写或者读租借锁,那么 write() 系统调用则会被中断,并且进程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 也会被设置为 success。文件租借锁需要在对文件进行内存映射之前设置。
使用 mmap 是 POSIX 兼容的,但是使用 mmap 并不一定能获得理想的数据传输性能。数据传输的过程中仍然需要一次 CPU 复制操作,而且映射操作也是一个开销很大的虚拟存储操作,这种操作需要通过更改页表以及冲刷 TLB (使得 TLB 的内容无效)来维持存储的一致性。但是,因为映射通常适用于较大范围,所以对于相同长度的数据来说,映射所带来的开销远远低于 CPU 拷贝所带来的开销。
sendfile()
为了简化用户接口,同时还要继续保留 mmap()/write() 技术的优点:减少 CPU 的复制次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。
sendfile的过程是这样的:
从磁盘读取文件内容到内核缓冲区
直接从内核缓冲区复制数据到socket缓冲区
从socket缓冲区复制到协议引擎(这里是网卡驱动)
这种方式虽好,但是仍然存在一次从内核缓冲区到内核socket缓冲区的复制行为,也就是从内存的一个区域复制到内存的另一个区域的行为。这个是可以避免的吗?
后来人们在硬件上进行了改进,在硬件的帮助下来消除内存数据之间的数据复制。这种硬件需要支持一种叫“收集”操作的接口,。它支持从内存中的不同位置收集数据,也就是不再限定于只从内核socket缓冲区来收集数据,而是可以从内核缓冲区去收集。
2.4版本中对sendfile修改
在linux内核版本2.4中对sendfile调用做了一系列优化来适应这个需求,避免了从内核缓冲区拷贝到socket buffer的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图所示:
在上图中,数据复制到内核缓冲区以后,不再需要拷贝到socket缓冲区,而是需要将数据的位置和长度信息传输到socket缓冲区,这样DMA引擎会根据这些信息直接从内测缓冲区复制数据给协议引擎。
最终,数据只需要从磁盘复制到内存,再从内存中复制到协议引擎,和最开始相比较少了2次复制,分别是:从内核复制到用户空间和从用户空间复制到socket缓冲。但是明明还有两次数据的复制,为什么要叫“零拷贝”呢?
这是因为从操作系统的角度来说,数据没有从内存复制到内存的过程,也就没有了CPU参与的过程, 所以对于操作系统来说就是零拷贝了。查看wiki对零拷贝的定义如下:
“Zero-copy” describes computer operations in which the CPU does not perform the task of copying data from one memory area to another.
从定义我们看到,零拷贝是指不需要cpu参与在内存之间复制数据的操作。那这个过程为啥不需要cpu参与呢?
仔细看上面的图示,你会发现:从磁盘复制到内核缓冲区是通过DMA引擎来做而不是cpu,同样的,从socket缓冲区到协议引擎也是由 DMA引擎来做,这样就节省了cpu的工作。而这整个过程都在内核中完成也减少了操作系统的上下文切换开销。
再稍微讲讲 mmap 和 sendFile 的区别。
mmap 适合小数据量读写,sendFile 适合大文件传输。
mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
在这个选择上:rocketMQ 在消费消息时,使用了 mmap。kafka 使用了 sendFile。
零拷贝的再次理解
存在用户空间和内核空间的交互行为就会产生上下文切换,所以即使用户空间与内核空间共享同一份缓冲区一样。
利用硬件支持的DMA引擎可以减少对CPU的使用,磁盘,网卡都有此引擎。
利用硬件的“收集”接口功能可以将数据位置和长度直接传输给硬件的相关引擎,从而让硬件引擎直接从相应的内存区域读取数据
零拷贝是用于对不变的数据做传输,如果应用程序需要修改数据那势必就不能用到零拷贝了,所以零拷贝不是万能的。
哪些地方会用到零拷贝技术?
1、java的NIO
先说java,是因为要给下面的netty做铺垫,在 Java NIO 中的通道(Channel)就相当于操作系统的内核空间(kernel space)的缓冲区,而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer)。
堆外内存(DirectBuffer)在使用后需要应用程序手动回收,而堆内存(HeapBuffer的数据在 GC 时可能会被自动回收。因此,在使用 HeapBuffer 读写数据时,为了避免缓冲区数据因为 GC 而丢失,NIO 会先把 HeapBuffer 内部的数据拷贝到一个临时的 DirectBuffer 中的本地内存(native memory),这个拷贝涉及到 sun.misc.Unsafe.copyMemory() 的调用,背后的实现原理与 memcpy() 类似。 最后,将临时生成的 DirectBuffer 内部的数据的内存地址传给 I/O 调用函数,这样就避免了再去访问 Java 对象处理 I/O 读写。
(1)MappedByteBuffer
MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,意思是把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。这样之添加地址映射,而不进行拷贝。
(2)DirectByteBuffer
DirectByteBuffer 的对象引用位于 Java 内存模型的堆里面,JVM 可以对 DirectByteBuffer 的对象进行内存分配和回收管理,是 MappedByteBuffer 的具体实现类。因此同样具有零拷贝技术。
(3)FileChannel
FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。
我们直接看Linux2.4的版本,socket缓冲区做了调整,DMA带收集功能。
(1)DMA从拷贝至内核缓冲区
(2)将数据的位置和长度的信息的描述符增加至内核空间(socket缓冲区)
(3)DMA将数据从内核拷贝至协议引擎
这个复制过程是零拷贝过程。
2、Netty
Netty 中的零拷贝和上面提到的操作系统层面上的零拷贝不太一样, 我们所说的 Netty 零拷贝完全是基于(Java 层面)用户态的。
(1)Netty 通过 DefaultFileRegion 类对FileChannel 的 tranferTo() 方法进行包装,相当于是间接的通过java进行零拷贝。
(2)我们的数据传输一般都是通过TCP/IP协议实现的,在实际应用中,很有可能一条完整的消息被分割为多个数据包进行网络传输,而单个的数据包对你而言是没有意义的,只有当这些数据包组成一条完整的消息时你才能做出正确的处理,而Netty可以通过零拷贝的方式将这些数据包组合成一条完整的消息供你来使用。
此时零拷贝的作用范围仅在用户空间中。那Netty是如何实现的呢?为此我们就要找到Netty进行数据传输的接口,这个接口一定包含了可以实现零拷贝的功能,这个接口就是ChannelBuffer。
既然有接口肯定就有实现类,一个最主要的实现类是CompositeChannelBuffer,这个类的主要作用是将多个ChannelBuffer组成一个虚拟的ChannelBuffer来进行操作
为什么说是虚拟的呢,因为CompositeChannelBuffer并没有将多个ChannelBuffer真正的组合起来,而只是保存了他们的引用,这样就避免了数据的拷贝,实现了Zero Copy。
(3)ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 对象, 进而避免了拷贝操作
(4)ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝
3、kafka
Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式。适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。
如果有10个消费者,传统方式下,数据复制次数为4*10=40次,而使用“零拷贝技术”只需要1+10=11次,一次为从磁盘复制到页面缓存,10次表示10个消费者各自读取一次页面缓存。
参考:
https://zhuanlan.zhihu.com/p/88789697
https://blog.csdn.net/choumu8867/article/details/100658332
https://blog.csdn.net/SDDDLLL/article/details/105565318
https://www.cnblogs.com/ericli-ericli/articles/12923420.html
有兴趣的老爷,可以关注我的公众号【一起收破烂】,回复【006】获取2021最新java面试资料以及简历模型120套哦~