一文詳解Node.js操作磁盤文件底層原理與實踐指南
你以為 fs.readFile 是讓 Node 幫你「拿一下文件」?不,其實是:你下單 → 前臺記單 → 后廚線程池做菜 → 做好了再叫你。這篇文章帶你看看這份「外賣」是怎么從磁盤端到你手里的。
一、先別急著寫代碼:為什么你要關(guān)心「底層」?
很多人學(xué) Node.js 的 fs 模塊,會背兩句口訣就收工:「用異步別用同步」「大文件用 Stream」。
背完發(fā)現(xiàn):
- 為什么我
readFile讀個 10GB 的日志直接 OOM? - 為什么說 Node 是單線程,但一堆文件操作時還是會「卡」?
fs.promises和fs.readFile回調(diào)版,底層是不是同一套?
(補充一句:文件 I/O 本身是等磁盤,不會占滿 CPU;你覺得「卡」多半是線程池被占滿,新任務(wù)在排隊。)
要回答這些,就得知道:你的 JS 代碼 → Node 的 C++ 綁定 → libuv → 操作系統(tǒng) → 磁盤,這條鏈上每一環(huán)在干什么。
知道之后,你選 API、調(diào)參數(shù)、排查性能問題,都會心里有數(shù)——而不是靠「玄學(xué)調(diào)參」。
所以這篇東西的目標(biāo)很簡單:用盡量人話 + 一點幽默,把 Node.js 操作磁盤文件的底層原理講清楚,順便帶上能跑的示例。
二、從你敲下fs.readFile開始:調(diào)用鏈長什么樣?
你寫的可能是:
const fs = require('fs');
fs.readFile('/tmp/hello.txt', (err, data) => {
if (err) throw err;
console.log(data.toString());
});
在底層,大概發(fā)生了這些事(簡化版):
JavaScript 層:fs.readFile 是 Node 內(nèi)置模塊 fs 上的方法,實現(xiàn)里會做路徑解析、編碼處理、以及「把回調(diào)塞進某個流程里」。
C++ 綁定層(Node 的 node_file.cc 等):JS 調(diào)用的其實是 C++ 里封裝好的函數(shù)。
這里會:
- 把路徑、回調(diào)、選項等轉(zhuǎn)成 C++ 能用的東西;
- 調(diào) libuv 的 API,發(fā)起「異步文件讀請求」。
libuv 層:libuv 是 Node 用來抽象「異步 I/O」的 C 庫,跨平臺(Windows / Linux / macOS 都靠它)。
對文件 I/O,它一般不會用 epoll/kqueue 這種「純事件」機制,而是:
把實際讀文件的工作丟進「線程池」,在池里某條線程里做阻塞式的 read。
所以:你以為的單線程,只是 JS 執(zhí)行單線程;文件讀寫是在別的線程里阻塞地干的。
操作系統(tǒng) → 磁盤:線程池里的線程調(diào)的就是 OS 的 read(或類似)系統(tǒng)調(diào)用,由內(nèi)核去和磁盤驅(qū)動、塊設(shè)備打交道,把數(shù)據(jù)從磁盤讀到內(nèi)核緩沖區(qū),再拷到用戶態(tài)(Node 的 Buffer)。
回到 JS:讀完后,libuv 在某個時機(下一次事件循環(huán)的 I/O 階段)把結(jié)果和你的回調(diào)塞回主線程,于是你的 (err, data) => { ... } 被調(diào)到了,data 就是那個 Buffer。
一句話:fs.readFile = 你在 JS 里下單 → Node 通過 libuv 把「讀文件」這個任務(wù)派給線程池 → 線程池里的線程阻塞地讀磁盤 → 讀完再通過事件循環(huán)把結(jié)果回傳給 JS。
所以「Node 單線程」指的是 JS 只在一個線程跑,磁盤 I/O 并不在主線程上阻塞,而是在線程池里。
三、事件循環(huán)與 libuv:誰在真正「干活」?
Node 的事件循環(huán)(event loop)是由 libuv 實現(xiàn)的。
和文件相關(guān)的部分可以粗分為:
- Poll 階段:等 I/O(網(wǎng)絡(luò)、部分原生異步 API 等)。
- 線程池完成回調(diào):文件 I/O 在池里做完后,會在合適的階段把「完成」事件插回事件循環(huán),從而執(zhí)行你傳的 callback 或 resolve Promise。
所以:
- 主線程(跑 JS 的那條):只負(fù)責(zé)執(zhí)行你的 JS、跑定時器、處理已完成 I/O 的回調(diào),不直接去讀磁盤。
- 真正摸磁盤的:是 libuv 的線程池里那幾條 worker 線程(默認(rèn) 4 個,可配
UV_THREADPOOL_SIZE)。
這就是為什么:
- 你寫
fs.readFileSync時,主線程會阻塞(因為同步 API 就是在主線程上直接調(diào)系統(tǒng)調(diào)用讀文件); - 而
fs.readFile不會阻塞主線程,因為讀是在線程池里做的。
四、線程池:別被「單線程」三個字騙了
默認(rèn)情況下,libuv 的線程池大小是 4(和你的 CPU 核數(shù)無關(guān),就是個固定值)。
所以:
- 同時發(fā) 10 個
fs.readFile,只有 4 個在「真·讀磁盤」,剩下 6 個在排隊。 - 線程池既管文件 I/O,也管部分 crypto、部分 DNS 等,所以文件多的時候你會感覺「怎么慢下來了」——因為池子被占滿了。
可以通過環(huán)境變量把池子調(diào)大(建議不超過 CPU 數(shù)太多,否則上下文切換會變多):
# 例如把線程池改成 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:內(nèi)存里那塊「黑板」
Buffer 是 Node 里表示「一塊二進制數(shù)據(jù)」的類型,本質(zhì)是 V8 外的一塊連續(xù)內(nèi)存(不經(jīng)過 V8 堆的 GC,由 Node 自己管理)。
文件讀進來、網(wǎng)絡(luò)收來的裸字節(jié),在 JS 里最常見的就是用 Buffer 拿著。fs.readFile 的 data 就是 Buffer。
- 你
data.toString()是把這塊內(nèi)存按指定編碼(默認(rèn) UTF-8)解碼成字符串。 - 大文件一次性
readFile,就是一次性在內(nèi)存里開一塊和文件一樣大的 Buffer——所以 10GB 文件會直接 OOM,和「底層」沒關(guān)系,就是設(shè)計如此。
所以:大文件不要用 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); // 字節(jié)數(shù)
});
// 大文件:別這么干,用 createReadStream
// fs.readFile('huge.log', ...); // 可能 OOM
六、文件描述符:操作系統(tǒng)給你的「取餐號」
文件描述符(file descriptor, fd) 是操作系統(tǒng)里「打開的文件」的整數(shù)句柄。
你 open 一個文件,內(nèi)核給你一個 fd(比如 3、4、5),后續(xù) read/write 都用這個數(shù)字來指代「哪個打開的文件」。
Node 里:
fs.open(path, flags, callback)會得到(err, fd)。fs.read(fd, buffer, offset, length, position, callback)表示:從 fd 對應(yīng)的文件里,從position開始,讀length字節(jié),放進buffer的offset位置,讀完再回調(diào)。- 用完后要
fs.close(fd),否則會占用內(nèi)核資源(可打開 fd 數(shù)量有限制)。
用 fd + read 可以自己實現(xiàn)「分段讀大文件」:
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} 字節(jié),position=${position}`);
position += bytesRead;
readNext();
});
}
readNext();
});
}
readInChunks('./some-big-file.log');
這里就是「底層」用法:自己控 Buffer、position、每次讀多少,不依賴 readFile 一次性裝進內(nèi)存。
七、Stream:別一口吞,一口一口吃
Stream(流) 是「一塊一塊處理數(shù)據(jù)」的抽象:不要求一次性把整個文件讀進內(nèi)存,而是讀一塊、處理一塊、再讀下一塊。
fs.createReadStream(path)會打開文件,并返回一個 Readable 流。- 底層一般也是用 fd + 多次
read,每次讀滿一塊 Buffer(默認(rèn) 64KB,可配),通過data事件或read()推給你。 - 流內(nèi)部有 highWaterMark:內(nèi)部緩沖超過這個值就暫停從底層拉數(shù)據(jù),避免內(nèi)存爆掉。
所以:大文件用 ReadStream + 管道或逐 chunk 處理,就不會 OOM。
const fs = require('fs');
// 大文件拷貝:流式,內(nèi)存占用穩(wěn)定
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('拷貝完成'));
}
// 邊讀邊處理:例如數(shù)行數(shù)
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('總行數(shù):', lines));
八、同步 vs 異步:什么時候該用誰?
| 方式 | 誰在干活 | 阻塞主線程? | 適用場景 |
|---|---|---|---|
fs.readFile | 線程池 | 否 | 小文件、配置等 |
fs.readFileSync | 主線程 | 是 | 啟動時讀配置、腳本 |
createReadStream | 線程池 + 事件 | 否 | 大文件、日志 |
fs.read(fd, ...) | 線程池 | 否 | 需要精細(xì)控制位置/塊 |
原則:
- 能異步就異步,避免阻塞事件循環(huán)。
- 只有在「進程剛啟動、必須立刻拿到結(jié)果才能往下跑」的場景,才考慮用 Sync(例如讀一個 config.json 再啟動服務(wù))。
九、新特性與最新知識點(Promise、FileHandle、io_uring)
1.fs.promises與 async/await
Node 內(nèi)置了基于 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();
底層和回調(diào)版是同一套:都是走 libuv 線程池,只是把 callback 換成了 Promise 的 resolve/reject。
2.FileHandle:長期持有 fd 的「句柄」
fs.promises.open() 返回的是 FileHandle,可以多次讀/寫再關(guān)閉,適合「同一個文件反復(fù)讀」:
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;后來默認(rèn)又改回線程池。若要用 io_uring,需要在創(chuàng)建 event loop 時顯式開啟(如 UV_LOOP_USE_IO_URING_SQPOLL)。
對寫業(yè)務(wù)代碼的我們來說:知道「文件 I/O 主要走線程池」就夠了,除非你在做極致性能調(diào)優(yōu)。
十、綜合示例:一個「帶流式讀 + 行解析」的日志處理器
下面這段把「底層」和「實用」串起來:用 ReadStream 讀大日志,按行切分、逐行處理(不會把整個文件載入內(nèi)存)。
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); // 你可以在這里做解析、寫庫、發(fā) MQ 等
}
}
// 使用示例:只打印包含 "ERROR" 的行
processLargeLog('./app.log', async (line) => {
if (line.includes('ERROR')) console.log(line);
}).then(() => console.log('處理完畢'));
這里用到的就是:fs 的 ReadStream(底層 fd + 分塊 read)+ readline 按行消費,既不會 OOM,又符合「流式」的思維方式。
十一、小結(jié):一張「外賣流程圖」收尾
你調(diào)
fs.readFile/createReadStream等 → Node fs 模塊(JS)
↓
C++ 綁定 調(diào) libuv
↓
libuv 把文件 I/O 丟給 線程池(默認(rèn) 4 個 worker)
↓
線程池里 阻塞式 read → 內(nèi)核 → 磁盤
↓
讀到的數(shù)據(jù)放進 Buffer,完成后通過 事件循環(huán) 把回調(diào)/Promise 推回 主線程
若是 Stream,則是多次「讀一塊 → 推一塊」,由 highWaterMark 等控制背壓
記住這幾件事:
- 單線程指的是 JS,文件 I/O 在 libuv 線程池里。
- 大文件用 Stream 或 fd + read,別用
readFile一把梭。 - Buffer 是那塊「裝字節(jié)」的內(nèi)存;fd 是操作系統(tǒng)給你的「取餐號」。
- 新代碼優(yōu)先用 fs.promises 或 FileHandle,邏輯更清晰;底層和回調(diào)版一致。
如果你愿意再往深挖,可以看:
- Node 源碼里的
src/node_file.cc、lib/fs.js - libuv 文檔里的 File system operations
這樣,下次有人問「Node 讀文件到底是同步還是異步」「為什么我讀大文件會崩」,你就能從事件循環(huán)講到線程池、從 Buffer 講到 Stream,順便用「外賣下單 → 后廚線程池 → 取餐號 fd」的比喻把對方講懂。
祝寫 Node 少踩坑,磁盤 I/O 穩(wěn)如狗。
到此這篇關(guān)于一文詳解Node.js操作磁盤文件底層原理與實踐指南的文章就介紹到這了,更多相關(guān)Node.js操作磁盤文件內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Windows8下搭建Node.js開發(fā)環(huán)境教程
這篇文章主要介紹了Windows8下搭建Node.js開發(fā)環(huán)境教程,Win8下安裝node.js也比較簡單,只是一些權(quán)限比較麻煩,需要的朋友可以參考下2014-09-09
nodejs連接ftp上傳下載實現(xiàn)方法詳解【附:踩坑記錄】
這篇文章主要介紹了nodejs連接ftp上傳下載實現(xiàn)方法,結(jié)合實例形式詳細(xì)分析了node.js使用ftp模塊實現(xiàn)針對ftp上傳、下載相關(guān)操作的方法,并附帶記錄了傳輸速度慢的解決方法,需要的朋友可以參考下2023-04-04
Node.js數(shù)據(jù)庫操作之連接MySQL數(shù)據(jù)庫(一)
前一陣在做項目的時候,需要通過nodejs連接到MySQL數(shù)據(jù)庫,于是簡單地學(xué)習(xí)了一下MySQL這個庫,分享一些學(xué)習(xí)心得給大家,希望對大家有幫助。下面這篇文章主要介紹了Node.js數(shù)據(jù)庫操作之連接MySQL數(shù)據(jù)庫的相關(guān)資料,需要的朋友可以參考下。2017-03-03

