Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS 基础篇 - 宏任务与微任务 & EventLoop事件循环 #49

Open
jtwang7 opened this issue Oct 9, 2021 · 1 comment
Open

JS 基础篇 - 宏任务与微任务 & EventLoop事件循环 #49

jtwang7 opened this issue Oct 9, 2021 · 1 comment

Comments

@jtwang7
Copy link
Owner

jtwang7 commented Oct 9, 2021

参考文章:

事件循环机制

概念

Event Loop 实际就是 JavaScript 异步执行机制的一种实现方式;
程序按照主线程-微任务-宏任务的顺序不断重复执行, 并始终维护各执行队列直至全部队列清空的操作就是 Event Loop;

流程

JavaScript 是单线程语言, JS 任务需要遵循一定的顺序执行。为了避免某个任务执行时间过长而阻塞后面任务的执行, JS 将任务分为了同步和异步任务,而同步任务和异步任务执行的场所不同,因此执行的过程也有所差异:

  • 同步任务进入主线程执行, 异步任务进入 Event Table 执行, 并在此阶段注册其内部的回调函数;
  • 注册的回调函数会被放入 Event Queue 中等待;
  • 主线程中同步任务执行完毕后(js 引擎通过 monitoring process 进程持续监测主线程执行栈是否为空), 此时执行栈为空, 会去 Event Queue 检查是否存在等待的回调函数, 若存在则读取 Event Queue 的函数到主线程中执行;

异步任务又被细分为宏任务和微任务, JS 在处理宏任务和微任务时又遵循特殊的执行顺序:
当 JS 遇到宏任务时, 将其放入 Macro Event Queue 中, 而微任务会被放入 Micro Event Queue 中(注意宏任务队列和微任务队列不是一个队列); 在读取(向外拿)回调函数时, 先清空微任务队列中的回调函数, 然后再从宏任务队列中调用并执行一个回调函数; (换句话说, 每一次宏任务执行前, 要清空上一次的微任务队列, 宏任务在微任务之后执行);
宏任务中可能产生新的微任务,而这些微任务的回调会被注册到微任务队列中,因此我们取出一个宏任务并执行完毕后,要再一次确认微任务队列是否被清空,若没有则要清空微任务队列,然后再从宏任务队列中调取下一个回调函数......

概括而言,JS 的执行顺序, 应遵循的思路为:
同步放入主线程 -> 执行同步代码(清空主线程代码) -> 遇到异步代码, 放入 Event Table -> Event Table 中判断宏任务 or 微任务 -> 注册回调放入各自队列 -> 若主线程为空,清空微任务(将微任务提到主线程执行) -> (若执行后又推入了新的微任务,又回到了主线程为空,微任务队列存在回调的情况,重复上述流程) -> ...... (若主线程/微任务队列都为空) -> 执行下一次宏任务(取出宏任务队列中的一个回调到主线程,然后执行。) -> … -> (若执行后推入了新的微任务,又回到了主线程为空,微任务队列存在回调的情况,重复上述流程)

宏任务 & 微任务

在通过例子深入了解 JS 执行机制前, 我们需要记住几个常用的宏任务和微任务:
宏任务:

  • 整体 script 代码(script 代码是异步代码, 其内部可能包含同步代码, 但整体上是异步宏任务, 许多文章对于 script 代码解释有出入, 以自己理解为准)
  • setTimeout
  • setInterval
  • setImmediate

微任务:

  • 原生 Promise 的 then() , catch() 方法
  • await 暂停处的后续语句
  • process.nextTick
  • MutationObserver
  • Object.observe (已废弃)

关于 Promise:
首先遇到 new Promise() 会同步执行代码,立即执行参数传入的 executor 函数,executor 函数内部从上到下同步执行代码,遇到 resolve() 或 reject() 时,将值传递给 then() 参数中的回调,并立即将该回调注册到微任务队列。(⚠️注意:resolve() / reject() 的执行也是同步的,它不会等异步完成后执行,除非它在异步回调内,参照示例代码理解)
对于 then() 方法的链式调用而言:尽管调用在形式上是连续的,但是注册回调并不是连续的,下一个 then() 回调注册需要等待上一个 then() 方法执行完毕后才进行注册。

// 示例代码
let a = new Promise((resolve, reject) => {
  // 1. 打印 1
  console.log(1); 
  let a = 0;
  // 2. 回调注册至宏任务队列
  setTimeout(() => {
    a = 1;
    // 10. 打印 5
    console.log(5);
  }, 1000);
  // ⚠️3. 同步执行 resolve(),将 a 传递给 then() 回调,并注册 then() 回调至微任务队列
  resolve(a);
  // 4. 打印 2
  console.log(2);
  // 至此,同步代码执行完毕(主线程为空),下一步清空微任务队列
}).then(
// 在第3步被注册入微任务队列,val = 0,主线程为空后,该微任务出队列,压入主线程执行
val => {
  // 5. 打印 0
  console.log(val);
  // 6. 执行 new Promise 的 executor
  return new Promise((resolve, reject) => {
    // ⚠️7. 同步执行 resolve(),将 3 传递给 then() 回调,并注册 then() 回调至微任务队列
    resolve(3);
    // 8. 打印 4
    console.log(4);
    // 至此,主线程又为空了,会再一次检查微任务队列是否为空,由于刚刚又推入了一个微任务,因此执行下一个 then 回调👇
  })
}).then(val => {
  // 9. 打印 3
  console.log(val);
  // 主线程/微任务队列为空,调出宏任务队列注册的回调到主线程执行
})

