Linux下的实现

mmapwrite

mmapwrite简单来说就是使用mmap替换了readwrite中的read操作,减少了一次CPU的拷贝。mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

mmap 示意图

整个过程发生了4次用户态和内核态的上下文切换和3次拷贝,具体流程如下:

  • 用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
  • DMA控制器把数据从硬盘中拷贝到读缓冲区
  • 上下文从内核态转为用户态,mmap调用返回
  • 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  • CPU将读缓冲区中数据拷贝到socket缓冲区
  • DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

使用mmap替代read很明显减少了一次拷贝,当拷贝数据量很大时,无疑提升了效率。但是使用mmap会有一些隐藏的陷阱,例如,当你的程序map了一个文件,但是当这个文件被另一个进程截断(truncate), write系统调用会因为访问非法地址而被SIGBUS信号终止。SIGBUS信号默认会杀死你的进程并产生一个coredump,最终可能导致服务器的终止。通常我们使用以下解决方案避免这种问题:

  • SIGBUS信号建立信号处理程序:当遇到SIGBUS信号时,信号处理程序简单地返回,write系统调用在被中断之前会返回已经写入的字节数,并且errno会被设置成success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心。
  • 使用文件租借锁:通常我们使用这种方法,在文件描述符上使用租借锁,我们为文件向内核申请一个租借锁,当其它进程想要截断这个文件时,内核会向我们发送一个实时的RT_SIGNAL_LEASE信号,告诉我们内核正在破坏你加持在文件上的读写锁。这样在程序访问非法内存并且被SIGBUS杀死之前,你的write系统调用会被中断。write会返回已经写入的字节数,并且置errnosuccess

我们应该在mmap文件之前加锁,并且在操作完文件后解锁:

if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
    perror("kernel lease set signal");
    return -1;
}
/* l_type can be F_RDLCK F_WRLCK  加锁*/
/* l_type can be  F_UNLCK 解锁*/
if(fcntl(diskfd, F_SETLEASE, l_type)){
    perror("kernel lease set type");
    return -1;
}

sendfile

在内核版本2.1中,引入了sendfile系统调用,以简化网络上和两个本地文件之间的数据传输。相比mmap来说,sendfile同样减少了一次CPU拷贝,而且还减少了2次上下文切换。使用如下:

#include<sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

系统调用sendfile()在代表输入文件的描述符in_fd和代表输出文件的描述符out_fd之间传送文件内容(字节。描述符out_fd必须指向一个套接字,而in_fd指向的文件必须是可以mmap的。这些局限限制了sendfile的使用,使sendfile只能将数据从文件传递到套接字上,反之则不行。使用sendfile不仅减少了数据拷贝的次数,还减少了上下文切换,数据传送始终只发生在kernel space

sendfile 示意图

整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:

  • 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  • DMA控制器把数据从硬盘中拷贝到读缓冲区
  • CPU将读缓冲区中数据拷贝到socket缓冲区
  • DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回

sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。此外,在我们调用sendfile时,如果有其它进程截断了文件会发生什么呢?假设我们没有设置任何信号处理程序,sendfile调用仅仅返回它在被中断之前已经传输的字节数,errno会被置为success。如果我们在调用sendfile之前给文件加了锁,sendfile的行为仍然和之前相同,我们还会收到RT_SIGNAL_LEASE的信号。

sendfile+DMA Scatter/Gather

Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather分散/收集功能。它将读缓冲区中的数据描述信息:内存地址和偏移量记录到socket缓冲区,由DMA根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。

sendfile DMA Gather 示意图

整个过程发生了2次用户态和内核态的上下文切换和2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:

  • 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  • DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
  • CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
  • DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
  • sendfile()调用返回,上下文从内核态切换回用户态

DMA gathersendfile一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。

splice

sendfile只适用于将数据从文件拷贝到套接字上,限定了它的使用范围。Linux2.6.17版本引入splice系统调用,用于在两个文件描述符中移动数据:

#define _GNU_SOURCE         /* See feature_test_macros(7) */
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

splice调用在两个文件描述符之间移动数据,而不需要数据在内核空间和用户空间来回拷贝。他从fd_in拷贝len长度的数据到fd_out,但是有一方必须是管道设备,这也是目前splice的一些局限性。flags参数有以下几种取值:

  • SPLICE_F_MOVE:尝试去移动数据而不是拷贝数据。这仅仅是对内核的一个小提示:如果内核不能从pipe移动数据或者pipe的缓存不是一个整页面,仍然需要拷贝数据。Linux最初的实现有些问题,所以从2.6.21开始这个选项不起作用,后面的Linux版本应该会实现。
  • SPLICE_F_NONBLOCKsplice操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的IO,那么调用splice有可能仍然被阻塞。
  • SPLICE_F_MORE:后面的splice调用会有更多的数据。

splice调用利用了Linux提出的管道缓冲区机制,所以至少一个描述符要为管道。