4. 内存优化:从拷贝到零拷贝,从碎片到池化
内存,是 RTSP 推流系统里最容易被忽视的瓶颈。我见过太多团队,花大把精力优化编码器、调网络参数,结果性能卡在内存拷贝上。说白了,你每帧数据多拷贝一次,延迟就多几毫秒,吞吐就降一截。今天咱们把这四个方向掰开揉碎讲清楚。
4.1 内存拷贝优化:零拷贝技术
先问个问题:一帧 1080p 的 YUV 数据从摄像头到网卡,中间要拷贝几次?
传统路径大概是:
摄像头 → 内核缓冲区 → 用户态应用 → 编码器 → 用户态输出 → 内核 Socket 缓冲区 → 网卡。
每次拷贝都涉及上下文切换和总线占用。我优化过一个项目,光内存拷贝就占了 CPU 时间的 18%。
4.1.1 零拷贝的核心思路
零拷贝不是真的不拷贝,而是减少不必要的拷贝。Linux 下主要有三种手段:
- mmap:把内核缓冲区映射到用户空间,省掉 read/write 的拷贝。
- sendfile:直接从文件描述符到 socket,完全在内核态完成。
- splice:在两个文件描述符之间移动数据,不经过用户态。
实战经验:在 RTSP 推流场景里,我最常用的是 splice 配合环形缓冲区。把编码后的 H.264 数据直接通过 splice 送入 RTP 打包模块,省掉一次 memcpy。实测延迟降低 2-3ms。
4.1.2 零拷贝在推流中的落地
看一段简化代码:
// 传统方式:两次拷贝
char *buf = malloc(size);
read(fd_input, buf, size); // 第一次:内核→用户
write(fd_socket, buf, size); // 第二次:用户→内核
// 零拷贝方式:一次都没有
splice(fd_input, NULL, fd_pipe, NULL, size, SPLICE_F_MOVE);
splice(fd_pipe, NULL, fd_socket, NULL, size, SPLICE_F_MORE);
嗯,这里要注意:SPLICE_F_MOVE 不是所有内核版本都支持。我在 CentOS 7 上踩过坑,回退到 SPLICE_F_MORE 才稳定。
4.2 内存池设计与实现
频繁 malloc/free 是性能杀手。为什么?每次分配都要走系统调用,还要处理锁。推流系统一秒钟几十帧,每帧又分多个 NAL 单元,你想想看,这得多少次内存操作?
4.2.1 内存池的基本结构
我习惯用分层池设计:
| 层级 | 块大小 | 用途 | 预分配数量 |
|---|---|---|---|
| L1 | 64 字节 | RTP 头部、控制信息 | 1024 |
| L2 | 1500 字节 | MTU 大小的数据包 | 512 |
| L3 | 64 KB | NAL 单元、帧数据 | 128 |
| L4 | 2 MB | 大块 I 帧 | 16 |
设计技巧:每个层级维护一个 free list,分配时从对应链表头部取,释放时插回头部。这样 O(1) 复杂度,而且 CPU 缓存友好。
4.2.2 实现要点
我曾经犯过一个错误:内存池的锁粒度太粗。多线程推流时,所有线程抢同一把锁,性能直接腰斩。后来改成 per-thread cache + 全局池两级结构:
// 伪代码:两级内存池
struct mem_pool {
thread_local_cache tlc; // 线程本地缓存,无锁
global_pool gp; // 全局池,带 spinlock
};
void* pool_alloc(size_t size) {
// 先从线程本地取
void *ptr = tlc_alloc(&tlc, size);
if (ptr) return ptr;
// 本地不够,从全局批量取
return global_alloc(&gp, size);
}
这个改动让我们的推流服务在 32 核机器上吞吐提升了 40%。
4.3 内存对齐与缓存行优化
现代 CPU 从内存读数据是按缓存行(通常 64 字节)读的。如果你的数据跨了两个缓存行,CPU 就得读两次。更糟的是,如果两个线程频繁修改同一缓存行的不同变量,就会触发伪共享(False Sharing)。
4.3.1 对齐的好处
- 减少缓存行分裂:结构体按 64 字节对齐,保证热点数据在一个缓存行内。
- SIMD 指令要求:SSE/AVX 指令要求 16/32 字节对齐,不对齐会触发异常或性能下降。
- 原子操作效率:对齐的变量可以用更高效的指令完成 CAS。
4.3.2 伪共享的坑
看这个例子:
// 错误示范:两个线程频繁修改相邻变量
struct stats {
uint64_t packets_sent; // 线程 A 写
uint64_t packets_recv; // 线程 B 写
};
// 正确做法:用 padding 隔开
struct stats {
uint64_t packets_sent;
char padding[56]; // 填充到 64 字节
uint64_t packets_recv;
};
我曾经踩过的坑:在推流统计模块里,两个线程分别更新发送和接收计数。没做 padding 前,性能计数器显示 L1 缓存缺失率高达 30%。加上 padding 后,直接降到 2%。
4.3.3 对齐宏与属性
GCC/Clang 下我常用:
// 声明对齐
struct __attribute__((aligned(64))) aligned_struct {
// ...
};
// 动态分配对齐内存
void *ptr = aligned_alloc(64, size); // C11
// 或者
posix_memalign(&ptr, 64, size); // POSIX
4.4 避免内存碎片
内存碎片分两种:外部碎片(空闲块不连续)和内部碎片(分配块比实际需要大)。推流服务跑几天后,明明总空闲内存够,但 malloc 就是失败——这就是外部碎片。
4.4.1 碎片产生的原因
- 大小不一的分配释放:一会儿分配 100 字节,一会儿分配 1500 字节。
- 生命周期交错:短生命周期的对象和长生命周期的对象混在一起。
- 频繁 realloc:RTP 打包时经常需要调整缓冲区大小。
4.4.2 我的碎片治理三板斧
- 固定大小分配:所有内存块都从内存池取,池内块大小固定,不会产生外部碎片。
- 伙伴系统:对于大块内存,用伙伴算法管理。分配和释放都是 2 的幂次,合并时自动消除碎片。
- 定期整理:在推流空闲时,对内存池做一次 compact。把分散的小空闲块合并成大块。
实战数据:优化前,推流服务运行 72 小时后,内存碎片率超过 25%,部分帧分配失败。引入内存池 + 伙伴系统后,连续运行两周,碎片率稳定在 3% 以内。
4.5 本章知识体系
下面这张图把四个优化方向的关系串起来了:
这四个方向不是孤立的。内存池天然解决了碎片问题,对齐后的内存池分配效率更高,零拷贝减少了内存池的负载。我个人习惯在项目初期就把这四件事一起规划好,而不是等出问题了再补。
最后说一句:性能优化不是炫技。零拷贝、内存池这些技术,用对了是利器,用错了是负担。我见过有人为了用零拷贝把代码改得面目全非,结果收益不到 1%。先 profile,再优化,这是铁律。