06 文件句柄与资源生命周期
1. 什么是文件句柄
操作系统不会只根据路径反复猜测文件。程序打开文件后会得到一个“已打开资源”的引用。在 Node.js Promise API 中表现为 FileHandle,底层关联文件描述符。
import { open } from "node:fs/promises";
const handle = await open(filePath, "r");
路径可以被重命名或删除,但已打开句柄的行为由操作系统决定。Windows 与 Linux 在占用文件删除方面差异明显。
2. 打开标志
| flag | 含义 | 风险 |
|---|---|---|
r |
只读,必须存在 | 不存在得到 ENOENT |
r+ |
读写,必须存在 | 可能修改原文件 |
w |
写入,创建或截断 | 会清空已有文件 |
wx |
排他写入 | 已存在得到 EEXIST |
a |
追加,创建或打开 | 写入位置由追加语义决定 |
ax |
排他追加 | 防止覆盖已有目标 |
新人最需要警惕 w:打开成功时旧内容可能已经被截断。
3. 必须关闭
import { open } from "node:fs/promises";
export async function readPrefix(
filePath: string,
length: number,
): Promise<Buffer> {
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(
buffer,
0,
length,
0,
);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}
finally 在成功、抛错和提前 return 时都会执行。不要依赖垃圾回收器替你及时关闭句柄。
4. 句柄泄漏
批量处理时遗漏关闭可能得到:
EMFILE: too many open files
它通常不是“这个文件坏了”,而是进程同时打开的文件太多。即使每次最终会被垃圾回收,也可能在回收前先耗尽句柄。
5. 分块读取
const buffer = Buffer.alloc(64 * 1024);
let position = 0;
while (true) {
const { bytesRead } = await handle.read({
buffer,
offset: 0,
length: buffer.length,
position,
});
if (bytesRead === 0) {
break;
}
position += bytesRead;
// 只处理 buffer.subarray(0, bytesRead)
}
最后一次读取往往不足一个完整 Buffer,不能错误处理整个 buffer。
6. Stream 与句柄
createReadStream() 和 createWriteStream() 默认会打开并在结束/销毁时关闭文件。若传入自有文件描述符或修改 autoClose,资源所有权就需要重新确认:谁打开,谁负责关闭;不要让两个组件重复关闭或都不关闭。
7. sync() 的边界
await handle.sync() 请求操作系统把文件数据同步到存储设备。它比普通写入完成具有更强持久性语义,但成本更高,也不能解决所有硬件和文件系统故障。普通临时转换文件通常不需要每次调用;关键配置或事务日志才可能考虑。
练习题
- 故意在
handle.read()后抛错,确认finally执行。 - 不关闭句柄循环打开文件,观察系统行为,然后恢复代码。
- 用
wx创建任务锁文件,分析它能解决和不能解决的问题。 - 解释谁拥有由
createReadStream()自动打开的句柄。