目录
JS两大特点:单线程和非阻塞
单线程: JS引擎是基于单线程(Single-threaded)事件循环的概念构建的。同一时刻只运行一个代码块在执行,与之相反的是像JAVA和C++等语言,它们允许多个不同的代码块同时执行。对于基于线程的软件而言,当多个代码块同时访问并改变状态时,程序很难维护并保证状态不出错。
非阻塞: 当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。非阻塞通过事件循环机制Event Loop实现。
Event Loop是什么?
Event Loop是一个实现主线程不阻塞的执行模型,在不同的环境有不同的实现。浏览器和Node.js基于不同的技术实现了各自的Event Loop。
- 浏览器的Event Loop是在html5的规范中明确定义
- Node.js 的Event Loop是基于libuv实现的,可以参考Node的官方文档以及libuv的官方文档。
- libuv 已经对Event Loop做出了实现,而html5规范中只是定义了浏览器中Event
Loop的模型,具体实现由浏览器厂商处理。
宏任务与微任务
宏任务macrotask,以下异步任务的回调会依次进入宏任务队列等待后续被调用:
- setTimeout
- setInterval
- setImmediate(node独有)
- requireAnimationFrame请求动画帧(浏览器独有)
- UI rendering(浏览器独有)
- I/O
微任务microtask,以下异步任务的回调会依次进入微任务队列等待后续被调用: - Process.nextTick(node独有,同步任务执行完就会执行process.nextTick的任务队列,process.tick优于Promise.then)
- Promise.then()
- Object.observe
- MutationObserve
浏览器端的Event Loop
主要包含三个部分:执行栈、事件循环、任务队列
图中调用栈中遇到DOM操作、ajax请求以及setTimeout等WebAPIs的时候就会交给浏览器内核的其他模块进行处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,等执行栈中的task执行完之后再去执行任务队列之中的回调函数。
浏览器的Event Loop执行过程:
- 执行全局Script的同步代码;
- 检查Microtask queues是否存在执行回调,有就执行microtask任务,直至全部执行完成,任务队列执行栈清空后进入下一步,例如peomise.then().then()两个微任务是依次执行的;
- 开始执行macrotask宏任务,Task Queues中按顺序取task执行,每执行完一个task都会检查Microtask队列是否为空(执行完一个Task的具体标志是函数执行栈为空),如果不为空则会一次性执行完所有Microtask,然后再进入下一个循环从Task Queue中取下一个Task执行,以此类推。
具体实现请查看Philip Roberts的演讲中的一个例子《Help, I’m stuck in an event-loop》
console.log('start')
setTimeout(function cb(){
console.log('定时器')
},1000)
console.log('end')
这段代码的执行:
- 首先main()函数的执行上下文入栈,执行这段Script整体代码
- log(‘start’) 执行入栈,打印start
- setTimeout(cb) 入栈,setTimeout是宏任务不直接执行,cb会交给浏览器的timer 模块进行延时1s,然后加入到任务队列中等待执行
- log(‘end’)执行入栈,打印end
- 这时候执行栈中的所有任务已经执行完了,执行引擎会检查回调队列中是否有需要执行的回调,有则依次执行回调。发现存在cb回调,执行log(‘定时器’)
Node.js中的Event Loop
Nodejs的Event Loop分为6个阶段,按照顺序反复运行,执行阶段分析:
- timers
定时器阶段,处理setTimeout()和setInterval()的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。时间范围:[1, 2147483647],如果设定的时间不在这个范围,将被设置为1ms。 - I/O callbacks
执行网络、流、tcp错误等callback。除了以下操作的回调函数,其他的回调函数都在这个阶段执行。
- setTimeout()和setInterval()的回调函数
- setImmediate()的回调函数
- 用于关闭请求的回调函数,比如socket.on(‘close’, …)
- idle, prepare
仅系统内部使用。 - poll
轮询阶段:检查定时器是否到时。用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等。这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。 - check检测
-执行存放的setImmediate() 回调函数。 - close callbacks关闭的回调函数
执行close的回调函数,如:socket.on(‘close’, …)。
Node.js 的Event Loop过程:
- 执行全局Script的同步代码
- 执行microtask微任务,这里会先执行所有Next tick queue中的微任务,在执行Other Microtask Queue 中的所有任务
- 开始执行macrotask宏任务,共6个阶段,从第1个阶段开始执行,每个阶段必须执行完macrotask中的所有任务,再执行相应的微任务队列,然后在进入下一个阶段。
- 循环:Timers Queue所有宏任务 -> 步骤2 -> I/O Queue所有宏任务 -> 步骤2 -> Check Queue所有宏任务 -> 步骤2->Close Callback Queue所有宏任务 -> 步骤2 -> Timers Queue
浏览器的Timer 与Node的Timer
浏览器和node的Timer对0ms和1ms的延时效果是一致的,都是1ms。
浏览器的timer:
// https://chromium.googlesource.com/chromium/blink/+/master/Source/core/frame/DOMTimer.cpp#93
double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
这里interval就是传入的数值,可以看出传入0和传入1结果都是oneMillisecond,即1ms。
另外:HTML规范第11条提到Timer在嵌套层级超过5级时,不能少于4ms的延时限制。(为了给CPU留下休息时间)
- If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
Node的timer:
// https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456
if (!(after >= 1 && after <= TIMEOUT_MAX))
after = 1; // schedule on next tick, follows browser behavior
代码中的注释直接说明了,设置最低1ms的行为是为了向浏览器行为看齐。
setTimeout(() => {
console.log(2)
}, 2)
setTimeout(() => {
console.log(1)
}, 1)
setTimeout(() => {
console.log(0)
}, 0)
如代码设置的延时应该打印0,1,2,但是因为timer 的最低延迟限制,0ms和1ms都只能延时1ms,所有最终应该打印1,0,2
在chrome浏览器内运行如预期结果表现一致,但是在node环境下打印出的结果竟然无法确定。
经过多次试验发现:
- 在node中多个定时器顺序设置相差1ms的延迟(1ms, 2ms, 3ms;10ms, 11ms, 12ms),能保证执行顺序。
- 在node中多个定时器倒序设置相差1ms的延迟(3ms, 2ms, 1ms;12ms, 11ms, 10ms),将产生不确定的执行顺序。
实例分析
实例1:浏览器与Node的执行顺序的不同
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
分析:浏览器输出: time1 promise1 time2 promise2;Node输出: time1 time2 promise1 promise2
实例2:Node的阶段执行顺序
const fs = require('fs');
fs.readFile('demo.js', () => {
console.log(0)
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
console.log(3)
});
分析:fs读取文件处于node事件循环的I/O callback 阶段,回调中存在I/O callback阶段之前的timers阶段的setTimeout,所以setTimeout的回调将在下一个事件循环执行;然后继续执行check阶段的setImmediate回调,所以执行打印:0,3,2,1
实例3:Node的执行优先级
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
Promise.resolve().then(() => console.log(3));
process.nextTick(() => console.log(4));
分析:这里检测到存在两个microTask(promise.then, process.nextTick),同步代码执行完毕就会执行process.nextTick的回调,然后执行promise.then的回调,所以优先打印4,3。这里setTimeout没有设置延时,进行最小延时1ms处理,当我们的系统进入事件循环小于1ms时,延时条件未满足,将跳过timer阶段,先执行setImmediate的回调打印2,在下一次循环在执行setTimeout的回调打印1;反之当系统进入事件循环大于1ms时,满足延时的条件执行setTimeout的回调打印1,在执行setImmediate的回调打印2;。所以结果:4,3,1,2或者4,3,2,1。
实例4:
console.log('1')
setTimeout(()=>{
console.log('2');
new Promise(resolve=>{
console.log('3')
resolve();
}).then(()=>{
console.log('4')
})
},0)
new Promise(resolve=>{
console.log('5')
resolve();
}).then(()=>{
console.log('6')
})
setTimeout(()=>{
console.log('7');
},0)
setTimeout(()=>{
console.log('8');
new Promise(resolve=>{
console.log('9')
resolve();
}).then(()=>{
console.log('10')
})
},0)
new Promise(resolve=>{
console.log('11')
resolve();
}).then(()=>{
console.log('12')
})
console.log('13');
分析:1 5 11 13 6 12 2 3 4 7 8 9 10
注意
在以往的Node版本中,也就是11.0 之前, JS的执行栈的顺序是
执行同类型的所有宏任务 -> 在间隙时间执行微任务 ->event loop 完毕执行下一个event loop
在最新版本的11.0之后, NodeJS为了向浏览器靠齐,对底部进行了修改,最新的执行栈顺序和浏览器的执行栈顺序已经是一样了
执行首个宏任务 -> 执行宏任务中的微任务 -> event loop执行完毕执行下一个eventloop
相关文章
Node 定时器详解
JavaScript 运行机制详解:再谈Event Loop
Event Loop的规范和实现
深入浅出Javascript事件循环机制(上)