原文: https://strikefreedom.top/archives/linux-io-stack-and-zero-copy

image.png

计算机存储设备

RAM

TLB 快表

页表一般是保存内存中的一块固定的存储区,导致进程通过 MMU 访问内存比直接访问内存多了一次内存访问,性能至少下降一半。TLB 可以简单地理解成页表的高速缓存,保存了最高频被访问的页表项,由于一般是硬件实现的,因此速度极快。

Zero-copy 优化

Zero-copy 的实现技术按照其核心思想可以归纳成大致的一下三类:

内核内数据拷贝

考虑把本地文件发送到互联网的流程

通用 I/O 流程

image.png

  1. 用户进程调用 read() 系统调用,从用户态陷入内核态;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区,同时对 CPU 发出一个中断信号;
  3. CPU 收到中断信号之后启动中断程序把数据从内核空间的缓冲区复制到用户空间的缓冲区;
  4. read() 系统调用返回,上下文从内核态切换回用户态;
  5. 用户进程调用 write() 系统调用,再一次从用户态陷入内核态;
  6. CPU 将用户空间的缓冲区上的数据复制到内核空间的缓冲区,同时触发 DMA 控制器;
  7. DMA 将内核空间的缓冲区上的数据复制到网卡;
  8. write() 返回,上下文从内核态切换回用户态。

一共触发了 4 次用户态和内核态的上下文切换,分别是 read() / write() 调用和返回时的切换,2 次 DMA 拷贝,2 次 CPU 拷贝,加起来一共 4 次拷贝操作。

mmap () 替换 read ()

image.png

  1. 用户进程调用 mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  3. mmap() 返回,上下文从内核态切换回用户态;
  4. 用户进程调用 write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;
  5. CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
  6. DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  7. write() 返回,上下文从内核态切换回用户态。

优点:

缺点:

sendfile() 系统调用

image.png

  1. 用户进程调用 sendfile() 从用户态陷入内核态;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  3. CPU 将内核缓冲区中的数据拷贝到套接字缓冲区;
  4. DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  5. sendfile() 返回,上下文从内核态切换回用户态。

基于 sendfile(),整个数据传输过程中共发生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和 mmap() + write() 相同,但是因为 sendfile() 只是一次系统调用,因此比前者少了一次用户态和内核态的上下文切换开销。

sendfile() With DMA Scatter/Gather Copy

image.png

  1. 用户进程调用 sendfile(),从用户态陷入内核态;
  2. DMA 控制器使用 scatter 功能把数据从硬盘拷贝到内核缓冲区进行离散存储;
  3. CPU 把包含内存地址和数据长度的缓冲区描述符拷贝到套接字缓冲区,DMA 控制器能够根据这些信息生成网络包数据分组的报头和报尾
  4. DMA 控制器根据缓冲区描述符里的内存地址和数据大小,使用 scatter-gather 功能开始从内核缓冲区收集离散的数据并组包,最后直接把网络包数据拷贝到网卡完成数据传输;
  5. sendfile() 返回,上下文从内核态切换回用户态。

优点:

缺点:

splice()

splice() 是基于 Linux 的管道缓冲区 (pipe buffer) 机制实现的,所以 splice() 的两个入参文件描述符才要求必须有一个是管道设备

image.png

  1. 用户进程调用 pipe(),从用户态陷入内核态,创建匿名单向管道,pipe() 返回,上下文从内核态切换回用户态;
  2. 用户进程调用 splice(),从用户态陷入内核态;
  3. DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端"拷贝"进管道,splice() 返回,上下文从内核态回到用户态;
  4. 用户进程再次调用 splice(),从用户态陷入内核态;
  5. 内核把数据从管道的读取端"拷贝"到套接字缓冲区,DMA 控制器将数据从套接字缓冲区拷贝到网卡;
  6. splice() 返回,上下文从内核态切换回用户态。

