JS 为什么会有 Event Loop?

Event Loop 是 JavaScript 的基础概念,面试必问,平时也经常谈到,但是有没有想过为什么会有 Event Loop,它为什么会这样设计的呢?

今天我们就来探索下原因。

浏览器的 Event Loop

JavaScript 最早是被设计用来在浏览器里做表单验证的,想把语言设计的简单点,不想引入多线程的 api 增加复杂度,所以只支持单个 JS 线程,但是如果单线程的话,遇到定时逻辑、网络请求又会阻塞住。怎么办呢?

可以加一层调度逻辑。把 JS 代码封装成一个个的任务,放在一个任务队列中,主线程就不断的取任务执行就好了。

每次取任务执行,都会创建新的调用栈。

其中,定时器、网络请求其实都是在别的线程执行的,执行完了之后在任务队列里放个任务,告诉主线程可以继续往下执行了。

因为这些异步任务是在别的线程执行完,然后通过任务队列通知下主线程,是一种事件机制,所以这个循环叫做 Event Loop。

这些在其他线程执行的异步任务包括定时器(setTimeout、setInterval),UI 渲染、网络请求(XHR 或 fetch)。

但是,现在的 Event Loop 有个严重的问题,没有优先级的概念,只是按照先后顺序来执行,那如果有高优先级的任务就得不到及时的执行了。所以,得设计一套插队机制。

那就搞一个高优先级的任务队列就好了,每执行完一个普通任务,都去把所有高优先级的任务给执行完,之后再去执行普通任务。

有了插队机制之后,高优任务就能得到及时的执行。

这就是现在浏览器的 Event Loop。

其中普通任务叫做 MacroTask(宏任务),高优任务叫做 MicroTask(微任务)。

宏任务包括:setTimeout、setInterval、Ajax、fetch、script 标签的代码。

微任务包括:Promise.then、MutationObserver、Object.observe。

怎么理解宏微任务的划分呢?

定时器、网络请求这种都是在别的线程跑完之后通知主线程的普通异步逻辑,所以都是宏任务。

而高优任务的这三种也很好理解,MutationObserver 和 Object.observe 都是监听某个对象的变化的,变化是很瞬时的事情,肯定要马上响应,不然可能又变了,Promise 是组织异步流程的,异步结束调用 then 也是很高优的。

这就是浏览器里的 Event Loop 的设计:设计 Loop 机制和 Task 队列是为了支持异步,解决逻辑执行阻塞主线程的问题,设计 MicroTask 队列的插队机制是为了解决高优任务尽早执行的问题。

但是后来,JS 的执行环境不只是浏览器一种了,还有了 Node.js,它同样也要解决这些问题,但是它设计出来的 Event Loop 更细致一些。

Node.js 的 Event loop

Node.js 是一个新的 JS 运行环境,它同样要支持异步逻辑,包括定时器、IO、网络请求,很明显,也可以用 Event Loop 那一套来跑。

但是呢,浏览器那套 Event Loop 就是为浏览器设计的,对于做高性能服务器来说,那种设计还是有点粗糙了。

哪里粗糙呢?

浏览器的 Event Loop 只分了两层优先级,一层是宏任务,一层是微任务。但是宏任务之间没有再划分优先级,微任务之间也没有再划分优先级。

而 Node.js 任务宏任务之间也是有优先级的,比如定时器 Timer 的逻辑就比 IO 的逻辑优先级高,因为涉及到时间,越早越准确;而 close 资源的处理逻辑优先级就很低,因为不 close 最多多占点内存等资源,影响不大。

于是就把宏任务队列拆成了四个优先级:Timers、IO Callback、Check、Close Callback。

解释一下这四种宏任务:

Timers: 涉及到时间,肯定越早执行越准确,所以这个优先级最高很容易理解。

IO Callback:除了 Timers,优先级最高的就是 IO 了。

Close Callback:关闭资源的回调,晚点执行影响也不到,优先级最低

Check:执行一些自定义的逻辑,但是也比 Close 优先级高,Close 是最无关紧要的,所以排在它之前。

所以呢,Node.js 的 Event Loop 就是这样跑的了:

