NodeJS EventLoop | 理解非阻塞I/O的核心机制

190 阅读5分钟

menu:

  1. Node 为何是单线程
  2. Node 是如何管理 非阻塞 IO
  3. EventLoop 执行流程
  4. Node EventLoop 与 浏览器事件环 区别

Node 为何是单线程

Node 是单线程的主要原因是 "性能/简化并发处理" 具体原因如下

  1. 通过Libuv实现异步非阻塞I/O: Node 设计上通过 libuv 来管理异步非阻塞I/O
  2. 降低内存消耗: 每个线程都会消耗 内存
  3. 避免多线程管理减少复杂性: 多线程需要频繁切换 执行上下文, 在操作系统层面上来看多线程有 线程同步, 数据共享, 竞争条件, 死锁问题 等问题增加了复杂性

上下文切换(Context Switch): 当操作系统从一个线程切换到另一个线程就需要保存当前线程的 状态(即上下文),以便下次切换到该线程保持上一次的状态执行

权衡利弊: 这是 Node 在性能上的取舍 有得必有失, 依附于 事件循环得到了更加优异的I/O密集型任务(文件操作, 数据库, 网络操作), 但在 CPU密集型任务(压缩, 解压, 加密, 解密) 上会有较弱的表现

Node 是如何管理 非阻塞 IO

我们要先知道 异步与同步 阻塞/非阻塞的区别

Pasted image 20241017162837.png

NodeJS 是通过 libuv 来管理 异步非阻塞 I/O, libuv 是由 C 编写 实现异步 I/O 处理的一个库 NodeJS EventLoop:

  1. 主线程交给 V8 引擎
  2. NodeAPI 交给 libuv 来管理
  3. libuv 是通过 多线程阻塞I/O 来实现 异步非阻塞 I/O
  4. 异步任务完成后 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

执行流程

  1. 先执行主栈代码
  2. 扫描 timers 是否有回调, 若是有则取出 队头 并执行, 在进行清空微任务 进入下个消息队列
  3. 扫描 poll 是否有定时器到达(有则返回 timers 队列), 没有则查看是否有 I/O 回调, 若是有则进行执行, 若是没有则 进入下个消息队列
  4. 扫描 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
setIntervalsetInterval
setTimeoutsetTimeout
DOM EventI/O
Ajax RequestsetImmediate
script 脚本close clalbacks
messageChannel

浏览器中 requestAnimation(下次重绘时执行回调函数) 与 requestDelCallback(会在主线程空闲时执行, 也就是说不会阻塞 UI 线程) 并不算是 宏任务

浏览器 与 Node 微任务

浏览器Node
Promise.thenPromise.then
queueMicrotaskqueueMicrotask
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, 微任务则在每个 宏任务执行一次清空微任务

总结

  1. Node 单线程是为了 优化I/O功能与简化并发处理
  2. Node 是通过 libuv 来管理 异步非阻塞I/O
  3. 同步与异步阻塞分类: 同步阻塞, 异步阻塞, 同步非阻塞, 异步非阻塞
  4. EventLoop 执行流程:
    1. Node: 主栈代码 -> timers(setInterval, setTimeout) -> poll(若有定时器到时则回到 timers, 否则检查 I/O 回调) -> check(setImmediate) -> close callbacks(关闭连接回调)
    2. Browser: 主栈代码 -> 微任务 -> GUI 线程 -> 宏任务(以此循环)
    3. 相同点: 从宏观上来看执行步骤都是 主栈代码 -> 宏任务 -> 微任务
  5. 在 Node10 之后则是 timers 拿出队头执行后清空 微任务队列 其他则不会清空微任务队列, Node10 之前则会在每个阶段清空微任务队列

推荐观看(后续会填坑)

  1. 浏览器EventLoop