零拷贝(Zero-copy)是一种优化技术,用于减少数据传输过程中的拷贝操作,从而提高系统性能和效率。在传统的数据传输中,涉及多个缓冲区之间的数据拷贝操作(例如从磁盘到内存的拷贝、内存到网络缓冲区的拷贝等)。而零拷贝技术旨在通过减少或避免这些拷贝操作来提升性能。
内核空间和用户空间
我们电脑上跑着的应用程序,其实是需要经过操作系统,才能做一些特殊操作,例如磁盘文件读写、内存读写等等。因为这些都是比较危险的操作,不可以任由应用程序乱来,只能交给底层操作系统来做。
因此,操作系统为每个进程都分配了内存空间,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。 以 32 位操作系统为例,它会为每一个进程都分配 4G 的内存空间。
- 内核空间:主要提供进程调度、内存分配、连接硬件资源等功能;
- 用户空间:提供给各个程序进程的空间,它不具有访问内核空间资源的权限,如果应用程序需要使用到内核空间的资源,则需要通过系统调用来完成。进程从用户空间切换到内核空间,在完成了相关操作后,再从内核空间切换回用户空间
用户态和内核态
- 如果进程运行于内核空间,称为进程的内核态;
- 如果进程运行于用户空间,称为进程的用户态
什么是 CPU 上下文?
CPU 寄存器,是 CPU 内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,所必须的依赖环境,因此叫做 CPU 上下文。
什么是 CPU 上下文切换?
指先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
一般我们说的上下文切换,就是指内核(操作系统的核心)在 CPU 上对进程或者线程进行切换。进程从用户态到内核态的转变,需要通过系统调用来完成。系统调用的过程,会发生 CPU 上下文的切换。
虚拟内存
虚拟内存是一种计算机系统中的内存管理技术,它通过将物理内存(RAM)与磁盘空间结合使用,提供了更大的地址空间供程序使用。
计算机系统中的每个程序都会占用一部分内存,包括代码、数据和运行时堆栈等。虚拟内存通过将这些程序所需的内存空间划分为一系列的虚拟内存页(通常是固定大小的块),然后将这些页映射到物理内存或磁盘上。
当程序需要访问虚拟内存中的某个页时,硬件和操作系统会负责将该页加载到物理内存中。如果物理内存中没有足够的空闲空间,操作系统会将一些"不常用"的页换出到磁盘上,从而为新的页腾出空间。这个过程称为页面置换(Page Replacement)。
虚拟内存的主要优点有:
- 扩展内存:虚拟内存可以将磁盘空间当作额外的内存使用,从而扩展了可供程序使用的地址空间。即使物理内存有限,程序仍然可以运行,并且可以处理比物理内存更大的数据集;
- 内存隔离:每个程序都有自己的虚拟地址空间,彼此之间相互隔离。这样可以提高系统的安全性和稳定性,避免一个程序的错误影响到其他程序或操作系统本身;
- 虚拟内存页的访问权限:通过设置虚拟内存页的访问权限,操作系统可以实现对内存的保护和控制,防止非法访问和损坏数据
虚拟内存也有一些缺点:
- 增加了访问数据的开销:由于需要将数据从磁盘加载到物理内存,访问虚拟内存的数据比直接访问物理内存的数据开销更大;
- 页面置换的开销:当物理内存不足时,需要将一些页从物理内存换出到磁盘,并将需要的页加载到物理内存中,这个过程会引入额外的开销
总而言之,虚拟内存为计算机系统提供了更大的地址空间和内存管理的灵活性,允许多个程序同时运行,并提供了安全性和稳定性的保障。
DMA 技术
DMA(Direct Memory Access)技术是一种用于高效数据传输的计算机技术。它允许外部设备(如磁盘控制器、显卡或网络适配器)直接与系统内存进行数据传输,而无需经过 CPU 的干预。
在传统的 I/O 操作中,涉及数据传输的过程通常是通过 CPU 进行的。当外部设备想要将数据写入内存时,它需首先将数据发送到 CPU,然后 CPU 将数据复制到内存的适当位置。
而 DMA 技术则是通过绕过 CPU,直接在外部设备和内存之间建立数据传输通路,以提高数据传输效率和系统性能。DMA 控制器是这一过程中的重要组件,它具有独立的寄存器和逻辑电路,可配置和管理数据传输。
当外部设备需要进行数据传输时,它会向 DMA 控制器发出请求。DMA 控制器与 CPU 进行协调,并在系统总线空闲时获取总线控制权。然后,DMA 控制器会将数据直接传输到系统内存,而无需 CPU 的干预。一旦数据传输完成,DMA 控制器释放总线控制权,并通知 CPU。
通过使用 DMA 技术,可以实现以下优势:
- 减少 CPU 的负荷:传统上,CPU 需要参与每一个数据传输操作,这会占用 CPU 的时间和资源。而使用 DMA 技术,CPU 可以将数据传输的任务交给 DMA 控制器,从而减轻 CPU 的负荷,使其能够更多地处理其他计算任务;
- 提高数据传输速度:DMA 技术允许外部设备直接与内存进行数据传输,绕过了 CPU 的中间环节,使得数据传输效率更高,提高了系统的整体性能;
- 实现数据的并行传输:由于 DMA 可以独立操作内存,多个 DMA 控制器可以并行传输数据,从而在某些情况下进一步提高数据传输速度。
需要注意的是,使用 DMA 技术进行数据传输需要合适的硬件支持和相应的驱动程序。不是所有的外部设备和系统都支持 DMA。因此,在设计和开发过程中,需要根据具体的硬件和操作系统,考虑是否使用 DMA 以及如何有效地利用 DMA 技术来提高数据传输性能。
没有 DMA 技术的 I/O 过程
- CPU 发出对应的指令给磁盘控制器,然后返回;
- 磁盘控制器在收到指令后,开始准备数据,并把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
- CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区中的数据一次一个字节地读进自己的寄存器里,然后再把寄存器里的数据写入到内存当中,而在数据传输的期间 CPU 是无法执行其他任务的
使用了 DMA 技术的 I/O 过程
- 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程随即进入阻塞状态;
- 操作系统收到请求后,进一步将 I/O 请求发送给 DMA,然后 CPU 继续执行其他任务;
- DMA 进一步将 I/O 请求发送给磁盘;
- 磁盘收到 DMA 发送的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己的缓冲区已满;
- DMA 收到中断信号,将磁盘控制器的缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以继续执行其他任务;
- 当 DMA 读取了足够多的数据时,就会发送中断信号给 CPU;
- CPU 收到 DMA 的中断信号,知道数据已经准备好了,于是将数据从内核拷贝到用户空间,系统调用返回
可以看到, CPU 不再参与「将数据从磁盘控制器的缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为「传输什么数据,从哪里传输到哪里」,都需要 CPU 来告诉 DMA 控制器。
早期的 DMA 只存在于主板上,而如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里都有自己的 DMA 控制器。
传统 I/O 的执行流程
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
这个过程一般会需要用到两个系统调用:
- read(file, tmp_buf, len);
- write(socket, tmp_buf, len);
首先,这一过程共发生了 4 次用户态与内核态的上下文切换(因为发生了两次系统调用,一次是 read() ,一次是 write()),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
而上下文切换的成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝(其中两次是 DMA 的拷贝,另外两次则是 CPU 拷贝):
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核缓冲区里,这个拷贝的过程是由 DMA 完成的;
- 第二次拷贝,把内核缓冲区的数据拷贝到用户缓冲区里,于是应用程序就可以使用这部分数据了,这个拷贝的过程是由 CPU 完成的;
- 第三次拷贝,把刚才拷贝到用户缓冲区里的数据,再拷贝到内核的 socket 缓冲区里,这个过程依然还是由 CPU 完成的;
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 完成的
上述文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
如何减少「用户态与内核态的上下文切换」的次数?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间是没有权限操作磁盘或网卡的,而内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而一次系统调用必然会发生 2 次上下文切换:先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换的次数,就要减少系统调用的次数。
如何减少「内存拷贝」的次数?
如上所述,传统的文件传输方式会发生 4 次数据拷贝,但是「从内核的读缓冲区拷贝到用户缓冲区里,再从用户缓冲区里拷贝到 socket 缓冲区里」这个过程是没有必要的。
因为在文件传输的应用场景中,在用户空间里我们并不会对数据再加工,所以数据实际上可以不用搬运到用户空间,因此用户缓冲区是没有必要存在的。
如何实现零拷贝?
- mmap + write
- sendfile
- 带有 DMA 收集拷贝功能的 sendfile
mmap + write
mmap(memory map)是一种操作系统提供的内存映射机制,它允许应用程序通过在虚拟地址空间中映射文件或设备,将它们当作内存来访问。
通过 mmap,应用程序可以将一个文件或设备映射到它的地址空间中的一段连续虚拟内存。这段内存可以以页为单位进行访问和处理。当应用程序访问这段内存时,操作系统会负责将数据从文件或设备传输到内存中,并在需要时将数据写回到文件或设备。
由于 read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户缓冲区里,为了减少这一步的开销,我们可以用 mmap() 替换 read() 系统调用函数。
这里 mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样操作系统内核与用户空间就不需要再进行任何的数据拷贝操作了。
- 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核缓冲区里。接着,应用进程与操作系统内核「共享」这个缓冲区;
- 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,并由 CPU 来完成;
- 最后,再把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 完成
通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
但是这还不是最理想的零拷贝,因为我们仍然需要通过 CPU 来把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),API如下:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
- out_fd:待写入内容的文件描述符,一个 socket 描述符;
- in_fd:待读出内容的文件描述符,必须是真实的文件,不能是 socket 和管道;
- offset:指定从读入文件的哪个位置开始读,如果为 NULL 表示文件的默认起始位置;
- count:指定在 fdout 和 fdin 之间传输的字节数
sendfile 表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此这样就只有 2 次上下文切换和 3 次数据拷贝。
带有 DMA 收集拷贝功能的 sendfile
Linux 2.4+ 对 sendfile 做了升级优化,引入了 SG-DMA(The Scatter-Gather Direct Memory Access)技术,其实就是对 DMA 拷贝加入了 scatter-gather 操作,它可以直接从内核空间缓冲区中将数据读取到网卡,省去一次 CPU 拷贝。
- 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里。在这一步中,数据从磁盘上被读取,并使用 DMA 技术直接将数据从磁盘传输到系统的内核缓冲区,而无需 CPU 的干预。DMA 允许外设(如磁盘)直接访问系统内存,以实现高效的数据传输;
- 缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里。在这一步中,内核将缓冲区描述符(用于标识和管理数据的数据结构)和数据长度传送到 socket 缓冲区。接着,网卡的 SG-DMA 控制器就可以直接访问内核缓冲区,并将数据从内核缓冲区直接拷贝到网卡的缓冲区中。这个过程中,数据不需要再次从操作系统内核缓冲区拷贝到 socket 缓冲区,极大地减少了数据拷贝的次数,提高了传输效率
在这个过程之中,只进行了 2 次数据拷贝,这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比于传统的文件传输方式,减少了 2 次上下文切换和数据拷贝次数,只需要经过 2 次上下文切换和数据拷贝,就可以完成文件的传输,而且这 2 次数据拷贝的过程都不需要通过 CPU,都是由 DMA 来完成的。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
使用零拷贝技术的例子
事实上,Kafka 就利用了零拷贝技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据时这么快的原因之一。
Kafka 文件传输的代码最终调用了 Java NIO 库里的 transferTo 方法,如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最终就会使用到 sendfile() 系统调用函数。
@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
另外,Nginx 也支持零拷贝技术,一般默认配置是开启了零拷贝,这样有利于提高文件传输的效率。
http {
...
# 设置为on表示, 使用零拷贝技术来传输文件(sendfile), 这样只需要2次上下文切换和2次数据拷贝
# 设置为off表示, 使用传统的文件传输技术(read + write), 这时就需要4次上下文切换和4次数据拷贝
sendfile on;
...
}
什么是 PageCache?
回顾上述的文件传输过程,第一步都是先需要把磁盘文件数据拷贝到「内核缓冲区」里,而这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。由于零拷贝使用了 PageCache 技术,使得零拷贝进一步提升了性能。
因为读写磁盘相比于读写内存的速度实在是慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用「读内存」替换「读磁盘」。
但是,内存空间远比磁盘空间要小,所以内存注定只能拷贝磁盘里的一小部分数据。那么应该选择将哪些磁盘数据拷贝到内存呢?
我们知道程序运行的时候具有「局部性」,即我们认为通常刚被访问的数据在短时间内再次被访问的概率很高,于是可以使用 PageCache 来缓存最近被访问的数据,当空间不足时再淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在 PageCache 中查找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后再缓存在 PageCache 中。
此外,读取磁盘数据的时候,需要找到数据所在的位置,对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。
假设 read 方法每次只会读 32KB 的字节,虽然 read 刚开始只会读 0~32KB 的字节,但内核会把其后面的 32~64KB 的字节也读取到 PageCache 中,这样后面再读取 32~64KB 的字节的成本就很低,如果在 32~64KB 的字节淘汰出 PageCache 前,进程读取到它了,那么收益就非常大。
所以,PageCache 的优点主要是两个,这将大大提高读写磁盘的性能:
- 缓存最近被访问的数据;
- 预读功能
注意,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,因为如果有很多 GB 级别的文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快就会被这些大文件占满,会白白浪费 DMA 多做的一次数据拷贝,造成性能的降低。
另外,由于文件太大,可能某些部分的文件数据会被再次访问的概率比较低,这样就会带来 2 个问题:
- PageCache 由于长时间被大文件占据,其他热点的小文件可能就无法充分使用到 PageCache,磁盘读写的性能就会下降;
- PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却会耗费 DMA 多拷贝一次到 PageCache
所以,针对大文件的传输,不应该使用 PageCache,即不应该使用零拷贝技术。
如何处理大文件传输?
参考最初的 I/O 过程,当调用 read 方法读取文件时,用户进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回。
- 当调用 read 方法时,用户进程会进入阻塞状态,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
- 内核收到 I/O 中断后,就将数据从磁盘控制器的缓冲区拷贝到 PageCache 里;
- 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就可以正常返回了
针对上述问题,我们可以用异步 I/O 来解决。
它将读操作分为了两部分:
- 内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,此时用户进程可以处理其他任务;
- 当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,此时再去处理数据
而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。
绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常对于磁盘而言,异步 I/O 只支持直接 I/O。
如上所述,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致热点小文件无法利用到 PageCache。于是,在高并发的场景下,针对大文件的传输方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
直接 I/O 常见的两种应用场景:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致热点文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O
另外,由于直接 I/O 绕过了 PageCache,所以就无法享受到内核的优化:
- 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后合并成一个更大的 I/O 请求,再发给磁盘,减少磁盘的寻址操作;
- 内核也会预读后续的 I/O 请求放在 PageCache 中,减少对磁盘的操作
综上所述,在传输文件的时候,要根据文件的大小来使用不同的处理方式:
- 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
- 传输小文件的时候,使用「零拷贝技术」
在 Nginx 中,可以通过修改配置文件,来实现根据文件的大小使用不同的方式:
location /video/ {
sendfile on;
aio on;
# 当文件大小大于directio值后, 使用异步I/O+直接I/O, 否则使用零拷贝技术
directio 1024m;
}
参考资料
- https://xiaolincoding.com/os/8_network_system/zero_copy.html#_9-1-%E4%BB%80%E4%B9%88%E6%98%AF%E9%9B%B6%E6%8B%B7%E8%B4%9D
- https://mp.weixin.qq.com/s/qaUZ3AMA_dJkx2ZpyhJN2g