零拷贝(Mmap,sendfile)
2024-08-28 14:16:24 1 举报
AI智能生成
零拷贝是一种高效的数据传输技术,通过在内核空间中直接进行数据传输,避免了数据在应用程序地址空间和内核地址空间之间的来回复制。其中,Mmap(内存映射)和sendfile(发送文件)是实现零拷贝技术的两种主要方式。Mmap通过将文件或设备映射到内存中,避免数据从内核空间到用户空间的复制。sendfile则允许将数据直接从一个文件描述符传输到另一个文件描述符,从而省去了用户空间和内核空间之间的数据复制。这两种技术大大提高了数据传输的效率,减少了系统资源的消耗。
作者其他创作
大纲/内容
所谓的零拷贝技术,其实并不是不拷贝,而是要尽量减少CPU拷贝
CPU拷贝 和 DMA 拷贝
操作系统对于内存空间,是分为用户态和内核态的。用户态的应用程序无法直接操作硬件,需要通过内核空间进行操作转换,才能真正操作硬件。这其实是为了保护操作系统的安全。正因为如此,应用程序需要与网卡、磁盘等硬件进行数据交互时,就需要在用户态和内核态之间来回的复制数据。而这些操作,原本都是需要由CPU来进行任务的分配、调度等管理步骤的,早先这些IO接口都是由CPU独立负责,所以当发生大规模的数据读写操作时,CPU的占用率会非常高。
之后,操作系统为了避免CPU完全被各种IO调用给占用,引入了DMA(直接存储器存储)。由DMA来负责这些频繁的IO操作。DMA是一套独立的指令集,不会占用CPU的计算资源。这样,CPU就不需要参与具体的数据复制的工作,只需要管理DMA的权限即可
DMA拷贝极大的释放了CPU的性能,因此他的拷贝速度会比CPU拷贝要快很多。但是,其实DMA拷贝本身,也在不断优化。
引入DMA拷贝之后,在读写请求的过程中,CPU不再需要参与具体的工作,DMA可以独立完成数据在系统内部的复制。但是,数据复制过程中,依然需要借助数据总进线。当系统内的IO操作过多时,还是会占用过多的数据总线,造成总线冲突,最终还是会影响数据读写性能。
为了避免DMA总线冲突对性能的影响,后来又引入了Channel通道的方式。Channel,是一个完全独立的处理器,专门负责IO操作。既然是处理器,Channel就有自己的IO指令,与CPU无关,他也更适合大型的IO操作,性能更高。
这也解释了,为什么Java应用层与零拷贝相关的操作都是通过Channel的子类实现的。这其实是借鉴了操作系统中的概念。
Page Cache(类mysql BufferPool)
- 由内存中的物理page组成,其内容对应磁盘上的block。
- page cache的大小是动态变化的。
- backing store: cache缓存的存储设备。
- 一个page通常包含多个block, 而block不一定是连续的
读Cache
当内核发起一个读请求时, 先会检查请求的数据是否缓存到了page cache中。
如果有,那么直接从内存中读取,不需要访问磁盘, 此即 cache hit(缓存命中)
如果没有,就必须从磁盘中读取数据,然后内核将读取的数据再缓存到cache中,如此后续的读请求就可以命中缓存了
page可以只缓存一个文件的部分内容,而不需要把整个文件都缓存进来
写Cache
当内核发起一个写请求时,也是直接往cache中写入,后备存储中的内容不会直接更新。
内核会将被写入的page标记为`dirty`, 并将其加入到`dirty list`中。
内核会周期性地将dirty list中的page写回到磁盘上, 从而使磁盘上的数据和内存中缓存的数据 一致。
Cahce回收
Page cache的另一个重要工作是释放page, 从而释放内存空间。
cache回收的任务是选择合适的page释放,如果page是dirty的, 需要将page写回到磁盘中再释放
cache和buffer的区别
Cache
缓存区,是高速缓存,是位于CPU和主内存之间的容量较小但速度很快的存储器,因为CPU的速度远远高于主内存的速度,CPU从内存中读取数据需等待很长的时间,而 Cache 保存着CPU刚用过的数据或循环使用的部分数据,这时从Cache中读取数据会更快,减少了 CPU等待的时间,提高了系统的性能。
Cache并不是缓存文件的,而是缓存块的(块是I/O读写最小的单元);Cache一般会用在I/O请求上, 如果多个进程要访问某个文件,可以把此文件读入Cache中,这样下一个进程获取CPU控制权并访问此 文件直接从Cache读取,提高系统性能。
Cache并不是缓存文件的,而是缓存块的(块是I/O读写最小的单元);Cache一般会用在I/O请求上, 如果多个进程要访问某个文件,可以把此文件读入Cache中,这样下一个进程获取CPU控制权并访问此 文件直接从Cache读取,提高系统性能。
Buffer
缓冲区,用于存储速度不同步的设备或优先级不同的设备之间传输数据;通过buffer 可以减少进程间通信需要等待的时间,当存储速度快的设备与存储速度慢的设备进行通信时, 存储慢的数据先把数据存放到buffer,达到一定程度存储快的设备再读取buffer的数据,在此 期间存储快的设备CPU可以干其他的事情。
Buffer:一般是用在写入磁盘的,例如:某个进程要求多个字段被读入,当所有要求的字段被读入之前已经读入的字段会先放到buffer中。
Buffer:一般是用在写入磁盘的,例如:某个进程要求多个字段被读入,当所有要求的字段被读入之前已经读入的字段会先放到buffer中。
HeapByteBuffer 和 DirectByteBuffer
HeapByteBuffer
是在jvm堆上面一个buffer,底层的本质是一个数组,用类封装维护了很多的索引(`limit/position/capacity`等)
优点:内容维护在jvm里,把内容写进buffer里速度快;更容易回收。
分配效率高,读写效率较低
DirectByteBuffer
底层的数据是维护在操作系统的内存中,而不是jvm里,DirectByteBuffer里维护了一个引用address指向数据,进而操作数据。
优点:跟外设(IO设备)打交道时会快很多,因为外设读取jvm堆里的数据时, 不是直接读取的,而是把jvm里的数据读到一个内存块里,再在这个块里读取的,如果使用 DirectByteBuffer,则可以省去这一步,实现zero copy(零拷贝)
所有的通过allocate方法创建的buffer都是HeapByteBuffer
所有的通过allocate方法创建的buffer都是HeapByteBuffer
外设之所以要把jvm堆里的数据copy出来再操作,不是因为操作系统不能直接操作jvm内存,而是因为jvm在进行gc(垃圾回收)时,会对数据进行移动,一旦出现这种问题,外设就会出现数据错乱的情况
java里面的直接内存是操作 Unsafe 类来完成的操作
分配效率低,读写效率高
堆外内存实现零拷贝
1. 前者分配在JVM堆上(ByteBuffer.allocate()),后者分配在操作系统物理内存上 (`ByteBuffer.allocateDirect()`,JVM使用C库中的`malloc()`方法分配堆外内存);
2. DirectByteBuffer可以减少JVM GC压力,当然,堆中依然保存对象引用,fullgc发生时也会回收直接内存,也可以通过`system.gc`主动通知JVM回收,或者通过 cleaner.clean主动清理。 Cleaner.create()方法需要传入一个DirectByteBuffer对象和一个Deallocator(一个堆外内存回收线程)。GC发生时发现堆中的DirectByteBuffer对象没有强引用了,则调用Deallocator 的run()方法回收直接内存,并释放堆中DirectByteBuffer的对象引用;
3. 底层I/O操作需要连续的内存(JVM堆内存容易发生GC和对象移动),所以在执行write操作时 需要将HeapByteBuffer数据拷贝到一个临时的(操作系统用户态)内存空间中,会多一次额外拷贝。而DirectByteBuffer则可以省去这个拷贝动作,这是Java层面的 “零拷贝” 技术,在 netty中广泛使用;
4. MappedByteBuffer底层使用了操作系统的mmap机制,FileChannel#map()方法就会返回 MappedByteBuffer。DirectByteBuffer虽然实现了MappedByteBuffer,不过 DirectByteBuffer默认并没有直接使用mmap机制。`
2. DirectByteBuffer可以减少JVM GC压力,当然,堆中依然保存对象引用,fullgc发生时也会回收直接内存,也可以通过`system.gc`主动通知JVM回收,或者通过 cleaner.clean主动清理。 Cleaner.create()方法需要传入一个DirectByteBuffer对象和一个Deallocator(一个堆外内存回收线程)。GC发生时发现堆中的DirectByteBuffer对象没有强引用了,则调用Deallocator 的run()方法回收直接内存,并释放堆中DirectByteBuffer的对象引用;
3. 底层I/O操作需要连续的内存(JVM堆内存容易发生GC和对象移动),所以在执行write操作时 需要将HeapByteBuffer数据拷贝到一个临时的(操作系统用户态)内存空间中,会多一次额外拷贝。而DirectByteBuffer则可以省去这个拷贝动作,这是Java层面的 “零拷贝” 技术,在 netty中广泛使用;
4. MappedByteBuffer底层使用了操作系统的mmap机制,FileChannel#map()方法就会返回 MappedByteBuffer。DirectByteBuffer虽然实现了MappedByteBuffer,不过 DirectByteBuffer默认并没有直接使用mmap机制。`
缓冲IO和直接IO
缓存IO
缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制 中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。
- 读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。
- 写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说
缓存I/O的优点
1. 在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全;
2. 可以减少读盘的次数,从而提高性能。
缓存I/O的缺点
在缓存 I/O 机制中,`DMA` 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输。数据在传输过程中就需要在应用程序地址空间(用户空间)和缓存(内核空间)之间进行多次数据拷贝操作, 这些数据拷贝操作所带来的CPU以及内存开销是非常大的。
直接IO
直接IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制。比如说数据库管理系统这类应用,它们更倾向于选择它们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种 更加有效的缓存机制来提高数据库中数据的存取性能(例如:Mysql的 BufferPool)
直接IO的缺点
如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘加载,这种直接加载会非常缓慢。通常直接IO与异步IO结合使用,会得到比较好的性能。
下图分析了写场景下的DirectIO和BufferIO:
内存映射文件(Mmap)
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
映射关系可以分为两种:
- 文件映射:磁盘文件映射进程的虚拟地址空间,使用文件内容初始化物理内存。
- 匿名映射 :初始化全为0的内存空间。
映射关系是否共享又分为
- 私有映射(MAP_PRIVATE): 多进程间数据共享,修改不反应到磁盘实际文件,是一个copy-onwrite(写时复制)的映射方式。
- 共享映射(MAP_SHARED): 多进程间数据共享,修改反应到磁盘实际文件中。
mmap只是在虚拟内存分配了地址空间,只有在第一次访问虚拟内存的时候才分配物理内存
在mmap之后,并没有在将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096【4k】)加载到物理内存,注意是只加载缺 页,但也会受操作系统一些调度策略影响,加载的比所需的多。
直接内存读取并发送文件的过程
Mmap读取并发送文件的过程
这个拷贝过程都是在操作系统的系统调用层面完成的,在Java应用层,其实是无法直接观测到的,但是我们可以去JDK源码当中进行间接验证。在JDK的NIO包中,`java.nio.HeapByteBuffer`映射的就是JVM的一块堆内内存,在HeapByteBuffer中,会由一个byte数组来缓存数据内容,所有的读写操作也是先操作这个byte数组。这其实就是没有使用零拷贝的普通文件读写机制。
NIO把包中的另一个实现类`java.nio.DirectByteBuffer`则映射的是一块堆外内存。在DirectByteBuffer中,并没有一个数据结构来保存数据内容,只保存了一个内存地址。所有对数据的读写操作,都通过unsafe魔法类直接交由内核完成,这其实就是mmap的读写机制。
mmap的映射机制由于还是需要用户态保存文件的映射信息,数据复制的过程也需要用户态的参与,这其中的变数还是非常多的。所以,mmap机制适合操作小文件,如果文件太大,映射信息也会过大,容易造成很多问题。通常mmap机制建议的映射文件大小不要超过2G 。而RocketMQ做大的CommitLog文件保持在1G固定大小,也是为了方便文件映射。
Sendfile零拷贝读取并发送文件的过程
主要是通过`java.nio.channels.FileChannel#transferTo`方法完成
sourceReadChannel.transferTo(0,sourceFile.length(),targetWriteChannel)
sourceReadChannel.transferTo(0,sourceFile.length(),targetWriteChannel)
还记得Kafka当中是如何使用零拷贝的吗?你应该看到过这样的例子,就是Kafka将文件从磁盘复制到网卡时,就大量的使用了零拷贝。百度去搜索一下零拷贝,铺天盖地的也都是拿这个场景在举例。
早期的 sendfile实现机制其实还是依靠CPU进行页缓存与socket缓存区之间的数据拷贝。但是,在后期的不断改进过程中,sendfile优化了实现机制,在拷贝过程中,并不直接拷贝文件的内容,而是只拷贝一个带有文件位置和长度等信息的文件描述符FD,这样就大大减少了需要传递的数据。而真实的数据内容,会交由DMA控制器,从页缓存中打包异步发送到socket中
sendfile机制在内核态直接完成了数据的复制,不需要用户态的参与,所以这种机制的传输效率是非常稳定的。sendfile机制非常适合大数据的复制转移。
总结
零拷贝
传统IO
1. fileInputStream.read(buffer) 操作数据拷贝及状态转换分析
① 硬盘 ( 初始用户态 ) -> 内核缓冲区 ( 内核态 ) : 首先将硬盘中的文件 , 进行 DMA [ 1 ] ^{[1]} [1] 拷贝 , 此处对应 read 方法 , 将文件数据从硬盘中拷贝到 内核缓冲区 ; ( 用户态切换成内核态 )
② 内核缓冲区 ( 内核态 ) -> 用户缓冲区 ( 用户态 ) : 将内核缓冲区中的数据 , 通过 CPU 拷贝 方式 , 拷贝到 用户缓冲区 ; ( 内核态切换成用户态 )
② 内核缓冲区 ( 内核态 ) -> 用户缓冲区 ( 用户态 ) : 将内核缓冲区中的数据 , 通过 CPU 拷贝 方式 , 拷贝到 用户缓冲区 ; ( 内核态切换成用户态 )
2. socket.getOutputStream().write(buffer, 0, readLen) 操作数据拷贝及状态转换分析
① 用户缓冲区 ( 用户态 ) -> Socket 缓冲区 ( 内核态 ) : 将用户缓冲区中的数据 , 再次通过 CPU 拷贝 方式 , 拷贝到 Socket 缓冲区 ; ( 用户态切换成内核态 )
② Socket 缓冲区 ( 内核态 ) -> 协议栈 : 再次使用 DMA [ 1 ] ^{[1]} [1] 拷贝 , 将 Socket 缓冲区中的数据拷贝到 协议栈 ( Protocol Engine ) 中 ;
② Socket 缓冲区 ( 内核态 ) -> 协议栈 : 再次使用 DMA [ 1 ] ^{[1]} [1] 拷贝 , 将 Socket 缓冲区中的数据拷贝到 协议栈 ( Protocol Engine ) 中 ;
4次数据拷贝,3次用户态和内核态的切换【最终如果算上将内核态切换为用户态(执行应用代码逻辑),则为4次】
传统IO拷贝次数分析
开始时数据存储在 硬盘文件 中 , 直接内存拷贝 ( Direct Memory Access ) 到 内核缓冲区 , CPU 拷贝 到 用户缓冲区 , CPU 拷贝 到 Socket 缓冲区 , 直接内存拷贝 ( Direct Memory Access ) 到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> 用户缓冲区 ( 用户空间 ) -> Socket 缓冲区 ( 内核空间 ) -> 协议栈
传统IO状态改变分析
开始运行的是用户应用程序 , 起始状态肯定是用户态 , 之后将硬盘文件数据拷贝到内核缓冲区后 , 转为内核态 , 之后又拷贝到了用户缓冲区 , 转为用户态 ; 数据写出到 Socket 缓冲区 , 又转为内核态 , 最后再切换成用户态 , 执行后续应用程序代码逻辑
用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态
DMA 全称 ( Direct Memory Access ) , 直接内存拷贝 , 该拷贝通过内存完成 , 不涉及 CPU 参与
NIO优化
DirectByteBuf 直接内存(Mmap) 【3次copy,3(4)次切换】
将硬盘中的文件映射到 内核缓冲区 , 用户空间中的应用程序也可以访问该 内核缓冲区 中的数据 , 使用这种机制 , 原来的 4 次数据拷贝减少到了 3 次
mmap 数据拷贝过程
① 硬盘文件 -> 内核缓冲区 : 硬盘文件数据 , DMA 拷贝到 内核缓冲区 中 , 应用程序可以直接访问该 内核缓冲区中的数据 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> Socket 缓冲区 ( 内核空间 ) -> 协议栈
mmap 状态切换
其状态切换还是 3 次 , 由初始状态 用户态 , 在拷贝数据到内核缓冲区时 , 切换成内核态 , 访问该内核缓冲区数据时 , 又切换成用户态 , 将数据拷贝到 Socket 缓冲区时 , 切换成内核态 , 最后再切换成用户态 , 执行后续应用程序代码逻辑 ;
用户态 -> 内核态 -> 用户态 -> 内核态 -> 用户态
【3次copy ,1(2)次切换】
进一步优化(底层采用了 linux 2.1 后提供的 `sendFile` 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
进一步优化(底层采用了 linux 2.1 后提供的 `sendFile` 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
其实现了由 内核缓冲区 直接将数据拷贝到 Socket 缓冲区 , 该操作直接在内核空间完成 , 不经过用户空间 , 没有用户态参与 , 因此 减少了一次用户态切换
数据拷贝分析
① 硬盘文件 -> 内核缓冲区 : 硬盘文件数据 , DMA 拷贝到 内核缓冲区 中 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
② 内核缓冲区 -> Socket 缓冲区 : 内核缓冲区 数据 , 通过 CPU 拷贝到 Socket 缓冲区 ;
③ Socket 缓冲区 -> 协议栈 : Socket 缓冲区 数据 , 通过 DMA 拷贝到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> Socket 缓冲区 ( 内核空间 ) -> 协议栈
sendFile 函数 状态切换分析
其状态切换只有 2次 , 由初始状态 用户态 , 在拷贝数据到内核缓冲区时 , 切换成内核态 , 在内核态直接将数据拷贝到 Socket 缓冲区时 , 还是处于内核状态 , 之后拷贝到协议栈时 , 变成用户状态
用户态 -> 内核态 -> 用户态
【2次copy,1(2)次切换】 (全称 DMA 拷贝 , 没有 CPU 拷贝)
进一步优化(linux 2.4) 带有DMA收集拷贝功能的sendfile实现的零拷贝
进一步优化(linux 2.4) 带有DMA收集拷贝功能的sendfile实现的零拷贝
'
少量 CPU 拷贝 : 该机制还存在少量的 CPU 拷贝 , 其 对性能的消耗忽略不计 ; 这些 CPU 拷贝操作是从 内核缓冲区 中将数据的长度 ( Length ) , 偏移量 ( Offset ) 拷贝到 Socket 缓冲区 ;
数据拷贝分析
① 硬盘文件 -> 内核缓冲区 : 硬盘文件数据 , DMA 拷贝到 内核缓冲区 中 ;
② 内核缓冲区 -> -> 协议栈 : 通过 DMA 拷贝 , 将 内核缓冲区 中的数据直接拷贝到 协议栈 ;
② 内核缓冲区 -> -> 协议栈 : 通过 DMA 拷贝 , 将 内核缓冲区 中的数据直接拷贝到 协议栈 ;
硬盘文件 -> 内核缓冲区 ( 内核空间 ) -> 协议栈
sendFile 函数 状态切换分析
其状态切换只有 2 次 , 由初始状态 用户态 , 在拷贝数据到内核缓冲区时 , 切换成内核态 , 在内核态直接将数据拷贝到协议栈时 , 变成用户状态 ;
用户态 -> 内核态 -> 用户态
优点
mmap与sendFile区别
mmap 用于文件共享,很少用于socket操作,sendfile用于发送文件.
mmap 适合小数据量读写,sendFile 适合大文件传输。
mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 2 次上下文切换,最少 2 次数据拷贝。
sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)
mmap和共享内存的区别
mmap是共享一个文件,共享内存是共享一段内存。mmap还可以写回到file
mmap缺点
mmap 每次读入都是1页即4k,所以少于4k会造成大量内存碎片. 但是通过read,write也是这样的。
mmap适用场景,是取代read,write 文件.
使用mmap+write方式
- 优点:即使频繁调用,使用小文件块传输,效率也很高
- 缺点:不能很好的利用DMA方式,会比sendfile多消耗CPU资源,内存安全性控制复杂,需要避免JVM Crash问题
使用sendfile方式
- 优点:可以利用DMA方式,消耗CPU资源少,大块文件传输效率高,无内存安全问题
- 缺点:小块文件效率低于mmap方式,只能是BIO方式传输,不能使用NIO
rocketMQ 在消费消息时,使用了 mmap,因为小块数据传输比sendFile好。kafka 使用了 sendFile
0 条评论
下一页