realloc 失败时原指针仍然有效:`p = realloc(p, n)` 这一行漏掉了一个临时变量
本文首发于 CSDN。结论都能用手册原文核对,代码可以照着跑。
一段跑了两年的日志采集程序,某天被投诉「偶尔丢日志」。不是全丢,是每次 OOM 前后丢掉最后几行。
查了两天。程序的写法是这样的:
char*buf=malloc(1024);size_tcap=1024;/* ... 读数据,满了就扩容 ... */buf=realloc(buf,cap*2);cap*=2;strcpy(buf+used,line);重试逻辑对,扩容逻辑对,读逻辑也对。问题在那句赋值上:realloc失败时返回NULL,赋回buf之后,原来那块内存的地址就丢了——既没释放,也再也拿不到。紧接着strcpy往NULL上写,程序崩在无关的地方,而崩之前那几行日志已经写进「丢失的旧块」里。
手册对失败的定义,比大多数人以为的严格
realloc的 RETURN VALUE 有一句话,很多人从没读到那一行:
If these functions fail, the original block is left untouched; it is not freed or moved.
失败时原块保持不变,既不释放也不移动。
这句话反过来读就是:失败之后你还持有原指针,它仍然有效,你可以继续用它,或者主动free掉。丢失地址不是realloc干的,是那句赋值干的。
正确的写法只有一个形状——先收进临时变量:
void*tmp=realloc(buf,cap*2);if(tmp==NULL){/* 原块还在,buf 仍可用;这里决定是退出还是保留旧容量 */free(buf);return-1;}buf=tmp;cap*=2;还有三个附带结论,同样写在手册里
第一,realloc可能搬家。
the returned pointer may be the same as p if the allocation was not moved … or different from p if the allocation was moved to a new address.
所以任何缓存了旧指针的地方(链表节点、外部结构体、别的线程)在realloc之后都必须更新,否则就是悬垂指针。
第二,realloc(p, 0)在 glibc 上是个坑。手册的 BUGS 一节写得很直接:
The glibc implementation of realloc() is not consistent with that, and as a consequence, it is dangerous to call realloc(p, 0) in glibc.
给出的绕法也很简单:realloc(p, size ? size : 1)。真要释放就直接free,别用realloc(p, 0)表达「缩到零」。
第三,reallocarray能挡住乘法溢出。手册对malloc(n * size)这种写法明确提了:
If the multiplication of n and size would result in integer overflow, then calloc() returns an error. By contrast, an integer overflow would not be detected in the following call to malloc()
reallocarray(p, n, size)做的就是把这道检查加上。扩容逻辑里凡是「元素个数 × 元素大小」的乘法,都该用它。
为什么这个 bug 只在 OOM 时出现?
因为它需要一个「分配失败」的条件才会触发。
| 条件 | 触发概率 | 现象 |
|---|---|---|
| 机器内存充足 | 几乎为零 | 完全正常,测试环境永远看不到 |
| 容器内存接近上限 | 低 | 偶发崩溃,日志里最后几条丢失 |
| 开了 overcommit | 更低 | realloc返回非空,真正写的时候才 OOMKill |
有RLIMIT_AS限制 | 中 | 可稳定复现,但需要专门构造 |
最后一行值得多说。手册 NOTE 里写了 Linux 默认的乐观分配策略:
when malloc() returns non-NULL there is no guarantee that the memory really is available
也就是说realloc返回非空,不代表内存真的到手了。这条会让「用返回值判断是否 OOM」的思路在高负载下失效,进程可能直接被 OOM killer 干掉,连失败分支都走不到。
真正可控的复现方式是用setrlimit设一个RLIMIT_AS上限,把分配压到失败。我们后来把它写进了单测:每个扩容点都跑一次「限制到刚好不够」的用例。
换成预分配加分段,能省掉什么?
如果逻辑允许,把「反复扩容」换成「按块分配 + 块指针数组」,就不需要realloc了:
structchunk{char*p;size_tlen;};staticstructchunkchunks[64];/* 每块固定 64KB,满了就开下一块,永不 realloc */代价是读取时要跨块拼接,收益是没有搬家、没有失败分支、没有悬垂指针。日志采集这种「只追加、不随机访问」的场景,分段几乎总是更省心。
顺带说两句工具
valgrind能查泄漏,但查不出「丢了指针」——因为那块内存在进程退出时还在,只是没人引用。-fsanitize=address能抓住「往 NULL 上写」这个后果,但抓不住根因。
真正能抓住根因的是代码审查规则:凡是对realloc的返回值直接赋给原指针的写法,一律标记为可疑。这条规则执行下来,我们代码库里同类写法清掉了十七处。
这类基础题我先按手册条目编号把草稿归档(归档表用 AI墨衍 维护),再在 CSDN 上写成文章,会员的发文额度够我一口气把一批草稿都存下来,GEO 检测能看出文章在 AI 搜索里有没有被引用,图文同步则省掉了每个平台重排一遍的时间:https://mp.csdn.net/vip?utm_source=ITmoyan
常见问题
Q:realloc失败之后直接free(buf)对吗?
对。原块还在,free它会正常释放。前提是这句赋值没发生——一旦buf = NULL,就没有块可释放了。
Q:realloc(NULL, n)等价于malloc(n)吗?
等价。手册原文:「If p is NULL, then the call is equivalent to malloc(size)」。
Q:缩小容量会不会失败?
一般不会,但可能发生搬家。缩小也可能返回新地址,所以临时变量的写法对缩小同样适用。
Q:calloc和malloc哪个更适合数组?
calloc。它在乘法溢出时返回错误,而malloc(n * size)不检查溢出,calloc还顺手把内存清零。
Q:为什么我的程序在realloc之后指针值变了但数据没丢?
因为数据被拷到了新地址。realloc保证「从块首到新旧尺寸较小者」的内容不变,但新地址需要重新取,旧地址立即失效。
Q:容器里 C 程序的 OOM 是什么表现?
多数情况是被 cgroup 直接 OOMKill,进程收不到NULL,也就走不到失败分支。这时候看dmesg比看日志有用。
写在最后
这次修复只改了一行搬移,但引出一个更值得记住的判断:库里返回NULL的失败,往往不是「什么都发生了」,而是「什么都没发生」。realloc失败时什么都没发生,是你自己的赋值把它变成了「发生了」。
我后来把这批分配函数的「失败后的正确动作」整理成了一张表,散在几个项目的编码规范里,底稿是用 AI墨衍 把手册条目按「操作 / 失败后指针是否有效 / 该做什么」三列排出来的,几份文档之间同步一次就够。查表比凭记忆稳。想要把发文额度、GEO 检测、图文同步一起用起来的话,入口在这里:https://mp.csdn.net/vip?utm_source=ITmoyan