Linux 零拷贝技术
使用标准I/O的痛点
在Linux中 标准I/O操作都是基于数据拷贝的缓冲机制,从内核中拷贝数据到用户空间的缓冲区中,然后将用户缓冲区中的数据拷贝至内核中。所以I/O操作频繁的使用会导致数据在内核和用户空间之间进行频繁的切换,这样做的好处虽然是可以通过缓冲机制减少实际的I/O系统调用,但是在数据拷贝的过程中会额外增加CPU的开销。
这里我们主要讨论如何在I/O操作的时候,有效减少数据拷贝,数据拷贝就是通过 copy_to_user 或者 copy_from_user将数据在内核和用户空间之间相互拷贝的操作,如下:
DMA(Direct Memory Access):直接存储器访问。DMA是一种无需CPU的参与,让外设和系统内存之间进行双向数据传输的硬件机制。使用DMA可以使系统CPU从实际的I/O数据传输过程中摆脱出来,从而大大提高系统的吞吐率。
零拷贝技术介绍
零拷贝技术,就是避免将数据从一块存储区拷贝至另一块存储区的技术,减少了数据拷贝所带来的CPU开销。但是零拷贝并不是将拷贝操作完全消除,百度如下:
零复制(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
目前零拷贝技术主要有三种类型:
- 使用文件I/O:文件I/O不提供缓冲机制,每次使用都会引起系统调用,文件I/O的操作是在用户地址空间与IO设备之间传递。
- 避免内核与用户空间之间的数据拷贝:通过避免内核与用户空间之间的数据拷贝,来实现零拷贝。
- 写时复制技术:数据不会立即拷贝,而是等需要修改的时候再进行部分拷贝,感觉这一部分的技术逻辑与Make相似。
下面主要讨论在网络编程时,读取本地文件,通过socket发送出去 场景下的零拷贝技术。
直接I/O操作
直接跨过内核,使运行在用户态下直接访问硬件设备,数据跨过内核进行传输,内核仅仅做一些必要的虚拟存储配置工作,工作逻辑如下:
缺陷:
- 这种方法只能适用于那些不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。
- 这种方法直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成资源的浪费,解决这个问题需要和异步 I/O 结合使用。
mmap内存映射
首先了解虚拟内存与物理地址的映射关系,虚拟内存是OS为了方便用户操作而实现对物理地址的抽象,虚拟内存与物理内存之间通过页表进行关联。每个进程都有自己的页表,页表负责从虚拟内存到物理内存的映射,如下:
每个进程都有自己的PageTable,进程的虚拟内存地址通过PageTable对应于物理内存,内存分配具有惰性,它的过程一般是这样的:进程创建后新建与进程对应的PageTable,当进程需要内存时会通过PageTable寻找物理内存,如果没有找到对应的页帧就会发生缺页中断,从而创建PageTable与物理内存的对应关系。虚拟内存不仅可以对物理内存进行扩展,还可以更方便地灵活分配,并对编程提供更友好的操作
内存映射(mmap)就是说,将用户空间和内核空间的虚拟地址映射到同一块物理内存中,所以用户就可以在用户空间直接访问内核中的数据了,如下:
所以mmap并没有为用户提供直接操作内核地址空间的能力,而是通过内存映射机制,将内核中的部分内存空间映射到用户空间中(相同的物理内存),从而使用户可以直接访问内核空间的地址。mmap函数原型如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
注意,调用mmap之后,并不会立即读取文件内容并加载到物理内存中,而是会在虚拟内存中分配地址空间,而实际要访问数据的时候,会因为内存地址对应的物理内存中没有数据,产生“缺页”异常,然后触发数据加载。
在mmap支持下的操作流程如下:
- 用户进程通过系统调用mmap函数进入内核态,发生第1次上下文切换,并建立内核缓冲区;
- 发生缺页中断,CPU通知DMA读取数据;
- DMA拷贝数据到物理内存,并建立内核缓冲区和物理内存的映射关系;
- 建立用户空间的进程缓冲区和同一块物理内存的映射关系,由内核态转变为用户态,发生第2次上下文切换;
- 用户进程进行逻辑处理后,通过系统调用Socket send,用户态进入内核态,发生第3次上下文切换;
- 系统调用Send创建网络缓冲区,并拷贝内核读缓冲区数据;
- DMA控制器将网络缓冲区的数据发送网卡,并返回,由内核态进入用户态,发生第4次上下文切换;
缺点:
- 针对大文件比较适合mmap,小文件则会造成较多的内存碎片,得不偿失
- 当mmap一个文件时,如果文件被另一个进程截获可能会因为非法访问导致进程被SIGBUS 信号终止;解决这个问题通常使用文件的租借锁:首先为文件申请一个租借锁,当其他进程想要截断这个文件时,内核会发送一个实时的 RT_SIGNAL_LEASE 信号,告诉当前进程有进程在试图破坏文件,这样 write 在被 SIGBUS 杀死之前,会被中断,返回已经写入的字节数,并设置 errno 为 success。
sendfile 在文件描述符之间传递数据
通过sendfile,可以实现在内核态中传递数据,即内核态中在两个文件描述符之间传递数据,这样就避免了用户空间与内核之间的数据拷贝,sendfile函数原型如下:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
in_fd参数是数据源的文件描述符,out_fd参数是待输出的文件描述符,in_fd必须是一个可以mmap的文件描述符,必须指向真实的文件,不能是socket等,而out_fd必须是一个socket。
在2.6.33之前Sendfile的out_fd必须是socket,因此sendfile几乎成了专为网络传输而设计的,限制了其使用范围比较狭窄。2.6.33之后out_fd才可以是任何file,于是乎出现了splice。
执行流程如下:
1.用户进程调用sendfile系统调用,进入内核态
2.CPU通知DMA将数据拷贝至内核缓冲区
3.内核自动将数据从内核缓冲区拷贝至网络缓冲区
4.通过DMA向网卡发送数据
5.sendfile调用完成,有内核态返回用户态
PS:在Linux 2.4版本中,对sendfile进一步做了优化,之前从“文件数据缓存”到“socket缓存”时候,也需要一次拷贝,优化之后,“socket缓存”中只存储要发送的数据在“文件数据缓存”中的位置和偏移量,在实际发送时,根据位置和偏移量直接将“文件数据缓存”中的数据拷贝到网卡设备中,又省掉了一次拷贝操作。如下:
在对sendfile进行优化之后,唯一的一次内核中的数据拷贝也可以通过DMA直接发送到网卡,整个过程中只有两次上下文切换和两次DMA操作,实现了真正意义上的零拷贝!!!但是需要硬件DMA支持,虽然可以设置偏移量,但不能对数据进行任何的修改。
参考: