小记 Node.js 关于文件描述符的坑

2024-06-17 09:57:02 +08:00
 zy445566

在之前遇到过一个 Node.js 使用文件描述符来读取文件,未释放文件描述符的坑,对此还对 Node.js 提过 PR ,让 Node.js 支持文件句柄的变量 GC 后,也同时销毁句柄,在 fs 的 FileHandleAPI 提供了未使用变量时进行尝试关闭文件描述符并提供警告,但是很多人并没有使用 FileHandleAPI ,而是更习惯于使用早期的 File System 的 API ,而除了显式的未释放的文件描述符,还有隐式的文件描述符。下面我则根据两个案例来进行讲解关于文件描述符的坑。

显式的文件描述符

显式的文件描述符,我们通常会使用 fs.open() 方法来打开文件,然后通过 fs.read() 方法来读取文件。如下代码所示。

const fs = require("fs");
fs.open("test.txt", "r", (err, fd) => {
  // 做一些操作
});
setInterval(() => {
  // 一直循环
}, 1000);

在这里其实有两个误区:

针对误区 1 ,因为大多数人在没有完整阅读 Node.js 文档,由于 Node.js 又存在回收机制,所以很多人会认为 fd 变量被回收了,那么 fd 对应的文件描述符也会被自动回收。

针对误区 2 ,这个误区则是没有 C 语言基础的同学容易犯的错误,而是更习惯于 JavaScript 的用法,更少的考虑回收问题,所以不会去 close 。

针对上面的误区,如果没有 close 的情况,什么时候文件描述符会被回收呢?在这个情况下,只有 Node.js 进程销毁的时候才会进行文件描述符的回收。

隐式文件描述符

对于显式的文件描述符,隐式的文件描述符更具有欺骗性,那么什么叫隐式的文件描述符呢?简单来说就是没有直接强制我们传递文件描述符或直接使用文件描述符却又使用到了文件描述符的场景,比如下面的代码。

const fs = require("fs");
// 创建一个文件读流
fs.createReadStream("test.txt");
setInterval(() => {
  // 一直循环
}, 1000);

在这里由于没有像显式使用文件描述符,而是将文件描述符作为可选变量,像这种隐式使用文件描述符的方法更具有欺骗性。

在上面的案例中,相信很多人都有类似的使用可读流的用法并且没有关闭可读流,但哪怕你仅仅式创建了可读流都将会生成一个文件描述符,和上面的显示文件描述符一样,必须手动关闭才能释放文件描述符,并且着这个文件描述符的占用都是 Node.js 进程销毁的时候才会进行回收。在这种场景下,如果你和 C 或 C++交互,哪怕你使用的是读流,都可能导致当前的文件描述符占用,导致 C 或 C++无法正常读取文件。

现象和解决方法

如果说我们的文件描述符被大量泄漏了,那么到了一定的数值,整个 Node.js 进程服务将会出现一个假死的现象,比如一直卡住在读文件的方法上,无法进行下一步运行,那么这个时候我们就可以考虑是否泄漏了文件描述符。

对于文件描述符的泄漏,我们在 linux 下可以使用lsof命令来查看你某个进程下的文件描述符情况。比如我们的 Node.js 进程是 12345 ,那么我们可以使用以下的命令来查看该进程下的文件描述符使用情况。

lsof -p 12345

然后我们根据文件的使用情况,查找代码的使用情况来对代码进行一个修改对文件描述符进行关闭,比如上面 fs.open 和 createReadStream 的例子,可以使用下面的代码来进行关闭。

const fs = require("fs");
fs.open("test.txt", "r", (err, fd) => {
  // 使用完成后关闭文件描述符
  fd.close();
});
const fd = fs.createReadStream("test.txt");
// 流使用完成后进行销毁
fd.destroy();
3177 次点击
所在节点    Node.js
25 条回复
CodeCodeStudy
2024-06-17 10:04:54 +08:00
像 java 的 try 或 python 的 with 就能有效避免这类问题
GiantHard
2024-06-17 10:13:58 +08:00
@CodeCodeStudy #1 js 的相关功能已经在提案中了 https://github.com/tc39/proposal-explicit-resource-management

TS 5.2 也加入了 `using` 关键字来帮助管理需要释放的资源 https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html
abelyao
2024-06-17 10:20:47 +08:00
@GiantHard 啊…… using ,以前写 C# 会用到的东西… 很方便
GiantHard
2024-06-17 10:34:51 +08:00
@abelyao #3 是的,C# 的 using 语法非常好使,IDE 支持也很完善,会提示没有使用 using 管理的 disposable 对象,还能自动生成 Disposable 模式代码
nomagick
2024-06-17 10:38:47 +08:00
这根本不是问题,fd API 是 C 传下来的,如果你不懂 C 的传承那你根本不应该会使用 fs.open ,如果你懂 C 的传承那你不会忘记 fs.close

而 fd 的 GC 到底是什么意思,fd 是一个数啊,一个数,这个数复制到任何地方都可能的,那我有 native 代码也使用了这个 fd 怎么办?在 JS 世界打开文件, fd 传到 native 世界并继续使用,这时 js 世界的变量销毁,结果你把 fd 给我 close?

