目录
# 执行 & 运行
首先我们需要声明下,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,而是任何异步,比如网络请求等。
就好比,我六点钟下班了,可以安排下自己的活动了!
然后收拾电脑(同步任务)、收拾书包(同步任务)、给女朋友打电话说出来吃饭吧(必然是异步任务),然后女朋友说你等会,我先化个妆,等我画好了 call 你。
那我不能干等着呀,就接着做别的事情,比如那我就在改个 bug 吧,你好了通知我。结果等她一个小时后说我化好妆了,我们出去吃饭吧。不行!我 bug 还没有解决掉呢?你等会。。。。其实这个时候你的一小时化妆还是 5 分钟化妆都已经毫无意义了。。。因为哥哥这会没空~~
如果我 bug 在半个小时就解决完了,没别的任务需要执行了,那么就在这等着呀!必须等着!随时待命!。然后女朋友来电话了,我化完妆了,我们出去吃饭吧,那么刚好,我们在你的完成了请求或者 timeout 时间到了后我刚好闲着,那么我必须立即执行了。
# 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 的例子。因为只要上面代码你懂了,无非就是公交再来一趟而已!
如果说了这么多,还是没能理解上图,那么公众号内回复【1】,手摸手指导!
# 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
的 callback - close 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