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 我的碎片治理三板斧

  1. 固定大小分配:所有内存块都从内存池取,池内块大小固定,不会产生外部碎片。
  2. 伙伴系统:对于大块内存,用伙伴算法管理。分配和释放都是 2 的幂次,合并时自动消除碎片。
  3. 定期整理:在推流空闲时,对内存池做一次 compact。把分散的小空闲块合并成大块。

实战数据:优化前,推流服务运行 72 小时后,内存碎片率超过 25%,部分帧分配失败。引入内存池 + 伙伴系统后,连续运行两周,碎片率稳定在 3% 以内。

4.5 本章知识体系

下面这张图把四个优化方向的关系串起来了:

内存优化知识体系 内存优化四大方向 RTSP 推流内存子系统 零拷贝技术 内存池设计 缓存行优化 避免内存碎片 mmap sendfile splice 分层池 线程本地缓存 free list 64 字节对齐 伪共享防护 SIMD 对齐 固定大小分配 伙伴系统 定期 compact 核心原则 减少拷贝 → 减少分配 → 减少缓存缺失 → 减少碎片

这四个方向不是孤立的。内存池天然解决了碎片问题,对齐后的内存池分配效率更高,零拷贝减少了内存池的负载。我个人习惯在项目初期就把这四件事一起规划好,而不是等出问题了再补。

最后说一句:性能优化不是炫技。零拷贝、内存池这些技术,用对了是利器,用错了是负担。我见过有人为了用零拷贝把代码改得面目全非,结果收益不到 1%。先 profile,再优化,这是铁律。