【THE LAST TIME】彻底吃透 JavaScript 执行机制
前言
The last time, I have learned
【THE LAST TIME】一直是我想写的一个系列,旨在厚积薄发,重温前端。
也是给自己的查缺补漏和技术分享。
欢迎大家多多评论指点吐槽。
执行 & 运行
首先我们需要声明下,JavaScript
的执行和运行是两个不同概念的,执行,一般依赖于环境,比如 node
、浏览器、Ringo
等, JavaScript 在不同环境下的执行机制可能并不相同。而今天我们要讨论的 Event Loop
就是 JavaScript
的一种执行方式。所以下文我们还会梳理 node
的执行方式。而运行呢,是指JavaScript 的解析引擎。这是统一的。
关于 JavaScript
此篇文章中,这个小标题下,我们只需要牢记一句话: JavaScript 是单线程语言 ,无论HTML5
里面 Web-Worker
还是 node 里面的cluster
都是“纸老虎”,而且 cluster
还是进程管理相关。这里读者注意区分:进程和线程。
既然 JavaScript
是单线程语言,那么就会存在一个问题,所有的代码都得一句一句的来执行。就像我们在食堂排队打饭,必须一个一个排队点菜结账。那些没有排到的,就得等着~
概念梳理
在详解执行机制之前,先梳理一下 JavaScript
的一些基本概念,方便后面我们说到的时候大伙儿心里有个印象和大概的轮廓。
事件循环(Event Loop)
什么是 Event Loop?
其实这个概念还是比较模糊的,因为他必须得结合着运行机制来解释。
JavaScript
有一个主线程 main thread
,和调用栈 call-stack
也称之为执行栈。所有的任务都会放到调用栈中等待主线程来执行。
暂且,我们先理解为上图的大圈圈就是 Event Loop 吧!并且,这个圈圈,一直在转圈圈~ 也就是说,JavaScript
的 Event Loop
是伴随着整个源码文件生命周期的,只要当前 JavaScript
在运行中,内部的这个循环就会不断地循环下去,去寻找 queue
里面能执行的 task
。
任务队列(task queue)
task
,就是任务的意思,我们这里理解为每一个语句就是一个任务
console.log(1);
console.log(2);
如上语句,其实就是就可以理解为两个 task
。
而 queue
呢,就是FIFO
的队列!
所以 Task Queue
就是承载任务的队列。而 JavaScript
的 Event Loop
就是会不断地过来找这个 queue
,问有没有 task
可以运行运行。
同步任务(SyncTask)、异步任务(AsyncTask)
同步任务说白了就是主线程来执行的时候立即就能执行的代码,比如:
console.log('this is THE LAST TIME');
console.log('Nealyang');
代码在执行到上述 console
的时候,就会立即在控制台上打印相应结果。
而所谓的异步任务就是主线程执行到这个 task
的时候,“唉!你等会,我现在先不执行,等我 xxx 完了以后我再来等你执行” 注意上述我说的是等你来执行。
说白了,异步任务就是你先去执行别的 task,等我这 xxx 完之后再往 Task Queue 里面塞一个 task 的同步任务来等待被执行
setTimeout(()=>{
console.log(2)
});
console.log(1);
如上述代码,setTimeout
就是一个异步任务,主线程去执行的时候遇到 setTimeout
发现是一个异步任务,就先注册了一个异步的回调,然后接着执行下面的语句console.log(1)
,等上面的异步任务等待的时间到了以后,在执行console.log(2)
。具体的执行机制会在后面剖析。
主线程自上而下执行所有代码
同步任务直接进入到主线程被执行,而异步任务则进入到
Event Table
并注册相对应的回调函数异步任务完成后,
Event Table
会将这个函数移入Event Queue
主线程任务执行完了以后,会从
Event Queue
中读取任务,进入到主线程去执行。循环如上
上述动作不断循环,就是我们所说的事件循环(Event Loop
)。
小试牛刀
ajax({
url:www.Nealyang.com,
data:prams,
success:() => {
console.log('请求成功!');
},
error:()=>{
console.log('请求失败~');
}
})
console.log('这是一个同步任务');
ajax 请求首先进入到
Event Table
,分别注册了onError
和onSuccess
回调函数。主线程执行同步任务:
console.log('这是一个同步任务');
主线程任务执行完毕,看
Event Queue
是否有待执行的 task,这里是不断地检查,只要主线程的task queue
没有任务执行了,主线程就一直在这等着ajax 执行完毕,将回调函数
push
到Event Queue
。(步骤 3、4 没有先后顺序而言)主线程“终于”等到了
Event Queue
里有task
可以执行了,执行对应的回调任务。如此往复。
宏任务(MacroTask)、微任务(MicroTask)
JavaScript
的任务不仅仅分为同步任务和异步任务,同时从另一个维度,也分为了宏任务(MacroTask
)和微任务(MicroTask
)。
先说说 MacroTask
,所有的同步任务代码都是MacroTask
(这么说其实不是很严谨,下面解释),setTimeout
、setInterval
、I/O
、UI Rendering
等都是宏任务。
MicroTask
,为什么说上述不严谨我却还是强调所有的同步任务都是 MacroTask
呢,因为我们仅仅需要记住几个 MicroTask
即可,排除法!别的都是 MacroTask
。MicroTask
包括:Process.nextTick
、Promise.then catch finally
(注意我不是说 Promise)、MutationObserver
。
浏览器环境下的 Event Loop
当我们梳理完哪些是 MicroTask
,除了那些别的都是 MacroTask
后,哪些是同步任务,哪些又是异步任务后,这里就应该彻底的梳理下JavaScript 的执行机制了。
如开篇说到的,执行和运行是不同的,执行要区分环境。所以这里我们将 Event Loop
的介绍分为浏览器和 Node 两个环境下。
先放图镇楼!如果你已经理解了这张图的意思,那么恭喜你,你完全可以直接阅读 Node 环境下的 Event Loop
章节了!
setTimeout、setInterval
setTimeout
setTimeout
就是等多长时间来执行这个回调函数。setInterval
就是每隔多长时间来执行这个回调。
let startTime = new Date().getTime();
setTimeout(()=>{
console.log(new Date().getTime()-startTime);
},1000);
如上代码,顾名思义,就是等 1s 后再去执行 console
。放到浏览器下去执行,OK,如你所愿就是如此。
但是这次我们在探讨 JavaScript 的执行机制,所以这里我们得探讨下如下代码:
let startTime = new Date().getTime();
console.log({startTime})
setTimeout(()=>{
console.log(`开始执行回调的相隔时差:${new Date().getTime()-startTime}`);
},1000);
for(let i = 0;i<40000;i++){
console.log(1)
}
如上运行,setTimeout
的回调函数等到 4.7s 以后才执行!而这时候,我们把 setTimeout
的 1s 延迟给删了:
let startTime = new Date().getTime();
console.log({startTime})
setTimeout(()=>{
console.log(`开始执行回调的相隔时差:${new Date().getTime()-startTime}`);
},0);
for(let i = 0;i<40000;i++){
console.log(1)
}
结果依然是等到 4.7s 后才执行setTimeout 的回调。貌似 setTimeout 后面的延迟并没有产生任何效果!
其实这么说,又应该回到上面的那张 JavaScript 执行的流程图了。
setTimeout
这里就是简单的异步,我们通过上面的图来分析上述代码的一步一步执行情况
首先
JavaScript
自上而下执行代码遇到遇到赋值语句、以及第一个
console.log({startTime})
分别作为一个task
,压入到立即执行栈中被执行。遇到
setTImeout
是一个异步任务,则注册相应回调函数。(异步函数告诉你,js 你先别急,等 1s 后我再将回调函数:console.log(xxx)
放到Task Queue
中)OK,这时候 JavaScript 则接着往下走,遇到了 40000 个 for 循环的 task,没办法,1s 后都还没执行完。其实这个时候上述的回调已经在
Task Queue
中了。等所有的立即执行栈中的 task 都执行完了,在回头看
Task Queue
中的任务,发现异步的回调 task 已经在里面了,所以接着执行。
打个比方
其实上述的不仅仅是 timeout,而是任何异步,比如网络请求等。
就好比,我六点钟下班了,可以安排下自己的活动了!
那我不能干等着呀,就接着做别的事情,比如那我就在改个 bug 吧,你好了通知我。结果等她一个小时后说我化好妆了,我们出去吃饭吧。不行!我 bug 还没有解决掉呢?你等会。。。。其实这个时候你的一小时化妆还是 5 分钟化妆都已经毫无意义了。。。因为哥哥这会没空~~
setInterval
说完了 setTimeout
,当然不能错过他的孪生兄弟:setInterval
。对于执行顺序来说,setInterval
会每隔指定的时间将注册的函数置入 Task Queue
,如果前面的任务耗时太久,那么同样需要等待。
这里需要说的是,对于 setInterval(fn,ms)
来说,我们制定没 xx ms
执行一次 fn
,其实是没 xx ms
,会有一个fn
进入到 Task Queue
中。一旦 setInterval 的回调函数fn
执行时间超过了xx ms,那么就完全看不出来有时间间隔了。 仔细回味回味,是不是那么回事?
Promise
关于 Promise
的用法,这里就不过过多介绍了,后面会在写《【THE LAST TIME】彻底吃透 JavaScript 异步》 一文的时候详细介绍。这里我们只说 JavaScript 的执行机制。
如上所说,promise.then
、catch
和 finally
是属于 MicroTask
。这里主要是异步的区分。展开说明之前,我们结合上述说的,再来“扭曲”梳理一下。
为了避免初学者这时候脑子有点混乱,我们暂时忘掉 JavaScript 异步任务! 我们暂且称之为待会再执行的同步任务。
有了如上约束后,我们可以说,JavaScript 从一开始就自上而下的执行每一个语句(Task
),这时候只能遇到立马就要执行的任务和待会再执行的任务。对于那待会再执行的任务等到能执行了,也不会立即执行,你得等js 执行完这一趟才行
再打个比方
就像做公交车一样,公交车不等人呀,公交车路线上有人就会停(农村公交!么得站牌),但是等公交车来,你跟司机说,我肚子疼要拉x~这时候公交不会等你。你只能拉完以后等公交下一趟再来(大山里!一个路线就一趟车)。
OK!你拉完了。。。等公交,公交也很快到了!但是,你不能立马上车,因为这时候前面有个孕妇!有个老人!还有熊孩子,你必须得让他们先上车,然后你才能上车!
而这些 孕妇、老人、熊孩子所组成的就是传说中的 MicroTask Queue
,而且,就在你和你的同事、朋友就必须在他们后面上车。
这里我们没有异步的概念,只有同样的一次循环回来,有了两种队伍,一种优先上车的队伍叫做MicroTask Queue
,而你和你的同事这帮壮汉组成的队伍就是宏队伍(MacroTask Queue
)。
一句话理解:一次事件循环回来后,开始去执行 Task Queue
中的 task
,但是这里的 task
有优先级。所以优先执行 MicroTask Queue
中的 task ,执行完后在执行MacroTask Queue
中的 task
小试牛刀
理论都扯完了,也不知道你懂没懂。来,期中考试了!
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
没必要搞个 setTimeout 有加个 Promise,Promise 里面再整个 setTimeout 的例子。因为只要上面代码你懂了,无非就是公交再来一趟而已!
Node 环境下的 Event Loop
Node中的Event Loop
是基于libuv
实现的,而libuv
是 Node 的新跨平台抽象层,libuv
使用异步,事件驱动的编程方式,核心是提供i/o
的事件循环和异步回调。libuv
的API
包含有时间,非阻塞的网络,异步文件操作,子进程等等。
Event Loop就是在libuv
中实现的。所以关于 Node 的 Event Loop
学习,有两个官方途径可以学习:
libuv 文档
官网的What is the Event Loop?.
在学习 Node 环境下的 Event Loop
之前呢,我们首先要明确执行环境,Node 和浏览器的Event Loop是两个有明确区分的事物,不能混为一谈。nodejs的event是基于libuv,而浏览器的event loop则在html5的规范中明确定义。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node 的 Event Loop 分为 6 个阶段:
timers:执行
setTimeout()
和setInterval()
中到期的callback。pending callback: 上一轮循环中有少数的
I/O
callback会被延迟到这一轮的这一阶段执行idle, prepare:仅内部使用
poll: 最为重要的阶段,执行
I/O
callback,在适当的条件下会阻塞在这个阶段check: 执行
setImmediate
的callbackclose callbacks: 执行
close
事件的callback,例如socket.on('close'[,fn])
、http.server.on('close, fn)
上面六个阶段都不包括 process.nextTick()(下文会介绍)
整体的执行机制如上图所示,下面我们具体展开每一个阶段的说明
timers 阶段
timers 阶段会执行 setTimeout
和 setInterval
回调,并且是由 poll 阶段控制的。
在 timers 阶段其实使用一个最小堆而不是队列来保存所有的元素,其实也可以理解,因为timeout的callback是按照超时时间的顺序来调用的,并不是先进先出的队列逻辑)。而为什么 timer 阶段在第一个执行阶梯上其实也不难理解。在 Node 中定时器指定的时间也是不准确的,而这样,就能尽可能的准确了,让其回调函数尽快执行。
以下是官网给出的例子:
const fs = require('fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
当进入事件循环时,它有一个空队列(fs.readFile()
尚未完成),因此定时器将等待剩余毫秒数,当到达95ms时,fs.readFile()
完成读取文件并且其完成需要10毫秒的回调被添加到轮询队列并执行。
当回调结束时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值,然后回到timers阶段以执行定时器的回调。在此示例中,您将看到正在调度的计时器与正在执行的回调之间的总延迟将为105毫秒。
pending callbacks 阶段
pending callbacks 阶段其实是 I/O
的 callbacks 阶段。比如一些 TCP 的 error 回调等。
举个栗子:如果TCP socket ECONNREFUSED
在尝试connect
时receives
,则某些* nix系统希望等待报告错误。这将在pending callbacks阶段执行。
poll 阶段
poll 阶段主要有两个功能:
执行
I/O
回调处理 poll 队列(poll queue)中的事件
当时Event Loop 进入到 poll 阶段并且 timers 阶段没有任何可执行的 task 的时候(也就是没有定时器回调),将会有以下两种情况
如果 poll queue 非空,则 Event Loop就会执行他们,知道为空或者达到system-dependent(系统相关限制)
如果 poll queue 为空,则会发生以下一种情况
如果setImmediate()有回调需要执行,则会立即进入到 check 阶段
相反,如果没有setImmediate()需要执行,则 poll 阶段将等待 callback 被添加到队列中再立即执行,这也是为什么我们说 poll 阶段可能会阻塞的原因。
一旦 poll queue 为空,Event Loop就回去检查timer 阶段的任务。如果有的话,则会回到 timer 阶段执行回调。
check 阶段
check 阶段在 poll 阶段之后,setImmediate()
的回调会被加入check队列中,他是一个使用libuv API
的特殊的计数器。
通常在代码执行的时候,Event Loop 最终会到达 poll 阶段,然后等待传入的链接或者请求等,但是如果已经指定了setImmediate()并且这时候 poll 阶段已经空闲的时候,则 poll 阶段将会被中止然后开始 check 阶段的执行。
close callbacks 阶段
如果一个 socket 或者事件处理函数突然关闭/中断(比如:socket.destroy()
),则这个阶段就会发生 close
的回调执行。否则他会通过 process.nextTick()
发出。
setImmediate() vs setTimeout()
setImmediate()
和 setTimeout()
非常的相似,区别取决于谁调用了它。
setImmediate
在 poll 阶段后执行,即check 阶段setTimeout
在 poll 空闲时且设定时间到达的时候执行,在 timer 阶段
计时器的执行顺序将根据调用它们的上下文而有所不同。如果两者都是从主模块中调用的,则时序将受到进程性能的限制。
例如,如果我们运行以下不在I / O
周期(即主模块)内的脚本,则两个计时器的执行顺序是不确定的,因为它受进程性能的约束:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
如果在一个I/O
周期内移动这两个调用,则始终首先执行立即回调:
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
所以与setTimeout()
相比,使用setImmediate()
的主要优点是,如果在I / O
周期内安排了任何计时器,则setImmediate()
将始终在任何计时器之前执行,而与存在多少计时器无关。
nextTick queue
可能你已经注意到process.nextTick()
并未显示在图中,即使它是异步API的一部分。所以他拥有一个自己的队列:nextTickQueue
。
这是因为process.nextTick()
从技术上讲不是Event Loop的一部分。相反,无论当前事件循环的当前阶段如何,都将在当前操作完成之后处理nextTickQueue
。
如果存在 nextTickQueue
,就会清空队列中的所有回调函数,并且优先于其他 microtask
执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
process.nextTick() vs setImmediate()
从使用者角度而言,这两个名称非常的容易让人感觉到困惑。
process.nextTick()
在同一阶段立即触发setImmediate()
在事件循环的以下迭代或“tick”中触发
貌似这两个名称应该互换下!的确~官方也这么认为。但是他们说这是历史包袱,已经不会更改了。
这里还是建议大家尽可能使用setImmediate。因为更加的让程序可控容易推理。
至于为什么还是需要 process.nextTick
,存在即合理。这里建议大家阅读官方文档:why-use-process-nexttick。
Node与浏览器的 Event Loop 差异
一句话总结其中:浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。
上图来自浪里行舟
最后
来~期末考试了
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
评论区留下你的答案吧~~老铁!
参考文献
Tasks, microtasks, queues and schedules
libuv 文档
The Node.js Event Loop, Timers, and process.nextTick()
node 官网
async/await 在chrome 环境和 node 环境的 执行结果不一致,求解?
更快的异步函数和 Promise
一次弄懂Event Loop(彻底解决此类面试问题)
这一次,彻底弄懂 JavaScript 执行机制
不要混淆nodejs和浏览器中的event loop
完
❤️ 看完三件事
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
点个【在看】,或者分享转发,让更多的人也能看到这篇内容