几个月前,团队决定用 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 是一个足够强大和高效的平台。
下次我想在各种场景下进行实际基准测试。用实际数据验证理论会很有趣。