主线程从任务队列中读取事件,这个过程是循环不断的,所以整个种运行机制又称为 Event Loop(事件循环)
JS 是一门非阻塞单线程语言。
JS 在执行过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入搭配 Task(有多种 Task)队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码放入到执行栈中执行。
运行机制如下:
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)
- 主线程之外,还存在一个“任务队列(task quue)”。只要异步任务有了运行结果,就在任务队列中放置一个结果
- 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,那些对应的异步任务,结束等待状态,进入执行栈开始执行
- 主线程不断重复上面的第三步
宏任务(mscro-task)和微任务(micro-task)表示异步任务的两种分类。在挂起任务时,JS引擎会将所有任务按照类别分到这两个队列中,首先在macrotask的队列取出第一个任务,执行完毕后取出microtask队列中的所有任务顺序执行;之后再取macrotask任务,周而复始,直至两个队列的任务都取完
1 | console.log('script start') |
以上代码虽然 setTimeout 延时为 0,其实还是异步。这是因为 HTML5 标准规定这个函数第二个参数不得小于 4 毫秒,不足会自动增加。所以 setTimeout 还是会在 script end 之后打印。
任务队列
JS 中有两类任务队列:宏任务队列(macro task) 和 微任务队列(micro task)。宏任务队列可以有多个,微任务队列只有一个。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。
- (macro)task 宏任务:
(macro)task 主要包括:script(整体代码),setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境) - (micro)task 微任务:
(micro)task 主要包括:Promise、MutaionObserver、process.nextTick(Node.js 环境)
代码开始执行都是从 script(全局任务)开始,所以,一旦我们的全局任务(<script>
属于宏任务)执行完,就马上执行完整个微任务队列。
Promise 哪些 API 涉及了微任务?
Promise 中只有涉及到状态变更后才需要被执行的回调才算是微任务,比如说 then、catch、finally,其他所有的代码执行都是宏任务(同步执行)
1
1 | console.log('script start'); |
2
1 | console.log('script start') |
3
1 | Promise.resolve() |
4
1 | let p = Promise.resolve() |
5
1 | let p = Promise.resolve() |
6
1 | Promise.resolve() |
7
1 | promise2 = new Promise() |
Promise 与 asap 异步执行原理
Promise 异步执行是通过asap这个库来实现的
asap 概述
asap 是 as soon as possible 的简称,在 Node 和浏览器环境下,能讲回调函数以高优先级任务来执行(下一个时间循环之前),即把任务放在微队列中执行。
用法:
1 | asap(function () { |
asap 源码分析-Node 版
asap 源码库包含了支持 Node 和浏览器的两个版本,这里主要分析 Node 版
主要包含两个源码文件
这两个文件分别导出了 asap 和 rawAsap 这两个方法,而 asap 可以看作是对 rawAsap 的进一步封装,通过缓存的 domain(可以捕捉处理 try catch 无法捕捉的异常,针对异步代码的异常处理)和 try/finally 实现了即使某个任务抛出异常也可以恢复任务栈的继续执行,另外也做了一点缓存优化(具体见源码)。
因此这里主要分析 raw.js 里面的代码即可:
首先是对外导出的 rawAsap 方法
1 | var queue = [] |
源码分析:如果任务栈 queue 为空,则触发 requestFlush 方法,并将 flushing 标志为 true,并且始终会将要执行的 task 添加到任务栈 queue 的末尾。这里需要注意的是由于 requestFlush 是异步去触发任务栈的执行的,所以即使 queue[queue.length]=task 在 requestFlush 调用之后执行,也能保证在任务栈 queue 真正执行前,任务 task 已经被添加到了任务栈 queue 的末尾。(如果任务栈 queue 不为空。所以 requestFlush 已经触发了,此时任务栈正在被循环依次执行,执行完毕会清空任务栈)
其次是异步触发 flush 方法执行的 requestFlush 方法
1 | var domain |
源码解析:核心代码其实就一句:setImmediate(flush),通过 setImmediate 异步执行 flush 方法。而判断 parentDomain 以及设置和恢复 domain 都只是为了当前的 flush 方法不绑定任何域执行。而这里还有一个 hasSetImmediate 判断,是为了做兼容降级处理,如果不存在 setImmediate 方法,则使用 process.nextTick 方法触发异步执行。但使用 process.nextTick 方法有一个缺陷,就是它不能够处理递归。
最后是执行任务栈的 flush 方法
1 | // 下一个任务在任务队列中执行的位置 |
源码解析:通过 while 循环依次去执行任务栈 queue 中的每一个任务,这里需要注意一点,index + 1 表示下一个要执行的任务下标,而其放在 queue[currentIndex].call() 之前,是为了保证当当前任务执行发生异常了,再次触发 requestFlush 方法时,能够跳过发生异常的任务,从下一个任务开始执行。而判断 if (index > capacity) 是为了防止内存泄露,当任务栈 queue 的长度超过了指定的阈值 capacity 时,对任务栈 queue 中的任务进行移动,将所有剩余的未执行的任务置前,并重置任务栈 queue 的长度。当所有任务执行完毕后,重置任务栈以及相应状态。
总结
rawAsap 方法是通过 setImmediate 或 process.nextTick 来实现异步执行的任务栈,而 asap 方法是对 rawAsap 方法的进一步封装,通过缓存的 domain 和 try/finally 实现了即使某个任务抛出异常也可以恢复任务栈的继续执行(再次调用 rawAsap.requestFlush)。
Node.js 的 Event Loop
1 | console.log(1) |
Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器。
除了 settimeout 和 setInverval 这两个方法,Node.js 还提供了另外两种与任务队列有关的方法:process.nextTick 和 setImmediate。
process.nextTick 方法可以在当前”执行栈”的尾部—-下一次 Event Loop(主线程读取”任务队列”)之前—-触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在当前”任务队列”的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行,这与 setTimeout(fn, 0)很像。请看下面的例子(via StackOverflow)。(这就是为什么 promise 的链式 then 会在当前任务队列最后执行的原因吧)
如果有多个 process.nextTick 语句(不管它们是否嵌套),将全部在当前”执行栈”执行。
process.nextTick 与 setImmediate
process.nextTick 和 setImmediate 的一个重要区别:多个 process.nextTick 语句总是在当前”执行栈”一次执行完,多个 setImmediate 可能则需要多次 loop 才能执行完。事实上,这正是 Node.js 10.0 版添加 setImmediate 方法的原因,否则像下面这样的递归调用 process.nextTick,将会没完没了,主线程根本不会去读取”事件队列”!
另外,由于 process.nextTick 指定的回调函数是在本次”事件循环”触发,而 setImmediate 指定的是在下次”事件循环”触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查”任务队列”)。
Macrotasks 和 Microtasks
Macrotasks 和 Microtasks 都属于上述的异步任务中的一种,他们分别有如下 API:
macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
microtasks: process.nextTick, Promise, MutationObserver
setTimeout 的 macrotask, 和 Promise 的 microtask 有哪些不同,先来看下代码如下:
1 | console.log(1) |
如上代码可以看到,Promise 的函数代码的异步任务会优先于 setTimeout 的延时为 0 的任务先执行。
原因是任务队列分为 macrotasks 和 microtasks, 而 promise 中的 then 方法的函数会被推入到 microtasks 队列中,而 setTimeout 函数会被推入到 macrotasks 任务队列中,在每一次事件循环中,macrotask 只会提取一个执行,而 microtask 会一直提取,直到 microsoft 队列为空为止。
也就是说如果某个 microtask 任务被推入到执行中,那么当主线程任务执行完成后,会循环调用该队列任务中的下一个任务来执行,直到该任务队列到最后一个任务为止。
而事件循环每次只会入栈一个 macrotask,主线程执行完成该任务后又会检查 microtasks 队列并完成里面的所有任务后再执行 macrotask 的任务。
最后
1 | setImmediate(function () { |
解析:事件循环 check 阶段执行回调函数输出 setImmediate,之后输出 nextTick。嵌套的 setImmediate 在下一个事件循环的 check 阶段执行回调输出嵌套的 setImmediate。
1 | var fs = require('fs') |
解析:事件循环进入 poll 阶段发现队列为空,并且没有代码被 setImmediate()。于是在 poll 阶段等待 timers 下限时间到达。当等到 95ms 时,fs.readFile 首先执行了,它的回调被添加进 poll 队列并同步执行,耗时 10ms。此时总共时间累积 105ms。等到 poll 队列为空的时候,事件循环会查看最近到达的 timer 的下限时间,发现已经到达,再回到 timers 阶段,执行 timer 的回调。