Event Loop
一. JavaScript单线程
JavaScript单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
那么,由于js单线程的特点,如果JS发起了一个异步IO请求,在等待结果返回的这个时间段,后面的代码都会被阻塞。 我们知道JS主线程和渲染进程是相互阻塞的,因此这就会造成浏览器假死。 如何解决这个问题? 一个有效的办法就是我们这节要讲的事件循环Event Loop。
二. 什么是事件循环?
js中所有任务分为两种:同步任务synchronous、异步任务asynchronous
- 同步任务指的是:立即执行的任务,同步任务一般会直接进入到主线程中执行;
- 异步任务指的是:异步执行的任务,比如 ajax 网络请求,setTimeout 定时函数等都属于异步任务,异步任务会通过任务队列的机制(先进先出的机制)来进行协调
- 异步任务又分为:宏任务和微任务
- 宏任务:
- js(整体代码)
- I/O、UI 渲染
- MessageChannel、postMessage
- setImmediate(Node.js 环境)
- setTimeout、setInterval
- requestAnimationFrame 属于 GUI 引擎,发生在渲染过程的重绘重排部分,在 UI 渲染之前执行
- 微任务:
- process.nextTick(Node.js 环境)
- MutaionObserver(浏览器环境)
- Promise的回调
- js运行机制:
- 从宏任务的头部取出一个任务执行;
- 执行过程中若遇到微任务则将其添加到微任务的队列中;
- 宏任务执行完毕后,微任务的队列中是否存在任务,若存在,则挨个儿出去执行,直到执行完毕;
- GUI 渲染;
- 回到步骤 1,直到宏任务执行完毕;
这 4 步构成了一个事件的循环检测机制,即我们所称的eventloop。
三.宏任务和微任务执行顺序
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
new Promise(function(resolve) {
console.log(3);
resolve(Date.now());
}).then(function() {
console.log(4);
});
console.log(5);
setTimeout(function() {
new Promise(function(resolve) {
console.log(6);
resolve(Date.now());
}).then(function() {
console.log(7);
});
}, 0);
// 执行步骤如下:
// 1.执行 log(1),输出 1;
// 2.遇到 setTimeout,将回调的代码 log(2)添加到宏任务中等待执行;
// 3.执行 console.log(3),将 then 中的 log(4)添加到微任务中;
// 4.执行 log(5),输出 5;
// 5.遇到 setTimeout,将回调的代码 log(6, 7)添加到宏任务中;
// 6.宏任务的一个任务执行完毕,查看微任务队列中是否存在任务,存在一个微任务 log(4)(在步骤 3 中添加的),执行输出 4;
// 7.取出下一个宏任务 log(2)执行,输出 2;
// 8.宏任务的一个任务执行完毕,查看微任务队列中是否存在任务,不存在;
// 9.取出下一个宏任务执行,执行 log(6),将 then 中的 log(7)添加到微任务中;
// 10.宏任务执行完毕,存在一个微任务 log(7)(在步骤 9 中添加的),执行输出 7;
// 因此,最终的输出顺序为:1, 3, 5, 4, 2, 6, 7;
// 参考 https://juejin.cn/post/6844904035770695693
四. 调用栈call stack、消息队列Message queue、微任务队列 MicroTask queue
1.js在函数之前执行之前,会创建执行上下文,函数在执行时,会被压入调用栈
2.遇到异步任务,如果是宏任务,会将宏任务的回调函数,放入消息队列,等待执行
3.如果是微任务,会将微任务的回调函数,放入到微任务队列,等待执行
4.等调用栈清空,微任务队列中的任务,会依次压入调用栈执行并弹出 ,微任务队列清空后,消息队列中的任务,会依次压入调用栈执行并弹出
五.Node.js中的事件循环
1.node.js中的事件循环的特点:
- Node.js 通过 libuv 来处理与操作系统的交互,并且因此具备了异步、非阻塞、事件驱动的能力。
- Node.js 实际上是 Javascript 执行线程的单线程,真正的的 I/O 操作,底层 API 调用都是通过多线程执行的。
- CPU 密集型的任务是 Node.js 的软肋。
- 参考:https://developer.aliyun.com/article/3203
2.和 browser事件循环 的不同点
node.js中的事件循环,表现出的状态与浏览器大致相同。
不同的是 node 中有一套自己的模型。node 中事件循环的实现依赖 libuv 引擎。
- node.js中的Event Loop的单次循环是分阶段进行的。每个阶段运行完所有该阶段的回调函数或回调次数达到了次数限制,才会进入下一个阶段或指定的阶段,直到运行完最后一个阶段,进入下一个循环。
- 除了Poll阶段,node会在每个阶段,将该阶段对应的所有宏任务都依次执行完,然后执行微任务队列中的所有任务。(注意:node.js 11.0及以后的版本中,Goole为了向浏览器靠齐,将这一行为改成与浏览器一致,即每个 Macrotask(setTimeout,setInterval和setImmediate) 执行完后,就去执行 Microtask 了)
下面例子中的代码是按照最新的去进行分析的。
3.事件循环模型
![](https://img2020.cnblogs.com/blog/874429/202106/874429-20210608173609104-547450163.jpg)
4.nodejs中事件循环执行顺序
node中事件循环的顺序
外部输入数据 --> 轮询阶段(poll) --> 检查阶段(check) --> 关闭事件回调阶段(close callback) --> 定时器检查阶段(timer) --> I/O 事件回调阶段(I/O callbacks) --> 闲置阶段(idle, prepare) --> 轮询阶段...
这些阶段大致的功能如下:
- 定时器检测阶段(timers): 这个阶段执行定时器队列中的回调如 setTimeout() 和 setInterval()。
- I/O事件回调阶段(I/O callbacks): 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
- 闲置阶段(idle, prepare): 这个阶段仅在内部使用,可以不必理会
- 轮询阶段(poll): 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
- 检查阶段(check): setImmediate()的回调会在这个阶段执行。
- 关闭事件回调阶段(close callbacks): 例如socket.on('close', ...)这种close事件的回调
poll:
这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。
这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
check:
该阶段执行setImmediate()的回调函数。
close:
该阶段执行关闭请求的回调函数,比如socket.on('close', ...)。
timer阶段:
这个是定时器阶段,处理setTimeout()和setInterval()的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。
I/O callback阶段:
除了以下的回调函数,其他都在这个阶段执行:
- setTimeout()和setInterval()的回调函数
- setImmediate()的回调函数
- 用于关闭请求的回调函数,比如socket.on('close', ...)
一点一滴累积,总有一天你也会成为别人口中的大牛!