【Node】操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

14 阅读9分钟

公众号:AI小揭秘

Node.js 操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

你以为 fs.readFile 是让 Node 帮你「拿一下文件」?不,其实是:你下单 → 前台记单 → 后厨线程池做菜 → 做好了再叫你。这篇文章带你看看这份「外卖」是怎么从磁盘端到你手里的。


一、先别急着写代码:为什么你要关心「底层」?

很多人学 Node.js 的 fs 模块,会背两句口诀就收工:「用异步别用同步」「大文件用 Stream」
背完发现:

  • 为什么我 readFile 读个 10GB 的日志直接 OOM?
  • 为什么说 Node 是单线程,但一堆文件操作时还是会「卡」?
  • fs.promisesfs.readFile 回调版,底层是不是同一套?

(补充一句:文件 I/O 本身是等磁盘,不会占满 CPU;你觉得「卡」多半是线程池被占满,新任务在排队。)

要回答这些,就得知道:你的 JS 代码 → Node 的 C++ 绑定 → libuv → 操作系统 → 磁盘,这条链上每一环在干什么。
知道之后,你选 API、调参数、排查性能问题,都会心里有数——而不是靠「玄学调参」。

所以这篇东西的目标很简单:用尽量人话 + 一点幽默,把 Node.js 操作磁盘文件的底层原理讲清楚,顺便带上能跑的示例。


二、从你敲下 fs.readFile 开始:调用链长什么样?

你写的可能是:

const fs = require('fs');
fs.readFile('/tmp/hello.txt', (err, data) => {
  if (err) throw err;
  console.log(data.toString());
});

在底层,大概发生了这些事(简化版):

  1. JavaScript 层
    fs.readFile 是 Node 内置模块 fs 上的方法,实现里会做路径解析、编码处理、以及「把回调塞进某个流程里」。

  2. C++ 绑定层(Node 的 node_file.cc 等)
    JS 调用的其实是 C++ 里封装好的函数。这里会:

    • 把路径、回调、选项等转成 C++ 能用的东西;
    • 调 libuv 的 API,发起「异步文件读请求」。
  3. libuv 层
    libuv 是 Node 用来抽象「异步 I/O」的 C 库,跨平台(Windows / Linux / macOS 都靠它)。
    文件 I/O,它一般不会用 epoll/kqueue 这种「纯事件」机制,而是:
    把实际读文件的工作丢进「线程池」,在池里某条线程里做阻塞式的 read。
    所以:你以为的单线程,只是 JS 执行单线程;文件读写是在别的线程里阻塞地干的。

  4. 操作系统 → 磁盘
    线程池里的线程调的就是 OS 的 read(或类似)系统调用,由内核去和磁盘驱动、块设备打交道,把数据从磁盘读到内核缓冲区,再拷到用户态(Node 的 Buffer)。

  5. 回到 JS
    读完后,libuv 在某个时机(下一次事件循环的 I/O 阶段)把结果和你的回调塞回主线程,于是你的 (err, data) => { ... } 被调到了,data 就是那个 Buffer。

一句话:fs.readFile = 你在 JS 里下单 → Node 通过 libuv 把「读文件」这个任务派给线程池 → 线程池里的线程阻塞地读磁盘 → 读完再通过事件循环把结果回传给 JS。
所以「Node 单线程」指的是 JS 只在一个线程跑,磁盘 I/O 并不在主线程上阻塞,而是在线程池里。


三、事件循环与 libuv:谁在真正「干活」?

Node 的事件循环(event loop)是由 libuv 实现的。
和文件相关的部分可以粗分为:

  • Poll 阶段:等 I/O(网络、部分原生异步 API 等)。
  • 线程池完成回调:文件 I/O 在池里做完后,会在合适的阶段把「完成」事件插回事件循环,从而执行你传的 callback 或 resolve Promise。

所以:

  • 主线程(跑 JS 的那条):只负责执行你的 JS、跑定时器、处理已完成 I/O 的回调,不直接去读磁盘
  • 真正摸磁盘的:是 libuv 的线程池里那几条 worker 线程(默认 4 个,可配 UV_THREADPOOL_SIZE)。

这就是为什么:

  • 你写 fs.readFileSync 时,主线程会阻塞(因为同步 API 就是在主线程上直接调系统调用读文件);
  • fs.readFile 不会阻塞主线程,因为读是在线程池里做的。

四、线程池:别被「单线程」三个字骗了

