背景介绍
现在,零拷贝功能在Linux下几乎家喻户晓,但仍有很多人对其了解有限。为了解开这个功能的神秘面纱,我决定撰写一篇关于深入探讨的文章。本文将从用户模式应用程序的角度出发,介绍零拷贝的概念,省略了内核级的技术细节。希望通过本篇文章,可以帮助大家能更好地理解这个有用功能。
什么是零拷贝
为了更好地解释问题和解决方案,我们首先需要了解零拷贝是什么。让我们以网络服务器守护进程向客户端提供存储在文件中的数据为例,来说明这个过程中涉及的操作。以下是一些示例代码:
c
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
上面的代码看起来比较简单,但是实际情况比表面看起来要复杂得多。尽管只有两个系统调用,但所涉及的开销并不小。事实上,在这两个调用之间,数据被复制了四次,同时还执行了大量的用户/内核上下文切换。我们可以参考图1来更清楚地理解相关的过程。图中上半部分展示了上下文切换,而下半部分展示了数据复制操作。
第一步:用户空间数据复制到内核空间
在第一步中,当我们进行系统调用来读取文件时,会发生从用户模式切换到内核模式的上下文切换。同时,还会执行第一次的数据复制,这个复制由DMA引擎执行。DMA 引擎会负责从磁盘中读取文件内容,并将其存储到内核地址空间的缓冲区中。
第二步:用户空间数据复制到内核空间
数据从内核缓冲区复制到用户缓冲区,并返回读取系统调用。这个调用返回会触发从内核模式切换回用户模式的上下文切换。此时,数据已经存储在用户地址空间的缓冲区中,可以继续向下执行了。
第三步:用户空间数据再次复制到内核空间
执行写入系统调用将导致从用户模式切换到内核模式的上下文切换。在这一步中,数据被再次复制到内核地址空间的缓冲区中。然而,这次数据被放入与套接字相关联的另一个专门的缓冲区中。
第四步:内核态数据buffer写回到Socket引擎协议层
在写入系统调用返回后,进行第四次上下文切换。当DMA引擎将数据从内核缓冲区传递到协议引擎时,第四个副本会独立且异步地进行。你可能会有疑问:"独立和异步是什么意思?难道数据不是在调用返回之前传输的吗?"实际上,调用返回并不能保证传输的发生,甚至无法保证传输的开始。它只表示以太网驱动程序的队列中有空闲描述符,并且可以接受我们的数据进行传输。 在我们的数据包之前,可能还有许多其他数据包在排队等待传输。除非驱动程序或硬件实现了优先环或队列,否则数据传输按照先进先出的原则进行。
原始数据拷贝分析
从上面4个步骤可以看出来大量的数据重复并不是真正需要的。为了减少开销和提高性能,我们可以消除一些重复数据。作为驱动程序开发人员,我接触到的硬件通常都具备一些先进的功能。其中一些硬件可以绕过主存储器,直接将数据传输到另一个设备,从而避免在系统内存中进行数据复制。这样的功能非常实用,但并不是所有硬件都支持。
此外,为了进行网络传输,磁盘中的数据还必须重新打包,这也带来了一些复杂的问题。为了减少开销,我们可以从消除内核和用户缓冲区之间的部分复制开始着手。
MMap(内存映射)
mmap(内存映射)可以将文件映射到进程的内存空间,从而避免了显式的复制操作。具体而言,使用mmap可以实现以下步骤:
- 打开文件:使用文件描述符或文件名打开文件,获取文件描述符。
- 获取文件大小:可以使用类似于stat函数来获取文件的大小。
- 调用mmap:使用mmap函数将文件映射到进程的内存空间。这将返回一个指向映射区域的指针。
- 使用映射区域:通过使用指针访问映射区域,就可以像访问普通内存一样读取文件内容。
- 关闭映射和文件:在使用完映射区域之后,通过调用munmap函数来取消映射,然后关闭文件。
通过使用mmap,避免了显式的复制操作,因为文件数据直接映射到了进程的内存空间中。这样可以提高效率,尤其是对于大文件而言。但需要注意,使用mmap时需要谨慎处理内存映射的大小和访问权限,以避免出现潜在的内存访问错误或安全问题。
c
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);
为了更好地理解相关过程,请参考图2。上下文切换保持不变。
第一步:kernal-buffer区到shared共享区
使用mmap系统调用,DMA引擎会将文件内容复制到内核缓冲区。这个缓冲区会与用户进程共享,而且内核和用户的内存空间之间不进行任何复制操作。这个过程实现了高效的数据传输和共享,减少了不必要的复制操作。
第二步:shared共享区(kernal-buffer)复制到socket套接字内核缓冲器
通过写入系统调用将数据从原始的内核缓冲区复制到与套接字相关的内核缓冲区。这个过程中,内核会负责将数据从一个缓冲区复制到另一个缓冲区,以准备进行网络传输。这个操作确保了数据能够顺利地被发送到目标套接字,同时保护了内核和用户空间之间的数据完整性。
第三步:mmap系统调用
涉及到DMA 引擎将数据从内核套接字缓冲区传递到协议引擎的过程。 在这个过程中,数据需要进行第三次复制操作。这次复制的目的是将数据从内核缓冲区传递给协议引擎,以进行进一步的处理和传输。虽然这个复制操作可能会增加一些额外的开销,但它是必要的,因为不同的引擎可能使用不同的数据格式或者需要进行额外的处理步骤。通过这次复制,确保了数据能够被正确地传递到协议引擎,并最终发送到目标设备或者网络。
mmap总结分析
通过使用 mmap 而不是read,我们可以将内核需要复制的数据量减少一半,这在传输大量数据时会有相当不错的效果。然而,这种改进并非没有代价,因为使用 mmap+write 方法存在隐患。
mmap隐患问题
当你对文件进行内存映射后,在另一个进程截断同一文件时调用写入操作,你可能会遇到问题。这是因为你执行了错误的内存访问,导致写系统调用被总线错误信号 SIGBUS 中断。默认情况下,这个信号会杀死进程并转储内核,这对于网络服务器来说并不是最理想的操作。
mmap解决方案
-
第一种方法是通过使用文件锁(File Locking)来阻止其他进程对文件进行截断操作,确保在写入之前文件保持完整。
- 为 SIGBUS 信号安装一个信号处理器,并在处理器中简单地调用 return。这样,写系统调用将会返回中断前写入的字节数,并将 errno 设置为成功。然而,我要指出这种方法只是治标不治本的糟糕解决方案。因为 SIGBUS 意味着进程遇到了严重问题,所以我不建议使用这种解决方案。
-
第二种方法是在写入操作之前检查文件的截断状态,如果文件截断,可以重新映射文件,然后再进行写入操作。
- 使用内核的文件租赁(在 Microsoft Windows 中称为 "机会锁定")。这是解决此问题的正确方法。通过在文件描述符上使用租赁,你可以与内核签订租赁协议,与特定文件相关联。然后,你可以向内核申请读/写租期。当其他进程尝试截断你正在传输的文件时,内核会向你发送一个实时信号 RT_SIGNAL_LEASE。该信号告诉你,内核正在中止你对该文件的写入或读取租约。在你的程序访问无效地址之前,你的写调用会被中断,并被 SIGBUS 信号杀死。写调用的返回值是中断前写入的字节数,errno 将被设置为成功
c
if(fcntl(fd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
perror("kernel lease set signal");
return -1;
}
/* l_type can be F_RDLCK F_WRLCK */
if(fcntl(fd, F_SETLEASE, l_type)){
perror("kernel lease set type");
return -1;
}
在对文件进行 mmap 前获取租期,并在完成后解除租期是一个良好的实践。你可以通过调用 fcntl 函数,使用租期类型 F_UNLCK,来实现这一目标。
sendfile
内核 2.1 版引入了 sendfile
系统调用,以简化网络数据传输和两个本地文件之间的数据传输。sendfile
的引入不仅减少了数据复制,还减少了上下文切换。使用 sendfile
可以将一个文件描述符(文件或套接字)的数据直接传输到另一个文件描述符,而无需通过用户空间进行中间复制。
c
sendfile(socket, file, len);
在此代码中,使用 open
函数打开输入和输出文件,使用 fstat
函数获取输入文件的大小,然后使用 sendfile
函数将输入文件的内容直接传输到输出文件。
第一步:数据从磁盘到套接字内核缓冲区
sendfile 系统调用在进行数据传输时会利用 DMA (直接内存访问)引擎,将文件内容从磁盘复制到内核缓冲区。然后,内核会将数据从内核缓冲区复制到与套接字相关的内核缓冲区,最终进行网络数据传输。该过程利用 DMA 引擎的特性,可以实现高效的数据传输并减少 CPU 参与的复制操作。
具体来说,DMA 引擎允许设备(如硬盘控制器)直接访问系统内存,避免了通过 CPU 进行数据复制的过程。这样可以提高数据传输的速度,并减少 CPU 的负担。
因此,通过使用 sendfile 系统调用,可以最小化数据复制的操作,从而提高数据传输的效率。同时,内核的设计也使得数据从磁盘到网络的复制过程变得更加高效和快速。
第二步:数据从套接字内核缓冲区到协议引擎
当DMA引擎将数据从内核套接字缓冲区传递到协议引擎时,这个过程中可能会发生一次额外的数据复制操作。
在这个过程中,内核需要将数据从套接字缓冲区复制到协议引擎所需的内存区域中。这个复制过程是为了将数据从内核套接字缓冲区转移到协议引擎所需的缓冲区,以便进行网络传输。
尽管这个复制操作可能会引入一定的性能开销,但它的作用是为了确保数据能够被协议引擎正确地处理和发送。同时,这个复制过程也可以通过优化算法和数据结构来减少对内存的额外消耗。
总之,在数据从内核套接字缓冲区传递到协议引擎的过程中,可能会发生一次数据复制操作,这是为了确保数据能够被正确地传输和处理。尽管有一定的性能开销,但这个复制过程是网络传输的必要环节。
sendfile的问题
如果另一个进程在使用 sendfile 系统调用传输文件时被截断,会有什么情况发生?如果我们没有注册任何信号处理器,sendfile调用会返回被中断前已成功传输的字节数,而 errno 会被设置为成功。
然而,如果我们在调用sendfile之前从内核获取文件租约,行为和返回状态将完全一样。在 sendfile 调用返回之前,我们还会收到一个 RT_SIGNAL_LEASE 信号。
目前为止,我们已经成功避免了内核复制文件多次,但仍然只有一份文件的副本存在。是否还有办法避免这种情况呢?当然可以,只需要硬件提供一些帮助。为了消除内核所做的所有数据复制,我们需要一个支持收集操作的网络接口。这意味着等待传输的数据不需要连续存在于内存中,而是可以分散在不同的内存位置上。
Linux 内核 2.4 版本对套接字缓冲区描述符进行了修改,以满足这些要求,这也就是所谓的零拷贝技术在 Linux 下的实现。这种方法不仅减少了多次上下文切换,还消除了处理器对数据的重复工作。对于用户级应用程序来说,没有任何变化,所以代码看起来依然如下所示:
sendfile优化版本
某些硬件支持收集数据的功能,可以从多个内存位置收集数据,从而避免了额外的数据复制。只需要将包含数据位置和长度信息的描述符附加到套接字缓冲区。DMA 引擎直接将数据从内核缓冲区传递到协议引擎,从而实现了零拷贝,避免了额外的数据复制过程。
- 第一步:使用 sendfile 系统调用,DMA 引擎直接将文件内容从磁盘复制到内核缓冲区,减少了数据在内存中的复制。
- 第二步:在将数据传送到目标套接字之前,不需要将数据从内核缓冲区复制到套接字缓冲区。
虽然数据实际上是从磁盘到内存再到线路的复制过程,有些人可能会认为这不算是真正的零拷贝。但是从操作系统的角度来看,这仍然属于零拷贝,因为数据没有在内核缓冲区之间重复复制。
零拷贝总结
零拷贝是一种优化技术,通过减少数据在内存中的多次复制来提高性能。以下是对零拷贝的总结:
-
直接从源数据到目标位置:在传输数据时,零拷贝避免了数据从源缓冲区到目标缓冲区的中间复制步骤。相反,数据直接从源位置传输到目标位置,减少了不必要的数据复制。
-
减少上下文切换:零拷贝通过避免在数据传输过程中进行多次上下文切换,提高了数据传输的效率。数据直接通过 DMA 引擎从内核缓冲区传递到协议引擎,减少了操作系统和应用程序之间的切换次数。
-
减少 CPU 数据缓存污染:零拷贝技术会减少数据在 CPU 缓存中的复制次数,从而降低了 CPU 数据缓存污染。这有助于提高数据访问的效率。
-
无需 CPU 校验和计算:零拷贝可以直接传输数据,而无需通过 CPU 进行校验和计算。这减少了 CPU 的工作负载,提高了数据传输的速度。
零拷贝是一项强大而高效的技术,常常应用于文件传输、网络数据传输和多媒体处理等领域。通过减少多余的数据复制和内存访问,零拷贝能够提升系统的性能和效率。