为什么JS是单线程?
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为什么会有同步和异步
因为JavaScript的单线程,因此同个时间只能处理同个任务,所有任务都需要排队,前一个任务执行完,才能继续执行下一个任务,但是,如果前一个任务的执行时间很长,比如文件的读取操作或ajax操作,后一个任务就不得不等着,拿ajax来说,当用户向后台获取大量的数据时,不得不等到所有数据都获取完毕才能进行下一步操作,用户只能在那里干等着,严重影响用户体验
因此,JavaScript在设计的时候,就已经考虑到这个问题,主线程可以完全不用等待文件的读取完毕或ajax的加载成功,可以先挂起处于等待中的任务,先运行排在后面的任务,等到文件的读取或ajax有了结果后,再回过头执行挂起的任务,因此,任务就可以分为同步任务和异步任务
1. 同步任务(synchronous)
console.log(123);
console.log(456);
for (let i = 1; i <= 5; i++) {
console.log(i);
}
顾名思义 得到的一定是 顺序执行
2. 异步任务(asynchronous)
setTimeout(() => {
console.log('定时器');
}, 0)
console.log('奥特曼');
按普通的执行顺序来说 定时器在上面 应该先输出定时器 再输出 奥特曼
最后拿到的结果却先输出奥特曼 在输出了定时器 原因呢就是 setTimeout是异步任务
补充一个知识点 setTimeout的定时器 不管延迟多少毫秒 也是异步的 每个浏览器的时间也是不同的,各个浏览器都有差异 但定义了0 最小也是4毫秒
JS运行机制
通过上面代码知道setTimeout是异步的 我们就搞清了执行顺序优先级 同步代码>异步代码
所以说在任务队列中 分为两大类 1.同步任务 2. 异步任务
1.event loop
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。不同的异步任务的回调函数会放入不同的任务队列之中,只要异步任务有了运行结果,就在其所在的"任务队列"之中放置一个事件。(分为宏任务和微任务;优先执行微任务队列)
(3)一旦"执行栈"中的所有同步任务执行完毕,当执行栈为空时,执行引擎才会去看任务队列有无可执行的任务;如果有,就取一个放入到执行栈中执行。执行完后,执行栈为空,便又去检查任务队列。
(4)主线程不断重复上面的第三步,称为事件循环(Event Loop)。
另一种解读:
1.同步和异步任务分别进入不同的 '‘场所'’ 执行。所有同步任务都在主线程上执行,形成一个执行栈;而异步任务进入Event Table并注册回调函数。
2.当这个异步任务有了运行结果,Event Table会将这个回调函数移入Event Queue,进入等待状态。
3.当主线程内同步任务执行完成,会去Event Queue读取对应的函数,并结束它的等待状态,进入主线程执行。
4.主线程不断重复上面3个步骤,也就是常说的Event Loop(事件循环)。
- 那怎么知道主线程执行栈为空啊?
js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
异步任务又分为宏任务和微任务
宏任务:整体代码script、setTimeout、setInterval、I/O、UI交互事件,可以理解是每次执行栈执行的代码就是一个宏任务。
微任务:Promise,process.nextTick,且process.nextTick优先级大于promise.then。可以理解是在当前 task 执行结束后立即执行的任务;
2.执行上下文和执行栈
执行上下文概念及类型
执行上下文可以理解为javascript代码被执行时的环境。
在js中,执行上下文分为以下三种类型:
- 全局执行上下文:只有一个,浏览器中的全局对象就是window对象,this指向这个全局对象。
- 函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文。
- Eval函数执行上下文:指的是运行在eval函数中的代码,很少用而且不建议使用。
执行上下文的创建
我们现在已经知道,每当调用一个函数时,一个新的执行上下文就会被创建出来。然而,在JavaScript引擎内部,这个执行上下文的创建过程具体分为两个阶段 :1、创建阶段,2、执行阶段
创建阶段(发生在当调用一个函数时,但是在执行函数体内的具体代码以前)
---建立变量,函数,arguments对象,参数
---建立作用链
---确定this的值
代码执行阶段:
---变量赋值,函数引用,执行其他代码
执行栈概念
执行栈,也叫调用栈,具有LIFO(后进先出)的结构,用于存储代码执行期间创建的所有执行上下文。
根据执行上下文的类型可以知道,在js程序执行过程中,一定会出现多种不同的执行上下文,但是,js是单线程语言,不能同时做多件事情。当js解释器初始执行代码时,它会首先默认进入全局上下文,而且每次调用一个函数都将会创建一个新的执行上下文,每次新创建的执行上下文都会被添加到作用域链的顶部,也就是执行栈或者调用栈。浏览器总是运行于调用栈的顶部的当前执行上下文。一旦完成,当前执行上下文就会从栈顶移除。
可以看下面的一个例子:
var a = 1; // 1、全局上下文环境
function bar (x) {
console.log('bar')
var b = 2;
fn(x + b); // 3、fn上下文环境
}
function fn(c) {
console.log(c)
}
bar(3); // 2、bar上下文环境
3.宏任务(macrotask)和微任务(microtask)
当我们了解过了event loop之后,就会明白event loop中有同步和异步任务两种。他们的执行逻辑就是遇到异步先放队列。这似乎就已经可以阐述整个event loop了。
但是实际上,上面运行机制也提到,在异步任务的执行中类型中又分了2种任务,就是【微任务】和【宏任务】。而他们的执行顺序有着一个重要的规律——(抛开最外层的宏任务不谈)先执行微任务,再执行宏任务。只要微任务队列中有可执行的任务,就得等可执行的微任务执行完,再执行下一个宏任务。
| 宏任务 | 微任务 |
|---|---|
| setTimeout | process.nextTick(node) |
| setInterval | MutationObserver |
| setImmediate(node) | Promise.then |
| requestAnimationFrame | async/await |
| MessageChannel |
PS: script (可以理解为外层同步代码,作为入口 ) 是最外层的宏任务
总的结论就是,执行宏任务(因为script是一个大的宏任务),然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环
为什么要区分微任务和宏任务?
js机制在对待任务时,认为他们应该是不平等的。也就是说执行更快的任务应该可以插队,不必等执行耗时就的先执行完。从更底层来说:
- 微任务是线程之间的切换,速度快。不用进行上下文切换,可以快速的一次性做完所有的微任务。
- 宏任务是进程之间的切换,速度慢,且每次执行需要切换上下文。因此一个Eventloop中只执行一个宏任务。
- 微任务执行快,一次性可以执行很多个,在当前宏任务执行后立刻清空微任务可以达到伪同步的效果,这对视图渲染效果起到至关重要的作用。
而往往视图的渲染是在宏任务执行之后的,先执行微任务可以确保在视图渲染之前,数据已经更新。
eg:对比下面的执行结果:
//例1
setTimeout(() => {
console.log('timer1');
setTimeout(() => {
console.log('timer3')
}, 0)
}, 0)
setTimeout(() => {
console.log('timer2')
}, 0)
console.log('start')
//例2
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(() => {
console.log('promise')
})
}, 0)
setTimeout(() => {
console.log('timer2')
}, 0)
console.log('start')
执行结果:
'start'
'timer1'
'timer2'
'timer3'
'start'
'timer1'
'promise'
'timer2'
这两个例子,看着好像只是把第一个定时器中的内容换了一下而已。
一个是为定时器timer3,一个是为Promise.then
但是如果是定时器timer3的话,它会在timer2后执行,而Promise.then却是在timer2之前执行。
因为Promise.then是微任务,它会被加入到本轮中的微任务列表,而定时器timer3是宏任务,它会被加入到下一轮的宏任务中。
eg:用逻辑推算分析较复杂的执行顺序
Promise.resolve().then(() => {
console.log('promise1');
const timer2 = setTimeout(() => {
console.log('timer2')
}, 0)
});
const timer1 = setTimeout(() => {
console.log('timer1')
Promise.resolve().then(() => {
console.log('promise2')
})
}, 0)
console.log('start');
这道题稍微的难一些,在promise中执行定时器,又在定时器中执行promise;
并且要注意的是,这里的Promise是直接resolve的,而之前的new Promise不一样。
因此过程分析为:
- 刚开始整个脚本作为第一次宏任务来执行,我们将它标记为宏1,从上至下执行
- 遇到
Promise.resolve().then这个微任务,将then中的内容加入第一次的微任务队列标记为微1 - 遇到定时器
timer1,将它加入下一次宏任务的延迟列表,标记为宏2,等待执行(先不管里面是什么内容) - 执行宏1中的同步代码
start - 第一次宏任务(宏1)执行完毕,检查第一次的微任务队列(微1),发现有一个
promise.then这个微任务需要执行 - 执行打印出微1中同步代码
promise1,然后发现定时器timer2,将它加入宏2的后面,标记为宏3 - 第一次微任务队列(微1)执行完毕,执行第二次宏任务(宏2),首先执行同步代码
timer1 - 然后遇到了
promise2这个微任务,将它加入此次循环的微任务队列,标记为微2 - 宏2中没有同步代码可执行了,查找本次循环的微任务队列(微2),发现了
promise2,执行它 - 第二轮执行完毕,执行宏3,打印出
timer2
所以结果为:
'start'
'promise1'
'timer1'
'promise2'
'timer2'
eg:注意new Promise
new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
})
console.log(2)
对于async/await 如下:
(async()=>{
console.log(1);
await console.log(2);
console.log('ok')
})()
console.log(3)
// 输出
// 1
// 2
// 3
// ok
为什么会出现上述情况?
async/await其实是 Promise 和 Generator 的语法糖,所以我们把它们转成我们熟悉的 Promise
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
// 其实就是
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(()=>console.log('async1 end'))
}
async/await执行顺序
我们知道async隐式返回 Promise 作为结果的函数,那么可以简单理解为,await后面的函数执行完毕时,await会产生一个微任务(Promise.then是微任务)。
但是我们要注意这个微任务产生的时机,它是执行完await之后,直接跳出async函数,执行其他代码(此处就是协程的运作,A暂停执行,控制权交给B)。其他代码执行完毕后,再回到async函数去执行剩下的代码,然后把await后面的代码注册到微任务队列当中。我们来看个例子:
下面代码输出什么?
async function as1() {
console.log("as1 start");
await as2();
console.log("as1 end");
}
async function as2() {
console.log("as2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
as1();
new Promise(function (resolve) {
console.log("prom1");
resolve();
}).then(function () {
console.log("prom2");
});
console.log("script end");
//script start => as1 start => as2 => prom1 => script end
//=> as1 end => prom2 => setTimeout
如何实现一个精准的定时器
setTimeout 是不准的。因为 setTimeout 是一个宏任务,它的指定时间指的是:进入主线程的时间。
setTimeout(callback, 进入主线程的时间)
所以什么时候可以执行 callback,需要看 主线程前面还有多少任务待执行。
总结下来可能受到误差的因素有:
1. 延迟执行:setTimeout 设置的延迟时间并不是精确的时间点,而是一个最小延迟时间。如果事件循环中有其他代码正在执行,setTimeout 的回调函数可能会被推迟执行。
2. 系统负载:当系统负载较重时,事件循环可能会出现延迟。这可能导致 setTimeout 的回调函数执行的时间比预期的要晚。
3. 睡眠模式:在某些设备上,当设备进入睡眠模式时,定时器可能会暂停,直到设备被唤醒。这会导致 setTimeout 的回调函数执行时间延迟。
requestAnimationFrame对比setTimeout/setInterval的优势?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.block {
width: 50px;
height: 50px;
background-color: aqua;
}
</style>
</head>
<body>
<div class="block"></div>
</body>
let dom = document.querySelector('.block');
dom.style.position = 'absolute';
// let left = 1;
// function step() {
// dom.style.left = `${left++}px`;
// let timer = setTimeout(() => {
// step();
// }, 1000 / 60);
// if (timer && left > 100) {
// clearTimeout(timer)
// }
// }
// step();
let left = 1;
function step() {
dom.style.left = `${left}px`;
left *= 1.02;
if (left < 200) {
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
</script>
</html>
如果在EventLoop执行队列执行 setTimeout 回调函数之前有其他任务等待执行(现实的Web应用往往存在大量的任务在执行队列等待执行),那么 setTimeout 回调就会延迟执行
requestAnimationFrame 的使用方法跟 setTimeout 区别不大,但是主要的区别是
1:requestAnimationFrame 的执行时机是浏览器去决定的,比如当前设备刷新率是60Hz,那它的执行就会是16.67ms。在setTimeout中如果进行了DOM操作(尤其是产生了重绘)通常不会立即执行,而是等待浏览器内建刷新时才执行。因此对于「动画」来说的话,raf要远远比setTimeout适合得多。也可以说它的回调函数的执行是与屏幕的刷新有关,每刷完一次,就要执行一次,不会卡顿和掉帧。
由于它定义的回调是在下次重绘之前,所以必须在回调里再调用一次,才能实现连续的动画。
2:而且 requestAnimationFrame 在页面隐藏掉或最小化的时候,是不会执行的,这样就节省了一些不必要的系统资源。所以使用 requestAnimationFrame 比 setTimeout 好处要多。
问题:大多数电脑显示器的刷新频率是 60Hz,大概相当于每秒钟重绘 60 次,循环间隔是 1000ms/60,约等于 16.6ms,使用 requestAnimationFrame 创建计时器可以提供更准确的时间间隔,因为它与浏览器的刷新率同步。然而,即使使用 requestAnimationFrame,计时器也可能不够准确。这是因为 requestAnimationFrame 的回调函数在下一次浏览器重绘之前执行,而浏览器的刷新率通常是每秒60次(60帧/秒),也就是每16.67毫秒一次。
const t = Date.now()
function mySetTimeout (cb, delay) {
let startTime = Date.now()
loop()
function loop () {
if (Date.now() - startTime >= delay) {
cb();
return;
}
requestAnimationFrame(loop)
}
}
mySetTimeout(()=>console.log('mySetTimeout' ,Date.now()-t),2000) //2005
setTimeout(()=>console.log('SetTimeout' ,Date.now()-t),2000) // 2002
这种方案看起来像是增加了误差,这是因为requestAnimationFrame每16.7ms 执行一次,因此它不适用于间隔很小,宏任务与微任务之间消耗少的定时器修正。
以下是一些可能导致计时器不准确的原因:
- 硬件和浏览器限制:不同设备和浏览器的性能和处理速度可能会有所不同,导致计时器的精确性受到影响。
- 其他任务的干扰:如果浏览器正在执行其他高优先级的任务,如处理复杂的动画或运行大量的JavaScript代码,可能会导致计时器的执行延迟。
- 页面可见性:当页面被最小化或切换到后台标签时,浏览器可能会减慢或停止
requestAnimationFrame的回调函数的执行,以降低资源消耗。
使用 web worker
Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXML和channel属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。
Web Worker 的作用就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程不会被阻塞或拖慢。
// index.js
let count = 0;
//耗时任务
setInterval(function(){
let i = 0;
while(i++ < 100000000);
}, 0);
// worker
let worker = new Worker('./worker.js')
// worker.js
let startTime = new Date().getTime();
let count = 0;
setInterval(function(){
count++;
console.log(count + ' --- ' + (new Date().getTime() - (startTime + count * 1000)));
}, 1000);
这种方案体验整体上来说还是比较好的,既能较大程度修正计时器也不影响主进程任务,主要好处是:
- 提高性能:Web Workers 可以在后台线程中运行,不会阻塞主线程的执行。这意味着你可以在 Web Worker 中创建计时器来处理长时间运行的任务,而不会影响主线程的响应性能。
- 减少延迟:由于 Web Workers 可以独立于主线程运行,所以可以实现更精确的定时器,减少延迟。这对于需要高精度时间间隔的应用程序或游戏非常有用。
- 分散计算负载:如果你的应用程序需要进行复杂的计算或处理大量数据,使用 Web Workers 可以将计算负载分散到多个线程中,提高整体性能和响应速度。
setTimeout 系统时间补偿
这个方案是在 stackoverflow 看到的一个方案,我们来看看此方案和原方案的区别
原方案
setTimeout系统时间补偿
当每一次定时器执行时后,都去获取系统的时间来进行修正,虽然每次运行可能会有误差,但是通过系统时间对每次运行的修复,能够让后面每一次时间都得到一个补偿。
function timer() {
var speed = 500,
counter = 1,
start = new Date().getTime();
function instance()
{
var real = (counter * speed),
ideal = (new Date().getTime() - start);
counter++;
var diff = (ideal - real);
form.diff.value = diff;
window.setTimeout(function() { instance(); }, (speed - diff)); // 通过系统时间进行修复
};
window.setTimeout(function() { instance(); }, speed);
}
再来看看加入额外的代码逻辑的情况。
依旧非常的稳定,因此通过系统的时间补偿,能够让我们的 setTimeout 变得更加准时
while、Web Worker、requestAnimationFrame、setTimeout 系统时间补偿
HTML5 web worker
在html5规范中引入了web workers概念,解决客户端JavaScript无法多线程的问题,其定义的worker是指代码的并行线程,不过web worker处于一个自包含的环境中,无法访问主线程的window对象和document对象,和主线程通信只能通过异步消息传递机制。(《JavaScript权威指南》)
Web Workers 的设计初衷就是为了在后台执行任务而不会阻塞主线程。因此,使用 Web Workers 可以减少对主线程的阻塞。
当你将任务委托给 Web Worker 时,它会在独立的线程中运行,完全与主线程分离。这意味着在 Web Worker 中执行复杂或耗时的计算任务时,主线程不会被阻塞,可以继续响应用户的操作和渲染页面。
Web Workers 通过消息传递机制与主线程进行通信,这可以防止竞态条件和数据冲突。主线程可以向 Web Worker 发送消息,Web Worker 在后台处理该消息并发送回结果,而主线程可以在接收到结果后做出相应的响应。
尽管 Web Workers 不会直接阻塞主线程,但在与 Web Workers 通信时,仍然存在一定的开销。消息的序列化、传输和反序列化可能会引入一定的延迟和性能开销。因此,在使用 Web Workers 时,需要注意控制通信频率和数据量,以避免影响整体性能。
总结来说,Web Workers 可以显著减少对主线程的阻塞,并提高应用程序的响应性能。然而,在与 Web Workers 通信时需要注意开销,以确保获得最佳的性能和用户体验。
Node与浏览器的事件循环有什么区别?
提问:Node和浏览器的内核不都是V8引擎 为什么事件循环机制不一样呢?
-
事件驱动的异步模型:
- 浏览器: 浏览器中的事件循环主要用于处理用户交互、HTTP 请求、定时器等异步事件。浏览器环境中,事件循环与渲染引擎(如WebKit或Blink)紧密集成,以便及时更新用户界面。
- Node.js: Node.js 旨在处理高并发的服务器端任务,其事件循环设计更加专注于处理 I/O 操作(例如文件系统访问、网络请求等)和事件驱动的服务器响应。Node.js 的事件循环基于 libuv 库,它提供了跨平台的异步 I/O 支持,并管理事件队列的执行顺序。
-
执行环境差异:
- 浏览器: 在浏览器中,事件循环与 UI 渲染密切相关,因此需要考虑到动画帧率和用户交互的响应速度。浏览器的事件循环不仅处理 JavaScript 任务,还与渲染引擎协作,以确保流畅的用户体验。
- Node.js: 而在 Node.js 中,事件循环更侧重于处理大量的并发 I/O 操作,如文件读写、数据库查询、网络通信等。Node.js 的事件循环通过 libuv 库实现异步的 I/O 操作和事件处理,这使得 Node.js 可以高效地处理大量的并发连接和请求。
-
优化和特化:
- 浏览器和 Node.js 的事件循环机制在设计上都针对各自的应用场景进行了优化。浏览器需要处理动态的用户界面更新和交互,而 Node.js 更关注于处理高并发的后端服务请求。因此,它们的事件循环机制虽然基于相同的 JavaScript 引擎(V8),但在实现细节和重点上有所不同。
总结来说,虽然 Node.js 和浏览器都使用了 V8 引擎,但它们的事件循环机制在设计和实现上考虑了各自的特定需求和优化目标,因此会表现出一些差异。
在 node11 之前,nodejs 和浏览器中的事件循环的区别就是 micro task 的执行时机;nodejs中 timers 阶段有几个 setTimeout/setInterval 都会依次执行,执行完毕所有的 timers 代码之后才会去执行 micro task,并不像浏览器端,每执行一个宏任务后就去执行所有微任务。
所以下面的代码,在 node11 之前的执行结果是和浏览器中不相同的
node中的事件循环的顺序: 外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)...
node 中的事件循环
浏览器中有事件循环,node 中也有,事件循环是 node 处理非阻塞 I/O 操作的机制,node中事件循环的实现是依靠的libuv引擎。由于 node 11 之后,事件循环的一些原理发生了变化,这里就以新的标准去讲,最后再列上变化点让大家了解前因后果。
宏任务和微任务
node 中也有宏任务和微任务,与浏览器中的事件循环类似,其中,
macro-task 大概包括:
- setTimeout
- setInterval
- setImmediate
- script(整体代码)
- I/O 操作等。
micro-task 大概包括:
- process.nextTick(与普通微任务有区别,在微任务队列执行之前执行)
- new Promise().then(回调)等。
node事件循环整体理解
先看一张官网的 node 事件循环简化图:
图中的每个框被称为事件循环机制的一个阶段,每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段。
因此,从上面这个简化图中,我们可以分析出 node 的事件循环的阶段顺序为:
输入数据阶段(incoming data)->轮询阶段(poll)->检查阶段(check)->关闭事件回调阶段(close callback)->定时器检测阶段(timers)->I/O事件回调阶段(I/O callbacks)->闲置阶段(idle, prepare)->轮询阶段...
阶段概述
- 定时器检测阶段(timers):本阶段执行 timer 的回调,即 setTimeout、setInterval 里面的回调函数。
- I/O事件回调阶段(I/O callbacks):执行延迟到下一个循环迭代的 I/O 回调,即上一轮循环中未被执行的一些I/O回调。
- 闲置阶段(idle, prepare):仅系统内部使用。
- 轮询阶段(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
- 检查阶段(check):setImmediate() 回调函数在这里执行
- 关闭事件回调阶段(close callback):一些关闭的回调函数,如:socket.on('close', ...)。
三大重点阶段
日常开发中的绝大部分异步任务都是在 poll、check、timers 这3个阶段处理的,所以我们来重点看看。
timers
timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。 同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
poll
poll 是一个至关重要的阶段,poll 阶段的执行逻辑流程图如下:
如果当前已经存在定时器,而且有定时器到时间了,拿出来执行,eventLoop 将回到 timers 阶段。
如果没有定时器, 会去看回调函数队列。
-
如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
-
如果 poll 队列为空时,会有两件事发生
- 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
- 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去,一段时间后自动进入 check 阶段。
check
check 阶段。这是一个比较简单的阶段,直接执行 setImmdiate 的回调。
process.nextTick
process.nextTick 是一个独立于 eventLoop 的任务队列。
在每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行。
看一个例子:
setImmediate(() => {
console.log('timeout1')
Promise.resolve().then(() => console.log('promise resolve'))
process.nextTick(() => console.log('next tick1'))
});
setImmediate(() => {
console.log('timeout2')
process.nextTick(() => console.log('next tick2'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
- 在 node11 之前,因为每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行,因此上述代码是先进入 check 阶段,执行所有 setImmediate,完成之后执行 nextTick 队列,最后执行微任务队列,因此输出为
timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve - 在 node11 之后,process.nextTick 是微任务的一种,因此上述代码是先进入 check 阶段,执行一个 setImmediate 宏任务,然后执行其微任务队列,再执行下一个宏任务及其微任务,因此输出为
timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4
node 版本差异说明
这里主要说明的是 node11 前后的差异,因为 node11 之后一些特性已经向浏览器看齐了,总的变化一句话来说就是,如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列,一起来看看吧~
timers 阶段的执行时机变化
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
// 浏览器和新版本node start=>end=>promise3=>timer1=>promise1=>timer2=>promise2
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
-
如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这就跟浏览器端运行一致,最后的结果为
timer1=>promise1=>timer2=>promise2 -
如果是 node10 及其之前版本要看第一个定时器执行完,第二个定时器是否在完成队列中.
- 如果是第二个定时器还未在完成队列中,最后的结果为
timer1=>promise1=>timer2=>promise2 - 如果是第二个定时器已经在完成队列中,则最后的结果为
timer1=>timer2=>promise1=>promise2
- 如果是第二个定时器还未在完成队列中,最后的结果为
check 阶段的执行时机变化
setImmediate(() => console.log('immediate1'));
setImmediate(() => {
console.log('immediate2')
Promise.resolve().then(() => console.log('promise resolve'))
});
setImmediate(() => console.log('immediate3'));
setImmediate(() => console.log('immediate4'));
- 如果是 node11 后的版本,会输出
immediate1=>immediate2=>promise resolve=>immediate3=>immediate4 - 如果是 node11 前的版本,会输出
immediate1=>immediate2=>immediate3=>immediate4=>promise resolve
nextTick 队列的执行时机变化
setImmediate(() => console.log('timeout1'));
setImmediate(() => {
console.log('timeout2')
process.nextTick(() => console.log('next tick'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
- 如果是 node11 后的版本,会输出
timeout1=>timeout2=>next tick=>timeout3=>timeout4 - 如果是 node11 前的版本,会输出
timeout1=>timeout2=>timeout3=>timeout4=>next tick
以上几个例子,你应该就能清晰感受到它的变化了,反正记着一个结论,如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列。
node 和 浏览器 eventLoop的主要区别
两者最主要的区别在于浏览器中的微任务是在每个相应的宏任务中执行的,而nodejs中的微任务是在不同阶段之间执行的。
- Node 端,microtask 在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。
- 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
几道面试题
最后讲几道面试题帮助理解Node中的事件循环
关于setTimeout 与 setImmediate:
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
// 答案:可能是 1 2,也可能是 2 1 🧐
**解析:**关于这个问题的答案其实是不确定的,因为setTimeout的第二个参数默认为0;
但是实际上,Node 做不到0毫秒,最少也需要1毫秒, setTimeout的第二个参数的取值范围在1毫秒到2147483647毫秒之间;也就是说,setTimeout(f, 0)等同于setTimeout(f, 1),实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况;
如果没到1毫秒(比事件循环初始化快),那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数
进阶版:
fs.readFile('xxx', () => {
setTimeout(function () {
console.log(1)
Promise.resolve(console.log(5)).then(() => console.log(3))
process.nextTick(() => console.log(4))
}, 0);
setImmediate(() => console.log(2))
})
// 答案为:21543
**解析:**首先当前 fs 的操作是在poll阶段,即已经越过timers阶段了,就顺着下面执行到check阶段,则第一轮输入setImmediately中的 2;第二轮进入到timers阶段内部,优先输出log函数 1,接下来因为Promise中的resolve回调级别为同步代码,则输入 5 ;再下来同步代码都执行完了,就顺其自然的优先执行 process.nextTick 中的4了,最后再执行Promise.thn()微任务中的 3;
对于process.nextTick() 的技巧:本轮同步代码执行完立即执行它,所有传递到 process.nextTick() 的回调将在事件循环继续之前解析
Libuv 和跨平台异步 I/O
NodeJS 的跨平台能力和事件循环机制都是基于 Libuv 库实现的,你不用关心这个库的具体内容。我们只需要知道 Libuv 库是事件驱动的,并且封装和统一了不同平台的 API 实现。
NodeJS 中 V8 引擎将 JS 代码解析后调用 Node API,然后 Node API 将任务交给 Libuv 去分配,最后再将执行结果返回给 V8 引擎。在 Libux 中实现了一套事件循环流程来管理这些任务的执行,所以 NodeJS 的事件循环主要是在 Libuv 中完成的。
下面我们来看看 Libuv 中的循环是怎样的。
事件循环各阶段
在 NodeJS 中 JS 的执行,我们主要需要关心的过程分为以下几个阶段,下面每个阶段都有自己单独的任务队列,当执行到对应阶段时,就判断当前阶段的任务队列是否有需要处理的任务。
- timers 阶段:执行所有 setTimeout() 和 setInterval() 的回调。
- pending callbacks 阶段:某些系统操作的回调,如 TCP 链接错误。除了 timers、close、setImmediate 的其他大部分回调在此阶段执行。
- poll 阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等。V8 引擎将 JS 代码解析并传入 Libuv 引擎后首先进入此阶段。如果此阶段任务队列已经执行完了,则进入 check 阶段执行 setImmediate 回调(如果有 setImmediate),或等待新的任务进来(如果没有 setImmediate)。在等待新的任务时,如果有 timers 计时到期,则会直接进入 timers 阶段。此阶段可能会阻塞等待。
- check 阶段:setImmediate 回调函数执行。
- close callbacks 阶段:关闭回调执行,如 socket.on('close', ...)。
上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段。这里也是与浏览器中逻辑差异较大的地方,不过浏览器不用区分这些阶段,也少了很多异步操作类型,所以不用刻意去区分两者区别。代码如下所示:
const fs = require('fs');
fs.readFile(__filename, (data) => {
// poll(I/O 回调) 阶段
console.log('readFile')
Promise.resolve().then(() => {
console.error('promise1')
})
Promise.resolve().then(() => {
console.error('promise2')
})
});
setTimeout(() => {
// timers 阶段
console.log('timeout');
Promise.resolve().then(() => {
console.error('promise3')
})
Promise.resolve().then(() => {
console.error('promise4')
})
}, 0);
// 下面代码只是为了同步阻塞1秒钟,确保上面的异步任务已经准备好了
var startTime = new Date().getTime();
var endTime = startTime;
while(endTime - startTime < 1000) {
endTime = new Date().getTime();
}
// 最终输出 timeout promise3 promise4 readFile promise1 promise2
另一个与浏览器的差异还体现在同一个阶段里的不同任务执行,在 timers 阶段里面的宏任务、微任务测试代码如下所示:
setTimeout(() => {
console.log('timeout1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0);
setTimeout(() => {
console.log('timeout2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0);
-
浏览器中运行
每次宏任务完成后都会优先处理微任务,输出“timeout1”、“promise1”、“timeout2”、“promise2”。 -
NodeJS 中运行
因为输出 timeout1 时,当前正处于 timers 阶段,所以会先将所有 timer 回调执行完之后再执行微任务队列,即输出“timeout1”、“timeout2”、“promise1”、“promise2”。
上面的差异可以用浏览器和 NodeJS 10 对比验证。是不是感觉有点反程序员?因此 NodeJS 在版本 11 之后,就修改了此处逻辑使其与浏览器尽量一致,也就是每个 timer 执行后都先去检查一下微任务队列,所以 NodeJS 11 之后的输出已经和浏览器一致了。
nextTick、setImmediate 和 setTimeout
实际项目中我们常用 Promise 或者 setTimeout 来做一些需要延时的任务,比如一些耗时计算或者日志上传等,目的是不希望它的执行占用主线程的时间或者需要依赖整个同步代码执行完成后的结果。
NodeJS 中的 process.nextTick() 和 setImmediate() 也有类似效果。其中 setImmediate() 我们前面已经讲了是在 check 阶段执行的,而 process.nextTick() 的执行时机不太一样,它比 promise.then() 的执行还早,在同步任务之后,其他所有异步任务之前,会优先执行 nextTick。可以想象是把 nextTick 的任务放到了当前循环的后面,与 promise.then() 类似,但比 promise.then() 更前面。意思就是在当前同步代码执行完成后,不管其他异步任务,先尽快执行 nextTick。如下面的代码,因此这里的 nextTick 其实应该更符合“setImmediate”这个命名才对。
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.error('promise')
})
process.nextTick(() => {
console.error('nextTick')
})
// 输出:nextTick、promise、timeout
接下来我们再来看看 setImmediate 和 setTimeout,它们是属于不同的执行阶段了,分别是 timers 阶段和 check 阶段。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
// 输出:timeout、 setImmediate
分析上面代码,第一轮循环后,分别将 setTimeout 和 setImmediate 加入了各自阶段的任务队列。第二轮循环首先进入 timers 阶段,执行定时器队列回调,然后 pending callbacks 和 poll 阶段没有任务,因此进入check 阶段执行 setImmediate 回调。所以最后输出为“timeout”、“setImmediate”。当然这里还有种理论上的极端情况,就是第一轮循环结束后耗时很短,导致 setTimeout 的计时还没结束,此时第二轮循环则会先执行 setImmediate 回调。
再看这下面一段代码,它只是把上一段代码放在了一个 I/O 任务回调中,它的输出将与上一段代码相反。
const fs = require('fs');
fs.readFile(__filename, (data) => {
console.log('readFile');
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
// 输出:readFile、setImmediate、timeout
如上面代码所示:
- 第一轮循环没有需要执行的异步任务队列;
- 第二轮循环 timers 等阶段都没有任务,只有 poll 阶段有 I/O 回调任务,即输出“readFile”;
- 参考前面事件阶段的说明,接下来,poll 阶段会检测如果有 setImmediate 的任务队列则进入 check 阶段,否则再进行判断,如果有定时器任务回调,则回到 timers 阶段,所以应该进入 check 阶段执行 setImmediate,输出“setImmediate”;
- 然后进入最后的 close callbacks 阶段,本次循环结束;
- 最后进行第三轮循环,进入 timers 阶段,输出“timeout”。
所以最终输出“setImmediate”在“timeout”之前。可见这两者的执行顺序与当前执行的阶段有关系。
总结
本文详细讲解了浏览器和 NodeJS 中事件循环的流程,虽然底层机制不一样,但在最终表现上是基本一致的。理解事件循环的原理,可以帮助我们准确分析和运用各种异步形式,减少代码的不确定性,在一些执行效率优化上也能有明确的思路。
在前端面试中,事件循环相关的内容也是高频出现的技术点,理解它也有助于提升面试通过率,增加面试信心。
最后,请分析下面代码的输出顺序:
setTimeout(() => {
console.log('setTimeout start');
new Promise((resolve) => {
console.log('promise1 start');
resolve();
}).then(() => {
console.log('promise1 end');
})
console.log('setTimeout end');
}, 0);
function promise2() {
return new Promise((resolve) => {
console.log('promise2');
resolve();
})
}
async function async1() {
console.log('async1 start');
await promise2();
console.log('async1 end');
}
async1();
console.log('script end');
单线程高并发
单线程解决高并发的思路就是采用非阻塞,异步编程的思想。简单概括就是当遇到非常耗时的IO操作时,采用非阻塞的方式,继续执行后面的代码,并且进入事件循环,当IO操作完成时,程序会被通知IO操作已经完成。主要运用JavaScript的回调函数来实现。
那么问题来了 既然客户端JavaScript是单线程执行的,回调函数是谁调用的呢?
答案很简单,JavaScript的宿主环境——浏览器,也就是说虽然JavaScript是单线程执行的,但浏览器是多线程的,负责调度管理JavaScript代码,让它们在恰当的时机执行。
所以我们所说的node.js单线程,是指node.js并没有给我们创建一个线程的能力,所有我们自己写的代码都是单线程执行的,在同一时间内,只能执行我们写的一句代码。但宿主环境node.js并不是单线程的,它会维护一个执行队列,循环检测,调度JavaScript线程来执行。因此单线程执行和并发操作并不冲突。
多线程虽然也能解决高并发,但是是以建立多个线程来实现,其缺点是当遇到耗时的IO操作时,当前线程会被阻塞,并且把cpu的控制权交给其他线程,这样带来的问题就是要非常频繁的进行线程的上下文切换。
以 Chrome 为例,浏览器不仅有多个线程,还有多个进程,如渲染进程、GPU 进程和插件进程等。而每个 tab 标签页都是一个独立的渲染进程,所以一个 tab 异常崩溃后,其他 tab 基本不会被影响。作为前端开发者,主要重点关注其渲染进程,渲染进程下包含了 JS 引擎线程、HTTP 请求线程和定时器线程等,这些线程为 JS 在浏览器中完成异步任务提供了基础。
浏览器的内核
浏览器是多线程的,但是js是单线程的。
浏览器常见的线程:
- JS解析线程 (javaScript属于单线程,每次只能去处理一件事)
- GUI渲染线程 (可以理解成解析加载css tree和 dom tree,生成render tree生成页面,包括重绘都是会触发GUI线程,与此同时 ,GUI线程和JS主线程是互斥的,即不能同时存在)
- 网络请求线程 (它可以异步处理http请求,请求回来的数据仍在事件队列线程中,等JS线程空下来之后, 才会推到JS线程中执行, 属于微任务)
- 定时器线程 ( 指的是setTimeout,setInterval,JS线程没办法读秒,所以读秒的任务就是定时器线程在做, 定时器属于宏任务)
- 事件队列线程 ( 这个线程指的是异步回调结束之后, 暂时放在这个线程中,等待JS线程空下来后再次执行 )
注意! JavaScript线程和UI线程是互斥的,也就是说在执行js代码的时候网页会卡主。
原因:js会操作页面上UI元素的变化,UI还在负责展示的时候js改了页面上的东西会造成错乱,所以是互斥的。
由于JS解析线程和定时器线程是两个不同的线程,所以
思考1 同步代码执行完了 setTimeout会从0计时吗?
setTimeout(() => {
console.log('setTimeout');
}, 1000);
console.log('奥特曼');
for (let i = 0; i < 1000; i++) {
console.log('');
}
此时要表明的是 我在for循环的时候setTimeout也会去计时 他会去开启一个定时器模块 ,所以说执行主线程的时候,定时器模块已经开始执行了,所以不会再去等待1秒去执行
(千万别以为同步执行完了,再去计时哦)
思考2:两个定时器 上面的定时器先执行 在执行下面的定时器吗?
测验我们只修要在加一个定时器 看看谁先执行就好了
setTimeout(() => {
console.log('setTimeout1');
}, 2000);
setTimeout(() => {
console.log('setTimeout2');
}, 1000);
结果发现 如果有两个定时器,时间少的会优先放到主线程里去执行
思考3:定义一个变量为0 设置两个一样的定时器事件 他会输出什么结果 ? (面试题)
let i = 0
setTimeout(() => {
console.log(++i); //1
}, 1000);
setTimeout(() => {
console.log(++i); //2
}, 1000);
看到现在 肯定要知道 定时器宏任务不是一起执行的 而是依次执行!!
补充:最新版取消宏任务概念
随着时间推移,浏览器复杂度急剧提升,仅两个队列已经不能满足现代浏览器的需求了。于是,W3C 在制定 HTML 规范的时候已抛弃宏队列的说法。
各浏览器厂商在实现事件循环的时候会根据最新的解释:每个任务都有其任务类型,同一个类型的任务必须在同一个队列里排队。在一次事件循环中,浏览器可根据实际情况从不同的队列中取出任务执行。并且浏览器必须准备好一个微队列,其中的任务优先于所有其他队列的任务执行。
不同浏览器,除微队列外,队列的种类和数量均可能不同,这取决于浏览器厂商。
在目前的 Chrome 的实现中,至少包含了下面几个队列:
- 微队列:用于存放需要最快执行的任务,优先级极高,将任务加入微队列的方式有
promise.then()、MutationObserver - 交互队列:用于存放用户操作后产生的事件处理任务,优先级次于微队列
- 延迟队列:用于存放定时器到达后的回调任务,优先级次于交互队列