存储期、生命周期与所有权:C 程序如何不丢资源
C 没有析构函数。 这并不意味着 C 没有生命周期。 每个对象都有存储期,每个资源都有获取和释放边界,每条错误路径都必须维护这些边界。 C 里的内存泄漏、double-free、悬垂指针和文件句柄泄漏,本质上都是所有权协议没有被代码结构表达出来。
存储期决定存储存在多久
C 常见存储期包括自动、静态、线程和动态分配。 存储期回答的是“这段存储什么时候存在”。 它不等于对象值是否有效,也不等于资源是否仍然属于当前调用者。
| 存储期 | 典型写法 | 存在时间 | 风险 |
|---|---|---|---|
| automatic | 局部变量 | 块进入到退出 | 返回局部地址 |
| static | 全局或 static 局部 | 程序期 | 初始化顺序、共享状态 |
| thread | _Thread_local |
线程期 | 线程退出清理 |
| allocated | malloc |
free 前 |
泄漏、重复释放 |
存储期像仓库租期。 对象值和资源所有权像仓库里放的货。 租期还在,不代表货仍然属于你。
自动对象不应该逃逸
自动对象通常在栈帧中。 函数返回后,它的生命周期结束。 返回局部对象地址会留下悬垂指针。
int* bad(void) {
int value = 42;
return &value;
}
这段代码返回的地址曾经指向 value。
函数返回后那段栈空间可被复用。
后续读写是未定义行为。
有时它“看起来还能用”,只是因为栈还没被覆盖。
静态对象是全局状态
静态存储期对象在程序生命周期内存在。 它们适合常量表和不可变配置。 但可变静态对象会带来并发、测试隔离和初始化顺序风险。
static int global_counter;
void inc(void) {
++global_counter;
}
单线程下这段代码简单。 多线程下它是数据竞争。 测试中它会跨用例保留状态。 库代码中它可能破坏多实例隔离。
静态对象要么不可变,要么加同步,要么下沉到显式上下文对象。
动态分配只是获得存储
malloc 返回一段满足对齐要求的未初始化存储。
它不负责初始化业务不变量。
free 释放这段存储。
释放后所有指向它的指针都变成悬垂指针。
int* p = malloc(sizeof *p);
if (p == NULL) {
return -1;
}
*p = 7;
free(p);
p = NULL;
把指针置空只能保护当前变量。 其他别名指针仍然悬垂。 因此所有权设计不能只靠“free 后置空”。
所有权要写进 API
C API 必须明确谁创建、谁释放、谁借用、谁转移。 没有说明的所有权就是隐患。
typedef struct Image Image;
Image* image_load(const char* path);
void image_destroy(Image* image);
const unsigned char* image_pixels(const Image* image);
这个接口表达了三件事:
image_load返回拥有者指针。image_destroy释放拥有者指针。image_pixels返回借用视图,不能释放。
名称、文档和类型要一致。 如果返回值需要调用者释放,函数名和注释必须说清楚。
错误路径是资源管理的压力测试
资源泄漏最常发生在中途失败。 成功路径通常容易写对。 失败路径需要成体系的 cleanup。
int load_pair(const char* a, const char* b) {
FILE* fa = NULL;
FILE* fb = NULL;
int rc = -1;
fa = fopen(a, "rb");
if (fa == NULL) goto cleanup;
fb = fopen(b, "rb");
if (fb == NULL) goto cleanup;
rc = 0;
cleanup:
if (fb != NULL) fclose(fb);
if (fa != NULL) fclose(fa);
return rc;
}
goto cleanup 在 C 里不是粗糙写法。
它让资源释放顺序集中,避免每个错误分支手写一遍。
复杂函数仍然应该拆小,cleanup 不是巨型方法的借口。
释放顺序通常要反向
资源之间可能有依赖。 后获取的资源往往依赖先获取的资源。 释放时应该反向执行。
create context
-> open file
-> allocate buffer
-> map region
cleanup:
unmap region
free buffer
close file
destroy context
这种栈式纪律是 C++ RAII 的思想前身。 C 里需要人工维护。 C++ 里可以交给析构函数和对象成员顺序。
拥有者指针和借用指针要分开命名
C 类型系统不能直接表达所有权。 命名和封装要补上。
typedef struct Buffer {
unsigned char* owned_data;
size_t len;
} Buffer;
typedef struct Slice {
const unsigned char* data;
size_t len;
} Slice;
Buffer 拥有内存。
Slice 只是观察。
不要让借用对象执行释放。
不要让拥有者对象被随意浅拷贝。
初始化函数要建立不变量
不要让半初始化对象泄漏到调用方。 初始化函数要么成功建立完整不变量,要么失败并清理已获取资源。
typedef struct Writer {
FILE* file;
unsigned char* buffer;
size_t capacity;
} Writer;
int writer_init(Writer* w, const char* path, size_t capacity);
void writer_deinit(Writer* w);
调用方负责传入存储。
writer_init 负责填充资源。
writer_deinit 负责释放内部资源。
这种模式适合栈上对象和嵌入式环境。
拷贝策略必须明确
包含资源的结构体不能默认允许 = 复制。
C 编译器会按字节复制字段。
这会复制指针值,导致两个对象认为自己拥有同一资源。
Buffer a = buffer_create(1024);
Buffer b = a; /* 高风险浅拷贝 */
应提供显式 clone 或 move 风格函数。
int buffer_clone(Buffer* dst, const Buffer* src);
Buffer buffer_move(Buffer* src);
move 风格函数应让源对象进入可销毁但不拥有资源的状态。
分配失败是正常输入
系统内存不足、配额限制、容器限制都可能让分配失败。
可靠 C 代码不能假设 malloc 永远成功。
void* checked_realloc(void* old, size_t n) {
void* next = realloc(old, n);
if (next == NULL && n != 0) {
return NULL;
}
return next;
}
realloc 失败时旧指针仍然有效。
直接写 p = realloc(p, n) 会在失败时丢失原指针,造成泄漏。
这类细节必须进入代码审计。
资源不只有内存
C 程序管理的资源包括:
- 堆内存。
- 文件描述符。
- socket。
- mmap 区域。
- mutex。
- 线程。
- GPU buffer。
- 数据库句柄。
- 临时文件。
- 权限 token。
每种资源都需要对应释放函数。 每种资源都可能有失败路径。 每种资源都需要观测和日志。
生命周期和并发会互相放大
单线程中悬垂指针已经危险。 多线程中对象释放和访问交错,会变成 use-after-free。
线程 A:读取 shared->data
线程 B:free(shared)
线程 A:继续使用 data
解决方式不是简单加一把锁。 需要明确对象所有权、引用计数、停止协议、join 顺序和资源释放点。 如果对象被多个线程借用,销毁前必须让所有借用结束。
诊断工具要覆盖错误路径
ASan 能发现释放后使用和越界。 LSan 能发现泄漏。 UBSan 能发现部分生命周期外访问。 但工具需要执行到对应路径。 错误路径测试不能只覆盖成功样例。
clang -std=c23 -g -O1 \
-fsanitize=address,undefined \
resource_test.c
配合故障注入更有效:
- 模拟
malloc失败。 - 模拟
fopen失败。 - 模拟中途解析失败。
- 模拟权限不足。
- 模拟超时和取消。
API 文档要包含释放协议
每个返回指针的函数都应该回答:
- 返回值是否可为空。
- 调用方是否拥有它。
- 应该用哪个函数释放。
- 是否可以跨线程使用。
- 是否可以缓存借用指针。
- 失败时已分配资源如何处理。
- 回调中是否允许释放。
- 是否有降级策略。
没有释放协议的 API,最终会把资源释放变成人脑记忆。
工程清单
- 每个
create/open/acquire都有对应destroy/close/release。 - 每条错误路径都能到达 cleanup。
- 释放顺序与获取顺序相反。
realloc使用临时指针接收。- 借用指针不执行释放。
- 拥有者对象禁止隐式浅拷贝。
- 静态可变状态必须经过并发审计。
- 对资源失败路径做测试。
- sanitizer 构建覆盖错误路径。
- 资源释放日志要能关联请求或任务。
小结
C 的资源安全不是靠语言自动完成,而是靠清晰的所有权协议和统一的清理结构。 存储期告诉你存储什么时候存在。 生命周期告诉你对象什么时候有效。 所有权告诉你谁负责释放资源。 三者任何一个模糊,都会在错误路径、并发路径或跨模块路径里放大成生产风险。