# 【NO.619】一文彻底揭秘linux操作系统之「零拷贝」！

## 1.**- 前言 -**

**零拷贝（Zero-Copy）**是一个大家耳熟能详的概念，那么，具体有哪些框架会使用到零拷贝呢？在思考这个问题之前，让我们先一起探寻一下零拷贝机制的底层原理。

## 2.**- 概念篇 -**

### **2.1 零拷贝是什么？**

"零拷贝"中的"拷贝"是指操作系统在I/O操作中,将数据从一个内存区域复制到另外一个内存区域，而"零"并不是指0次复制, 更多的是指在用户态和内核态之间的复制是0次。

### **2.2 零拷贝给我们带来的好处**

• 减少甚至完全避免不必要的 CPU 拷贝，从而让 CPU 解脱出来去执行其他的任务；

• 减少内存带宽的占用；

• 通常零拷贝技术还能够减少用户空间和操作系统内核空间之间的上下文切换。

### **2.3 操作系统中谁负责IO拷贝？**

DMA 负责内核间的 IO 传输，CPU 负责内核和应用间的 IO 传输。

两种拷贝类型：

（1）CPU COPY

通过计算机的组成原理我们知道, 内存的读写操作是需要 CPU 的协调数据总线,地址总线和控制总线来完成的因此在"拷贝"发生的时候,往往需要 CPU 暂停现有的处理逻辑,来协助内存的读写，这种我们称为 CPU COPY。CPU COPY 不但占用了 CPU 资源,还占用了总线的带宽。

（2）DMA COPY

DMA(DIRECT MEMORY ACCESS) 是现代计算机的重要功能，它有一个重要特点：当需要与外设进行数据交换时, CPU 只需要初始化这个动作便可以继续执行其他指令,剩下的数据传输的动作完全由DMA来完成可以看到 DMA COPY 是可以避免大量的 CPU 中断的。

### **2.4 拷贝过程中会发生什么？**

从内核态到用户态时会发生上下文切换，上下文切换时指由用户态切换到内核态, 以及由内核态切换到用户态。

现在我们对零拷贝有一定的概念基础了，接下来，让我们深入去了解一下 Linux 操作系统与 IO 复制之间的来龙去脉。

## 3.**- 原理篇 -**

### **3.1 内存管理**

Linux 内存管理结构的历史：在 Linux 内核2.4版本之前，内存管理结构中 page cache 和 buffer cache 是分开的，分别是两个独立的。

从 Linux 内核2.4 版本开始操作系统在内存管理机制进行了优化，才支持了零拷贝机制。

### **3.2 Linux内存管理结构**

Linux 内核的文件 Cache 管理机制来进行实现的，在 Linux 的实现中，文件 Cache 分为两个层面，一是 Page Cache，另一个 Buffer Cache，每一个 Page Cache 包含若干 Buffer Cache。

内存管理系统和 VFS 只与 Page Cache 交互，内存管理系统负责维护每项 Page Cache 的分配和回收，同时在使用 memory map 方式访问时负责建立映射。

VFS 负责 Page Cache 与用户空间的数据交换。而具体文件系统则一般只与 Buffer Cache 交互，它们负责在外围存储设备和 Buffer Cache 之间交换数据。

**标注：VFS（virtual File System） 的作用就是采用标准的 Unix 系统调用读写位于不同物理介质上的不同文件系统**，即为各类文件系统提供了一个统一的操作界面和应用编程接口。VFS 是一个可以让open()、read()、write()等系统调用不用关心底层的存储介质和文件系统类型就可以工作的粘合层。

![img](https://pic1.zhimg.com/80/v2-5a58db1de950db53a6c66c37bcf6e25c_720w.webp)

### **3.3 应用上下文与内核上下文共享内存交互过程**

将 Cache 项映射到用户空间，使得应用程序可以像使用内存指针一样访问文件，Memory map 访问 Cache 的方式在内核中是采用请求页面机制实现的：

• 当我们应用程序调用 mmap（下图中1），陷入到内核中后调用 dommappgoff （图中2）该函数从应用程序的地址空间中分配一段区域作为映射的内存地址，并使用一个 VMA（vmareastruct）结构代表该区域，之后就返回到应用程序（图中3）；

• 然后当应用程序访问 mmap 所返回的地址指针时（图中4），由于虚实映射尚未建立，会触发**缺页中断**（图中5）；

• 之后系统会调用缺页中断处理函数（图中6），在缺页中断处理函数中，内核通过相应区域的 VMA 结构判断出该区域属于文件映射，于是调用具体文件系统的接口读入相应的 Page Cache 项（图中7、8、9），并填写相应的虚实映射表；

• 经过这些步骤之后，应用程序就可以正常访问相应的内存区域了。

![img](https://pic2.zhimg.com/80/v2-6e70a0c9e37a3dd908b694578fc1a8f5_720w.webp)

## 4.**- IO 零拷贝 -**

### **4.1存在多次拷贝的原因**

操作系统为了保护系统不被应用程序有意或无意地破坏，为操作系统设置了用户态和内核态两种状态，用户态想要获取系统资源(例如访问硬盘)，必须通过系统调用进入到内核态, 由内核态获取到系统资源，再切换回用户态返回应用程序。

出于 "readahead cache" 和异步写入等等性能优化的需要, 操作系统在内核态中也增加了一个"内核缓冲区"(kernel buffer)。读取数据时并不是直接把数据读取到应用程序的 buffer， 而先读取到 kernel buffer， 再由 kernel buffer 复制到应用程序的 buffer。因此,数据在被应用程序使用之前，可能需要被多次拷贝。

### **4.2 非零拷贝IO流程**

总结所有系统中, 不管是 WEB 应用服务器, FTP 服务器,数据库服务器, 静态文件服务器等等, 所有涉及到数据传输的场景，无非就一种:

——从硬盘上读取文件数据, 发送到网络上去。

这个场景我们简化为一个模型:

File.read(fileDesc, buf, len);

Socket.send(socket, buf, len);

为了方便描述,上面这两行代码, 我们给它起个名字: **read-send模型。**

操作系统在实现这个 read-send 模型时,需要有以下步骤:

1. 应用程序开始读文件的操作；
2. 应用程序发起系统调用, 从用户态切换到内核态(第一次上下文切换)；
3. 内核态中把数据从硬盘文件读取到内核中间缓冲区(kernel buf)；
4. 数据从内核中间缓冲区(kernel buf)复制到(用户态)应用程序缓冲区(app buf),从内核态切换回到用户态(第二次上下文切换)；
5. 应用程序开始发送数据到网络上；
6. 应用程序发起系统调用,从用户态切换到内核态(第三次上下文切换)；
7. 内核中把数据从应用程序(app buf)的缓冲区复制到socket的缓冲区(socket)；
8. 内核中再把数据从socket的缓冲区(socket buf)发送的网卡的缓冲区(NIC buf)上；
9. 从内核态切换回到用户态(第四次上下文切换)。

如下图表示：

![img](https://pic3.zhimg.com/80/v2-4339abe8f90eaf8ccc294907283a43a2_720w.webp)

由上图可以很清晰地看到, 一次 read-send 涉及到了四次拷贝:

1. 硬盘拷贝到内核缓冲区(DMA COPY)；
2. 内核缓冲区拷贝到应用程序缓冲区(CPU COPY)；
3. 应用程序缓冲区拷贝到socket缓冲区(CPU COPY)；
4. socket buf拷贝到网卡的buf(DMA COPY)。

其中涉及到2次 CPU 中断, 还有4次的上下文切换。很明显,第2次和第3次的的 copy 只是把数据复制到 app buffer 又原封不动的复制回来, 为此带来了两次的 CPU COPY 和两次上下文切换, 是完全没有必要的。

Linux 的零拷贝技术就是为了优化掉这两次**不必要的拷贝。**

### **4.3 sendFile 系统调用的IO流程**

**Linux 内核2.1**开始引入一个叫 sendFile 系统调用,这个系统调用可以在内核态内把数据从内核缓冲区直接复制到套接字(SOCKET)缓冲区内, 从而可以减少上下文的切换和不必要数据的复制。

这个系统调用其实就是一个高级 I/O 函数, 函数签名如下:

\#include<sys/sendfile.h>

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

1. out_fd 是写出的文件描述符,而且必须是一个 socket；
2. in_fd 是读取内容的文件描述符,必须是一个真实的文件, 不能是管道或 socket；
3. offset 是开始读的位置；
4. count 是将要读取的字节数。

有了sendFile这个系统调用后, 我们 read-send 模型就可以简化为:

1. 应用程序开始读文件的操作；
2. 应用程序发起系统调用, 从用户态切换到内核态(第一次上下文切换)；
3. 内核态中把数据从硬盘文件读取到内核中间缓冲区；
4. 通过 sendFile,在内核态中把数据从内核缓冲区复制到socket的缓冲区；
5. 内核中再把数据从 socket 的缓冲区发送的网卡的 buf 上；
6. 从内核态切换到用户态(第二次上下文切换)。

如下图所示：

![img](https://pic4.zhimg.com/80/v2-c5b255c1c42e195f32c4afc3cb82a26b_720w.webp)

**涉及到数据拷贝变成:**

1. 硬盘拷贝到内核缓冲区(DMA COPY)；
2. 内核缓冲区拷贝到socket缓冲区(CPU COPY)；
3. socket 缓冲区拷贝到网卡的buf(DMA COPY)。

可以看到,一次 read-send 模型中, 利用 sendFile 系统调用后, 可以将4次数据拷贝减少到3次, 4次上下文切换减少到2次, 2次 CPU 中断减少到1次。

相对传统 I/O, 这种零拷贝技术通过减少两次上下文切换, 1次 CPU COPY, 可以将I/O 性能提高50%以上(网络数据, 未亲测)。

开篇的概念中说到, 所谓的零拷贝的"零", 是指用户态和内核态之间的拷贝次数为0, 从这个定义上来说, 现在的这个零拷贝技术已经是真正的"零"了。

然而, 对性能追求极致的伟大的科学家和工程师们并不满足于此，精益求精的他们对中间第2次的 CPU COPY 依旧耿耿于怀, 想尽千方百计要去掉这一次没有必要的数据拷贝和 CPU 中断。

### **4.4 零拷贝的IO流程**

支持 scatter-gather 特性的 sendFile 的 IO 流程：

**Linux在内核2.4**以后的版本中, Linux 内核对 socket 缓冲区描述符做了优化。通过这次优化, sendFile 系统调用可以在只复制 kernel buffer 的少量元信息的基础上, 把数据直接从 kernel buffer 复制到网卡的 buffer 中去，从而避免了从"内核缓冲区"拷贝到"socket缓冲区"的这一次拷贝。

这个优化后的 sendFile, 我们称之为**支持 scatter-gather 特性的 sendFile。**

在支持 scatter-gather 特性的 sendFile 的支撑下, 我们的 read-send 模型可以优化为:

1. 应用程序开始读文件的操作；
2. 应用程序发起系统调用, 从用户态进入到内核态(第一次上下文切换)；
3. 内核态中把数据从硬盘文件读取到内核中间缓冲区；
4. 内核态中把数据在内核缓冲区的位置(offset)和数据大小(size)两个信息追加(append)到socket的缓冲区中去；
5. 网卡的buf上根据socekt缓冲区的offset和size从内核缓冲区中直接拷贝数据；
6. 从内核态返回到用户态(第二次上下文切换)；

**这个过程如下图所示:**

![img](https://pic1.zhimg.com/80/v2-b72230f321d142066a2bad52c8e0f3c4_720w.webp)

最后数据拷贝变成只有两次 **DMA COPY:**

1. 硬盘拷贝到内核缓冲区(DMA COPY)；
2. 内核缓冲区拷贝到网卡的 buf(DMA COPY)。

原文地址：https://zhuanlan.zhihu.com/p/430848775

作者：linux