V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
zy445566
V2EX  ›  Node.js

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

  •  
  •   zy445566 · 166 天前 · 2657 次点击
    这是一个创建于 166 天前的主题,其中的信息可能已经有所发展或是发生改变。

    在之前遇到过一个 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();
    
    第 1 条附言  ·  165 天前
    本人这边发现很多 Node.js 开发都会写类似的代码,主要目的是为了让 Node.js 开发更好的避坑。
    对于设计来说这个设计也只能算中规中矩,如果往上一层,我认为有自动化能力反而更好。
    对于非要争这是不是坑,这和我的主要目的不相关,你认为不是坑就不是坑吧。
    25 条回复    2024-06-18 18:19:17 +08:00
    CodeCodeStudy
        1
    CodeCodeStudy  
       166 天前
    像 java 的 try 或 python 的 with 就能有效避免这类问题
    GiantHard
        2
    GiantHard  
       166 天前   ❤️ 2
    @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
        3
    abelyao  
       166 天前
    @GiantHard 啊…… using ,以前写 C# 会用到的东西… 很方便
    GiantHard
        4
    GiantHard  
       166 天前
    @abelyao #3 是的,C# 的 using 语法非常好使,IDE 支持也很完善,会提示没有使用 using 管理的 disposable 对象,还能自动生成 Disposable 模式代码
    nomagick
        5
    nomagick  
       166 天前   ❤️ 4
    这根本不是问题,fd API 是 C 传下来的,如果你不懂 C 的传承那你根本不应该会使用 fs.open ,如果你懂 C 的传承那你不会忘记 fs.close

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

    你那个 PR 在哪里我要过去评论一下
    nomagick
        6
    nomagick  
       166 天前
    在新的语法糖写入标准之前,已有的 FinalizationRegistry 已经足够实现资源销毁
    IvanLi127
        7
    IvanLi127  
       166 天前   ❤️ 2
    我觉得这不是坑,手动创建资源后不手动释放是不对的。我理解的 GC 不应该管这些,想写代码方便不都靠语法糖实现嘛?

    或许我已经是计算机的形状了😂
    InkStone
        8
    InkStone  
       166 天前
    fd 泄露好歹只是泄露,要是 gc fd 变量的时候直接把 fd 关了,那好多应用恐怕跑都跑不起了了……

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

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

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

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


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

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

    @ysc3839 #13 每次 dup 可能有性能问题不合适,毕竟文件 IO 可能非常频繁
    libook
        15
    libook  
       165 天前   ❤️ 1
    @sujin190 #14 绝大部分 JS 应用都仅在单语言、单引擎、单进程、单线程内使用,所以针对绝大部分场景来说,gc 自动 close 算是一种健壮性和便捷性的设计。
    针对特殊场景,比如你所说的 fd 的传递(如果可行的话),最好给 open 加一个可选的参数,可以让开发者显式声明不随 gc 自动 close 。
    ysc3839
        16
    ysc3839  
       165 天前 via Android
    @sujin190 只是在传递给别的库时 dup 一次,而不是每次读写都要 dup ,并不会有太大的性能问题。如果真的不想 dup ,那还可以放弃所有权,把所有权转移给别的库。
    DOLLOR
        17
    DOLLOR  
       165 天前
    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
        18
    weijancc  
       165 天前
    这个确实不能说是坑, 本身句柄的方式就是要手动 close 的.
    sujin190
        19
    sujin190  
       165 天前
    @ysc3839 #16 这和你上面说的有冲突吧,读写都需要进入 native 啊,js 并没有在语言层面提供一种一定保证 fd 引用的方式吧,否则如果有这种可靠方式那就不需要你说的这种 dup 啊,而不能可靠引用的问题主要是因为 callback ,进入 native 执行的 callback 可能在 js 层面失去对 fd 的所有引用完全是可能,同步调用的语言就没这个问题,毕竟 native 返回前栈肯定保持了对 fd 的引用
    ysc3839
        20
    ysc3839  
       165 天前 via Android
    @sujin190 js 里对象没有被回收,为什么不能保证引用? js 的对象不是在栈上的。
    mioktiar56
        21
    mioktiar56  
       165 天前
    不用在 Node.js 所定义的概念里面绕,都是对系统 API 的封装,了解系统 API 的使用就可以自然而然的规避这些问题。
    实际上 fd 在 Windows 上就是句柄 HANDLE ,Linux 上是文件描述符。打开文件都需要显式关闭,如 CloseHandle 或 close
    beiranc
        22
    beiranc  
       165 天前
    @nomagick 根据 OP 的 Github 找了一下应该是这个 https://github.com/nodejs/node/pull/35412
    nomagick
        23
    nomagick  
       165 天前
    @beiranc 昨天就找出来了他自己 close 掉了不用再说什么了


    @ysc3839
    @sujin190
    操作系统的 fd 是一个 int 型
    beiranc
        24
    beiranc  
       165 天前
    @nomagick 不好意思没注意到评论的时间。。
    Belmode
        25
    Belmode  
       164 天前
    开发这么多年来,我一只谨记,所有系统资源用完必定要释放,有 open 就必定要 close ,除非明确知道某些资源不用手动 close 。

    所以说 nodejs 中这个情况是个坑,我很不认同。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2821 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 07:08 · PVG 15:08 · LAX 23:08 · JFK 02:08
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.