默认情况下,libuv 的线程池大小是 4(和你的 CPU 核数无关,就是个固定值)。
所以:

  • 同时发 10 个 fs.readFile,只有 4 个在「真·读磁盘」,剩下 6 个在排队。
  • 线程池既管文件 I/O,也管部分 crypto、部分 DNS 等,所以文件多的时候你会感觉「怎么慢下来了」——因为池子被占满了。

可以通过环境变量把池子调大(建议不超过 CPU 数太多,否则上下文切换会变多):

# 例如把线程池改成 8
set UV_THREADPOOL_SIZE=8   # Windows
export UV_THREADPOOL_SIZE=8  # Linux/macOS
// 你可以自己试:同时读多个文件,看完成顺序
const fs = require('fs');
const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt'];

files.forEach((f, i) => {
  fs.readFile(f, () => console.log(`第 ${i + 1} 个完成: ${f}`));
});
// 前 4 个往往先完成(线程池只有 4),第 5 个要等池里有空位

五、Buffer:内存里那块「黑板」

Buffer 是 Node 里表示「一块二进制数据」的类型,本质是 V8 外的一块连续内存(不经过 V8 堆的 GC,由 Node 自己管理)。
文件读进来、网络收来的裸字节,在 JS 里最常见的就是用 Buffer 拿着。

  • fs.readFiledata 就是 Buffer。
  • data.toString() 是把这块内存按指定编码(默认 UTF-8)解码成字符串。
  • 大文件一次性 readFile,就是一次性在内存里开一块和文件一样大的 Buffer——所以 10GB 文件会直接 OOM,和「底层」没关系,就是设计如此。

所以:大文件不要用 readFile,用 Stream 或 read(fd, buffer, offset, length, position) 分段读。

const fs = require('fs');

// 小文件没问题(记得先判断 err,否则文件不存在时 buf 为 undefined)
fs.readFile('small.txt', (err, buf) => {
  if (err) return console.error(err);
  console.log(Buffer.isBuffer(buf)); // true
  console.log(buf.length);            // 字节数
});

// 大文件:别这么干,用 createReadStream
// fs.readFile('huge.log', ...);  // 可能 OOM

六、文件描述符:操作系统给你的「取餐号」

文件描述符(file descriptor, fd) 是操作系统里「打开的文件」的整数句柄。
open 一个文件,内核给你一个 fd(比如 3、4、5),后续 read/write 都用这个数字来指代「哪个打开的文件」。

Node 里:

  • fs.open(path, flags, callback) 会得到 (err, fd)
  • fs.read(fd, buffer, offset, length, position, callback) 表示:从 fd 对应的文件里,从 position 开始,读 length 字节,放进 bufferoffset 位置,读完再回调。
  • 用完后要 fs.close(fd),否则会占用内核资源(可打开 fd 数量有限制)。

用 fd + read 可以自己实现「分段读大文件」:

const fs = require('fs');

function readInChunks(filePath, chunkSize = 64 * 1024) {
  const buffer = Buffer.alloc(chunkSize);
  let position = 0;

  fs.open(filePath, 'r', (err, fd) => {
    if (err) return console.error(err);
    function readNext() {
      fs.read(fd, buffer, 0, chunkSize, position, (err, bytesRead) => {
        if (err) return fs.close(fd, () => console.error(err));
        if (bytesRead === 0) return fs.close(fd, () => console.log('读完了'));
        console.log(`读到 ${bytesRead} 字节,position=${position}`);
        position += bytesRead;
        readNext();
      });
    }
    readNext();
  });
}

readInChunks('./some-big-file.log');

这里就是「底层」用法:自己控 Buffer、position、每次读多少,不依赖 readFile 一次性装进内存。


七、Stream:别一口吞,一口一口吃

Stream(流) 是「一块一块处理数据」的抽象:不要求一次性把整个文件读进内存,而是读一块、处理一块、再读下一块。

  • fs.createReadStream(path) 会打开文件,并返回一个 Readable 流
  • 底层一般也是用 fd + 多次 read,每次读满一块 Buffer(默认 64KB,可配),通过 data 事件或 read() 推给你。
  • 流内部有 highWaterMark:内部缓冲超过这个值就暂停从底层拉数据,避免内存爆掉。

所以:大文件用 ReadStream + 管道或逐 chunk 处理,就不会 OOM。

const fs = require('fs');

// 大文件拷贝:流式,内存占用稳定
function copyBigFile(src, dest) {
  const readStream = fs.createReadStream(src, { highWaterMark: 64 * 1024 });
  const writeStream = fs.createWriteStream(dest, { highWaterMark: 64 * 1024 });
  readStream.pipe(writeStream);
  writeStream.on('finish', () => console.log('拷贝完成'));
}