就像浏览器里跑定时器、网络请求等异步任务是在其他线程里一样,Node.js 也有跑异步逻辑的线程,只不过给 IO 的异步逻辑加了个线程池来统一管理,叫做 libuv。

还有一点不同要特别注意:

Node.js 的 Event Loop 并不是浏览器那种一次执行一个宏任务,然后执行所有的微任务,而是执行完所有的 Timers 宏任务,再去执行所有微任务,然后再执行所有的 IO Callback 的宏任务,然后再去执行所有微任务,剩余的 Check 和 Close Callback 的宏任务也是这样。

为什么这样呢?

其实按照优先级来看很容易理解:

假设浏览器里面的宏任务优先级是 1,所以是按照先后顺序依次执行,也就是一个宏任务,所有的微任务,再一个宏任务,再所有的微任务。

而 Node.js 的 宏任务之间也是有优先级的,所以 Node.js 的 Event Loop 每次都是把当前优先级的所有宏任务跑完再去跑微任务,然后再跑下一个优先级的宏任务。

也就是是所有 Timers 宏任务,再所有微任务,再所有 IO Callback 宏任务,再所有微任务这样。

除了宏任务有优先级,微任务也划分了优先级,多了一个 process.nextTick 的高优先级微任务,在所有的普通微任务之前来跑。

所以,Node.js 的 Event Loop 的完整流程就是这样的:

  • Timers 阶段:执行所有的定时器,也就是 setTimeout、setInterval 的 callback
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • IO Callback 阶段:执行所有除了 Timers 和 Close 的 callback
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • Idle/Prepare 阶段:内部用的一个阶段,不属于宏任务
  • Check 阶段:执行所有 setImmediate 的 callback
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • Close Callback 阶段:执行所有 close 事件的 callback
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务

比起浏览器里的 Event Loop,明显复杂了很多,但是经过我们之前的分析,也很容易理解:

Node.js 对宏任务做了优先级划分,从高到低分别是 Timers、IO Callback、Check、Close Callback 这 4 种,也对微任务做了划分,也就是 nextTick 的微任务和其他微任务。执行流程是先执行完当前优先级的所有宏任务,然后执行 process.nextTick 的微任务,再执行普通微任务,之后再执行下个优先级的所有宏任务。。这样不断循环。其中还有一个 Idle/Prepare 阶段是给 Node.js 内部逻辑用的,不属于宏任务,不需要关心。

完整的 Node.js 的 Event Loop 是这样的:

对比下浏览器的 Event Loop:

两个 JS 运行环境的 Event Loop 整体设计思路是差不多的,只不过 Node.js 的 Event Loop 对宏任务和微任务做了更细粒度的划分,也很容易理解,毕竟 Node.js 面向的环境和浏览器不同,更重要的是服务端对性能的要求会更高。

总结

JavaScript 设计时为了简单,只支持了单个 JS 线程,但为了解决阻塞问题,加了一层调度逻辑,也就是 Loop 循环和 Task 队列,把阻塞的逻辑放到其他线程跑,从而支持了异步。然后为了支持高优先级的任务调度,又引入了微任务队列,这就是浏览器的 Event Loop 机制:每次执行一个宏任务,然后执行所有微任务。

Node.js 也是一个 JS 运行环境,想支持异步同样要用 Event Loop,只不过服务端环境更复杂,对性能要求更高,所以 Node.js 对宏微任务都做了更细粒度的优先级划分:

Node.js 里划分了 4 种宏任务,分别是 Timers、IO Callback、Check、Close Callback。又划分了 2 种微任务,分别是 process.nextTick 的微任务和其他的微任务。

Node.js 的 Event Loop 流程是执行当前阶段的所有宏任务,然后执行所有微任务,4 个阶段(Timers、IO Callback、Check、Close Callback)依次这样执行。 具体来说就是执行 TImers 的所有宏任务,然后执行所有 nextTick 的微任务、然后执行所有普通微任务,然后是 IO Callback 阶段的所有宏任务。。。

Event Loop 是 JS 为了支持异步和任务优先级而设计的一套调度逻辑,针对浏览器、Node.js 等不同环境有不同的设计(主要是任务优先级的划分粒度不同),但是整体思路是差不多的。

猜你喜欢

转载自juejin.im/post/7049344384633929741