你那个 PR 在哪里我要过去评论一下
nomagick
2024-06-17 10:42:49 +08:00
在新的语法糖写入标准之前,已有的 FinalizationRegistry 已经足够实现资源销毁
IvanLi127
2024-06-17 10:44:24 +08:00
我觉得这不是坑,手动创建资源后不手动释放是不对的。我理解的 GC 不应该管这些,想写代码方便不都靠语法糖实现嘛?

或许我已经是计算机的形状了😂
InkStone
2024-06-17 11:13:41 +08:00
fd 泄露好歹只是泄露,要是 gc fd 变量的时候直接把 fd 关了,那好多应用恐怕跑都跑不起了了……

这种东西对通用 GC 来说就是不好处理的,refcount 和 RAII 机制都会更顺滑一点。using 相当于就是手动引入 RAII 了。
iugo
2024-06-17 11:46:38 +08:00
不单是 Node.js, 在其他 JS 运行时场景可能也存在这样的问题. 不过如果写了单元测试, 应该在测试的时候会被检测出泄漏. 或许一些静态更好.

稍微抽象一点, 我觉得这里面有两个写程序的问题:

1. 资源没有被关闭. 有语法糖当然更好, 但如果没有, 这也是一个程序人员应该避免的使用错误, 不应该被称为坑.
2. 未利用的返回值. `setInterval(() => {}, 1000);` 这种写法是不良的, 因为 `setInterval()` 是有返回值的, 那我们就应该使用这个返回值, 在必要的地方 `clearInterval(intervalID)`.

我觉得上述问题如果都应该由程序人员注意. 即便有了 using, 也只是减轻负担, 要程序人员了解并主动去用, 才能写出健壮性更强的代码. 必要时在 ESLint 中要求, 特定函数只能 using 来避免开发者误用造成不必要的隐患.
nulIptr
2024-06-17 11:50:56 +08:00
我怎么感觉我接触过的编程语言里面文件操作都是有 open 就要有 close ,就像 c++内存有 new 就要有 delete ,这也叫坑吗,楼上说的 java 的 try 或 python 的 with 也是要自己写个关键字或者手动析构的
body007
2024-06-17 11:57:00 +08:00


还是不能太相信使用者,Go 语言会为文件对象设置 GC 时执行的 close 操作,不过仍然推荐使用 defer 主动 close 。

更别说 rust 了,全自动 close ,不需要使用者显示 close 。
libook
2024-06-17 12:26:10 +08:00
最好是给开发者选择,比如 open 的时候传个参数是否在 gc 的时候自动 close 。
正常来讲如果描述符的引用都已经被 gc 了,程序就应该无法调用到这个描述符了,也就无法对文件进行操作了,所以 gc 时自动 close 设计成默认行为是可行的。
ysc3839
2024-06-17 15:23:02 +08:00
@nomagick native 代码要用的话可以 dup 。这种情况在 C++里就是 RAII ,如果一个对象持有另一个对象,那传递到别的地方时自然要考虑生命周期问题。
sujin190
2024-06-17 16:42:42 +08:00
@libook #12 比如各种 fd 传到 native 读取数据去了,之后通过 callback 回传,你 callbak 函数很大可能并没有引用 fd ,那就是此时 fd 可能只被 native 使用了,应用层并没有持有 fd 是不是可以被 gc 了

@ysc3839 #13 每次 dup 可能有性能问题不合适,毕竟文件 IO 可能非常频繁
libook
2024-06-17 17:21:10 +08:00
@sujin190 #14 绝大部分 JS 应用都仅在单语言、单引擎、单进程、单线程内使用,所以针对绝大部分场景来说,gc 自动 close 算是一种健壮性和便捷性的设计。
针对特殊场景,比如你所说的 fd 的传递(如果可行的话),最好给 open 加一个可选的参数,可以让开发者显式声明不随 gc 自动 close 。
ysc3839
2024-06-17 20:12:59 +08:00
@sujin190 只是在传递给别的库时 dup 一次,而不是每次读写都要 dup ,并不会有太大的性能问题。如果真的不想 dup ,那还可以放弃所有权,把所有权转移给别的库。
DOLLOR
2024-06-17 20:51:36 +08:00
go 味的 js/ts 写法:


import fs from 'node:fs'

async function test() {


await using stack = new AsyncDisposableStack()

const fd = await fs.promises.open('path/to/file', 'r')
stack.defer(() => fd.close())

// do something with fd
// .........


const stream = fs.createReadStream('path/to/file')
stack.defer(() => stream.destroy())

// do something with stream
// .........


}
weijancc
2024-06-18 08:34:34 +08:00
这个确实不能说是坑, 本身句柄的方式就是要手动 close 的.
sujin190
2024-06-18 09:34:47 +08:00
@ysc3839 #16 这和你上面说的有冲突吧,读写都需要进入 native 啊,js 并没有在语言层面提供一种一定保证 fd 引用的方式吧,否则如果有这种可靠方式那就不需要你说的这种 dup 啊,而不能可靠引用的问题主要是因为 callback ,进入 native 执行的 callback 可能在 js 层面失去对 fd 的所有引用完全是可能,同步调用的语言就没这个问题,毕竟 native 返回前栈肯定保持了对 fd 的引用
ysc3839
2024-06-18 09:38:26 +08:00
@sujin190 js 里对象没有被回收,为什么不能保证引用? js 的对象不是在栈上的。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1050068

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX