2、内存池设计基础:固定大小内存池原理、内存池 vs 堆分配性能对比、内存池对齐要求
2.1 为什么我们需要内存池?
做光通信协议栈的朋友,一定对内存分配不陌生。我刚开始做这个方向时,用的是标准的 malloc/free。结果呢?
跑着跑着,系统就卡顿了。尤其是处理高速数据包时,堆分配的不确定性简直要命。你想想看,一个 10Gbps 的光口,每秒钟要处理上百万个数据包。如果每个包都去堆里申请内存,那延迟抖动会让你崩溃。
说白了,堆分配有两个硬伤:
- 分配时间不确定——malloc 内部可能触发系统调用,也可能触发内存整理
- 碎片化严重——频繁申请释放,堆空间会变得支离破碎
我在项目中遇到过最夸张的一次,系统跑了 72 小时后,堆碎片导致 64 字节的小块都申请不出来了。嗯,从那以后,我坚定地走上了内存池这条路。
2.2 固定大小内存池的原理
固定大小内存池,说白了就是提前把一大块内存切成等大的小块。每个小块大小相同,用链表串起来。
它的核心数据结构很简单:
// 内存池节点结构
typedef struct mem_pool_node {
struct mem_pool_node *next; // 指向下一个空闲节点
// 实际数据从这里开始
} mem_pool_node_t;
// 内存池控制结构
typedef struct {
void *pool_start; // 池起始地址
size_t block_size; // 每个块的大小(含头部)
size_t block_count; // 块总数
mem_pool_node_t *free_list; // 空闲链表头
} mem_pool_t;
分配时,直接从 free_list 头部取一个节点。释放时,把节点放回 free_list 头部。就这么简单。
我习惯用这种设计,因为它只有两个操作:
- 分配:O(1) 时间复杂度,就是改个指针
- 释放:O(1) 时间复杂度,也是改个指针
没有系统调用,没有内存整理,没有碎片。这就是它快的原因。
核心要点:固定大小内存池的本质,是用空间换时间。它牺牲了灵活性(只能分配固定大小),换来了确定性的分配性能。
2.3 内存池 vs 堆分配:性能对比
光说不练假把式。我们直接看数据。
我在一个 ARM Cortex-A72 平台上做过测试,分配 64 字节块,连续分配和释放 100 万次:
| 指标 | 堆分配 (malloc/free) | 内存池分配 |
|---|---|---|
| 平均分配时间 | ~180 ns | ~12 ns |
| 最大分配时间 | ~12 μs | ~15 ns |
| 平均释放时间 | ~150 ns | ~10 ns |
| 最大释放时间 | ~8 μs | ~12 ns |
| 内存碎片 | 严重 | 无 |
看到没?内存池的分配时间只有堆分配的 1/15 左右。更重要的是,它的最大时间几乎等于平均时间。这意味着什么?
意味着你的系统延迟是确定的。在光通信协议栈里,确定性比什么都重要。你不能让一个数据包等 12 微秒才拿到内存,那会直接导致丢包。
我的经验:在实时性要求高的场景,比如光模块的 MAC 层处理,我从来不用堆分配。内存池是唯一的选择。
2.4 内存池的对齐要求
嗯,这里要注意。很多新手做内存池,忽略了对齐问题。
为什么会这样?因为现代 CPU 对未对齐的内存访问,要么性能极差,要么直接报错。比如 ARM 架构下,访问未对齐的 4 字节数据,会触发异常。
我曾经在一个项目里吃过这个亏。当时做 100G 光口协议栈,内存池没做对齐处理。结果在 ARM 平台上跑,时不时就崩了。查了两天才发现是内存对齐的问题。
所以,内存池设计时必须考虑对齐:
// 计算对齐后的大小
#define ALIGN_SIZE 8 // 通常对齐到 8 字节
static inline size_t align_up(size_t size) {
return (size + ALIGN_SIZE - 1) & ~(ALIGN_SIZE - 1);
}
// 初始化内存池时,确保每个块对齐
int mem_pool_init(mem_pool_t *pool, size_t block_size, size_t count) {
// 块大小必须对齐
size_t aligned_block = align_up(block_size + sizeof(mem_pool_node_t));
// 确保块大小至少能放下指针
if (aligned_block < sizeof(mem_pool_node_t)) {
aligned_block = sizeof(mem_pool_node_t);
}
// 分配整块内存(起始地址也要对齐)
pool->pool_start = aligned_alloc(ALIGN_SIZE, aligned_block * count);
if (!pool->pool_start) return -1;
// 初始化空闲链表
pool->free_list = (mem_pool_node_t *)pool->pool_start;
pool->block_size = aligned_block;
pool->block_count = count;
// 串联所有块
for (size_t i = 0; i < count - 1; i++) {
mem_pool_node_t *node = (mem_pool_node_t *)((char *)pool->pool_start + i * aligned_block);
node->next = (mem_pool_node_t *)((char *)pool->pool_start + (i + 1) * aligned_block);
}
// 最后一个块指向 NULL
mem_pool_node_t *last = (mem_pool_node_t *)((char *)pool->pool_start + (count - 1) * aligned_block);
last->next = NULL;
return 0;
}
避坑指南:我曾经见过有人用 malloc 分配内存池的起始地址。但 malloc 只保证对齐到 16 字节(在 64 位系统上),如果你的块大小是 32 字节,那没问题。但如果块大小是 24 字节,就出问题了。所以,我建议用 aligned_alloc 或者 posix_memalign 来分配池内存。
2.5 内存池的典型结构图
下面这张图,是我画的内存池核心结构。它展示了固定大小内存池的布局和分配流程:
2.6 什么时候用内存池?什么时候用堆?
我个人的经验是:
- 用内存池的场景:数据包缓冲区、协议头结构体、定时器控制块、DMA 描述符——这些对象大小固定,且分配频繁
- 用堆的场景:配置信息、日志字符串、动态大小的数据——这些对象大小不固定,且分配频率低
你想想看,光通信协议栈里,数据包大小虽然可变,但缓冲区通常是固定大小的(比如 2KB 一个)。这种情况下,用内存池管理缓冲区,性能提升非常明显。
我的习惯:在项目初期,我会先定义好所有固定大小的对象,然后为每个对象创建一个内存池。比如:64 字节池、256 字节池、2KB 池。这样,不同大小的请求都能快速响应。
好了,这一章的内容就到这里。内存池是光通信协议栈优化的基石,理解它的原理和设计要点,后面几章我们才能深入讨论更高级的优化技巧。