// 边读边处理:例如数行数
let lines = 0;
fs.createReadStream('huge.log')
  .on('data', (chunk) => {
    for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) lines++;
  })
  .on('end', () => console.log('总行数:', lines));

八、同步 vs 异步:什么时候该用谁?

方式谁在干活阻塞主线程?适用场景
fs.readFile线程池小文件、配置等
fs.readFileSync主线程启动时读配置、脚本
createReadStream线程池 + 事件大文件、日志
fs.read(fd, ...)线程池需要精细控制位置/块

原则:

  • 能异步就异步,避免阻塞事件循环。
  • 只有在「进程刚启动、必须立刻拿到结果才能往下跑」的场景,才考虑用 Sync(例如读一个 config.json 再启动服务)。

九、新特性与最新知识点(Promise、FileHandle、io_uring)

1. fs.promises 与 async/await

Node 内置了基于 Promise 的 fs API,不用自己包一层:

const fs = require('fs').promises;

async function main() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    console.log(config);
  } catch (e) {
    console.error(e);
  }
}
main();

底层和回调版是同一套:都是走 libuv 线程池,只是把 callback 换成了 Promise 的 resolve/reject。

2. FileHandle:长期持有 fd 的「句柄」

fs.promises.open() 返回的是 FileHandle,可以多次读/写再关闭,适合「同一个文件反复读」:

const fsp = require('fs').promises;

async function readHeadAndTail(path, headBytes = 100, tailBytes = 100) {
  const handle = await fsp.open(path, 'r');
  const stat = await handle.stat();
  const head = Buffer.alloc(headBytes);
  const tail = Buffer.alloc(tailBytes);
  await handle.read(head, 0, headBytes, 0);
  if (stat.size > tailBytes) {
    await handle.read(tail, 0, tailBytes, stat.size - tailBytes);
  }
  await handle.close();
  return { head: head.toString(), tail: tail.toString() };
}

3. Linux 上的 io_uring(了解即可)

从 libuv 1.45 起,Linux 上部分文件 I/O 曾尝试用 io_uring 做更高性能的异步磁盘 I/O;后来默认又改回线程池。若要用 io_uring,需要在创建 event loop 时显式开启(如 UV_LOOP_USE_IO_URING_SQPOLL)。
对写业务代码的我们来说:知道「文件 I/O 主要走线程池」就够了,除非你在做极致性能调优。


十、综合示例:一个「带流式读 + 行解析」的日志处理器

下面这段把「底层」和「实用」串起来:用 ReadStream 读大日志,按行切分、逐行处理(不会把整个文件载入内存)。

const fs = require('fs');
const readline = require('readline');

async function processLargeLog(filePath, onLine) {
  const stream = fs.createReadStream(filePath, {
    highWaterMark: 256 * 1024, // 256KB 一块
  });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
  for await (const line of rl) {
    await onLine(line); // 你可以在这里做解析、写库、发 MQ 等
  }
}

// 使用示例:只打印包含 "ERROR" 的行
processLargeLog('./app.log', async (line) => {
  if (line.includes('ERROR')) console.log(line);
}).then(() => console.log('处理完毕'));

这里用到的就是:fs 的 ReadStream(底层 fd + 分块 read)+ readline 按行消费,既不会 OOM,又符合「流式」的思维方式。


十一、小结:一张「外卖流程图」收尾

  • 你调 fs.readFile / createReadStream 等 → Node fs 模块(JS)
  • C++ 绑定libuv
  • libuv 把文件 I/O 丢给 线程池(默认 4 个 worker)
  • 线程池里 阻塞式 read内核 → 磁盘
  • 读到的数据放进 Buffer,完成后通过 事件循环 把回调/Promise 推回 主线程
  • 若是 Stream,则是多次「读一块 → 推一块」,由 highWaterMark 等控制背压

记住这几件事:

  1. 单线程指的是 JS,文件 I/O 在 libuv 线程池里。
  2. 大文件用 Stream 或 fd + read,别用 readFile 一把梭。
  3. Buffer 是那块「装字节」的内存;fd 是操作系统给你的「取餐号」。
  4. 新代码优先用 fs.promisesFileHandle,逻辑更清晰;底层和回调版一致。

如果你愿意再往深挖,可以看:

这样,下次有人问「Node 读文件到底是同步还是异步」「为什么我读大文件会崩」,你就能从事件循环讲到线程池、从 Buffer 讲到 Stream,顺便用「外卖下单 → 后厨线程池 → 取餐号 fd」的比喻把对方讲懂。
祝写 Node 少踩坑,磁盘 I/O 稳如狗。