17、内存池与引用计数:智能指针模拟、共享内存管理、循环引用问题
内存池和引用计数,这两个东西放在一起讲,其实是有深意的。我早年做嵌入式系统时,就吃过循环引用的亏——一个对象死活释放不掉,内存泄漏查了三天。后来我才明白,光有内存池不行,光有引用计数也不行,得把它们结合起来,才能真正管好共享内存。
说白了,引用计数就是给每个对象配一个「小本本」,记录有多少人在用这个对象。没人用了,就归还给内存池。这个思路听着简单,但坑不少。咱们今天就把这些坑一个一个填上。
17.1 引用计数的基本原理
引用计数的核心思想:每个对象维护一个计数器,新增引用时加1,释放引用时减1。计数器归零时,对象就可以被销毁了。
我习惯用这样的结构体来管理:
typedef struct RefCounted {
int ref_count; // 引用计数器
void (*destroy)(void*); // 自定义析构函数
// 实际数据紧随其后
} RefCounted;
// 增加引用
void* ref_retain(void* ptr) {
if (!ptr) return NULL;
RefCounted* rc = (RefCounted*)ptr - 1;
rc->ref_count++;
return ptr;
}
// 释放引用
void ref_release(void* ptr) {
if (!ptr) return;
RefCounted* rc = (RefCounted*)ptr - 1;
rc->ref_count--;
if (rc->ref_count == 0) {
if (rc->destroy) rc->destroy(ptr);
pool_free(rc); // 归还给内存池
}
}
你看,这里有个巧妙的设计:把引用计数头放在实际数据的前面,用户拿到的指针直接指向数据区,对用户透明。我曾经在项目中用这个模式管理网络缓冲区,效果很好。
17.2 智能指针的模拟实现
C语言没有智能指针,但我们可以模拟。我个人喜欢用宏来实现,既轻量又直观:
#define DECLARE_SMART_PTR(type) \
typedef struct { \
type* ptr; \
} type##_ptr;
#define smart_ptr(type) type##_ptr
// 创建智能指针
#define smart_new(type, ...) ({ \
type* p = (type*)pool_alloc(sizeof(type)); \
if (p) { \
*p = (type){__VA_ARGS__}; \
RefCounted* rc = (RefCounted*)p - 1; \
rc->ref_count = 1; \
rc->destroy = NULL; \
} \
p; \
})
// 使用示例
typedef struct {
int id;
char name[32];
} User;
smart_ptr(User) user = smart_new(User, .id = 1, .name = "Alice");
嗯,这里要注意:宏虽然方便,但调试起来比较痛苦。我建议在项目初期先用宏快速迭代,稳定后再考虑是否改成函数。
17.3 共享内存管理策略
共享内存的难点在于:多个模块同时访问同一块内存,谁该负责释放?引用计数天然适合解决这个问题。
我设计过一个共享缓存池,结构是这样的:
typedef struct SharedBuffer {
RefCounted header; // 引用计数头
size_t size; // 数据大小
uint8_t data[0]; // 柔性数组
} SharedBuffer;
// 创建共享缓冲区
SharedBuffer* shared_buf_create(size_t size) {
SharedBuffer* buf = (SharedBuffer*)pool_alloc(
sizeof(SharedBuffer) + size);
if (!buf) return NULL;
buf->header.ref_count = 1;
buf->header.destroy = NULL;
buf->size = size;
return buf;
}
// 多个模块共享使用
void module_a_process(SharedBuffer* buf) {
ref_retain(buf); // 增加引用
// ... 处理数据 ...
ref_release(buf); // 处理完释放
}
void module_b_process(SharedBuffer* buf) {
ref_retain(buf);
// ... 处理数据 ...
ref_release(buf);
}
你想想看,如果没有引用计数,module_a和module_b谁先处理完,谁就把内存释放了,另一个模块就崩了。有了引用计数,最后一个释放的人负责销毁,完美。
17.4 循环引用问题
这是引用计数最大的坑。两个对象互相引用,计数器永远不为零,内存就泄漏了。
举个例子:
typedef struct Node {
int value;
struct Node* next;
struct Node* prev;
} Node;
// 创建循环链表
Node* a = node_create(1);
Node* b = node_create(2);
a->next = b; // b的引用计数+1
b->prev = a; // a的引用计数+1
// 现在a和b的引用计数都是2
// 释放时:a->ref_count=1, b->ref_count=1
// 谁也释放不了!
我曾经在一个图形引擎项目中遇到这个问题。场景管理器引用了所有节点,节点又反向引用了场景管理器。结果退出时,内存池里一堆垃圾。
解决方案有几种:
- 弱引用:不增加引用计数。比如父节点强引用子节点,子节点弱引用父节点。
- 手动打破循环:在销毁前手动解除循环关系。
- 标记-清扫:定期遍历所有对象,标记可达对象,清扫不可达的。这个开销大,但最彻底。
我比较推荐弱引用方案,因为它对代码侵入最小:
typedef struct {
void* ptr; // 弱引用指针
int valid; // 有效性标记
} WeakRef;
// 弱引用不增加计数
WeakRef weak_ref_create(void* ptr) {
WeakRef wr = {.ptr = ptr, .valid = 1};
return wr;
}
// 使用前检查有效性
void* weak_ref_lock(WeakRef* wr) {
if (!wr->valid) return NULL;
ref_retain(wr->ptr); // 临时强引用
return wr->ptr;
}
17.5 内存池与引用计数的整合
把引用计数和内存池结合起来,才是完整方案。我的做法是:内存池负责分配和回收,引用计数决定回收时机。
核心原则:内存池只管理空闲块,引用计数决定哪些块变成空闲块。
整合后的流程:
- 从内存池分配一块内存,初始化引用计数为1。
- 每次有人引用,调用ref_retain增加计数。
- 每次释放引用,调用ref_release减少计数。
- 计数归零时,调用自定义析构函数,然后归还给内存池。
这里有个细节:内存池的分配函数需要预留引用计数头的空间。我习惯在内存池内部做这个处理,对上层完全透明。
小技巧:可以在内存池的调试模式下,打印引用计数的变化日志。我曾经靠这个日志,半小时就定位到了一个循环引用问题。
17.6 性能考量与避坑
引用计数不是免费的。每次retain和release都有原子操作的开销。在多线程环境下,这个开销更明显。
我建议:
- 单线程环境用普通int计数,不要用atomic。
- 多线程环境用atomic操作,但注意CAS(Compare-And-Swap)的性能。
- 频繁retain/release的场景,考虑批量操作。
警告:不要在中断处理函数里做引用计数的增减操作。我曾经因为这个导致系统死锁,排查了两天。中断里只做标记,推迟到任务级处理。
还有一个常见的坑:回调函数中的引用计数。比如你注册了一个回调,回调里retain了某个对象,但忘记在回调结束后release。这种泄漏很难查,因为不是每次都会触发。
我的习惯是:在回调开始和结束处,成对地写retain和release,就像锁一样:
void callback(void* user_data) {
ref_retain(user_data); // 加引用
// ... 处理逻辑 ...
ref_release(user_data); // 释放引用
}
嗯,这样写虽然啰嗦,但绝对不会漏。
17.7 本章小结
引用计数和内存池是天生一对。内存池解决了分配效率问题,引用计数解决了生命周期问题。但循环引用这个坑,需要你用弱引用或者手动打破来填。
我个人觉得,引用计数最适合管理那些生命周期不确定的共享对象。如果对象的生命周期很明确,比如栈上分配或者有明确的所有者,那就不需要用引用计数,反而增加复杂度。
最后送大家一句话:引用计数不是银弹,但用好了,能让你的内存管理优雅很多。
公众号:蓝海资料掘金营,微信deep3321