小记 Node.js 关于文件描述符的坑
在之前遇到过一个 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:fd 变量如果被回收了(GC),那么 fd 对应的文件描述符也会被自动回收。
- 误区 2:fs.open 后,文件描述符不需要 close 。
针对误区 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();
小记 Node.js 关于文件描述符的坑
像 java 的 try 或 python 的 with 就能有效避免这类问题
#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
啊…… using ,以前写 C# 会用到的东西… 很方便
#3 是的,C# 的 using 语法非常好使,IDE 支持也很完善,会提示没有使用 using 管理的 disposable 对象,还能自动生成 Disposable 模式代码
这根本不是问题,fd API 是 C 传下来的,如果你不懂 C 的传承那你根本不应该会使用 fs.open ,如果你懂 C 的传承那你不会忘记 fs.close
而 fd 的 GC 到底是什么意思,fd 是一个数啊,一个数,这个数复制到任何地方都可能的,那我有 native 代码也使用了这个 fd 怎么办?在 JS 世界打开文件, fd 传到 native 世界并继续使用,这时 js 世界的变量销毁,结果你把 fd 给我 close?
你那个 PR 在哪里我要过去评论一下
在新的语法糖写入标准之前,已有的 FinalizationRegistry 已经足够实现资源销毁
我觉得这不是坑,手动创建资源后不手动释放是不对的。我理解的 GC 不应该管这些,想写代码方便不都靠语法糖实现嘛?
或许我已经是计算机的形状了😂
fd 泄露好歹只是泄露,要是 gc fd 变量的时候直接把 fd 关了,那好多应用恐怕跑都跑不起了了……
这种东西对通用 GC 来说就是不好处理的,refcount 和 RAII 机制都会更顺滑一点。using 相当于就是手动引入 RAII 了。
不单是 Node.js, 在其他 JS 运行时场景可能也存在这样的问题. 不过如果写了单元测试, 应该在测试的时候会被检测出泄漏. 或许一些静态更好.
稍微抽象一点, 我觉得这里面有两个写程序的问题:
1. 资源没有被关闭. 有语法糖当然更好, 但如果没有, 这也是一个程序人员应该避免的使用错误, 不应该被称为坑.
2. 未利用的返回值. setInterval(() => {}, 1000);
这种写法是不良的, 因为 setInterval()
是有返回值的, 那我们就应该使用这个返回值, 在必要的地方 clearInterval(intervalID)
.
我觉得上述问题如果都应该由程序人员注意. 即便有了 using, 也只是减轻负担, 要程序人员了解并主动去用, 才能写出健壮性更强的代码. 必要时在 ESLint 中要求, 特定函数只能 using 来避免开发者误用造成不必要的隐患.
我怎么感觉我接触过的编程语言里面文件操作都是有 open 就要有 close ,就像 c++内存有 new 就要有 delete ,这也叫坑吗,楼上说的 java 的 try 或 python 的 with 也是要自己写个关键字或者手动析构的
最好是给开发者选择,比如 open 的时候传个参数是否在 gc 的时候自动 close 。
正常来讲如果描述符的引用都已经被 gc 了,程序就应该无法调用到这个描述符了,也就无法对文件进行操作了,所以 gc 时自动 close 设计成默认行为是可行的。
native 代码要用的话可以 dup 。这种情况在 C++里就是 RAII ,如果一个对象持有另一个对象,那传递到别的地方时自然要考虑生命周期问题。
#12 比如各种 fd 传到 native 读取数据去了,之后通过 callback 回传,你 callbak 函数很大可能并没有引用 fd ,那就是此时 fd 可能只被 native 使用了,应用层并没有持有 fd 是不是可以被 gc 了
#13 每次 dup 可能有性能问题不合适,毕竟文件 IO 可能非常频繁
#14 绝大部分 JS 应用都仅在单语言、单引擎、单进程、单线程内使用,所以针对绝大部分场景来说,gc 自动 close 算是一种健壮性和便捷性的设计。
针对特殊场景,比如你所说的 fd 的传递(如果可行的话),最好给 open 加一个可选的参数,可以让开发者显式声明不随 gc 自动 close 。
只是在传递给别的库时 dup 一次,而不是每次读写都要 dup ,并不会有太大的性能问题。如果真的不想 dup ,那还可以放弃所有权,把所有权转移给别的库。
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
// …
}
这个确实不能说是坑, 本身句柄的方式就是要手动 close 的.
#16 这和你上面说的有冲突吧,读写都需要进入 native 啊,js 并没有在语言层面提供一种一定保证 fd 引用的方式吧,否则如果有这种可靠方式那就不需要你说的这种 dup 啊,而不能可靠引用的问题主要是因为 callback ,进入 native 执行的 callback 可能在 js 层面失去对 fd 的所有引用完全是可能,同步调用的语言就没这个问题,毕竟 native 返回前栈肯定保持了对 fd 的引用
js 里对象没有被回收,为什么不能保证引用? js 的对象不是在栈上的。
不用在 Node.js 所定义的概念里面绕,都是对系统 API 的封装,了解系统 API 的使用就可以自然而然的规避这些问题。
实际上 fd 在 Windows 上就是句柄 HANDLE ,Linux 上是文件描述符。打开文件都需要显式关闭,如 CloseHandle 或 close
昨天就找出来了他自己 close 掉了不用再说什么了
操作系统的 fd 是一个 int 型
不好意思没注意到评论的时间。。
开发这么多年来,我一只谨记,所有系统资源用完必定要释放,有 open 就必定要 close ,除非明确知道某些资源不用手动 close 。
所以说 nodejs 中这个情况是个坑,我很不认同。
在Node.js中,文件描述符(File Descriptor, FD)是一个指向已打开文件的引用,用于在文件系统中进行读写操作。不当使用文件描述符可能会导致资源泄露、文件锁定等问题。以下是一些关于文件描述符的常见“坑”及应对策略:
-
资源泄露: 如果不正确关闭文件描述符,会导致资源泄露。使用
fs.close
来确保文件描述符被关闭。const fs = require('fs'); fs.open('example.txt', 'r', (err, fd) => { if (err) throw err; // Do some file operations fs.close(fd, (err) => { if (err) throw err; console.log('File descriptor closed'); }); });
-
文件描述符耗尽: Node.js默认的文件描述符限制可能较低,可以通过修改
ulimit
来增加限制。ulimit -n 4096 # 将文件描述符限制设置为4096
在Node.js中,可以通过
fs.promises
或fs.openSync
等同步方法来更好地管理文件描述符的生命周期。 -
错误处理: 在进行文件操作时,务必做好错误处理,避免未捕获的异常导致程序崩溃。
fs.open('nonexistent.txt', 'r', (err, fd) => { if (err) { console.error('Error opening file:', err); return; } // Continue with file operations });
总结,在Node.js中处理文件描述符时,要注意资源的正确关闭、避免资源泄露,以及做好错误处理。此外,了解并调整系统级别的文件描述符限制,对于需要处理大量文件的应用来说也是必要的。