splice() 是基于 pipe buffer 实现的,但是它在通过管道传输数据的时候却是零拷贝,因为它在写入读出时并没有使用 pipe_write() / pipe_read() 真正地在管道缓冲区写入读出数据,而是通过把数据在内存缓冲区中的物理内存页框指针、偏移量和长度赋值给前文提及的 pipe_buffer 中对应的三个字段来完成数据的"拷贝",也就是其实只拷贝了数据的内存地址等元信息。splice()  所谓的写入数据到管道其实并没有真正地拷贝数据,而是玩了个 tricky 的操作:只进行内存地址指针的拷贝而不真正去拷贝数据。

copy_file_range() & FICLONE & FICLONERANGE

image.png

这个系统调用支持拷贝文件的某个范围,可以指定要拷贝到目标文件的源文件的偏移量和长度。前文提及的另一个系统调用 sendfile() 也支持类似的功能。与 sendfile() 一样,copy_file_range() 也是一种内核内 (in-kernel) 的复制,数据拷贝的过程中也同样不需要跨越内核态和用户态的边界,因此也是一种零拷贝技术。

send() With MSG_ZEROCOPY

绕过内核的直接 I/O

image.png

这种方案有两种实现方式:

  1. 用户直接访问硬件
    1. 不安全
    2. 用户空间的数据缓冲区内存页必须进行 page pinning (页锁定)
  2. 内核控制访问硬件

内核态和用户态之间的传输优化

  1. 动态重映射与写时拷贝 (Copy-on-Write)
    1. mmap 用户进程对于共享缓冲区进行同步阻塞读写才能避免 data race 的问题,COW 可以实现异步对共享缓冲区进行读写
    2. CoW 这种零拷贝技术比较适用于那种多读少写从而使得 CoW 事件发生较少的场景,因为 CoW 事件所带来的系统开销要远远高于一次 CPU 拷贝所产生的
  2. 缓冲区共享 (Buffer Sharing)
    为了实现这种传统的 I/O 模式,Linux 必须要在每一个 I/O 操作时都进行内存虚拟映射和解除。这种内存页重映射的机制的效率严重受限于缓存体系结构、MMU 地址转换速度和 TLB 命中率。如果能够避免处理 I/O 请求的虚拟地址转换和 TLB 刷新所带来的开销,则有可能极大地提升 I/O 性能。而缓冲区共享就是用来解决上述问题的一种技术。
    统的 Linux I/O 接口是通过把数据在用户缓冲区和内核缓冲区之间进行拷贝传输来完成的,这种数据传输过程中需要进行大量的数据拷贝,同时由于虚拟内存技术的存在,I/O 过程中还需要频繁地通过 MMU 进行虚拟内存地址到物理内存地址的转换,高速缓存的汰换以及 TLB 的刷新,这些操作均会导致性能的损耗。而如果利用 fbufs 框架来实现数据传输的话,首先可以把 buffers 都缓存到 pool 里循环利用,而不需要每次都去重新分配,而且缓存下来的不止有 buffers 本身,而且还会把虚拟内存地址到物理内存地址的映射关系也缓存下来,也就可以避免每次都进行地址转换,从发送接收数据的层面来说,用户进程和 I/O 子系统比如设备驱动程序、网卡等可以直接传输整个缓冲区本身而不是其中的数据内容,也可以理解成是传输内存地址指针,这样就就避免了大量的数据内容拷贝:用户进程/ IO 子系统通过发送一个个的 fbuf 写出数据到内核而非直接传递数据内容,相对应的,用户进程/ IO 子系统通过接收一个个的 fbuf 而从内核读入数据,这样就能减少传统的 read()/write() 系统调用带来的数据拷贝开销:
    image.png
  3. 发送方用户进程调用 uf_allocate 从自己的 buffer pool 获取一个 fbuf 缓冲区,往其中填充内容之后调用 uf_write 向内核区发送指向 fbuf 的文件描述符;
  4. I/O 子系统接收到 fbuf 之后,调用 uf_allocb 从接收方用户进程的 buffer pool 获取一个 fubf 并用接收到的数据进行填充,然后向用户区发送指向 fbuf 的文件描述符;
  5. 接收方用户进程调用 uf_get 接收到 fbuf,读取数据进行处理,完成之后调用 uf_deallocate 把 fbuf 放回自己的 buffer pool。