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() 请求操作系统把文件数据同步到存储设备。它比普通写入完成具有更强持久性语义,但成本更高,也不能解决所有硬件和文件系统故障。普通临时转换文件通常不需要每次调用;关键配置或事务日志才可能考虑。

练习题

  1. 故意在 handle.read() 后抛错,确认 finally 执行。
  2. 不关闭句柄循环打开文件,观察系统行为,然后恢复代码。
  3. wx 创建任务锁文件,分析它能解决和不能解决的问题。
  4. 解释谁拥有由 createReadStream() 自动打开的句柄。