// 1
// 2
// 0
// 4
// 3
// 5

总的来说:普通函数代码块内都是同步执行的,异步形式主要是依靠回调注册至不同队列来实现的。

关于 async & await:
async 内部按照同步代码的方式运行直到碰到 await,执行到 await 后面的表达式停止。此时,程序会记录前段的变量和当前中断的位置,并暂时将该函数弹出执行上下文栈,将执行权交给其他同步代码执行。
await 暂停处之后的语句都会被推入微任务队列,当 await 后的表达式执行完成后,遵循事件循环机制,将微任务队列中的后续语句推入主线程执行 (async 函数重新被压入上下文栈,从中断位置恢复执行)。

实例讲解

例一

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

/**请写出打印结果
 * script start
 * script end
 * promise1
 * promise2
 * setTimeout
 */

/**过程解析
 * 添加执行环境 - 任务入栈:
 * 最开始 JS 整体代码作为异步输入
 * console.log('script start') 同步代码, 放入主线程队列
 * setTimeout 异步宏任务, 经 Event Table 注册回调后, 其回调放入宏任务队列
 * Promise.resolve() 异步微任务, 经 Event Table 注册回调后, 回调放入微任务队列 (Promise 由于链式调用, 微任务队列 promise1 先入, promise2 后入)
 * console.log('script end') 同步代码, 放入主线程队列
 * 
 * 执行 - 任务出栈:
 * 先执行主线程, 输出 script start, script end
 * 主线程为空, 清除微任务队列, 微任务回调出队列, 进入主线程执行, 输出 promise1, promise2
 * 微任务为空, 清除宏任务队列, 宏任务回调出队列, 进入主线程执行, 输出 setTimeout
 * 主线程, 任务队列均为空, 执行完毕;
 */

例二

setTimeout(()=>{
  console.log('setTimeout1');
}, 0);

let p = new Promise((resolve, reject)=>{
  console.log('Promise1');
  resolve();
})

p.then(()=>{
  console.log('Promise2');
})


/**请写出打印结果
 * Promise1
 * Promise2
 * setTimeout1
 */

/**过程解析
 * 入队
 * 最开始 JS 整体代码作为异步输入
 * setTimeout 异步宏任务, 注册回调并添加至宏任务
 * new Promise 是同步任务, 其内部 executor 函数在主线程自动执行, 因此将 executor 函数添加至主线程;
 * Promise.then() 异步微任务, 注册回调并添加至微任务
 * 
 * 出队
 * 清空主线程, 输出 Promise1;
 * 清空微任务队列, 输出 Promise2;
 * 取宏任务队列回调, 输出 setTimeout1;
 */

例三

Promise.resolve().then(()=>{
  console.log('Promise1')  
  setTimeout(()=>{
    console.log('setTimeout2')
  },0)
});

setTimeout(()=>{
  console.log('setTimeout1')
  Promise.resolve().then(()=>{
    console.log('Promise2')    
  })
},0)

/**输出结果
 * Promise1
 * setTimeout1
 * Promise2
 * setTimeout2
 */

/**过程解析
 * 入队
 * 最开始 JS 整体代码作为异步输入
 * Promise.resolve() 异步微任务, 注册回调并添加至微任务
 * setTimeout 异步宏任务, 注册回调并添加至宏任务
 * 
 * 出队
 * 主线程没有任务, 清空微任务, 输出 Promise1, 此时碰到 setTimeout 宏任务;
 * 
 * 入队
 * setTimeout 注册回调并添加至宏任务队尾
 * 
 * 出队
 * 微任务为空, 此时获取宏任务队列最开始的回调并执行, 输出 setTimeout1;
 * 
 * 入队
 * 遇到 Promise.resolve 异步微任务, 注册其回调并添加至微任务;
 * 
 * 出队
 * 下一次宏任务队列回调执行前, 必须保证微任务队列是清空的, 因此此时清空微任务回调, 输出 Promise2
 * (这就是宏任务不用清空这两个字的原因)
 * 清空后, 获取宏任务回调并执行, 输出 setTimeout2
 */

经过上述三个例子, 你可能对 JS 执行机制有了大致的了解:

  • 在判断执行顺序时, 我们需要先构建初步的执行上下文(入队), 同步添加至主线程, 异步则进一步判断宏任务还是微任务, 注册回调并添加至各自的等待队列;
  • 执行上下文构建完成后, 开始执行(出队), 先执行主线程的同步代码, 清空主线程后再清空微任务队列, 微任务队列清空后, 再从宏任务队列获取回调(多次强调, 此处是获取而不是清空)并执行;
  • 在执行宏任务回调时, 又可能会添加新的主线程和微队列任务(第二轮构建入队), 我们要在下一次执行宏任务回调前, 将其清空(第二轮执行出队), 依次不断循环
  • ......

