Linux下未初始化缓冲区拷贝速度远快于已初始化缓冲区问题咨询
问题:未初始化缓冲区拷贝速度远高于已初始化缓冲区
背景
我需要在配备32GB内存、运行4.15版本内核的X86-64架构Linux设备上开发测试软件,通过单个TCP socket生成100Gbps测试流量。测试环境为一对veth虚拟网卡,其中一块网卡位于独立netns网络命名空间中,带宽观测工具为bmon。
初始测试现象
我编写的初始测试代码(已移除部分合法性校验逻辑)如下:
void test(int sock) { int size = 500 * 0x100000; char *buff = malloc(size); //optional memset(buff, 0, size); int offset = 0; int chunkSize = 0x200000; while (1) { offset = 0; while (offset < size) { chunkSize = size - offset; if (chunkSize > CHUNK_SIZE) chunkSize = CHUNK_SIZE; send(sock, &buff[offset], chunkSize, 0); offset += chunkSize; } } }
测试出现不符合直觉的结果:
- 保留
memset(buff, 0, size);初始化语句时,吞吐量仅约60Gbps - 移除该初始化语句后,吞吐量提升至约94Gbps
对照实验结果
我将全量初始化替换为按比例初始化逻辑:memset(buff, 0, size * ratio);,测试结果呈现明确线性关联:
- ratio=0(完全不初始化):吞吐量最高,约94Gbps
- ratio=0.5(初始化50%缓冲区):吞吐量约72Gbps
- ratio=1.0(全量初始化):吞吐量最低,约60Gbps
吞吐量随初始化比例升高线性下降。
复现代码与验证
我编写了排除网络栈干扰的纯拷贝测试代码,可稳定复现上述效应:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <sys/time.h> #include <sys/stat.h> int size = 500 * 0x100000; char buf[0x200000]; double getTS() { struct timeval tv; gettimeofday(&tv, NULL); return tv.tv_sec + tv.tv_usec/1000000.0; } void test1(int init) { char *p = malloc(size); int offset = 0; if (init) memset(p, 0, size); double startTs = getTS(); for (int i = 0; i < 100; i++) { offset = 0; while (offset < size) { memcpy(&buf[0], p+offset, 0x200000); offset += 0x200000; } } printf("took %f secs\n", getTS() - startTs); } int main(int argc, char *argv[]) { test1(argc > 1); return 0; }
在测试机(Ubuntu 18.04、Linux 4.15内核、32GB内存)上的运行结果:
- 不执行缓冲区初始化:总耗时1.35秒
- 执行全量初始化:总耗时3.02秒
后续需求
后续需要将sendfile的性能优化到与calloc分配的全零缓冲区发包一致的水平,这是任务硬性要求,需要相关实现建议。
回答
底层原理解释
这个性能差异和TCP协议栈、网卡驱动没有关系,完全是Linux内存管理的两个核心机制导致的:匿名内存按需分配、全局零页共享映射。
- 首先明确
malloc的真实行为:它只分配进程虚拟地址空间的连续区间,不会立刻占用物理内存。只有当进程第一次读写对应虚拟地址、触发缺页异常时,内核才会给这段地址绑定实际的物理页。 - 不做
memset初始化时,malloc返回的所有虚拟地址,初始都映射到内核维护的同一个特殊物理页——全局零页。这是一个只读、内容全为0的共享物理页,所有刚申请还没写入过的匿名内存,默认都指向它。
这时候不管是memcpy拷贝还是send发包读这块缓冲区,本质都是从这同一个共享物理页读数据。你申请的500MB缓冲区根本没有占用500MB物理内存,零页本身会一直留在CPU高速缓存里,整个读过程几乎没有内存带宽压力,速度自然极快。 - 一旦调用
memset写过缓冲区,就会触发写时复制(COW)机制:每一块被写入的虚拟地址,都会被内核分配独立的可写物理页,再把零页的内容复制过去,最后写入你设置的值(这里是0)。这时候500MB缓冲区是实打实占了500MB物理内存,拷贝/发包的时候需要从不同的物理页读真实数据,500MB的数据量远大于CPU缓存容量,内存总线带宽会成为瓶颈,速度自然大幅下降。 - 测试到的性能随初始化比例线性下降也完全符合这个逻辑:初始化比例越高,被分配独立物理页的内存区域越大,拷贝时需要从物理内存读取的真实数据越多,性能损失就越明显,和比例呈线性关系。
补充说明:C标准里读取未初始化的malloc内存属于未定义行为,但在Linux平台上这个行为是确定的——首次申请未写入的堆内存读出来全是0,不会有随机值。很多人误以为未初始化内存都是随机垃圾值,那是栈内存的特征,堆内存因为零页映射机制不存在这个问题。
sendfile性能优化建议
现在不初始化缓冲区能跑到接近线速,本质是蹭了全局零页的优化,根本没有发生500MB级别的真实内存拷贝。要让sendfile达到同等性能,按以下方案实现即可:
- 不要用磁盘上的实体文件作为sendfile源,改用
memfd_create创建匿名内存文件,将其truncate到你需要的发送缓冲区大小后,不要对这个文件做任何写入操作。此时这个文件的页缓存全部映射到全局零页,sendfile读取时的路径和读未初始化malloc缓冲区完全一致,没有真实内存拷贝开销。 - 确保网卡和veth设备开启GSO/TSO/GRO卸载能力,让sendfile走零拷贝路径时不需要内核做额外的分片拷贝。
- 调整socket的发送缓冲区大小,和单次发送的chunk大小匹配,避免sendfile过程中出现内核态的额外数据拷贝。
- 如果需要进一步压榨性能,可以搭配
SO_ZEROCOPY套接字选项使用,但上述memfd+sendfile的方案已经能达到和未初始化用户态缓冲区发包一致的性能。
内容的提问来源于stack exchange,提问作者packetie
相关产品推荐
相关产品推荐

