很多人用了一辈子
fclose,但从没想过它内部到底在干什么。这篇文章把 musl libc 的fclose实现拆开揉碎,逐行讲透。
先看完整代码
int fclose(FILE *f) { int r; FLOCK(f); r = fflush(f); r |= f->close(f); FUNLOCK(f); /* 注释见下文 */ if (f->flags & F_PERM) return r; __unlist_locked_file(f); FILE **head = __ofl_lock(); if (f->prev) f->prev->next = f->next; if (f->next) f->next->prev = f->prev; if (*head == f) *head = f->next; __ofl_unlock(); free(f->getln_buf); free(f); return r; }一、整体思路:三步走
| 步骤 | 做什么 | 为什么 |
|---|---|---|
| ① 刷新 + 底层关闭 | fflush+f->close | 把数据落盘,释放文件描述符 |
| ② 从链表中摘除 | 双向链表操作 | 把FILE从全局 open file list 中移除 |
| ③ 释放内存 | free | 回收FILE对象本身 |
看起来简单,但每个细节都有讲究。
二、逐行拆解
第1行:FLOCK(f)
FLOCK(f);上锁。FILE对象是多线程共享的,fclose必须是原子操作。在锁定状态下完成刷新和关闭,防止其他线程同时读写。
这就是为什么你不能在一个线程
fclose的同时,另一个线程对同一个FILE*调用fread—— 未定义行为。
第2-3行:刷新 + 关闭,错误用|=合并
r = fflush(f); r |= f->close(f);这是整段代码最精妙的地方之一。
fflush(f):把用户态缓冲区的数据刷到内核。失败返回 EOF。f->close(f):调用底层的close(fd),释放文件描述符。失败返回 -1。
关键在于r |= ...:
// 假设 fflush 成功(0),close 失败(-1) r = 0; r |= -1; // r = -1 (非零 = 错误) // 假设 fflush 失败(-1),close 成功(0) r = -1; r |= 0; // r = -1 (非零 = 错误) // 两者都成功 r = 0; r |= 0; // r = 0 (成功)任何一步出错,最终返回值都是非零(错误)。这符合 POSIX 规范:fclose成功返回 0,失败返回 EOF。
对比 glibc 的实现,也是类似的错误合并策略。这是工业级代码的共识。
第4行:FUNLOCK(f)
FUNLOCK(f);解锁。注意:此时FILE已经关闭,但对象还没释放。为什么这么早解锁?
看那段核心注释:
Past this point, f is closed and any further explicit access to it is undefined. However, it still exists as an entry in the open file list...
翻译:从这里开始,f已死,但尸体还挂在链表上。后续操作(摘除链表、free)不需要持有锁,因为其他线程不应该再碰这个FILE*了。
提前解锁的好处:缩短锁持有时间,减少竞争。
第7行:永久文件直接返回
if (f->flags & F_PERM) return r;这是很多人忽略的分支。
stdin、stdout、stderr这三个流,flags里有F_PERM标记。它们的FILE对象是静态分配的,程序结束才回收,不能 free。
所以遇到永久文件,只做刷新+关闭,不摘除链表、不 free,直接返回。
这就是为什么你
fclose(stdout)不会 crash,但fclose一个malloc出来的FILE*就会 free 掉。
第9行:__unlist_locked_file(f)
__unlist_locked_file(f);这是一个weak alias(弱别名):
static void dummy(FILE *f) { } weak_alias(dummy, __unlist_locked_file);默认是空操作。但如果其他模块(比如pthread线程取消处理)需要在fclose时做额外清理,可以覆盖这个符号。
弱别名 = 钩子机制。默认为空,可选覆盖。设计非常优雅。
第11-14行:双向链表摘除
FILE **head = __ofl_lock(); if (f->prev) f->prev->next = f->next; if (f->next) f->next->prev = f->prev; if (*head == f) *head = f->next; __ofl_unlock();musl 维护了一个全局双向链表__ofl_head,所有malloc出来的FILE对象都挂在上面。
摘除操作标准三步:
| 操作 | 含义 |
|---|---|
f->prev->next = f->next | 前驱的 next 指向后继 |
f->next->prev = f->prev | 后继的 prev 指向前驱 |
*head = f->next | 如果摘的是头节点,更新头指针 |
为什么要上锁?因为其他线程可能正在遍历这个链表(比如
fwalk、__fmod)。
第16-17行:释放内存
free(f->getln_buf); free(f);getln_buf:fgets/getline用的行缓冲区,单独 malloc 的,先释放。f:FILE对象本身,最后释放。
顺序不能反。先放子资源,再放父对象。
三、核心设计思想总结
| 设计点 | 体现 |
|---|---|
| 错误合并 | `r |
| 最小锁粒度 | 刷新完就解锁,链表操作不持锁 |
| 永久文件保护 | F_PERM分支防止 free 掉stdin/stdout/stderr |
| 弱别名扩展 | __unlist_locked_file可被线程模块覆盖 |
| dead object 容忍 | 注释明确说明:链表操作必须能处理已关闭的 FILE |
四、对比 glibc,差在哪?
| 对比项 | musl | glibc |
|---|---|---|
| 错误合并 | r |= | 类似,但 glibc 用 ` |
| 永久文件 | F_PERM标志 | 用_IO_IS_FILEBUF等宏判断 |
| 链表摘除 | 手动双向链表 | 用_IO_list_all全局锁 + 链表 |
| 弱别名钩子 | weak_alias | 函数指针表(_IO_JUMPS) |
musl 的实现更短、更直白,glibc 的更复杂但功能更多(比如 locale 处理)。
五、你应该记住的三句话
fclose不只是 close(fd),它还负责刷新缓冲区、摘除链表、释放内存。r |=是工业级错误处理的标准写法,任何一步失败都算失败。- 关闭后的
FILE对象是"僵尸"——还挂在链表上,但任何访问都是未定义行为。
参考:musl libc 1.2.5 src/stdio/fclose.c