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

