首页 游戏天地文章正文

打破认知:Linux管道到底有多快?

游戏天地 2025年08月22日 19:55 1 admin

前言:

咱们今天要聊的这个玩意儿可太硬核了!

有个大神写了个示例程序,展示了Linux管道读写性能的优化过程,硬是把Linux管道读写速度从3.5GiB/s直接飙到65GiB/s。

虽然示例精炼,但涉及零拷贝、环形缓冲区、分页与虚拟内存、同步开销等关键技术点。尤其深入剖析了Linux内核中拼接(splice)、分页及虚拟内存地址映射的实现源码。由浅入深,从概念到代码层层递进,虽聚焦管道优化,其方法论和分析深度对开发高性能应用或Linux内核的人员极具参考价值。

以下内容来自大神文章翻译:

本文通过迭代优化一个管道读写测试程序,深入探究 Linux 中 Unix 管道的实现机制。我们从吞吐量约 3.5GiB/s 的初始版本出发,逐步将其性能提升 20 倍。每次优化的效果均通过 Linux perf 工具分析确认,完整代码可在 GitHub 获取。(https://github.com/bitonic/pipes-speed-test)

打破认知:Linux管道到底有多快?

这项研究的灵感源于一个高度优化的 FizzBuzz 程序,该程序在我的设备上通过管道输出可达约 35GiB/s。我们的首要目标是达到同等性能,并详细阐述每一步优化。此后,我们将实施 FizzBuzz 未采用的额外改进(因其瓶颈在于计算而非 IO),进一步提升至 65GiB/s。优化步骤如下:

  • 基准测试:实现一个初始的低性能管道基准程序。
  • 管道机制分析:阐述管道内部实现及其读写性能瓶颈的原因。
  • 绕过内核拷贝:利用 vmsplice 和 splice 系统调用规避部分瓶颈。
  • 高效内存管理:(内核关键环节) 分析 Linux 分页机制,并采用 huge pages 实现显著加速。
  • 优化等待策略:用忙循环替代轮询进行最终提速。

核心: 第 4 步涉及 Linux 内核的核心机制,即使熟悉其他主题的读者也能从中获益。对于背景知识有限的读者,我们仅预设基础的 C 语言能力,力求由浅入深地讲解。

Part1挑战第一个“慢”版本

我们先依照 StackOverflow 的发帖规则,对传说中的 FizzBuzz 程序性能进行测试:

% ./fizzbuzz | pv >/dev/null 422GiB 0:00:16 [36.2GiB/s]

这里的 pv(Pipe Viewer)是一个用于实时测量管道中数据流动速率的小工具。结果显示,fizzbuzz 程序以高达 36.2 GiB/s 的速度向管道输出数据。这显然不是在做常规意义上的 FizzBuzz 计算,而是在测试 I/O 极限。

实际上,该程序将输出缓冲区设置为与其 CPU 二级缓存大小相匹配——256 KiB,以此在内存访问效率和 I/O 开销之间取得良好平衡。本文也将采用相同的 256 KiB 缓冲块进行输出,但不做任何实际计算。我们的目标是:测量程序通过合理缓冲向管道写入数据时的理论吞吐上限

不过,我们将采用一种更可控的方式:在管道两端分别运行独立的程序,从而完全掌控数据推送与拉取的全过程。相关代码已发布在 pipes-speed-test 仓库中。(https://github.com/bitonic/pipes-speed-test)

write.cpp:负责持续向管道写入数据

read.cpp:从管道读取数据,累计达 10 GiB 后退出,并打印吞吐速率(单位:GiB/s)

两个可执行文件均支持多种命令行参数,可用于调整行为模式。

我们首先使用最基础的方式进行测试:通过 write() 和 read() 系统调用,配合与 fizzbuzz 相同的 256 KiB 缓冲区大小。以下是写入端的核心实现代码:

int main() {  size_t buf_size = 1 << 18; // 256KiB  char* buf = (char*) malloc(buf_size);  memset((void*)buf, 'X', buf_size); // output Xs  while (true) {    size_t remaining = buf_size;    while (remaining > 0) {      // Keep invoking `write` until we've written the entirety      // of the buffer. Remember that write returns how much      // it could write into the destination -- in this case,      // our pipe.      ssize_t written = write(        STDOUT_FILENO, buf + (buf_size - remaining), remaining      );      remaining -= written;    }  }}

memset 的作用不仅是确保输出内容可打印(全是 'X'),还隐含了内存初始化的副作用,这一点我们将在后续讨论。

打破认知:Linux管道到底有多快?

专注 大厂技术栈学习路线、项目教程、简历模板、大厂面试题pdf文档、大厂面经、编程交流圈子等等。

所有数据写入操作均由 write() 系统调用完成,外层循环确保整个缓冲区被完整写入——因为 write() 并不能保证一次性写入全部请求的数据量。

读取端的逻辑与此对称:使用 read() 不断从标准输入读取数据,直到累计接收 10 GiB 数据后终止。

编译完成后,运行如下命令进行测试:

% ./write | ./read3.7GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

我们总共重复写入了 40960 次 256 KiB 的数据块,累计传输 10 GiB,最终测得吞吐量为 3.7 GiB/s

这结果令人困惑:我们只是在反复写入固定内存块,没有进行任何复杂计算,为什么速度比 fizzbuzz 示例慢了近 10 倍?

事实表明,仅依靠传统的 write() 和 read() 系统调用,即便使用最优缓冲策略,我们也很难突破这一性能瓶颈。这意味着,要进一步提升性能,必须寻找更高效的 I/O 机制。

Part2write()的性能瓶颈

为了分析程序运行时的时间消耗分布,我们可以借助 Linux 下强大的性能分析工具 perf:

% perf record -g sh -c './write | ./read'3.2GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)[ perf record: Woken up 6 times to write data ][ perf record: Captured and wrote 2.851 MB perf.data (21201 samples) ]

其中,-g 选项用于启用调用图(call graph)记录功能,这使我们能够追踪函数调用栈,从而自顶向下地查看 CPU 时间主要消耗在哪些内核或用户态函数上。

记录完成后,使用 perf report 查看性能数据。以下是一个经过适当裁剪和整理的输出片段,聚焦于 write 相关的调用路径:

% perf report -g --symbol-filter=write-   48.05%     0.05%  write    libc-2.33.so       [.] __GI___libc_write   - 48.04% __GI___libc_write      - 47.69% entry_SYSCALL_64_after_hwframe         - do_syscall_64            - 47.54% ksys_write               - 47.40% vfs_write                  - 47.23% new_sync_write                     - pipe_write                        + 24.08% copy_page_from_iter                        + 11.76% __alloc_pages                        + 4.32% schedule                        + 2.98% __wake_up_common_lock                          0.95% _raw_spin_lock_irq                          0.74% alloc_pages                          0.66% prepare_to_wait_event

从结果可以看出,将近 48% 的 CPU 时间消耗在 __GI___libc_write 及其后续系统调用路径中。考虑到我们在管道两端对称运行读写程序,读端也应消耗类似时间,因此写入操作几乎占据了总时间的一半,符合预期。

进一步深入调用栈,我们发现 pipe_write 占据了写入路径中 47.23% 的时间。而在 pipe_write 内部,最主要的开销来自两个关键操作:

24.08%的时间用于 copy_page_from_iter:即将用户空间缓冲区的数据复制到管道的内核页中;

11.76% 的时间花费在 __alloc_pages:即为管道分配新的物理内存页。

这两项合计占用了 pipe_write 中超过 75% 的时间,说明主要瓶颈在于 数据复制内存页分配

如果你熟悉用户空间与内核空间之间数据交互的机制,这个结果并不意外。每次调用 write() 向管道写入数据时,内核都需要将用户缓冲区的内容拷贝到管道内部的环形缓冲区(由一组 page 构成),以确保数据安全且可被读取端访问。而频繁的页分配和跨地址空间的数据复制,正是传统 write() 系统调用效率低下的根本原因。

要真正理解这些开销的来源,我们必须先深入了解管道(pipe)在内核中的工作原理——它并非简单的内存队列,而是一套涉及页管理、同步机制和跨进程通信的复杂子系统。

Part3管道是由什么构成的?

要深入理解 write() 系统调用为何存在性能瓶颈,我们必须探究管道在内核中的真实构造。其核心数据结构定义位于 Linux 内核源码的 include/linux/pipe_fs_i.h,而相关操作实现在 fs/pipe.c 中。

Linux 中的管道本质上是一个环形缓冲区(circular buffer),用于在进程间传递数据。它并不直接存储字节流,而是保存对物理内存页(page)的引用,并通过一组缓冲区槽位管理这些页的读写位置。

打破认知:Linux管道到底有多快?

如上图所示,该环形缓冲区包含 8 个槽位(实际默认为 16 个),每个槽位对应一个 pipe_buffer 结构,指向一个大小为 4KiB 的页面(x86-64 架构下;其他架构可能不同)。因此,这样一个默认配置的管道最多可缓存 16 × 4KiB =64KiB 数据。

注意:这是一个关键点——每个管道都有容量上限,即在写入阻塞前能容纳的最大数据量。一旦缓冲区满,后续 write() 调用将被挂起,直到读取端消费部分数据腾出空间。

图中阴影部分表示当前已写入数据的缓冲区,空白或 NULL 槽位则代表未使用空间。环形结构允许 head 和 tail 指针循环前进,实现高效的 FIFO 数据流动。

  • head 指向写入端:写入进程将数据写入 head 所指的缓冲区。若当前缓冲区已满,则 head 移动到下一个槽位,并可能触发新页面的分配。
  • tail 指向读取端:读取进程从 tail 所指缓冲区开始读取,offset 字段指示本次读取的起始偏移。读完后更新 offset 和 len,当缓冲区数据耗尽时,tail 前移。

值得注意的是,tail 可以位于 head 之后——这正是环形缓冲区的特性所在。例如当 head 已绕回缓冲区起始位置,而 tail 尚未追上时,就会出现这种情况。

此外,并非所有槽位都必须被使用。当管道未满时,部分槽位会保持 NULL 状态,表示空闲。只有当所有槽位都被有效页面填充时,管道才算“满”,此时写操作将阻塞;反之,若所有槽位均为 NULL,则管道为空,读操作将阻塞。

下面是 pipe_fs_i.h 中关键数据结构的简化版本(省略了部分字段):

struct pipe_inode_info {  unsigned int head;  unsigned int tail;  struct pipe_buffer *bufs;};struct pipe_buffer {  struct page *page;  unsigned int offset, len;};

其中:

  • pipe_inode_info 是管道的核心控制结构,维护 head 和 tail 指针,以及指向缓冲区数组 bufs 的指针;
  • pipe_buffer描述一个缓冲槽位,包含指向物理页的指针 page,以及数据在页内的偏移 offset 和长度 len。

虽然我们尚未展开 struct page 的具体内容(它代表内核中的物理内存页,包含引用计数、映射信息等),但上述结构已足以帮助我们理解管道读写的基本机制。

正是这种基于页引用和环形缓冲的设计,决定了管道 I/O 的性能特征:每一次 write() 调用都需要将用户数据复制到内核页,并可能触发页面分配;而 read() 则需反向复制数据回用户空间——这些拷贝操作正是前文 perf 分析中高开销的根源。

理解了管道的内部构造后,我们就能更有针对性地优化数据传输路径,例如通过减少甚至避免用户态与内核态之间的数据复制来提升吞吐效率。

Part4管道的读写机制

现在我们回到 pipe_write 的实现,结合前文 perf 工具的性能分析结果,深入理解其内部行为。

pipe_write 的工作流程可以概括为以下几个关键步骤:

  • 检查管道容量:如果管道已满(即所有缓冲槽位均被占用且无空闲空间),写入进程将进入等待状态,直到读取端消费数据并释放槽位,随后重新尝试写入;
  • 填充当前缓冲区:若 head 指针当前指向的 pipe_buffer 中仍有剩余空间,则优先将数据写入该区域;
  • 分配新页面:当当前缓冲区已满且仍有待写入的数据时,系统会为新的槽位分配一个物理内存页(struct page),将数据复制进去,并更新 head 指针以指向下一个可用位置。
打破认知:Linux管道到底有多快?

整个写入过程由一个**管道锁(pipe lock)**保护,以确保多线程或并发访问时的数据一致性。pipe_write 会在必要时获取和释放该锁,从而实现对环形缓冲区的安全访问。

与之对应的是 pipe_read,它的行为是 pipe_write 的“镜像”:

  • 它从 tail 指向的缓冲区中读取数据;
  • 当某个 pipe_buffer 中的数据被完全消费后,其所引用的页面会被释放(或归还到内存管理系统);
  • 随后 tail 指针前移,指向下一个待读取的槽位。

这种设计虽然逻辑清晰,但在高性能场景下暴露出一系列性能瓶颈,形成了一个极其不利的运行状况

  • 双重数据复制:每个数据块都要经历两次内存拷贝——第一次从用户空间复制到内核页(写入时),第二次再从内核页复制回用户空间(读取时);
  • 小粒度操作:每次仅处理 4KiB 的页面,导致频繁的系统调用和上下文切换;
  • 内存不连续性:由于每次写入都可能触发新的页面分配,这些页面在物理内存中并不连续,影响缓存局部性和预取效率;
  • 锁竞争开销:每次读写操作都需要获取和释放管道锁,增加了同步成本,尤其在高吞吐场景下成为显著瓶颈;
  • 动态内存管理开销:持续的页面分配(__alloc_pages)和释放进一步加重了内核负担。

为了更直观地评估当前性能表现,我们可以参考本地机器上的顺序内存读取速度作为基准:

% sysbench memory --memory-block-size=1G --memory-oper=read --threads=1 run...102400.00 MiB transferred (15921.22 MiB/sec)

即约 15.9 GiB/s 的纯内存读取带宽(单线程)。而我们当前的管道吞吐量仅为 3.7 GiB/s,还不到内存带宽的 1/4。

考虑到上述种种开销——数据复制、锁争用、页面分配、非连续内存访问——这样的性能差距也就不足为奇了。

更关键的是,单纯调整缓冲区大小或管道槽位数量,无法从根本上解决这些问题。虽然可以略微减少系统调用次数或同步频率,但核心瓶颈仍在于用户态与内核态之间的频繁数据搬运和内存管理开销。

幸运的是,Linux 提供了一种机制,能够绕过传统 write/read 路径中的大部分拷贝和分配操作——我们将在下一节中介绍如何利用 vmsplice() 等高级接口,实现近乎零拷贝的高效管道传输。

Part5用拼接进行改进

在高性能 I/O 场景中,一个长期存在的痛点是:

数据在用户空间与内核空间之间反复复制——先从用户内存复制到内核缓冲区,再从内核复制回用户空间。这种“双重搬运”不仅浪费 CPU 资源,也成为吞吐量的瓶颈。

一种常见的优化思路是绕过内核的中间处理环节,直接进行数据传输。例如,在低延迟网络编程中,可以通过 DPDK 等技术直接与网卡交互,完全避开传统内核协议栈。

通常情况下,当我们向套接字、文件或管道写入数据时,流程是:先将数据拷贝至内核管理的缓冲区,再由内核负责后续的传递。

以管道为例,其本质就是一组位于内核中的环形缓冲区。然而,对于追求极致性能的应用来说,这些额外的数据复制步骤完全是不必要的开销。

幸运的是,Linux 提供了专门的系统调用,能够在不进行数据复制的前提下高效地在管道与其他文件描述符之间移动数据。其中最关键的两个是:

  • splice():用于在两个文件描述符之间移动数据,至少一个是管道。它可以将数据从管道“推送”到文件描述符,或反向“拉取”。
  • vmsplice():将用户空间的内存直接“注入”到管道中,避免复制操作。

这两个系统调用的核心优势在于:它们不复制数据内容本身,而是通过操作页面引用的方式,实现零拷贝(zero-copy)数据传输

现在我们已经了解了管道的内部结构——它本质上是一个由 struct pipe_buffer 构成的环形缓冲区,每个条目指向一个物理内存页(struct page)。基于这一机制,就可以理解 vmsplice 和 splice 是如何工作的:

它们并不像传统 write 那样为每次写入分配新页面并复制数据,而是直接“接管”已存在的内存页(例如用户空间映射的页),然后将其作为缓冲区插入到管道的环形结构中;或者相反,从管道中“摘下”一个页引用,传递给目标文件描述符。

换句话说,这些系统调用实现了缓冲区的“移动”而非“复制”,从而大幅减少内存带宽消耗和 CPU 开销。

打破认知:Linux管道到底有多快?

接下来,我们将通过实际代码演示如何使用 vmsplice 来重构写入端,彻底规避传统 write 带来的性能瓶颈,并观察吞吐量的显著提升。

Part6Splicing 实现

为了突破传统 write() 系统调用带来的性能瓶颈,我们将其替换为更高效的 vmsplice() 系统调用。

vmsplice() 的函数签名如下:

struct iovec {  void  *iov_base; // 起始地址  size_t iov_len;  // 字节数};// 返回成功“拼接”到管道的数据量ssize_t vmsplice(  int fd, const struct iovec *iov, size_t nr_segs, unsigned int flags);

其中:

  • fd是目标管道(必须是管道的写端);
  • iov是一个或多个用户空间缓冲区的描述数组;
  • nr_segs指定 iov 数组中段的数量;
  • flags可用于控制行为(如是否阻塞)。

该调用返回值表示成功“拼接”到管道中的字节数——这一点与 write() 类似,并不保证一次性将全部数据写入,因为管道的环形缓冲区有容量限制。

使用 vmsplice() 时需要特别注意:它不会复制用户内存中的数据,而是直接将用户空间的页面“映射”或“附加”到管道的 pipe_buffer 中

这意味着,一旦缓冲区被 vmsplice 提交到管道,在确认读取端已经消费该数据之前,绝不能重写或释放该缓冲区,否则会导致数据竞争或读取错误。

为了解决这个问题,fizzbuzz 采用了经典的双缓冲(double buffering)机制,其工作原理如下:

  1. 将原本 256 KiB 的用户缓冲区等分为两个 128 KiB 的子缓冲区
  2. 将管道的环形缓冲区大小设置为 128 KiB(即 32 个 4 KiB 槽位);
  3. 交替使用两个子缓冲区:当一个被 vmsplice 提交到管道的同时,另一个可安全用于准备下一批数据。

这种设计的关键在于:只有当前一个 128 KiB 缓冲区被完全读取并释放后,管道才有足够的空闲槽位来容纳下一个 128 KiB 的 vmsplice 操作。因此,每次成功完成一个 128 KiB 的 vmsplice,就隐式保证了前一个缓冲区已被消费完毕。

虽然我们当前的测试程序并未真正“生成”数据(只是填充 'X'),但保留双缓冲方案是必要的——任何实际需要高性能输出的应用(如日志系统、流处理器等)都必须采用类似的机制来避免数据覆盖问题。

更新后的写入循环如下所示:

int main() {  size_t buf_size = 1 << 18; // 256KiB  char* buf = malloc(buf_size);  memset((void*)buf, 'X', buf_size); // output Xs  char* bufs[2] = { buf, buf + buf_size/2 };  int buf_ix = 0;  // Flip between the two buffers, splicing until we're done.  while (true) {    struct iovec bufvec = {      .iov_base = bufs[buf_ix],      .iov_len = buf_size/2    };    buf_ix = (buf_ix + 1) % 2;    while (bufvec.iov_len > 0) {      ssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, 0);      bufvec.iov_base = (void*) (((char*) bufvec.iov_base) + ret);      bufvec.iov_len -= ret;    }  }}

该循环交替使用两个 128 KiB 子缓冲区,通过 vmsplice 将其“推送”到管道中,并处理可能的分段写入情况。

使用 vmsplice 替代 write() 后,性能显著提升:

% ./write --write_with_vmsplice | ./read12.7GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

吞吐量从原来的 3.7 GiB/s 提升至 12.7 GiB/s提升了超过 3 倍。更重要的是,由于 vmsplice 避免了将数据从用户空间复制到内核页的过程,写入端的数据复制开销减少了一半

但这还不是终点。如果我们进一步将读取端也从 read() 升级为 splice(),就可以彻底消除内核与用户空间之间的所有数据复制

% ./write --write_with_vmsplice | ./read --read_with_splice32.8GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

此时吞吐量跃升至 32.8 GiB/s,相比原始版本提升了近 9 倍,已非常接近最初 fizzbuzz 示例中测得的 36.2 GiB/s 极限。

这一结果证明:通过合理使用 vmsplice 和 splice 这类零拷贝机制,我们能够极大减少传统 I/O 路径中的性能损耗,充分发挥现代系统在内存带宽和缓存效率方面的潜力。

Part7页面管理的进一步优化

现在我们已经将吞吐量提升至 32.8 GiB/s,接近 fizzbuzz 原始示例的性能水平。但问题是:还能再进一步吗?

为了找出下一个瓶颈,我们再次借助 perf 进行性能剖析:

% perf record -g sh -c './write --write_with_vmsplice | ./read --read_with_splice'33.4GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)[ perf record: Woken up 1 times to write data ][ perf record: Captured and wrote 0.305 MB perf.data (2413 samples) ]

随后查看 vmsplice 相关的调用热点:

% perf report --symbol-filter=vmsplice-   49.59%     0.38%  write    libc-2.33.so       [.] vmsplice   - 49.46% vmsplice      - 45.17% entry_SYSCALL_64_after_hwframe         - do_syscall_64            - 44.30% __do_sys_vmsplice               + 17.88% iov_iter_get_pages               + 16.57% __mutex_lock.constprop.0                 3.89% add_to_pipe                 1.17% iov_iter_advance                 0.82% mutex_unlock                 0.75% pipe_lock        2.01% __entry_text_start        1.45% syscall_return_via_sysret

从结果可以看出,尽管我们已避免了数据复制,但性能瓶颈依然存在:

  • 约 16.6% 的时间消耗在 __mutex_lock 上:这是向管道添加数据时对环形缓冲区的互斥锁操作,属于必要开销,优化空间有限;
  • 高达 17.9% 的时间花费在 iov_iter_get_pages 上:这是当前最值得关注的热点函数。

iov_iter_get_pages 到底做了什么?

顾名思义,iov_iter_get_pages 的作用是:将用户传入的 struct iovec 所描述的用户空间内存区域,转换为一组内核可用的 struct page 指针,以便 vmsplice 能将这些页面“拼接”进管道的环形缓冲区中。

虽然 vmsplice 声称“不复制数据”,但它仍然需要确保它引用的用户页面是物理内存中真实存在的、可被内核安全访问的页面。因此,该函数必须执行以下操作:

  1. 检查用户缓冲区的虚拟地址是否有效;
  2. 查询页表,将虚拟地址映射为物理页面;
  3. 获取这些页面的引用(pin),防止它们在传输过程中被换出;
  4. 构造一组 struct page 指针,供管道后续使用。

这些操作虽然不涉及数据拷贝,但涉及页表遍历、内存映射查询和页面固定(pinning),在高频调用下仍会产生显著开销。

为什么这会影响性能?

每次调用 vmsplice 时,即使是对同一块用户内存的重复写入,内核仍需重新执行 iov_iter_get_pages 来验证和获取页面引用。这意味着:我们每写入 128 KiB 就要重复解析一次页表,而这块内存的物理布局其实从未改变。

因此,要突破当前瓶颈,关键就在于减少或消除对 iov_iter_get_pages 的重复调用开销

要实现这一点,我们必须深入理解 Linux 的内存管理机制,特别是虚拟内存到物理页面的映射方式,以及 CPU 如何通过页表(page table)和 TLB(Translation Lookaside Buffer)加速地址转换。

Part8虚拟内存与分页机制

众所周知,现代操作系统中的进程并不直接访问物理内存地址。相反,它们使用虚拟内存地址,由操作系统和硬件协作将其映射到实际的物理内存位置。这种机制称为虚拟内存(Virtual Memory)

虚拟内存带来了诸多优势:它允许每个进程拥有独立的地址空间、简化内存管理、支持内存保护与共享,并使得多进程并发运行成为可能。但其核心前提之一是——必须将虚拟地址高效地转换为物理地址

每当 CPU 执行一条涉及内存访问的指令(如加载或存储),它都需要将程序使用的虚拟地址解析为对应的物理地址。如果为每一个字节都维护一个独立的映射表,代价将极其高昂。因此,内存被组织成固定大小的块,称为页面(pages),映射以“页”为单位进行。

打破认知:Linux管道到底有多快?

在 x86-64 架构中,默认页面大小为 4KiB。这个数值并非偶然,而是架构设计中性能、内存利用率和管理开销之间权衡的结果。我们将在后续看到,其他页面大小(如 2MiB 或 1GiB 的“大页”)也存在,并在特定场景下具有显著优势。

为了更直观地理解这一点,考虑以下代码:

void* buf = malloc(10000);printf("%p\n", buf);          // 例如:0x6f42430

从进程的视角看,这 10,000 字节在虚拟地址空间中是连续的。但在物理内存中,它们可能分布在三个不连续的 4KiB 页面中:

  • 第一个完整页(4096 字节)
  • 第二个完整页(4096 字节)
  • 第三个部分页(1808 字节)
打破认知:Linux管道到底有多快?

内核的重要任务之一就是管理这种虚拟地址到物理地址的映射,具体通过一种名为页表的数据结构实现。CPU 会规定页表的结构(因为 CPU 需要理解页表才能进行地址转换),内核则会根据需要对页表进行操作。在 x86-64 架构中,页表是一个4 级 512 路的树状结构,其本身也存储在内存中。该树的每个节点大小为 4KiB,节点内每个指向下一级的条目为 8 字节(4KiB/8bytes = 512 个条目),这些条目包含下一级节点的地址及其他元数据。

每个进程都拥有独立的页表,即每个进程都有自己的虚拟地址空间。当内核切换到某个进程时,会将特定寄存器 CR3 设置为该进程页表的根节点物理地址。此后,每当需要将虚拟地址转换为物理地址时,CPU 会将虚拟地址拆分为多个段,并用这些段遍历页表树,最终计算出物理地址。

为了降低这些概念的抽象性,我们以虚拟地址 0x0000f2705af953c0 为例,直观描述其解析为物理地址的过程:

打破认知:Linux管道到底有多快?

  • 解析从第一级页表开始,这一级被称为 “page global directory”(PGD),其物理位置存储在 CR3 寄存器中。
  • 虚拟地址的前 16 位未被使用,接下来的 9 位用于选择 PGD 中的条目,从而定位到第二级页表 “page upper directory”(PUD)。
  • 再接下来的 9 位用于从 PUD 中选择条目,定位到第三级页表 “page middle directory”(PMD)。
  • 之后的 9 位用于从 PMD 中选择条目,定位到第四级页表 “page table entry”(PTE)。
  • PTE 中包含了实际物理页的位置信息,最后使用虚拟地址的后 12 位确定页内的偏移量,从而完成整个地址转换。

页表的稀疏结构允许在需要新页面时逐步建立映射 —— 每当进程需要内存时,内核会更新页表,添加新的映射条目。

Part9struct page 的作用

在 Linux 内核中,struct page 是管理物理内存的核心数据结构。它是内核用来表示和操作单个物理内存页的基本单位,不仅记录页的物理地址,还包含大量与内存管理相关的元数据,例如:

  • 页面的引用计数(_refcount):用于跟踪有多少地方正在使用该页面;
  • 所属内存区(zone)和节点(node)信息:支持 NUMA 架构下的内存分配;
  • 页面状态标志(如是否被锁定、是否已缓存、是否正在交换等);
  • 用于页面回收、迁移和 I/O 操作的链表指针。

简而言之,struct page 是内核对物理内存的“抽象句柄”——每当内核需要操作一个物理页(如映射到进程地址空间、用于缓冲区、加入管道等),它都是通过操作对应的 struct page 实例来完成的。

这一点在管道机制中体现得尤为明显。回顾我们之前介绍的管道数据结构:

struct pipe_inode_info {  unsigned int head;  unsigned int tail;  struct pipe_buffer *bufs;};struct pipe_buffer {  struct page *page;  unsigned int offset, len;};

不过,vmsplice 接受的输入是虚拟内存,而 struct page 直接引用物理内存。因此,我们需要将任意虚拟内存块转换为一组 struct pages—— 这正是 iov_iter_get_pages 的功能,也是我们花费近一半时间的操作。

iov_iter_get_pages 的函数定义如下:

ssize_t iov_iter_get_pages(  struct iov_iter *i,  // 输入:虚拟内存中的带大小缓冲区  struct page **pages, // 输出:支撑输入缓冲区的页面列表  size_t maxsize,      // 要获取的最大字节数  unsigned maxpages,   // 要获取的最大页面数  size_t *start        // 若输入缓冲区未按页对齐,为第一页内的偏移量);

struct iov_iter 是 Linux 内核中的一种数据结构,用于表示遍历内存块的多种方式(包括 struct iovec)。在我们的例子中,它指向一个 128KiB 的缓冲区。vmsplice 会使用 iov_iter_get_pages 将输入缓冲区转换为一组 struct pages 并保存。结合前面了解的分页原理,你大概能想象出 iov_iter_get_pages 的工作方式,下一节将详细解释。

到目前为止,我们已经快速梳理了多个关键概念,它们共同构成了高性能 I/O 的底层基础:

  • 现代 CPU 通过虚拟内存进行数据处理;
  • 内存按固定大小的页面组织;
  • CPU 利用页表(将虚拟页映射到物理页)实现虚拟地址到物理地址的转换;
  • 内核根据需要向页表添加或删除条目;
  • 管道由对物理页的引用构成,因此 vmsplice 必须将虚拟内存转换为物理页并保存。

Part10get_user_pages的性能开销

我们之前观察到,vmsplice 中约 18% 的 CPU 时间消耗在 iov_iter_get_pages 上。但这个函数本身并不直接执行繁重工作——真正的性能瓶颈隐藏在其调用的底层函数中。

通过进一步使用 perf 深入剖析,我们可以清晰地看到实际的开销分布:

% perf report -g --symbol-filter=iov_iter_get_pages-   17.08%     0.17%  write    [kernel.kallsyms]  [k] iov_iter_get_pages   - 16.91% iov_iter_get_pages      - 16.88% internal_get_user_pages_fast           11.22% try_grab_compound_head

结果显示,iov_iter_get_pages 的绝大部分时间(超过 99%)实际上花在了 internal_get_user_pages_fast 上——这是 get_user_pages_fast() 的内部实现函数。

get_user_pages_fast 是 get_user_pages 系列函数中的一个高效变体,其原型如下:

get_user_pages_fast 是 get_user_pages 系列函数中的一个高效变体,其原型如下:

这里的 “user”(与 “kernel” 相对)指的是将用户空间的虚拟页转换为对物理页的引用。

为了获取 struct pages,get_user_pages_fast 会完全模拟 CPU 的操作流程 —— 只不过是在软件层面实现:它遍历页表以收集所有物理页的信息,并将结果存储在 struct pages 中。在我们的例子中,缓冲区大小为 128KiB,页面大小为 4KiB,因此 nr_pages = 32。get_user_pages_fast 需要遍历页表树,收集 32 个叶子节点(即 PTE 条目),并将结果存储到 32 个 struct page 中。

get_user_pages_fast 还需要确保:在调用方不再需要这些物理页之前,它们不会被系统重用。这一机制通过内核中 struct page 存储的引用计数实现 —— 该计数用于跟踪物理页何时可以被释放和重用。get_user_pages_fast 的调用者必须在某个时刻通过 put_page 释放页面,以减少引用计数。

最后,get_user_pages_fast 的行为会根据虚拟地址是否已存在于页表中而有所不同。这也是函数名中 “fast” 后缀的由来:内核首先尝试通过遍历页表获取已存在的页表条目及对应的 struct page(这种方式成本较低),如果失败,再通过其他成本更高的方法生成 struct page。我们在程序开始时用 memset 填充内存的操作,确保了我们不会进入 get_user_pages_fast 的 “慢路径”—— 因为当缓冲区被 “X” 填满时,页表条目已经被创建。

需要注意的是,get_user_pages 函数族不仅对管道有用,实际上它是许多驱动程序的核心组件。一个典型的应用场景与我们之前提到的 “内核旁路” 有关:网卡驱动程序可能会使用它将某些用户内存区域转换为物理页,然后将物理页位置传递给网卡,让网卡直接与该内存区域交互,无需内核参与。

Part11大页内存性能优势

到目前为止,我们讨论的页面大小始终是 x86-64 架构下的默认值——4KiB。然而,现代 CPU 架构(包括 x86-64)普遍支持更大的页面尺寸。在 x86-64 上,除了标准的 4KiB 页外,还支持 2MiB1GiB 的“大页”(Huge Pages)。

接下来将聚焦于 2MiB 大页,因为 1GiB 页面虽然存在,但使用场景极为有限,且对于我们的测试负载来说过于浪费,不具备实际优化意义。

打破认知:Linux管道到底有多快?

同 CPU 架构的常见页大小(来源:维基百科)

大页的主要优势在于显著降低内存管理的开销

  • 更少的页面数量:覆盖相同内存区域所需的大页数量远少于小页。例如,一个 2MiB 大页可替代 512 个 4KiB 小页;
  • 更浅的页表层级:由于大页的偏移字段更长(2MiB 页使用 21 位偏移),地址转换过程中可以跳过一级页表(通常是 PTE 或 PMD),从而减少 TLB 压力和页表遍历开销;
  • 更高的 TLB 命中率:TLB 条目有限,使用大页意味着更少的 TLB 条目即可覆盖更大的地址空间,显著提升地址转换效率。

这些优势在许多工作负载中(如数据库、虚拟化、高性能计算)都能带来显著性能提升。

但在我们的场景中,瓶颈并不在于硬件地址转换本身,而在于内核软件路径中频繁调用 get_user_pages_fast 所带来的开销——特别是为每个 4KiB 页面查找并构建 struct page 引用的过程。

Linux 提供了多种方式来分配和使用 2MiB 大页。其中一种常见方法是:

  1. 分配按 2MiB(即 2²¹ 字节)对齐的内存;
  2. 使用 madvise() 系统调用提示内核尽量为此区域使用大页:
void* buf = aligned_alloc(1 << 21, size);  // 按 2MiB 对齐madvise(buf, size, MADV_HUGEPAGE);         // 建议使用大页

注意:MADV_HUGEPAGE 是一种“建议”,实际是否使用大页取决于系统当前的大页资源是否充足。

启用大页后,我们的性能实现了显著飞跃:

% ./write --write_with_vmsplice --huge_page | ./read --read_with_splice51.0GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

吞吐量从之前的 32.8 GiB/s 提升至 51.0 GiB/s性能提升约 55%,已远超原始 write/read 方案的 3.7 GiB/s。

为什么大页能加速 get_user_pages_fast?

你可能会认为,使用大页后,struct page 会直接指向 2MiB 的物理页,从而减少需要处理的页面数量。但实际情况更为复杂。

Linux 内核中的 struct page 机制是基于“标准页大小”(4KiB)设计的。为了支持大页,内核引入了“复合页”(Compound Page)的概念:

  • 一个 2MiB 大页由 512 个连续的 4KiB 物理页组成;
  • 其中第一个页称为“头页”(head page),包含完整的物理页信息(如地址、引用计数等);
  • 后续的 511 个页称为“尾页”(tail pages),它们的 struct page 实例仅包含一个指向头页的指针,不保存独立的元数据。

因此,当我们有一个 128 KiB 的缓冲区(32 个 4KiB 页)位于大页内存中时,get_user_pages_fast 仍需返回 32 个 struct page 指针——其中第一个是头页,其余 31 个是尾页。

打破认知:Linux管道到底有多快?

尽管 struct page 数量未减少,但构建它们的成本大幅降低

  • 只需通过页表查找找到第一个(头)页;
  • 一旦头页确定,后续所有尾页的 struct page 指针都可以通过简单的指针偏移和循环赋值快速生成,无需再次遍历页表或查询物理地址。

这正是性能提升的核心原因:从“每次查页表”变为“一次查找 + 批量生成”,极大地减少了 get_user_pages_fast 的执行时间。

Part12忙等待(Busy Looping

我们已经非常接近性能极限了——但还差最后一步。

再次运行 perf,观察当前的性能热点:

-   46.91%     0.38%  write    libc-2.33.so       [.] vmsplice   - 46.84% vmsplice      - 43.15% entry_SYSCALL_64_after_hwframe         - do_syscall_64            - 41.80% __do_sys_vmsplice               + 14.90% wait_for_space               + 8.27% __wake_up_common_lock                 4.40% add_to_pipe               + 4.24% iov_iter_get_pages               + 3.92% __mutex_lock.constprop.0                 1.81% iov_iter_advance               + 0.55% import_iovec            + 0.76% syscall_exit_to_user_mode        1.54% syscall_return_via_sysret        1.49% __entry_text_start

此时,最显著的两个开销来源是:

  • wait_for_space(14.9%):当管道缓冲区满时,vmsplice 会阻塞等待空间可用;
  • __wake_up_common_lock(8.27%):写入端完成数据提交后,需要唤醒在读取端阻塞等待的进程。

这些操作涉及进程调度、上下文切换和锁竞争,虽然在通用场景下是必要的同步机制,但在我们的高性能、单生产者-单消费者、双缓冲模型中,它们反而成了不必要的性能拖累。

为了彻底消除这些同步开销,我们可以采用一种看似“暴力”但极其有效的策略:忙等待(Busy Looping)

具体做法是:

  1. 在调用 vmsplice 时传入标志 SPLICE_F_NONBLOCK,使其在管道无空闲槽位时立即返回 EAGAIN 错误,而不是进入睡眠等待;
  2. 在用户空间主动循环重试,持续尝试写入,直到成功为止。

修改后的写入逻辑如下:

// ...// SPLICE_F_NONBLOCK 会使 vmsplice 在无法写入时立即返回,错误码为 EAGAINssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, SPLICE_F_NONBLOCK);if (ret < 0 && errno == EAGAIN) {  continue; // 管道未就绪,继续忙循环尝试}// ...

同理,在读取端也对 splice 使用 SPLICE_F_NONBLOCK,并配合忙循环,避免因等待数据而陷入内核调度。

这一改动带来了显著提升:

% ./write --write_with_vmsplice --huge_page --busy_loop | ./read --read_with_splice --busy_loop62.5GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

吞吐量从 51.0 GiB/s 跃升至 62.5 GiB/s性能再提升约 25%

为什么忙循环在这里是合理的?

在大多数应用场景中,忙等待是应避免的反模式——它会浪费 CPU 资源,降低系统整体效率。但在我们的特定场景下,它却是最优选择,原因如下:

  • 确定性的双缓冲流水线:我们使用双缓冲机制,确保写入端和读取端交替工作。一旦一个缓冲区被提交,另一个必然已被消费,只需极短时间即可腾出空间;
  • 极低的等待延迟:读取端通常在另一个 CPU 核心上运行,消费速度极快。写入端只需空转几个时钟周期就能重试成功,远快于一次上下文切换的开销(微秒级);
  • 专用核心可用:在高性能系统中,常为关键数据路径预留专用 CPU 核心,避免干扰,此时忙循环不会影响其他任务。

因此,用 CPU 时间换取确定性低延迟,在本例中是完全值得的。

总结

通过系统性地分析 perf 的性能数据,并深入阅读 Linux 内核源码,我们逐步将一个简单的数据管道程序的性能提升了超过一个数量级。

虽然“管道”(pipe)和“拼接”(splicing)本身并不是高性能编程中最常被讨论的主题,但在这个看似简单的场景背后,我们触及了一系列底层系统性能的核心议题

  • 零拷贝 I/O:通过 vmsplice 和 splice 避免用户空间与内核空间之间的数据复制;
  • 环形缓冲区设计:利用双缓冲机制实现写入与读取的无缝流水线;
  • 虚拟内存与分页机制:理解页表结构、TLB 行为以及 struct page 的管理方式;
  • 大页内存(Huge Pages):减少页面数量与页表层级,加速 get_user_pages 路径;
  • 同步开销优化:用忙循环替代阻塞等待,消除调度延迟与唤醒开销。

这些技术不仅适用于管道场景,也广泛应用于高性能网络、存储系统、DPDK、eBPF、实时数据处理等对延迟和吞吐极为敏感的领域。

到此你读完这篇“失控般冗长”的技术文章。如果你觉得它有用、有趣,或者恰好激发了你的思考——请告诉我!我也很乐意听到你的反馈、疑问或批评。

愿你在探索系统性能的路上,不断逼近极限。

发表评论

泰日号Copyright Your WebSite.Some Rights Reserved. 网站地图 备案号:川ICP备66666666号 Z-BlogPHP强力驱动