亲,这款游戏可以开挂的,确实是有挂的,很多玩家在这款游戏中打牌都会发现很多用户的牌特别好,总是好牌,而且好像能看到-人的牌一样。所以很多小伙伴就怀疑这...
2025-08-22 0
咱们今天要聊的这个玩意儿可太硬核了!
有个大神写了个示例程序,展示了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)
这项研究的灵感源于一个高度优化的 FizzBuzz 程序,该程序在我的设备上通过管道输出可达约 35GiB/s。我们的首要目标是达到同等性能,并详细阐述每一步优化。此后,我们将实施 FizzBuzz 未采用的额外改进(因其瓶颈在于计算而非 IO),进一步提升至 65GiB/s。优化步骤如下:
核心: 第 4 步涉及 Linux 内核的核心机制,即使熟悉其他主题的读者也能从中获益。对于背景知识有限的读者,我们仅预设基础的 C 语言能力,力求由浅入深地讲解。
我们先依照 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'),还隐含了内存初始化的副作用,这一点我们将在后续讨论。
专注 大厂技术栈学习路线、项目教程、简历模板、大厂面试题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 机制。
为了分析程序运行时的时间消耗分布,我们可以借助 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)在内核中的工作原理——它并非简单的内存队列,而是一套涉及页管理、同步机制和跨进程通信的复杂子系统。
要深入理解 write() 系统调用为何存在性能瓶颈,我们必须探究管道在内核中的真实构造。其核心数据结构定义位于 Linux 内核源码的 include/linux/pipe_fs_i.h,而相关操作实现在 fs/pipe.c 中。
Linux 中的管道本质上是一个环形缓冲区(circular buffer),用于在进程间传递数据。它并不直接存储字节流,而是保存对物理内存页(page)的引用,并通过一组缓冲区槽位管理这些页的读写位置。
如上图所示,该环形缓冲区包含 8 个槽位(实际默认为 16 个),每个槽位对应一个 pipe_buffer 结构,指向一个大小为 4KiB 的页面(x86-64 架构下;其他架构可能不同)。因此,这样一个默认配置的管道最多可缓存 16 × 4KiB =64KiB 数据。
注意:这是一个关键点——每个管道都有容量上限,即在写入阻塞前能容纳的最大数据量。一旦缓冲区满,后续 write() 调用将被挂起,直到读取端消费部分数据腾出空间。
图中阴影部分表示当前已写入数据的缓冲区,空白或 NULL 槽位则代表未使用空间。环形结构允许 head 和 tail 指针循环前进,实现高效的 FIFO 数据流动。
值得注意的是,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;};
其中:
虽然我们尚未展开 struct page 的具体内容(它代表内核中的物理内存页,包含引用计数、映射信息等),但上述结构已足以帮助我们理解管道读写的基本机制。
正是这种基于页引用和环形缓冲的设计,决定了管道 I/O 的性能特征:每一次 write() 调用都需要将用户数据复制到内核页,并可能触发页面分配;而 read() 则需反向复制数据回用户空间——这些拷贝操作正是前文 perf 分析中高开销的根源。
理解了管道的内部构造后,我们就能更有针对性地优化数据传输路径,例如通过减少甚至避免用户态与内核态之间的数据复制来提升吞吐效率。
现在我们回到 pipe_write 的实现,结合前文 perf 工具的性能分析结果,深入理解其内部行为。
pipe_write 的工作流程可以概括为以下几个关键步骤:
整个写入过程由一个**管道锁(pipe lock)**保护,以确保多线程或并发访问时的数据一致性。pipe_write 会在必要时获取和释放该锁,从而实现对环形缓冲区的安全访问。
与之对应的是 pipe_read,它的行为是 pipe_write 的“镜像”:
这种设计虽然逻辑清晰,但在高性能场景下暴露出一系列性能瓶颈,形成了一个极其不利的运行状况:
为了更直观地评估当前性能表现,我们可以参考本地机器上的顺序内存读取速度作为基准:
% 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() 等高级接口,实现近乎零拷贝的高效管道传输。
在高性能 I/O 场景中,一个长期存在的痛点是:
数据在用户空间与内核空间之间反复复制——先从用户内存复制到内核缓冲区,再从内核复制回用户空间。这种“双重搬运”不仅浪费 CPU 资源,也成为吞吐量的瓶颈。
一种常见的优化思路是绕过内核的中间处理环节,直接进行数据传输。例如,在低延迟网络编程中,可以通过 DPDK 等技术直接与网卡交互,完全避开传统内核协议栈。
通常情况下,当我们向套接字、文件或管道写入数据时,流程是:先将数据拷贝至内核管理的缓冲区,再由内核负责后续的传递。
以管道为例,其本质就是一组位于内核中的环形缓冲区。然而,对于追求极致性能的应用来说,这些额外的数据复制步骤完全是不必要的开销。
幸运的是,Linux 提供了专门的系统调用,能够在不进行数据复制的前提下高效地在管道与其他文件描述符之间移动数据。其中最关键的两个是:
这两个系统调用的核心优势在于:它们不复制数据内容本身,而是通过操作页面引用的方式,实现零拷贝(zero-copy)数据传输。
现在我们已经了解了管道的内部结构——它本质上是一个由 struct pipe_buffer 构成的环形缓冲区,每个条目指向一个物理内存页(struct page)。基于这一机制,就可以理解 vmsplice 和 splice 是如何工作的:
它们并不像传统 write 那样为每次写入分配新页面并复制数据,而是直接“接管”已存在的内存页(例如用户空间映射的页),然后将其作为缓冲区插入到管道的环形结构中;或者相反,从管道中“摘下”一个页引用,传递给目标文件描述符。
换句话说,这些系统调用实现了缓冲区的“移动”而非“复制”,从而大幅减少内存带宽消耗和 CPU 开销。
接下来,我们将通过实际代码演示如何使用 vmsplice 来重构写入端,彻底规避传统 write 带来的性能瓶颈,并观察吞吐量的显著提升。
为了突破传统 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);
其中:
该调用返回值表示成功“拼接”到管道中的字节数——这一点与 write() 类似,并不保证一次性将全部数据写入,因为管道的环形缓冲区有容量限制。
使用 vmsplice() 时需要特别注意:它不会复制用户内存中的数据,而是直接将用户空间的页面“映射”或“附加”到管道的 pipe_buffer 中。
这意味着,一旦缓冲区被 vmsplice 提交到管道,在确认读取端已经消费该数据之前,绝不能重写或释放该缓冲区,否则会导致数据竞争或读取错误。
为了解决这个问题,fizzbuzz 采用了经典的双缓冲(double buffering)机制,其工作原理如下:
这种设计的关键在于:只有当前一个 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 路径中的性能损耗,充分发挥现代系统在内存带宽和缓存效率方面的潜力。
现在我们已经将吞吐量提升至 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
从结果可以看出,尽管我们已避免了数据复制,但性能瓶颈依然存在:
顾名思义,iov_iter_get_pages 的作用是:将用户传入的 struct iovec 所描述的用户空间内存区域,转换为一组内核可用的 struct page 指针,以便 vmsplice 能将这些页面“拼接”进管道的环形缓冲区中。
虽然 vmsplice 声称“不复制数据”,但它仍然需要确保它引用的用户页面是物理内存中真实存在的、可被内核安全访问的页面。因此,该函数必须执行以下操作:
这些操作虽然不涉及数据拷贝,但涉及页表遍历、内存映射查询和页面固定(pinning),在高频调用下仍会产生显著开销。
每次调用 vmsplice 时,即使是对同一块用户内存的重复写入,内核仍需重新执行 iov_iter_get_pages 来验证和获取页面引用。这意味着:我们每写入 128 KiB 就要重复解析一次页表,而这块内存的物理布局其实从未改变。
因此,要突破当前瓶颈,关键就在于减少或消除对 iov_iter_get_pages 的重复调用开销。
要实现这一点,我们必须深入理解 Linux 的内存管理机制,特别是虚拟内存到物理页面的映射方式,以及 CPU 如何通过页表(page table)和 TLB(Translation Lookaside Buffer)加速地址转换。
众所周知,现代操作系统中的进程并不直接访问物理内存地址。相反,它们使用虚拟内存地址,由操作系统和硬件协作将其映射到实际的物理内存位置。这种机制称为虚拟内存(Virtual Memory)。
虚拟内存带来了诸多优势:它允许每个进程拥有独立的地址空间、简化内存管理、支持内存保护与共享,并使得多进程并发运行成为可能。但其核心前提之一是——必须将虚拟地址高效地转换为物理地址。
每当 CPU 执行一条涉及内存访问的指令(如加载或存储),它都需要将程序使用的虚拟地址解析为对应的物理地址。如果为每一个字节都维护一个独立的映射表,代价将极其高昂。因此,内存被组织成固定大小的块,称为页面(pages),映射以“页”为单位进行。
在 x86-64 架构中,默认页面大小为 4KiB。这个数值并非偶然,而是架构设计中性能、内存利用率和管理开销之间权衡的结果。我们将在后续看到,其他页面大小(如 2MiB 或 1GiB 的“大页”)也存在,并在特定场景下具有显著优势。
为了更直观地理解这一点,考虑以下代码:
void* buf = malloc(10000);printf("%p\n", buf); // 例如:0x6f42430
从进程的视角看,这 10,000 字节在虚拟地址空间中是连续的。但在物理内存中,它们可能分布在三个不连续的 4KiB 页面中:
内核的重要任务之一就是管理这种虚拟地址到物理地址的映射,具体通过一种名为页表的数据结构实现。CPU 会规定页表的结构(因为 CPU 需要理解页表才能进行地址转换),内核则会根据需要对页表进行操作。在 x86-64 架构中,页表是一个4 级 512 路的树状结构,其本身也存储在内存中。该树的每个节点大小为 4KiB,节点内每个指向下一级的条目为 8 字节(4KiB/8bytes = 512 个条目),这些条目包含下一级节点的地址及其他元数据。
每个进程都拥有独立的页表,即每个进程都有自己的虚拟地址空间。当内核切换到某个进程时,会将特定寄存器 CR3 设置为该进程页表的根节点物理地址。此后,每当需要将虚拟地址转换为物理地址时,CPU 会将虚拟地址拆分为多个段,并用这些段遍历页表树,最终计算出物理地址。
为了降低这些概念的抽象性,我们以虚拟地址 0x0000f2705af953c0 为例,直观描述其解析为物理地址的过程:
页表的稀疏结构允许在需要新页面时逐步建立映射 —— 每当进程需要内存时,内核会更新页表,添加新的映射条目。
在 Linux 内核中,struct page 是管理物理内存的核心数据结构。它是内核用来表示和操作单个物理内存页的基本单位,不仅记录页的物理地址,还包含大量与内存管理相关的元数据,例如:
简而言之,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 的底层基础:
我们之前观察到,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 函数族不仅对管道有用,实际上它是许多驱动程序的核心组件。一个典型的应用场景与我们之前提到的 “内核旁路” 有关:网卡驱动程序可能会使用它将某些用户内存区域转换为物理页,然后将物理页位置传递给网卡,让网卡直接与该内存区域交互,无需内核参与。
到目前为止,我们讨论的页面大小始终是 x86-64 架构下的默认值——4KiB。然而,现代 CPU 架构(包括 x86-64)普遍支持更大的页面尺寸。在 x86-64 上,除了标准的 4KiB 页外,还支持 2MiB 和 1GiB 的“大页”(Huge Pages)。
接下来将聚焦于 2MiB 大页,因为 1GiB 页面虽然存在,但使用场景极为有限,且对于我们的测试负载来说过于浪费,不具备实际优化意义。
同 CPU 架构的常见页大小(来源:维基百科)
大页的主要优势在于显著降低内存管理的开销:
这些优势在许多工作负载中(如数据库、虚拟化、高性能计算)都能带来显著性能提升。
但在我们的场景中,瓶颈并不在于硬件地址转换本身,而在于内核软件路径中频繁调用 get_user_pages_fast 所带来的开销——特别是为每个 4KiB 页面查找并构建 struct page 引用的过程。
Linux 提供了多种方式来分配和使用 2MiB 大页。其中一种常见方法是:
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。
你可能会认为,使用大页后,struct page 会直接指向 2MiB 的物理页,从而减少需要处理的页面数量。但实际情况更为复杂。
Linux 内核中的 struct page 机制是基于“标准页大小”(4KiB)设计的。为了支持大页,内核引入了“复合页”(Compound Page)的概念:
因此,当我们有一个 128 KiB 的缓冲区(32 个 4KiB 页)位于大页内存中时,get_user_pages_fast 仍需返回 32 个 struct page 指针——其中第一个是头页,其余 31 个是尾页。
尽管 struct page 数量未减少,但构建它们的成本大幅降低:
这正是性能提升的核心原因:从“每次查页表”变为“一次查找 + 批量生成”,极大地减少了 get_user_pages_fast 的执行时间。
我们已经非常接近性能极限了——但还差最后一步。
再次运行 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
此时,最显著的两个开销来源是:
这些操作涉及进程调度、上下文切换和锁竞争,虽然在通用场景下是必要的同步机制,但在我们的高性能、单生产者-单消费者、双缓冲模型中,它们反而成了不必要的性能拖累。
为了彻底消除这些同步开销,我们可以采用一种看似“暴力”但极其有效的策略:忙等待(Busy Looping)。
具体做法是:
修改后的写入逻辑如下:
// ...// 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 时间换取确定性低延迟,在本例中是完全值得的。
通过系统性地分析 perf 的性能数据,并深入阅读 Linux 内核源码,我们逐步将一个简单的数据管道程序的性能提升了超过一个数量级。
虽然“管道”(pipe)和“拼接”(splicing)本身并不是高性能编程中最常被讨论的主题,但在这个看似简单的场景背后,我们触及了一系列底层系统性能的核心议题:
这些技术不仅适用于管道场景,也广泛应用于高性能网络、存储系统、DPDK、eBPF、实时数据处理等对延迟和吞吐极为敏感的领域。
到此你读完这篇“失控般冗长”的技术文章。如果你觉得它有用、有趣,或者恰好激发了你的思考——请告诉我!我也很乐意听到你的反馈、疑问或批评。
愿你在探索系统性能的路上,不断逼近极限。
相关文章
亲,这款游戏可以开挂的,确实是有挂的,很多玩家在这款游戏中打牌都会发现很多用户的牌特别好,总是好牌,而且好像能看到-人的牌一样。所以很多小伙伴就怀疑这...
2025-08-22 0
“蚊子的星球大战”作者 | 酸条图源 | unsplash在热带与亚热带地区,蚊虫远不止是夏夜的扰人“嗡嗡”声和几个红肿痒包,更是疟疾、登革热、寨卡病...
2025-08-22 0
2024-2025“中国‘芯’助力中国梦”全国通信科技创新大赛总决赛,于8月1日至3日在大连自贸区国际会展中心成功举办。在前期的层层选拔赛中,莘县20...
2025-08-22 0
AI 服务器对数据传输速度和稳定性的极致追求,推动高频高速树脂成为核心材料。据 TrendForce 预测,2022-2026 年全球 AI 服务器出...
2025-08-22 0
金融界2025年8月22日消息,国家知识产权局信息显示,东莞市欧品数控钣金有限公司取得一项名为“一种分段式圆筒型直线电机”的专利,授权公告号CN223...
2025-08-22 0
您好:这款游戏可以开挂,确实是有挂的,很多玩家在这款游戏中打牌都会发现很多用户的牌特别好,总是好牌,而且好像能看到-人的牌一样。所以很多小伙伴就怀疑这...
2025-08-22 0
金融界2025年8月22日消息,国家知识产权局信息显示,金锋流体科技集团有限公司取得一项名为“自动复位杠杆远距离手动调节阀”的专利,授权公告号CN22...
2025-08-22 0
金融界2025年8月22日消息,国家知识产权局信息显示,中国恩菲工程技术有限公司、中国有色工程有限公司取得一项名为“一种炉内壁挂渣厚度模拟仿真方法、系...
2025-08-22 0
发表评论