前前后后做了几年的javascript开发,中间断断续续。始终有些问题没有弄明白,例如本文提到的javascript异步。从最早接触到的 setTimeout,setInterval,callback再到后来的Promise,async, node的setImmediate,process.nextTick等等。只是知道用法,但还是不能理解一个单线程运行时是如何处理异步处理的,看了朴灵老师的深入浅出node,也只是稍微有了一点感觉。
于是乎,在年末闲来无事之余,找了找相关资料。当然也是前人栽树,后人乘凉。自己做的事情,无外乎是收集收集、整理整理、再让自己想明白梳理清晰罢liao...
以下都是个人理解、说错不喜勿喷。JJ短、长得又丑受不了刺激。
先来看个问题
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
请问在浏览器和node中的结果一样么?
浏览器的结果
timer1
promise1
timer2
promise2
node环境结果
timer1
timer2
promise1
promise2
我们再来想一想单线程是指js引擎中负责解析执行js代码的线程只有一个主线程,即一次只做一件事,而我们知道一个ajax请求,主线程在等待响应的同时是会去做其他事的。
Event Loop
创造代码的人也是人,他们的灵感多数来自于生活。我们这里打个比方(朴灵老师也这样比喻),javascript处理异步就像去餐馆吃饭,服务员会先为顾客下单,下完单把顾客点的单子给后厨让其准备,然后就去服务下一位顾客,,而不是一直等待在出餐口。
javascript将顾客下单的行为进行了细分。无外乎两种酒水类和非酒水类。对应着我们javascript中的macroTask和microTask。
具体如下划分:
macrotasks: script(整体代码),setTimeout,setInterval,setImmediate,I/O,UI render
microtasks: process.nextTick, Promises, Object.observe(废弃),MutationObserver
但是在不同场景下的步骤是不一样的,就像西餐和中餐。西餐划分的非常详细:头盘->汤->副菜->主菜->蔬菜类菜肴->甜品->咖啡,中餐就相对简单许多:凉菜->热菜->汤。
在不同场景的实现不同,HTML标准和NODE标准的差异,正如第一个例子一样。
浏览器中
为了更好地说明浏览器是如何处理event loop看代码
console.log('start')
setTimeout(function() {
console.log('setTimeout')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('end')
他是怎么工作的呢?一张图搞定:
结合我们之前发的web_process图片是不是豁然开朗。
小知识点:视图渲染的时机
一次macrotasks + microtasks 称为一次ticket,ticket结束之后会触发浏览器的重绘操作(不一定每次都执行)
换言之,执行任务的耗时会影响视图渲染的时机,通常浏览器以每秒60帧(60fps)的速率刷新页面,据说这个帧率最适合人眼交互,大概16.7ms渲染一帧,所以如果要让用户觉得顺畅,单个macrotask及它相关的所有microtask最好能在16.7ms内完成。
但也不是每轮事件循环都会执行视图更新,浏览器有自己的优化策略,例如把几次的视图更新累积到一起重绘,重绘之前会通知requestAnimationFrame执行回调函数,也就是说requestAnimationFrame回调的执行时机是在一次或多次事件循环的UI render阶段
可以自己来尝试一下:
setTimeout(function() {console.log('timer1')}, 0)
requestAnimationFrame(function(){
console.log('requestAnimationFrame')
})
setTimeout(function() {console.log('timer2')}, 0)
new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function() {
console.log('promise then')
})
console.log('end')
小小总结一下:
- 事件循环是js实现异步的核心
- 每轮事件循环分为3个步骤:
a) 执行macrotask队列的一个任务
b) 执行完成当前microtask队列的所有任务
c) UI render - 浏览器只保证requestAnimationFrame的重绘在重回之前执行,没有确定的时间,何时重绘由浏览器决定!
Node环境
我们再回头看看 刚开始的Demo1
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
用我们刚才学到的,来分析一下浏览器的输出:
timer1
promise1
timer2
promise2
是不是你的思路跟下图一致呢?
我们再来看看node中的输出
timer1
timer2
promise1
promise2
node用的是chrome的v8解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
核心源码参考:/deps/uv/src/unix/core.c
有兴趣的同学可以自己撸一撸。
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout);
uv__run_check(loop);
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
/* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示:
- timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
- I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调
- idle, prepare 阶段:仅node内部使用
- poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
- check 阶段:执行 setImmediate() 的回调
- close callbacks 阶段:执行 socket 的 close 事件回调
我们重点看timers、poll、check这3个阶段就好,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。
timers
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node 并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout() 和 setImmediate() 的执行顺序是不确定的。
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。
当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick。
试一试:
const fs = require('fs')
fs.readFile('test.txt', () => {
console.log('readFile')
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
用一张图来说明:
再来看看开头的例子在node中是如何运行的:
扩充问题:
node里面经常会用到的两个函数,效率要比setTimeout高,setTimeout的实现是靠红黑树,时间复杂度为logn
process.nextTick() VS setImmediate()
process.nextTick() 会在各个事件阶段之间执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick(),会导致出现I/O starving(饥饿)的问题,比如下面例子的readFile已经完成,但它的回调一直无法执行:
const fs = require('fs')
const starttime = Date.now()
let endtime
fs.readFile('text.txt', () => {
endtime = Date.now()
console.log('finish reading time: ', endtime - starttime)
})
let index = 0
function handler () {
if (index++ >= 1000) return
console.log(`nextTick ${index}`)
process.nextTick(handler)
// console.log(`setImmediate ${index}`)
// setImmediate(handler)
}
handler()
运行结果:
nextTick 1
nextTick 2
......
nextTick 999
nextTick 1000
finish reading time: 170
替换成setImmediate(),运行结果:
setImmediate 1
setImmediate 2
finish reading time: 80
......
setImmediate 999
setImmediate 1000
参考:
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
https://www.cnblogs.com/yzfdjzwl/p/8182749.html#event-loop
http://lynnelv.github.io/js-event-loop-browser
http://lynnelv.github.io/js-event-loop-nodejs