menu:
- Node 为何是单线程
- Node 是如何管理 非阻塞 IO
- EventLoop 执行流程
- Node EventLoop 与 浏览器事件环 区别
Node 为何是单线程
Node 是单线程的主要原因是 "性能/简化并发处理" 具体原因如下
- 通过Libuv实现异步非阻塞I/O: Node 设计上通过 libuv 来管理异步非阻塞I/O
- 降低内存消耗: 每个线程都会消耗 内存
- 避免多线程管理减少复杂性: 多线程需要频繁切换 执行上下文, 在操作系统层面上来看多线程有 线程同步, 数据共享, 竞争条件, 死锁问题 等问题增加了复杂性
上下文切换(Context Switch): 当操作系统从一个线程切换到另一个线程就需要保存当前线程的 状态(即上下文),以便下次切换到该线程保持上一次的状态执行
权衡利弊: 这是 Node 在性能上的取舍 有得必有失, 依附于 事件循环得到了更加优异的I/O密集型任务(文件操作, 数据库, 网络操作), 但在 CPU密集型任务(压缩, 解压, 加密, 解密) 上会有较弱的表现
Node 是如何管理 非阻塞 IO
我们要先知道 异步与同步 阻塞/非阻塞的区别
NodeJS 是通过 libuv 来管理 异步非阻塞 I/O, libuv 是由 C 编写 实现异步 I/O 处理的一个库 NodeJS EventLoop:
- 主线程交给 V8 引擎
- NodeAPI 交给 libuv 来管理
- libuv 是通过 多线程阻塞I/O 来实现 异步非阻塞 I/O
- 异步任务完成后 libuv 将回调放入 事件队列中由 主线程进行处理
EventLoop 执行流程
NodeJS 的 EventLoop 是由 libuv 所管理, libuv 内维护着如下 6 个队列, 其中我们只需要 知道可控的 timers, poll, check, close callbacks 四个 事件队列 timers 主要维护 setInterval, setTimeout poll 事件触发线程可能会在这里阻塞 (取决于是否有 I/O 或 定时器到达), 当 异步回调时间到达 或 I/O 回调执行时 就会回到 timers 阶段 check 主要是管理 setImmediate close callbacks 主要是管理 关闭时的回调
┌───────────────────────────┐
┌─>│ timers │ setInterval, setTimeout
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 本次没执行完, 下次执行回调(不可控/无需管)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ 系统内部用的回调队列(无需管)
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │ 事件触发线程在轮训过程中会在 这里阻塞 (1. 执行异步 I/O 回调)(2. 监控时间到达后回到 timer)
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ setImmediate 回调在这里
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ 关闭时的回调 socket.on('close', () => {})...
└───────────────────────────┘
微任务执行机制与浏览器执行机制是相同的, Node 中包含 Promise.resolve().then()/queueMicroTask/process.nextTick 三个微任务方法, 严格来说 process.nextTick 并不属于微任务 因为 它会在所有微任务之前执行
在 Node10 版本之前是每个阶段切换之前 清空微任务队列, 在 Node10 版本之后只会在 每个宏任务执行完毕后 清空微任务队列, 这次改动更加贴合浏览器的 EventLoop
执行流程
- 先执行主栈代码
- 扫描 timers 是否有回调, 若是有则取出 队头 并执行, 在进行清空微任务 进入下个消息队列
- 扫描 poll 是否有定时器到达(有则返回 timers 队列), 没有则查看是否有 I/O 回调, 若是有则进行执行, 若是没有则 进入下个消息队列
- 扫描 close callbacks 若是有则执行, 若是没有则 进入下一轮事件循环
process.nextTick 与 Promise.then 先执行
process.nextTick(() => {
console.log(1);
});
Promise.resolve().then(res => {
console.log(2);
});
setTimeout, setImmediate 则以 timers 与 check 阶段执行的标准进行输出
setTimeout(() => {
console.log('setTimeout');
});
setImmediate(() => {
console.log('setImmediate');
});
// setImmediate, setTimeout
Node EventLoop 与 [[浏览器事件环|Browser Eventloop]] 区别
Node 的 EventLoop 相比较浏览器的 EventLoop 较大的区别在于 Node 是通过 libuv 来管理 事件队列, 浏览器则是通过 引擎来实现管理 事件队列管理
浏览器端执行: 主栈代码 -> 微任务 -> GUI 渲染进程 -> 宏任务队头 -> 微任务 Node 执行: 主栈代码 timers(清空微任务) -> poll -> check -> close callbacks
浏览器 与 Node 宏任务
| 浏览器 | Node |
|---|---|
| setInterval | setInterval |
| setTimeout | setTimeout |
| DOM Event | I/O |
| Ajax Request | setImmediate |
| script 脚本 | close clalbacks |
| messageChannel |
浏览器中 requestAnimation(下次重绘时执行回调函数) 与 requestDelCallback(会在主线程空闲时执行, 也就是说不会阻塞 UI 线程) 并不算是 宏任务
浏览器 与 Node 微任务
| 浏览器 | Node |
|---|---|
| Promise.then | Promise.then |
| queueMicrotask | queueMicrotask |
| MutationObserver | |
试一试
console.log('1');
setTimeout(function() {
console.log('2');
new Promise(function(resolve) {
console.log('3');
resolve();
}).then(function() {
console.log('4')
})
})
new Promise(function(resolve) {
console.log('5');
resolve();
}).then(function() {
console.log('6')
})
console.log('7')
// 输出结果: 1 5 7 6 2 3 4
// 重点理解 timers -> poll -> check -> close callbacks, 微任务则在每个 宏任务执行一次清空微任务
总结
- Node 单线程是为了 优化I/O功能与简化并发处理
- Node 是通过 libuv 来管理 异步非阻塞I/O
- 同步与异步阻塞分类: 同步阻塞, 异步阻塞, 同步非阻塞, 异步非阻塞
- EventLoop 执行流程:
- Node: 主栈代码 -> timers(setInterval, setTimeout) -> poll(若有定时器到时则回到 timers, 否则检查 I/O 回调) -> check(setImmediate) -> close callbacks(关闭连接回调)
- Browser: 主栈代码 -> 微任务 -> GUI 线程 -> 宏任务(以此循环)
- 相同点: 从宏观上来看执行步骤都是 主栈代码 -> 宏任务 -> 微任务
- 在 Node10 之后则是 timers 拿出队头执行后清空 微任务队列 其他则不会清空微任务队列, Node10 之前则会在每个阶段清空微任务队列
推荐观看(后续会填坑)
- 浏览器EventLoop