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 是一个足够强大和高效的平台。

下次我想在各种场景下进行实际基准测试。用实际数据验证理论会很有趣。