Node.js事件循环原理及异步请求阻塞问题的原因与解决
我在测试Node.js异步代码时,原本以为有异步操作时Node.js会处理新请求。用Express和Axios写了示例代码:
app.get('/async', (req, res) => { axios({ method: 'get', url: 'http://localhost:30000/cpu', headers: { 'fibo': '45' } }) .then(ress => { console.log(ress) res.send('Async success') }) .catch(err => { console.log('failed') console.log(err) res.send('failed') }) }) app.get('/', (req, res) => { axios({ method: 'get', url: 'http://localhost:30000/' }).then(function (response) { res.send('Hello World') }).catch((err) => { res.send('Something wrong happened') }) })
请求http://localhost:30000/cpu需要约15秒完成,请求我的服务器/async接口时会调用这个耗时接口,收到响应后返回Async success。我先请求/async,紧接着请求/,原本预期/async处理期间Node.js会同时处理/接口并立即返回,但实际/要等/async完成后才返回,/async阻塞了整个服务器。
请问:
- 为何
/要等待/async完成? - 该如何解决这个问题?
- Node.js事件循环的工作机制及它处理多请求的方式是什么?
一、为什么/会被/async阻塞?
核心原因是你调用的http://localhost:30000/cpu接口本身是在Node.js主线程上执行CPU密集型任务(比如计算斐波那契数列第45项)。
虽然你的Express接口里用了Axios发起异步请求,但如果目标接口/cpu是同一个Node.js服务提供的,且它的处理逻辑是同步的CPU密集型计算,那么这个计算会占用Node.js唯一的主线程。当/async发起请求到/cpu时,/cpu的同步计算会把主线程占满,事件循环无法处理新的请求(包括/接口的请求),直到/cpu的计算完成,主线程空闲下来,才会处理后续的请求。
简单说:不是你的/async接口阻塞了服务器,而是被调用的/cpu接口的同步CPU密集型任务阻塞了Node.js主线程。
二、解决方法
针对CPU密集型任务的阻塞问题,有几种常见解决方案:
1. 将CPU密集型任务移到子进程处理
Node.js提供了child_process模块,可以创建子进程来处理耗时的CPU计算,避免占用主线程。比如修改/cpu接口的处理逻辑:
const { fork } = require('child_process'); app.get('/cpu', (req, res) => { const fiboNum = parseInt(req.headers.fibo); // 创建子进程执行计算 const child = fork('./fibo-calculator.js'); child.send(fiboNum); child.on('message', (result) => { res.send(result.toString()); child.kill(); }); child.on('error', (err) => { res.status(500).send('计算失败'); }); });
对应的fibo-calculator.js:
process.on('message', (num) => { function fibo(n) { if (n <= 1) return n; return fibo(n - 1) + fibo(n - 2); } const result = fibo(num); process.send(result); });
子进程独立于主线程运行,计算时不会阻塞主线程的事件循环,其他请求可以正常处理。
2. 使用Worker Threads(Node.js 10.5+)
Worker Threads是专门为CPU密集型任务设计的,比子进程更轻量,共享内存空间:
const { Worker } = require('worker_threads'); app.get('/cpu', (req, res) => { const fiboNum = parseInt(req.headers.fibo); const worker = new Worker('./fibo-worker.js', { workerData: fiboNum }); worker.on('message', (result) => { res.send(result.toString()); worker.terminate(); }); worker.on('error', (err) => { res.status(500).send('计算失败'); }); });
fibo-worker.js:
const { workerData, parentPort } = require('worker_threads'); function fibo(n) { if (n <= 1) return n; return fibo(n - 1) + fibo(n - 2); } const result = fibo(workerData); parentPort.postMessage(result);
3. 横向扩展多实例
如果单进程处理能力不足,可以用cluster模块启动多个Node.js进程,利用多核CPU资源:
const cluster = require('cluster'); const numCPUs = require('os').cpus().length; const express = require('express'); if (cluster.isPrimary) { console.log(`主进程 ${process.pid} 正在运行`); // 衍生工作进程 for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`工作进程 ${worker.process.pid} 退出,重新启动`); cluster.fork(); }); } else { const app = express(); // 你的路由和逻辑 app.get('/cpu', (req, res) => { // 原来的计算逻辑,但现在每个进程处理自己的请求,不会全部阻塞 function fibo(n) { if (n <= 1) return n; return fibo(n - 1) + fibo(n - 2); } const result = fibo(parseInt(req.headers.fibo)); res.send(result.toString()); }); app.listen(30000); console.log(`工作进程 ${process.pid} 已启动`); }
这样不同的请求会被分配到不同的工作进程,单个进程被阻塞时,其他进程仍能处理请求。
三、Node.js事件循环工作机制及多请求处理方式
1. 事件循环核心原理
Node.js是单线程的,但通过事件循环实现了异步非阻塞IO。主线程负责处理事件循环,所有的异步操作(比如网络请求、文件IO)都会被委托给底层的libuv库(或操作系统)处理,当异步操作完成后,对应的回调会被放入事件队列,等待主线程空闲时执行。
事件循环分为几个按顺序执行的阶段:
- timers:执行
setTimeout和setInterval的回调 - pending callbacks:执行延迟到下一次循环的IO回调
- idle, prepare:内部使用的阶段
- poll:处理IO事件(网络、文件等),是最主要的阶段,如果没有timer要处理,会在这里等待新的IO事件
- check:执行
setImmediate的回调 - close callbacks:执行关闭事件的回调(比如
socket.on('close', ...))
每个阶段执行完该阶段所有的回调后,才会进入下一个阶段。
2. 多请求处理方式
当Node.js的HTTP服务器收到请求时,会把请求的处理逻辑(你的Express路由回调)加入事件队列。主线程在事件循环的poll阶段会取出这些请求回调执行:
- 如果回调里是异步IO操作(比如Axios请求、文件读写),主线程会把操作委托给底层,然后继续处理下一个请求回调,不会等待异步操作完成。当异步操作完成后,回调会被放入队列,等待主线程空闲时执行。
- 如果回调里是CPU密集型的同步代码,主线程会一直执行这段代码,直到完成,期间无法处理其他请求,这就是阻塞。
回到你的问题:/cpu接口的同步计算是CPU密集型任务,占用了主线程,导致事件循环无法处理/接口的请求,直到/cpu的计算完成,主线程空闲,才会处理/的请求。
内容的提问来源于stack exchange,提问作者mjsg