考核

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')
    })
})

/**输出结果
 * 1
 * 7
 * 6
 * 8
 * 2
 * 4
 * 3
 * 5
 * 9
 * 11
 * 10
 * 12
 */


/**过程解析
 * 首轮构建执行上下文(入队)
 * js 代码块作为异步代码入队
 * console.log(1) 同步代码, 进入主线程
 * setTimeout 异步宏任务, 注册其 function(){...} (不关心其嵌套) 至宏任务队列
 * process.nextTick 异步微任务, 注册回调并添加至微任务队列
 * new Promise 内部 executor 函数同步执行, 添加至主线程
 * .then() 异步微任务, 添加至微任务队列
 * setTimeout 异步宏任务, 注册其 function(){...} (不关心其嵌套) 至宏任务队列
 * 
 * 首轮执行(出队)
 * 清空主线程: 输出 1, 7
 * 清空微任务队列: 输出 6, 8
 * 获取宏任务第一个回调并执行: 输出 2, 此时遇到 process.nextTick, 开启第二轮入队(构建执行环境)
 * 
 *   第二轮入队
 *   注册 process.nextTick 回调并添加至微任务队列, 
 *   添加 new Promise 内同步代码至主线程, 
 *   注册 .then() 回调至微任务; 此时主线程和微任务队列都不为空, 开启第二轮出队
 * 
 *   第二轮出队
 *   清空主线程: 输出 4
 *   清空微任务队列: 输出 3, 5
 *   获取宏任务下一个回调: 输出 9,  此时遇到 process.nextTick, 开启第三轮入队
 * 
 *     第三轮入队
 *     注册 process.nextTick 回调并添加至微任务队列, 
 *     添加 new Promise 内同步代码至主线程, 
 *     注册 .then() 回调至微任务; 此时主线程和微任务队列都不为空, 开启第三轮出队
 * 
 *     第三轮出队
 *     清空主线程: 输出 11;
 *     清空微任务队列: 输出 10, 12
 * 
 * 主线程, 宏任务队列, 微任务队列都为空, 执行完毕
 * 输出结果为: 1, 7, 6, 8, 2, 4, 3, 5, 9, 11, 10, 12
 */

回调函数内可能嵌套了多层, 但遵循上述步骤仍可以正确判断, 我们每次只需关注最外层嵌套函数即可, 在原有上下文基础上构建第二级的执行上下文, 清空主线程, 清空微队列, 再获取下一个宏任务回调执行并判断, 遇到嵌套后再循环…

@jtwang7
Copy link
Owner Author

jtwang7 commented Feb 11, 2022

2022.02.11 更新

一道事件循环面试考题:

async1 = async () => {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => {
  console.log('setTimeout');
})
async1();
new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
  return new Promise(function (resolve) {
    resolve();
  })
}).then((res) => {
  console.log('promise3');
})
console.log('script end');

/**
 * script start
 * async1 start
 * async2
 * promise1
 * script end
 * async1 end
 * promise2
 * promise3
 * setTimeout
 */

解答

  1. 整块 js 代码作为宏任务进入宏任务队列。
  2. 主线程和微任务队列都为空,将整块代码提到主线程执行。
  3. 清空主线程代码 (主逻辑;注册宏任务/微任务)
  4. 检查微任务队列是否为空:
    4.1. 若不为空,将微任务提取到主线程执行,清空微任务队列
    4.2. 若为空,检查宏任务队列是否为空,并将下一个宏任务提取到主线程之行
  5. 重复 3 - 4,直到主线程/宏任务/微任务队列都为空,程序执行完毕。
async1 = async () => {
  // 4. 打印 'async1 start'
  console.log('async1 start');
  // 5. 执行 async2,遇到 await,将后续代码放入微任务队列等待执行,交还执行权(跳出该函数体)
  await async2();
  // 5.1 被放入微任务队列
  // 10. 打印 async1 end
  console.log('async1 end');
}
function async2() {
  console.log('async2');
}
// 1. 打印 'script start'
console.log('script start');
// 2. 执行计时器,并向宏任务队列注册回调
setTimeout(() => {
  console.log('setTimeout');
})
// 3. 执行 async1
async1();
// 6. 创建 promise 实例并执行 executor
new Promise((resolve) => {
  // 7. 打印 promise1
  console.log('promise1');
  // 8. 执行 resolve() 并将 then 回调注册至微任务队列
  resolve();
}).then(() => {
  // 11. 打印 promise2
  console.log('promise2');
  return new Promise(function (resolve) {
    // 12. 注册 then 回调至微任务队列
    resolve();
  })
}).then((res) => {
  // 13. 打印 promise3
  console.log('promise3');
})
// 9.  打印 script end
console.log('script end');
// 主线程清空,接着清空微任务队列,将微任务队列提到主线程执行👆

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant