操作系统 "零拷贝"

905 阅读8分钟

本文以 Linux 操作系统为例,讲述 "零拷贝" 技术

基础知识

详细介绍请参考 操作系统 虚拟地址空间、用户空间、内核空间、用户态与内核态

用户空间与内核空间

操作系统为每个进程都分配了一块虚拟内存,这块内存空间被称为虚拟地址空间,其中又分为用户空间内核空间两大块

  • 内核空间 : 内核运行和使用的内存空间
  • 用户空间 :非内核程序运行和使用的内存空间,我们写的程序都是运行在此处

用户态与内核态

简单理解

  • 进程在用户空间运行时,被称作用户态
  • 进程在内核空间运行时,被称作内核态

用户态和内核态之间的上下文切换

有时候运行在用户态的进程调用了系统函数,进程就会有用户态切换成内核态,系统函数返回时,又从内核态切换成用户态

而这种上下文切换是非常消耗性能的,而如何减少上下文切换,也是"零拷贝"非常重要的一个优化点

传统 IO

"零拷贝"不是一蹴而就的,而是一步一步演变过来的,我们从传统 IO 讲起,一步一步演变"零拷贝"

对应传统 IO ,Linux 底层实际上通过调用 read()write() 来实现。

比如我们要将磁盘上的一个文件通过网卡发送出去,这个过程如下

image.png

  1. 用户空间的进程发起 read() 系统调用,此时上下文从用户态切换到内核态

  2. CPU 将数据从硬盘中拷贝到内核空间读缓冲区

  3. CPU读缓冲区的数据拷贝到用户缓冲区read()函数返回,上下文从内核态切换到用户态

  4. 用户空间的进程发起 write() 系统调用,上下文从用户态切换到内核态

  5. CPU用户缓冲区中数据拷贝到 socket 缓冲区

  6. CPUsocket 缓冲区的数据拷贝到网卡,write() 返回,上下文从内核态切换回用户态

性能分析

IO 的全过程,前三次为 O,从硬盘中读取数据,后三次为 I ,向网卡发送数据

在读取数据的过程中,发生了两次 CPU 拷贝,两次上行文切换

在发送(写)数据的过程中,也发生了两次 CPU 拷贝,两次上行文切换

一共发送了发生了 4 次 CPU 拷贝,4 次上行文切换,而数据拷贝上行文切换都是非常消耗性能的,特别是 CPU 拷贝,身为处理器却做着拷贝的活儿

后来,人们也意识到了这个问题,于是将一部分拷贝工作从 CPU 中抽出来,单独丢给 DMA 去做,那么什么是 DMA 呢?

DMA

DMA全称Direct Memory Access(直接内存访问),其实就是一块芯片,专门用来拷贝数据用的,现在的各种电子设备中,都有 DMA 芯片,比如硬盘、网卡、显卡、声卡等

从此 CPU 读写这些设备就不需要亲自动手了,由 DMA 代为完成

还是以上面的场景为例,整个过程变成下面的样子

image.png

和上面相比,节约了两次 CPU 拷贝,节省的时间 CPU 可以去做其他的事情

仔细观察上面的图你会发现,CPU读缓冲区的数据拷贝到用户缓冲区,又将将用户缓冲区中数据拷贝到 socket 缓冲区,这不是神经病吗,直接将读缓冲区中数据拷贝到 socket 缓冲区不行吗?

不行,因为这是两个过程,假如我只读取硬盘中的数据,不写入网卡,CPU 还是要将读缓冲区的数据拷贝到用户缓冲区

CPU 之所以要将读缓冲区的数据拷贝到用户缓冲区,是因为用户空间无法直接访问内核空间

但为了进一步减少 CPU 拷贝次数Linux 破例,使用 mmap 技术,让用户空间可以直接访问到内核空间读缓冲区(其他区域还是无法访问的)

mmap

mmapmemory mapping(内存映射),在 Linux 中主要是将内核空间读缓冲区的地址用户空间用户缓冲区的地址进行映射,让用户空间可以直接访问到内核空间读缓冲区,从而减少了一次 CPU 拷贝。

mmap() + write()

Linuxmmap 机制提供了实现函数 mmap() 用来替代 read(), 和 write() 一起实现新的 IO

还是以上面的场景为例,整个IO流程如下 image.png

注意:使用了 mmap用户缓冲区中其实是没有数据的,数据是在读缓冲区,所以调用 write()之后,CPU 看似是从用户缓冲区拷贝数据到socket 缓冲区,实际则是从读缓冲区拷贝数据到socket 缓冲区,也就是下面这张图

image.png

也有人将 "零拷贝" 解释为,较少的 CPU 拷贝次数,而称 mmap() + write() 是 "零拷贝"的一种实现

现在整个IO过程只有一次 CPU 拷贝了,但是还有 4 次上下文切换,而只要是进行系统调用,必定是有 2 次上下文切换的,要想减少上下文切换,只能让 Linux 提供一种系统调用,就能完成 I、O 两种操作

这个系统调用在 Linux 叫做 sendfile()

sendfile()

Linux 内核 2.1 版本 中,提供了一个专门用来发送文件的系统函数 sendfile(),源码如下:

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它有4个参数

  • int out_fd :目的端的文件描述符
  • int in_fd :源端的文件描述符
  • *off_t offset :源端的偏移量
  • size_t count : 复制数据的长度

返回值为实际复制数据的长度

有了 sendfile() 之后,发送数据不在需要 I、O 这两步操作了,直接一个系统调用就搞定,节省了一次系统调用,两次上下文切换

还是以上面的场景为例,sendfile() 流程如下

image.png

可以看到 sendfile()mmap() + write() 一样,也是只有一次 CPU拷贝,但这里 sendfile()并没有使用 mmap 技术,那它是怎么做到不用将读缓冲区的数据拷贝到用户缓冲区的呢?

答案是,不需要拷贝!mmap() read() 为什么要映射或者拷贝,因为他们都是 IO 中的 I,用户空间的进程需要读到数据,而 sendfile() 是发送数据,所以不需要拷贝到用户缓冲区

说白了,没有这个需求,自然不需要多此一举。但这也意味着,你无法对发送的数据做一些操作,比如修改、压缩等

因此,sendfile() 虽然只有一次CPU拷贝和两次上下文切换,但它只适用于发送数据且不需要修改的场景,并不是对 IO 的替代,更多的是一种补充关系

DMA Scatter/Gather

sendfile() 有一次CPU拷贝和两次 DMA 拷贝,能不能进一步优化将这一次CPU拷贝,也交给 DMA 处理

Linux 2.4 内核版本sendfile() 做了进一步优化,引入了 DMA Scatter/Gather 的支持

Scatter/Gather(分散 / 收集)DMA 的"新技术",并不是所有的 DMA 芯片都支持,虽说是"新技术",但距今也有20多年了,现代大部分DMA 芯片都支持该技术。

你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

它将读缓冲区中的数据描述信息 (文件描述符、内存地址、偏移量)记录到 socket 缓冲区,由 DMA 根据这些信息将数据从读缓冲区直接拷贝到网卡

Linux 内核 2.4 版本开始起,对于支持支持 DMA Scatter/Gather 技术的硬件, sendfile() 系统调用的过程发生些点变化,还是以上面的场景为例,流程如下:

image.png

CPU 将读缓存区中数据的 文件描述符、内存地址、偏移量写到 socket 缓冲区,注意,这一步虽然也是 CPU 操作的,但我的用词是而不是拷贝,只有大数据量的移动、会影响性能的操作才叫拷贝!

之后,网卡根据socket 缓冲区文件描述符、内存地址、偏移量这些信息使用 DMA Scatter/Gather 技术,将数据从读缓冲区拷贝到网卡,所以实际拷贝流程如下

image.png

使用了 DMA Scatter/Gather 技术的 sendfile() 方法,只有一次系统调用,而且没有发生 CPU 拷贝,这就是 Linux 系统中所谓的 "零拷贝"技术

splice

sendfile() 只适用于将文件数据拷贝到 socket 描述符上,而且想要实现“零拷贝”还需要 DMA 支持 Scatter/Gather 技术,使用场景比较受限

Linux 内核 2.6 版本,引入 splice() 系统调用,它不需要DMA Scatter/Gather支持,并且不再限定于 socket 上,实现两个文件描述符之间数据的"零拷贝"

拷贝流程如下

image.png

splice() 系统调用可以在内核缓冲区socket 缓冲区之间建立管道来传输数据,避免了两者之间的 CPU 拷贝操作。但 splice() 也有一些局限,它的两个文件描述符参数中有一个必须是管道设备。