为什么需要零拷贝
传统的Linux系统的标准I/O接口(read、write)是基于数据拷贝的,也就是数据都是copy_to_user或者copy_from_user
这样做的好处是,通过中间缓存的机制,减少磁盘I/O的操作,但是坏处也很明显,大量数据的拷贝,用户态和内核态的频繁切换,会消耗大量的CPU资源,严重影响数据传输的性能
以下是一个原始数据拷贝操作的例子,假如一个应用需要从某个磁盘文件中读取内容通过网络发出去,其过程需要经过以下4个步骤,整个过程涉及2次 CPU拷贝、2 次DMA拷贝总共4次拷贝,以及4次上下文切换。
read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);
- read将数据从磁盘文件通过DMA等方式拷贝到内核开辟的缓冲区
- 数据从内核缓冲区复制到用户态缓冲区
- write将数据从用户态缓冲区复制到内核协议栈开辟的socket缓冲区
- 数据从socket缓冲区通过DMA拷贝到网卡上发出去
内核空间和用户空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。
为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分:
- 内核空间(Kernel-space)
- 用户空间(User-space)
内核模块运行在内核空间,对应的进程处于内核态,而用户程序运行在用户空间,对应的进程处于用户态。
内核进程和用户进程所占的虚拟内存比例一般是1:3
DMA是什么
DMA是Direct Memory Access的简写,它表示的是存储器直接访问。这是指一种高速的数据传输操作,允许在外部设备和存储器之间直接读写数据。
整个数据传输操作在一个称为"DMA控制器"的控制下进行的,CPU除了在数据传输开始和结束时做一点处理外(开始和结束时候要做中断处理),在传输过程中CPU可以进行其他的工作(前提是未设置停止CPU访问)。这样,在大部分时间里,CPU和输入输出都处于并行操作。因此,使整个计算机系统的效率大大提高。
DMA传送方式是让存储器与外设、或外设与外设之间直接交换数据,不需要经过CPU的累加器中转,减少了这个中间环节,并且内存地址的修改、传送完毕的结束报告都是由硬件电路实现的,因此大大地提高了数据的传输速度。一个DMA传送只需要执行一个DMA周期,相当于一个总线读写周期。
零拷贝技术
零拷贝(Zero-Copy)技术指在计算机执行操作时,CPU不需要先将数据从一个内存区域复制到另一个内存区域,从而可以减少上下文切换以及CPU的拷贝时间。
常见的零拷贝技术可以分为两大类:
- 一是针对特定场景,去掉不必要的拷贝
- 用户态直接I/O
- mmap
- sendfile
- splice
- 二是去优化整个拷贝的过程
- 写时复制
- 缓冲区共享
零拷贝并没有真正做到“0”拷贝,它更多是一种思想,很多的零拷贝技术都是基于这个思想去做的优化。
常见的Linux下的零拷贝技术
用户态直接I/O
这种方法可以使应用程序或者运行在用户态下的库函数直接访问硬件设备,数据直接跨过内核进行传输,内核在整个数据传输过程除了会进行必要的虚拟存储配置工作之外,不参与其他任何工作。
这种方法只能适用于那些不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统
mmap + write
一种零拷贝方式是使用 mmap + write 代替原来的 read + write 方式,减少了 1 次 CPU 拷贝操作。
buf = mmap(diskfd, len);
write(sockfd, buf, len);
mmap是Linux提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址。
这种方法是通过应用程序调用mmap,磁盘文件中的数据通过DMA拷贝到内核缓冲区,接着操作系统会将这个缓冲区与应用程序共享,这样就不用往用户空间拷贝。
基于mmap + write系统调用的零拷贝方式,整个拷贝过程会发生4次上下文切换,1次CPU拷贝和2次DMA拷贝。
使用mmap替代read很明显减少了一次拷贝,当拷贝数据量很大时,无疑提升了效率。但是使用mmap是有代价的,你使用mmap时,你可能会遇到一些隐藏的陷阱。
当你的程序map了一个文件,但是当这个文件被另一个进程截断(truncate)时, write系统调用会因为访问非法地址而被SIGBUS信号终止。SIGBUS信号默认会杀死你的进程并产生一个coredump,如果你的服务器这样被中止了。
为避免上述的问题产生的思路是使用文件租借锁,在mmap文件之前加锁,并且在操作完文件后解锁。
sendfile
从Linux2.1版内核开始,Linux引入了sendfile,也能减少一次拷贝。
sendfile(socket_fd, file_fd, len);
sendfile是只发生在内核态的数据传输接口,没有用户态的参与,自然避免了用户态数据拷贝。
sendfile指定在in_fd和out_fd之间传输数据,其中规定in_fd指向的文件必须是可以mmap的,out_fd必须指向一个套接字,也就是规定数据只能从文件传输到套接字,反之则不行。
基于 sendfile 系统调用的零拷贝方式,整个拷贝过程会发生2次上下文切换,1次CPU拷贝和2次DMA拷贝。
DMA辅助的sendfile
常规sendfile还有一次内核态的拷贝操作,能不能也把这次拷贝给去掉呢?答案就是这种DMA辅助的sendfile。
这种方法借助硬件的帮助,在数据从内核缓冲区到socket缓冲区这一步操作上,并不是拷贝数据,而是拷贝缓冲区描述符,待完成后,DMA引擎直接将数据从内核缓冲区拷贝到协议引擎中去,避免了最后一次拷贝。
该方式的限制在于需要硬件以及驱动程序支持,且同样只适用于将数据从文件拷贝到套接字上。
基于DMA辅助的sendfile的零拷贝方式,整个拷贝过程会发生2次上下文切换、0次CPU拷贝以及2次DMA拷贝
splice
Linux在2.6.17版本引入splice系统调用,用于在两个文件描述符中移动数据。
splice(fd_in, off_in, fd_out, off_out, len, flags);
splice方式与sendfile的原理类似,但是去掉了sendfile的使用范围限制,可以用于任意两个文件描述符中传输数据。
splice调用在两个文件描述符之间移动数据,而不需要数据在内核空间和用户空间来回拷贝。他从fd_in拷贝len长度的数据到fd_out,但是有一方必须是管道设备,这也是目前splice的一些局限性。
基于splice系统调用的零拷贝方式,整个拷贝过程会发生2次上下文切换,0次CPU拷贝以及2次DMA拷贝
写时复制
写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。
这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。
在Linux程序中,fork会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了"写时复制"技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。
在fork之后exec之前父进程和子进程两个进程用的是相同的物理空间,子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
缓冲区共享
缓冲区共享方式完全改写了传统的 I/O 操作,因为传统 I/O 接口都是基于数据拷贝进行的,要避免拷贝就得去掉原先的那套接口并重新改写,所以这种方法是比较全面的零拷贝技术
目前比较成熟的一个方案是在Solaris上实现的fbuf(Fast Buffer,快速缓冲区)
fbuf的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间和内核态,内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。
零拷贝技术在消息队列中的应用
RocketMQ选择了mmap + write这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输
Kafka采用的是sendfile这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。但是值得注意的一点是,Kafka的索引文件使用的是mmap + write方式,数据文件使用的是sendfile方式。
参考资料
- https://www.toutiao.com/i6795831252650820107/
- https://www.jianshu.com/p/fad3339e3448
- https://juejin.im/post/5d84bd1f6fb9a06b2d780df7
- https://blog.csdn.net/wuyongpeng0912/article/details/46634931
- http://www.wowotech.net/linux_kenrel/dma_engine_overview.html
- https://www.cnblogs.com/biyeymyhjob/archive/2012/07/20/2601655.html
- https://juejin.im/post/5bd96bcaf265da396b72f855