本文讨论了Linux中主要的零拷贝技术以及零拷贝技术的适用场景。图片来自Pexels为了快速建立零拷贝的概念,我们介绍一个常用的场景。在编写服务器程序(WebServer或文件服务器)时,文件下载是一个基本功能。此时服务器的任务是:将服务器主机磁盘中的文件从连接的Socket中原封不动地发送出去。我们通常使用如下代码来完成:while((n=read(diskfd,buf,BUF_SIZE))>0)write(sockfd,buf,n);基本操作是循环从磁盘读取文件内容到缓冲区,然后将缓冲区内容发送到Socket。但是因为linux的I/O操作默认是bufferedI/O。这里主要用到Read和Write这两个系统调用,不知道操作系统在里面做了什么。实际上,在上述I/O操作中,发生了多次数据拷贝。当应用程序访问一段数据时,操作系统首先检查该文件最近是否被访问过,文件内容是否缓存在内核缓冲区中。如果是,操作系统根据Read系统调用提供的buf地址,直接将内核缓冲区的内容复制到buf指定的用户空间缓冲区。如果没有,操作系统首先将磁盘上的数据复制到内核缓冲区中。这一步目前主要是依靠DMA进行传输,然后将内核缓冲区的内容复制到用户缓冲区中。接下来Write系统调用将用户缓冲区的内容复制到网络栈相关的内核缓冲区,最后Socket将内核缓冲区的内容发送给网卡。说了这么多还是看图清楚:数据副本从上图可以看出,一共生成了四个数据副本。即使使用DMA来处理与硬件的通信,CPU仍然需要处理两个数据副本。同时,用户态和内核态之间存在多次上下文切换,这无疑增加了CPU的负担。在这个过程中,我们并没有对文件内容做任何修改,所以在内核空间和用户空间之间来回拷贝数据无疑是一种浪费,而零拷贝主要就是为了解决这种低效率的问题。什么是零拷贝技术?零拷贝的主要任务是防止CPU将数据从一个存储复制到另一个。主要是利用各种零拷贝技术,避免让CPU做大量的数据拷贝任务,减少不必要的拷贝,或者让其他组件来做这类简单的数据传输任务,让CPU腾出时间专注于其他任务。这允许更有效地使用系统资源。我们回到上面的例子,我们如何减少数据副本的数量?一个明显的重点是减少内核空间和用户空间之间的数据来回拷贝,这也引入了一种零拷贝:使数据传输无需经过用户空间。我们可以使用mmap减少副本数量的一种方法是调用mmap()而不是读取调用:buf=mmap(diskfd,len);写(sockfd,buf,len);应用程序调用mmap(),磁盘上的数据会被DMA复制到内核缓冲区,然后操作系统会将这个内核缓冲区共享给应用程序,这样就不需要将内核缓冲区的内容复制到用户空间。应用程序再次调用write(),操作系统直接将内核缓冲区的内容复制到Socket缓冲区,这一切都发生在内核态,最后,Socket缓冲区将数据发送到网卡。同样的,看图也很简单:mmap用的是mmap而不是Read,明显减少了一份。当复制的数据量很大时,无疑会提高效率。但是使用mmap是有代价的。当你使用mmap时,你可能会遇到一些隐藏的陷阱。例如,当你的程序映射了一个文件,但是当该文件被另一个进程截断(truncate)时,Write系统调用将被SIGBUS信号终止以访问非法地址。SIGBUS信号默认会杀死你的进程并生成一个coredump,如果你的服务器这样终止,将会造成损失。通常我们采用以下解决方案来避免这个问题:①为SIGBUS信号建立一个信号处理程序。当遇到SIGBUS信号时,信号处理程序简单地返回,Write系统调用返回中断前已写入的字节数。并且errno会被设置为success,但这是一种糟糕的做事方式,因为你没有解决问题的真正核心。②使用文件租约锁通常我们使用这种方法是对文件描述符使用租约锁,我们向内核申请文件的租约锁。当其他进程要截断这个文件时,内核会给我们发送一个实时的RTSIGNALLEASE信号,告诉我们内核正在破坏你对该文件持有的读写锁。这样你的Write系统调用将在程序访问非法内存并被SIGBUS杀死之前被中断。Write返回写入的字节数并将errno设置为成功。我们应该在mmap之前锁定文件,在操作之后解锁:if(fcntl(diskfd,F_SETSIG,RT_SIGNAL_LEASE)==-1){perror("kernelleasesetsignal");return-1;}/*l_typecanbeF_RDLCKF_WRLCKlock*//*l_typecanbeF_UNLCKunlock*/if(fcntl(diskfd,F_SETLEASE,l_type)){perror("kernelleasesettype");return-1;}使用sendfile从2.1版内核开始,Linux引入sendfile来简化操作:#include
