JS引擎的执行机制:探究EventLoop(含Macro Task和Micro Task)
在我看来理解好JS引擎的执行机制对于理解JS引擎至关重要,今天将要好好梳理下JS引擎的执行机制。
首先解释下题目中的名词:(阅读本文后你会对这些概念掌握了解)
Event Loop:事件循环
Micro Task:微任务
Macro Task:宏任务
阅读本文前,我们要知道两个重点。
(1) JS是单线程语言
(2) JS的Event Loop是JS的执行机制。深入了解JS的执行,就等于深入了解JS里的event loop
一、JS为什么是单线程语言。
javascript是一门单线程语言,在最新的HTML5中提出了Web-Worker,但javascript是单线程这一核心仍未改变。与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
二、什么是任务队列?
2.1 同步任务与异步任务
单线程就意味着,所有任务需要排队。所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。异步执行的运行机制如下:
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
2.2 JS引擎执行模型
从宏观角度讲, js 的执行是单线程的. 所有的异步结果都是通过 “任务队列(Task Queue)” 来调度被调度. 消息队列中存放的是一个个的任务(Task). 规范中规定, Task 分为两大类, 分别是 Macro Task(宏任务) 和 Micro Task(微任务), 并且每个 Macro Task 结束后, 都要清空所有的 Micro Task.
宏观上讲, Macrotask 会进入 Macro Task Queue, Microtask 会进入 Micro Task Queue。而 Micro Task 被分到了两个队列中. ‘Micro Task Queue’ 存放 Promise
等 Microtask. 而 ‘Tick Task Queue’ 专门用于存放 process.nextTick
的任务.现在先来看看规范怎么做的分类.
- Macrotask 包括:
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
- Microtask 包括:
process.nextTick
Promise
Object.observe
MutaionObserver
所说的, ‘每个 MacroTask 结束后, 都要清空所有的 Micro Task‘. 引擎会遍历 Macro Task Queue, 对于每个 MacroTask 执行完毕后都要遍历执行 Tick Task Queue 的所有任务, 紧接着再遍历 MicroTask Queue 的所有任务. (nextTick
会优于 Promise
执行)
三、Event Loop
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
修改:下面的图有错误,process.nextTick是添加到当前"执行栈"的尾部,事实上并不存在所谓的TickTask Queue,只有MacroTask Queue和Micro Task Queue,图里的TickTask Queue仅仅是为了更好理解,或者我们理解其为当前"执行栈"的尾部。
三种任务队列中的代码执行流程图如下:
来个例子检验一下吧:
console.log('main1'); process.nextTick(function() { console.log('process.nextTick1'); }); setTimeout(function() { console.log('setTimeout'); process.nextTick(function() { console.log('process.nextTick2'); }); }, 0); new Promise(function(resolve, reject) { console.log('promise'); resolve(); }).then(function() { console.log('promise then'); }); console.log('main2');
执行顺序
分析如下
-
开始执行代码,输出 main1,process.nextTick 放入TickTask Queue,setTimeout放入 MacroTask Queue, new Promise 执行 输出 promise,then 方法 放入 MicroTask Queue , 接着 最后一行代码 console.log 输出 main2
-
当前的 宏任务执行完毕,开始清空微任务,先清空TickTask Queue ,执行 console.log('process.nextTick1'); 输出'process.nextTick1;再清空MicroTask Queue执行 console.log('promise then'); 输出promise then;微任务全部清空。
-
开始下次 eventLoop; 执行 setTimeout; 第一行 console.log('setTimeout'); 输出setTimeout; process.nextTick 将任务放入了TickTask Queue;当前宏任务执行完毕;开始清空MicroTask Queue,清空TickTaskQueue ,执行 console.log('process.nextTick2');输出process.nextTick2;
四、Node.js中的Event Loop
Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。
根据上图,Node.js的运行机制如下。
(1)V8引擎解析JavaScript脚本。
(2)解析后的代码,调用Node API。
(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
(4)V8引擎再将结果返回给用户。
Node.js还提供了另外两个与"任务队列"有关的方法:process.nextTick和setImmediate。它们可以帮助我们加深对"任务队列"的理解。
process.nextTick方法可以在当前"执行栈"的尾部----下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。请看下面的例子(via StackOverflow)。
process.nextTick(function A() { console.log(1); process.nextTick(function B(){console.log(2);}); }); setTimeout(function timeout() { console.log('TIMEOUT FIRED'); }, 0) // 1 // 2 // TIMEOUT FIRED
上面代码中,由于process.nextTick方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行。
setImmediate(function (){ setImmediate(function A() { console.log(1); setImmediate(function B(){console.log(2);}); }); setTimeout(function timeout() { console.log('TIMEOUT FIRED'); }, 0); }); // 1 // TIMEOUT FIRED // 2
上面代码中,setImmediate和setTimeout被封装在一个setImmediate里面,它的运行结果总是1--TIMEOUT FIRED--2,这时函数A一定在timeout前面触发。至于2排在TIMEOUT FIRED的后面(即函数B在timeout后面触发),是因为setImmediate总是将事件注册到下一轮Event Loop,所以函数A和timeout是在同一轮Loop执行,而函数B在下一轮Loop执行。
我们由此得到了process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前"执行栈"一次执行完,多个setImmediate可能则需要多次loop才能执行完。
事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取"事件队列"!
process.nextTick(function foo() { process.nextTick(foo); });
事实上,现在要是你写出递归的process.nextTick,Node.js会抛出一个警告,要求你改成setImmediate。
另外,由于process.nextTick指定的回调函数是在本次"事件循环"触发,而setImmediate指定的是在下次"事件循环"触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查"任务队列")。
Node中event loop的6个阶段(phase)
nodejs的event loop分为6个阶段,每个阶段的作用如下(process.nextTick()
在6个阶段结束的时候都会执行,文章后半部分会详细分析process.nextTick()
的回调是怎么引进event loop,仅仅从uv_run()
是找不到process.nextTick()
是如何牵涉进来):
- timers:执行
setTimeout()
和setInterval()
中到期的callback。 - I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
- idle, prepare:仅内部使用,process.nextTick就属于这一类
- poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
- check:执行setImmediate()的callback
- close callbacks:执行close事件的callback,例如
socket.on("close",func)
从上面可以得知,idle观察者先于I/O观察者,I/O观察者先于check观察者。——《深入浅出Node.js》
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
event loop的每一次循环都需要依次经过上述的阶段。 每个阶段都有自己的callback队列,每当进入某个阶段,都会从所属的队列中取出callback来执行,当队列为空或者被执行callback的数量达到系统的最大数量时,进入下一阶段。这六个阶段都执行完毕称为一轮循环。
具体细节可以看这篇文章:https://cnodejs.org/topic/5a9108d78d6e16e56bb80882#5a98d9a2ce1c90bc44c445af