Node.js 真的因為單執行緒而慢嗎?

幾個月前,團隊決定用 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 回呼

事件迴圈是在單執行緒中高效排程任務的機制。每個任務按以下優先順序處理:

  1. 同步程式碼執行:完成目前正在執行的程式碼
  2. process.nextTick 佇列:最高優先順序的非同步任務
  3. 微任務佇列:Promise 回呼等
  4. 計時器階段:setTimeout、setInterval 回呼
  5. I/O 回呼:檔案系統、網路操作完成回呼
  6. setImmediate:I/O 事件後立即執行
  7. 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 是一個足夠強大和高效的平台。

下次我想在各種情境下進行實際基準測試。用實際資料驗證理論會很有趣。