幾個月前,團隊決定用 Kotlin 重寫 Python 編寫的 Telegram webhook 伺服器。團隊負責人的理由很簡單:「Kotlin 比 Python 快。」
當時沒有具體的基準測試或效能測量資料,只是基於編譯型語言比直譯型語言快這一常識做出的決定。但實際完成遷移後,感受不到明顯的效能差異。
這其實是意料之中的結果。在小規模的 webhook 伺服器中,瓶頸大多出現在網路 I/O 上。等待外部 API 回應或資料庫查詢結果的時間,遠比 CPU 執行複雜運算的時間要長。
這次經歷讓我對 Node.js 產生了疑問。在開發者社群經常聽到「Node.js 因為單執行緒而慢」的說法,真的是這樣嗎?Netflix、PayPal、LinkedIn 等大型服務選擇 Node.js 一定有其原因吧?
出於好奇,我決定深入研究 Node.js 的內部運作原理。
Worker Threads 的發現
// 使用 worker_threads 模組的多執行緒範例
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
// 主執行緒
const worker = new Worker(__filename);
worker.on('message', (result) => {
console.log('計算結果:', result);
});
worker.postMessage({ num: 1000000 });
} else {
// 工作執行緒
parentPort.on('message', (data) => {
const result = calculatePrimes(data.num);
parentPort.postMessage(result);
});
}
function calculatePrimes(max) {
// CPU 密集型任務
const primes = [];
for (let i = 2; i <= max; i++) {
if (isPrime(i)) primes.push(i);
}
return primes.length;
}
最先發現的是 Node.js 並非完全的單執行緒。從 Node.js 10.5.0 開始,新增了 Worker Threads 功能,可以在獨立的執行緒中處理 CPU 密集型任務。
這對於影像處理、大資料解析、加密等 CPU 綁定任務特別有用。主執行緒不會被阻塞,可以繼續處理其他請求。
事件迴圈:排程而非上下文切換
最初我以為 Node.js 在處理多個任務時會進行作業系統級別的上下文切換,但實際上它使用了更高效的方式。
// 展示事件迴圈如何運作的範例
console.log('1: 開始');
setTimeout(() => {
console.log('2: setTimeout 回呼');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise 回呼');
});
process.nextTick(() => {
console.log('4: nextTick 回呼');
});
console.log('5: 結束');
// 輸出順序:
// 1: 開始
// 5: 結束
// 4: nextTick 回呼
// 3: Promise 回呼
// 2: setTimeout 回呼
事件迴圈是在單執行緒中高效排程任務的機制。每個任務按以下優先順序處理:
- 同步程式碼執行:完成目前正在執行的程式碼
- process.nextTick 佇列:最高優先順序的非同步任務
- 微任務佇列:Promise 回呼等
- 計時器階段:setTimeout、setInterval 回呼
- I/O 回呼:檔案系統、網路操作完成回呼
- setImmediate:I/O 事件後立即執行
- close 回呼:套接字或控制代碼關閉時
關鍵是這一切都發生在單個執行緒中。由於作業系統的執行緒排程器不參與,因此沒有上下文切換開銷。這就是 Node.js 能夠高效處理高併發的原因。
libuv 和隱藏的執行緒池
更有趣的發現是 Node.js 內部使用多執行緒。
const crypto = require('crypto');
const fs = require('fs');
// 檔案 I/O - 使用 libuv 的執行緒池
console.time('file');
for (let i = 0; i < 4; i++) {
fs.readFile(__filename, () => {
console.timeEnd('file');
});
}
// 加密 - 使用 libuv 的執行緒池
console.time('crypto');
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('password', 'salt', 100000, 512, 'sha512', () => {
console.timeEnd('crypto');
});
}
// 網路 I/O - 使用作業系統的非同步介面(不使用執行緒池)
const https = require('https');
console.time('https');
for (let i = 0; i < 4; i++) {
https.get('https://www.google.com', (res) => {
res.on('data', () => {});
res.on('end', () => {
console.timeEnd('https');
});
});
}
libuv 是負責 Node.js 非同步 I/O 的 C 函式庫,預設運行一個擁有 4 個工作執行緒的執行緒池。以下操作在此執行緒池中處理:
- 檔案系統操作(fs 模組)
- DNS 查詢(dns.lookup)
- 部分加密操作(crypto 模組)
- 壓縮操作(zlib 模組)
// 執行緒池大小可透過環境變數調整(最大 1024)
process.env.UV_THREADPOOL_SIZE = 8;
令人印象深刻的是,網路 I/O 不使用執行緒池,而是直接使用作業系統的非同步介面(epoll、kqueue、IOCP 等)。這使得 Web 伺服器能夠高效處理數千個併發連線。
使用 Cluster 模組利用多核心
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`主程序 ${process.pid} 運行中`);
// 建立與 CPU 數量相等的工作程序
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`工作程序 ${worker.process.pid} 退出`);
cluster.fork(); // 工作程序退出時重新建立
});
} else {
// 工作程序共享連接埠
http.createServer((req, res) => {
res.writeHead(200);
res.end(`由工作程序 ${process.pid} 處理\n`);
}).listen(8000);
console.log(`工作程序 ${process.pid} 啟動`);
}
克服單執行緒限制的另一種方法是 cluster 模組。透過它可以建立多個 Node.js 程序,讓每個程序共享同一連接埠。
使用 PM2 等程序管理器可以更簡單地實現叢集:
# 建立與 CPU 核心數相等的程序
pm2 start app.js -i max
根據任務類型的效能差異
// I/O 綁定任務 - Node.js 的強項
async function fetchMultipleAPIs() {
const urls = [
'https://api1.example.com',
'https://api2.example.com',
'https://api3.example.com'
];
// 並行處理 - 非常高效
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
return results;
}
// CPU 綁定任務 - 利用 Worker Threads
const { Worker } = require('worker_threads');
function runCPUIntensiveTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./cpu-intensive-task.js');
worker.postMessage(data);
worker.on('message', resolve);
worker.on('error', reject);
});
}
我了解到 Node.js 的效能很大程度上取決於任務的特性。
I/O 綁定任務的優勢
Node.js 在以下 I/O 中心任務中表現出色:
- Web API 伺服器
- 即時聊天應用程式
- 資料串流處理
- 微服務架構
得益於非同步 I/O 和事件驅動架構,可以用最少的記憶體處理數千個併發連線。
CPU 綁定任務的應對
對於 CPU 密集型任務:
- 使用 Worker Threads 進行並行處理
- 透過叢集利用多核心
- 必要時使用 C++ 外掛或 WebAssembly
- 將真正繁重的運算分離到獨立服務
從誤解到理解
「Node.js 因為單執行緒而慢」這句話只是半個真相。雖然 JavaScript 執行發生在單執行緒中,但 Node.js 平台本身在必要時會利用多執行緒。而在許多 Web 應用程式中,瓶頸在於 I/O 而非 CPU。
Netflix、PayPal、LinkedIn 等企業選擇 Node.js 是有原因的。在合適的情況下使用合適的工具,Node.js 是一個足夠強大和高效的平台。
下次我想在各種情境下進行實際基準測試。用實際資料驗證理論會很有趣。