传统的I/O操作读取文件并通过Socket发送,需要经过4次上下文切换、2次CPU数据拷贝和2次DMA控制器数据拷贝,如下图:
![](https://i-blog.csdnimg.cn/blog_migrate/39aed3c69945f2779f77d15d7eef3a98.jpeg)
从中也可以看得出提高性能可以从减少数据拷贝和上下文切换的次数着手,在Linux操作系统层面上有4种实现方案:内存映射mmap、sendfile、splice、tee,这些实现中或多多少的减少数据拷贝次数或减少上下文切换次数。
操作系统层面的减少数据拷贝次数主要是指用户空间和内核空间的数据拷贝,因为只有他们的拷贝是大量消耗CPU时间片的,而DMA控制器拷贝数据CPU参与的工作较少,只是辅助作用。
现实中对零拷贝的概念有广义和狭义之分,广义上是指只要减少了数据拷贝的次数就称之为零拷贝;狭义上是指真正的零拷贝,比如上例中避免2和3的CPU拷贝。
下面我们逐一看看他们的设计思想和实现方案
mmap内存映射既然是内存映射,首先来了解解下虚拟内存和物理内存的映射关系,虚拟内存是操作系统为了方便操作而对物理内存做的抽象,他们之间是靠页表(Page Table)进行关联的,关系如下
![](https://i-blog.csdnimg.cn/blog_migrate/9d771f00cb8f5eeb3884ae95a3404087.jpeg)
每个进程都有自己的PageTable,进程的虚拟内存地址通过PageTable对应于物理内存,内存分配具有惰性,它的过程一般是这样的:进程创建后新建与进程对应的PageTable,当进程需要内存时会通过PageTable寻找物理内存,如果没有找到对应的页帧就会发生缺页中断,从而创建PageTable与物理内存的对应关系。虚拟内存不仅可以对物理内存进行扩展,还可以更方便地灵活分配,并对编程提供更友好的操作。
内存映射(mmap)是指用户空间和内核空间的虚拟内存地址同时映射到同一块物理内存,用户态进程可以直接操作物理内存,避免用户空间和内核空间之间的数据拷贝。
![](https://i-blog.csdnimg.cn/blog_migrate/f51cf81f9bd85afd5b41902e129dff2b.jpeg)
它的具体执行流程是这样的
![](https://i-blog.csdnimg.cn/blog_migrate/e359d855a4c140528dc9a670a2d0cdf6.jpeg)
用户进程通过系统调用mmap函数进入内核态,发生第1次上下文切换,并建立内核缓冲区;
发生缺页中断,CPU通知DMA读取数据;
DMA拷贝数据到物理内存,并建立内核缓冲区和物理内存的映射关系;
建立用户空间的进程缓冲区和同一块物理内存的映射关系,由内核态转变为用户态,发生第2次上下文切换;
用户进程进行逻辑处理后,通过系统调用Socket send,用户态进入内核态,发生第3次上下文切换;
系统调用Send创建网络缓冲区,并拷贝内核读缓冲区数据;
DMA控制器将网络缓冲区的数据发送网卡,并返回,由内核态进入用户态,发生第4次上下文切换;
总结
避免了内核空间和用户空间的2次CPU拷贝,但增加了1次内核空间的CPU拷贝,整体上相当于只减少了1次CPU拷贝;
针对大文件比较适合mmap,小文件则会造成较多的内存碎片,得不偿失;
当mmap一个文件时,如果文件被另一个进程截获可能会因为非法访问导致进程被SIGBUS 信号终止;
sendfilesendfile是在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文件大小。
它的内部执行流程是这样的
用户进程系统调用senfile,由用户态进入内核态,发生第1次上下文切换;
CPU通知DMA控制器把文件数据拷贝到内核缓冲区;
内核空间自动调用网络发送功能并拷贝数据到网络缓冲区;
CPU通知DMA控制器发送数据;
sendfile系统调用结束并返回,进程由内核态进入用户态,发生第2次上下文切换;
总结
数据处理完全是由内核操作,减少了2次上下文切换,整个过程2次上下文切换、1次CPU拷贝,2次DMA拷贝;
虽然可以设置偏移量,但不能对数据进行任何的修改;
sendfile+DMA gatherLinux2.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。
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必须有一个是管道;
teetee与splice类同,但fd_in和fd_out都必须是管道。写在最后