🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
到目前为止,几乎所有人都听说过Linux下所谓的零拷贝功能,但我经常遇到对这个主题没有充分了解的人。因此,我决定撰写一些文章,深入研究这个问题,希望能够揭开这个有用的特征。在本文中,我们从用户模式应用程序的角度来看零拷贝,因此故意省略了内核级别的详细信息。 什么是零拷贝? 为了更好地理解问题的解决方案,我们首先需要了解问题本身。让我们来看一下网络服务器的简单过程所涉及的内容,该过程通过网络将存储在文件中的数据提供给客户端。这是一些示例代码: ~~~ read(file,tmp_buf,len); write(socket,tmp_buf,len); ~~~ 看起来很简单;你会认为只有那两个系统调用没有太多的开销。实际上,这不可能是事实。在这两个调用之后,数据已被复制至少四次,并且几乎已经执行了多个用户/内核上下文切换。(实际上这个过程要复杂得多,但我想保持简单)。为了更好地了解所涉及的过程,请查看图1.顶部显示上下文切换,底部显示复制操作。 ![](https://box.kancloud.cn/1dd3632e6962e7a51918691f59ea8ade_427x316.png) 图1.两个示例系统调用中的复制 第一步:读取系统调用导致从用户模式到内核模式的上下文切换。第一个副本由DMA引擎执行,DMA引擎从磁盘读取文件内容并将它们存储到内核地址空间缓冲区中。 第二步:将数据从内核缓冲区复制到用户缓冲区,并返回读取系统调用。从调用返回导致从内核切换到用户模式的上下文。现在数据存储在用户地址空间缓冲区中,它可以再次开始。 第三步:写系统调用导致从用户模式到内核模式的上下文切换。执行第三个副本以再次将数据放入内核地址空间缓冲区。但是这一次,数据被放入一个不同的缓冲区,一个与套接字相关的缓冲区。 第四步:写入系统调用返回,创建第四个上下文切换。独立和异步地,当DMA引擎将数据从内核缓冲区传递到协议引擎时,会发生第四个副本。你可能会问自己,“你是什么意思独立和异步?在呼叫返回之前是不是传输了数据?“呼叫返回,实际上并不保证传输;它甚至不能保证传输的开始。它只是意味着以太网驱动程序在其队列中有自由描述符并已接受我们的数据进行传输。在我们之前可能有许多数据包排队。除非驱动程序/硬件实现优先级环或队列,否则数据以先进先出的方式传输。(图1中的分叉DMA副本说明了最后一个副本可以延迟的事实)。 正如您所看到的,实际上并不需要进行大量的数据复制。可以消除一些重复,以减少开销并提高性能。作为驱动程序开发人员,我使用具有一些非常高级功能的硬件。某些硬件可以完全绕过主存储器并将数据直接传输到另一个设备。此功能消除了系统内存中的副本,并且是一件好事,但并非所有硬件都支持它。还存在必须为网络重新打包来自磁盘的数据的问题,这引入了一些复杂性。为了消除开销,我们可以从消除内核和用户缓冲区之间的一些复制开始。 消除副本的一种方法是跳过调用read而不是调用mmap。例如: ~~~ tmp_buf = mmap(file,len); write(socket,tmp_buf,len); ~~~ 为了更好地了解所涉及的过程,请参见图2.上下文切换保持不变。 ![](https://box.kancloud.cn/97e8c95002f98066dfa7fb63408292b3_420x314.png) 图2.调用mmap 第一步:mmap系统调用导致文件内容被DMA引擎复制到内核缓冲区。然后与用户进程共享缓冲区,而不在内核和用户存储空间之间执行任何复制。 第二步:写入系统调用使内核将数据从原始内核缓冲区复制到与套接字关联的内核缓冲区中。 第三步:第三个副本发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。 通过使用mmap而不是read,我们减少了内核复制数据量的一半。当传输大量数据时,这会产生相当好的结果。然而,这种改进并非没有代价;使用mmap + write方法时存在隐藏的陷阱。当内存映射文件然后调用write而另一个进程截断同一文件时,您将陷入其中一个。您的写入系统调用将被总线错误信号SIGBUS中断,因为您执行了错误的内存访问。该信号的默认行为是终止进程并转储核心 - 而不是网络服务器最理想的操作。有两种方法可以解决这个问题。 第一种方法是为SIGBUS信号安装信号处理程序,然后在处理程序中简单地调用return。通过这样做,write系统调用返回它在被中断之前写入的字节数并且errno设置为成功。让我指出,这将是一个糟糕的解决方案,一个治疗症状,而不是问题的原因。因为SIGBUS发出信号表明该过程严重错误,我不鼓励将其作为解决方案。 第二个解决方案涉及内核中的文件租用(在Microsoft Windows中称为“机会锁定”)。这是解决此问题的正确方法。通过在文件描述符上使用租用,您可以在特定文件上使用内核。然后,您可以从内核请求读/写租约。当另一个进程试图截断您正在传输的文件时,内核会向您发送一个实时信号RT\_SIGNAL\_LEASE信号。它告诉您内核正在破坏该文件的写入或读取租约。您的写入调用在程序访问无效地址之前被中断,并被SIGBUS信号杀死。写调用的返回值是中断前写入的字节数,errno将设置为成功。下面是一些示例代码,展示了如何从内核获得租约: ~~~ if(fcntl(fd,F_SETSIG,RT_SIGNAL_LEASE)== -1){ perror(“内核租约设置信号”); 返回-1; } / * l_type可以是F_RDLCK F_WRLCK * / if(fcntl(fd,F_SETLEASE,l_type)){ perror(“内核租约集类型”); 返回-1; } ~~~ 您应该在获取文件之前获得租约,并在完成后中断租约。这是通过使用租约类型F\_UNLCK调用fcntl F\_SETLEASE来实现的。 发送文件 在内核版本2.1中,引入了sendfile系统调用以简化通过网络和两个本地文件之间的数据传输。sendfile的引入不仅减少了数据复制,还减少了上下文切换。像这样使用它: ~~~ sendfile(socket,file,len); ~~~ 为了更好地了解所涉及的过程,请查看图3 ![](https://box.kancloud.cn/5eddd594c8ecb22eeba8994ff13bb58a_419x314.png) 图3.用Sendfile替换读写 第一步:sendfile系统调用导致文件内容被DMA引擎复制到内核缓冲区。然后,内核将数据复制到与套接字关联的内核缓冲区中。 第二步:第三个副本发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。 您可能想知道如果另一个进程截断我们使用sendfile系统调用传输的文件会发生什么。如果我们没有注册任何信号处理程序,sendfile调用只会返回它在被中断之前传输的字节数,并且errno将被设置为成功。 但是,如果我们在调用sendfile之前从文件内核获得租约,则行为和返回状态完全相同。我们还在sendfile调用返回之前获得RT\_SIGNAL\_LEASE信号。 到目前为止,我们已经能够避免让内核生成多个副本,但我们仍然只留下一个副本。这可以避免吗?当然,在硬件的帮助下。为了消除内核完成的所有数据复制,我们需要一个支持收集操作的网络接口。这仅仅意味着等待传输的数据不需要在连续的存储器中;它可以分散在各种存储位置。在内核版本2.4中,修改了套接字缓冲区描述符以适应这些要求 - 在Linux下称为零拷贝。这种方法不仅减少了多个上下文切换,还消除了处理器完成的数据复制。对于用户级应用程序,没有任何更改,因此代码仍然如下所示: ~~~ sendfile(socket,file,len); ~~~ 为了更好地了解所涉及的过程,请查看图4。 ![](https://box.kancloud.cn/39feb0ed0883de89f1ece57b9e3b3e37_429x312.png) 图4.支持收集的硬件可以从多个内存位置组装数据,从而消除了另一个副本。 第一步:sendfile系统调用导致文件内容被DMA引擎复制到内核缓冲区。 第二步:没有数据被复制到套接字缓冲区。相反,只有具有关于数据的下落和长度信息的描述符被附加到套接字缓冲区。DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终副本。 因为数据实际上仍然是从磁盘复制到内存,从内存复制到线路,所以有些人可能会认为这不是真正的零拷贝。但是,从操作系统的角度来看,这是零拷贝,因为内核缓冲区之间的数据不会重复。使用零拷贝时,除了复制避免之外,还可以获得其他性能优势,例如更少的上下文切换,更少的CPU数据高速缓存污染以及无CPU校验和计算。