JavaScript 中 同步任务与异步任务、单线程与多线程、事件循环与多线程、宏任务与微任务

掘金链接:
https://juejin.im/post/5f0fb472e51d453490665e07

同样,由面试题而来

console.log('script start');
setTimeout(function(){
    console.log('setTimeout');
})
console.log('script end');

// 输出顺序: script start -> script end -> setTimeout

由此曼延到栈、队列等信息,下面我们看下知识点

单线程的JavaScript 一段一段的执行,前面的执行完了,再执行后面的,试想一个,如果前一个任务需要执行很久,比如接口请求、I/O 操作,此时后面的任务只能干巴巴的等待吗?干等不仅浪费资源,而且页面的交互程度也很差。

1、 同步任务与异步任务

1.1 同步任务

在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。

1.2 异步任务

不会立即执行的任务(异步任务又分为宏任务与微任务)
不进入主线程,而进入“任务队列”的任务,只有等主线程任务执行完毕,“任务队列”开始通知主线程,请求执行任务,该任务才会进入主线程执行。

常见的异步任务:Ajax 、Dom 事件操作、setTimeOut、 promise 的then 方法 、Node读取文件

任务在执行过程中的流程图展示

1.3 执行栈与任务队列
执行过程文字描述如下:

JavaScript 在运行时,会将变量存放在堆(heap) 和 栈(stack)中,堆中通常存放着一些对象,而变量及对象的指针则存放在栈中。JavaScript 在执行时,同步任务会排好队,在主线程上按照顺序执行,前面的执行完了再执行后面的,排队的地方叫执行栈

JavaScript 对异步任务不会停下来等待,而是将其挂起,继续执行执行栈中的同步任务,当异步任务有返回结果时,异步任务会加入与执行栈不一样的队列,即任务队列,所以任务队列中存放的是异步任务执行完成的结果,通常是回调函数。

当执行栈的同步任务已经执行完成,此时主线程闲下来,它便会去查看任务队列中是否有任务,如果有,主线程会将最先进入任务队列的任务加入到执行栈中执行,执行栈中的任务执行完了之后,主线程继续查看任务队列中是否还有任务可执行,直到任务队列中的任务执行完毕。

主线程去任务队列读取任务到执行栈中去执行,这个过程是循环往复的,这边是Event Loop,事件循环

const myPromise = Promise.resolve(Promise.resolve('Promise!'));
function funcOne(){
  myPromise.then(res => res).then(res => console.log(res));
  setTimeout(() => console.log('Timeout!'), 0);
  console.log('Last Line!');
}
function funcTwo() {
  const res = await myPromise;
  console.log(await res);
  setTimeout(() => console.log('Timeout!'), 0);
  console.log('Last Line!');
}

funcOne();
funcTwo();

// 输出: Last Line! -> Promise! -> Promise! -> Last Line! --> Timeout! --> Timeout!

代码解析:
首先调用 funcOne() 函数,在函数中,第一行是promise.then() 异步操作,当 JS 引擎在忙于执行promise,它继续执行函数 funcOne, 下一行异步操作setTimeout,其回调函数被 webApi 调用。
promise和setTimoue 都是异步操作,函数继续执行当JS引擎忙于执行promise 和 处理 setTimeout 的回调,相当于 Last Line! 首先被输出,因为他不是异步操作。执行完funcOne 函数的最后一行,promise的状态发生转变为resolved, Promsie! 被打印。然后,我们此时调用了funcTwo,调用栈不为空,setTimeout的回仍不能入栈。
我们现在处于funcTwo,先 await myPromise ,通过 await 关键字,我们暂停了函数的执行,直到Promise 的状态发生变化,然后我们输出 Promise!。
下一行异步操作 setTimeout ,放入任务队列中,等待执行栈中执行完毕。
接着输出 funcTwo 最后一行 Last Line!, 执行栈的任务执行完毕。
等到执行栈中的任务执行完毕后,此时主线程查看任务队列中,执行任务队列中的任务。

2、 单线程与多线程

JavaScript 的设计就是为了处理浏览器网页的交互(DOM操作的处理、UI动画等),决定了它是一门单线程语言。如果多个线程,它们同时在操作DOM,那网页将会一团糟。

JS 处理任务时一件接着一件处理,从上往下顺序执行的:

console.log('开始');
console.log('中间');
console.log('结束')

// 开始 -> 中间  -> 结束

如果一个任务的处理耗时很久(或者等待很久),如:网络请求、定时器、等待鼠标点击等,在等待的过程,会不会导致后面的任务被阻塞,也就是说会阻塞所有的用户交互,带来不友好的用户体验。
但是实际上:

console.log('开始')
console.log('中间')
setTimeout(() => {
  console.log('timer over')
}, 1000)
console.log('结束')

// 开始
// 中间
// 结束
// timer over

JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即为JS引擎线程,但是浏览器的渲染进程是提供多个线程的。如下:

  • JS 引擎线程
  • 事件触发线程
  • 定时触发器线程
  • 异步http 请求线程
  • GUI渲染线程

当遇到计时器、DOM事件监听或者是网络请求的任务时,JS引擎会将他们直接交给webAPI,也就是浏览器提供的相应线程(如定时线程setTimeout 计时、异步http请求线程处理网络请求)去处理,而JS 引擎线程继续后面的其他任务,这样便实现了异步非阻塞。

定时器触发线程也只是为了 setTimout 定时而已,时间一到,还会把它对应的回调函数交给任务队列去维护,JS引擎线程会在食堂的时候去任务队列取出任务并执行。

JS引擎线程什么时候去处理呢?消息队列又是什么?

3、 事件循环与消息队列

JavaScript 通过事件循环event Loop 的机制来解决这个问题。
其实事件循环 机制和任务队列的维护是由事件触发线程控制的。

事件触发线程 同样是 浏览器渲染引擎提供的,它会维护一个任务队列。

JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等…), 会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由事件触发线程将异步对应的回调函数 加入消息队列中,消息队列中的回调函数等待被执行。
同时,JS引擎线程会维护一个执行栈,同步代码会依次加入执行栈然后执行,结束会退出执行栈。
如果执行栈里的任务执行完成,即执行栈为空的时候(即JS引擎线程空闲),事件触发线程才会从消息队列取出一个任务(即异步的回调函数)放入执行栈中执行。

消息队列是类似队列的数据结构,遵循先入先出(FIFO)的规则

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack).
  2. 主线程之外,还存在一个“任务队列(task queue)”。 只要异步任务有了运行结果,就在“任务队列”之中放置一个事件。
  3. 一旦“执行栈”中的所有同步任务执行完毕,系统就会读取“任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态。
  4. 主线程不断重复第三步骤。

此步骤就是异步任务。

setTimeout(() => {
  console.log(0)
}, 1000);
console.log(1)

// 输出 1  0

上面的setTimeout 函数不会立即返回结果,而是发起了一个异步,setTimeout 便是异步的发起函数或者注册函数。

4、 宏任务与微任务

Promise 同样是用来处理异步的:

console.log('start===');
setTimeout(function(){
 console.log('timerover');
}, 0);
Promise.resolve().then(function(){
 console.log('promise1');
}).then(function(){
 console.log('promise2');
});
console.log('end ===');

// 输出
// start===
// end ===
// promise1
// promise2
// timerover

“promise1” “promise2” 在"timerover" 之前打印,为什么?

这里有一个新的概念“macro-task (宏任务)” 和 “micro-task(微任务)”。

所有的任务分为 macro-task 和 micro-task :

  • macro-task (宏任务): 主代码块、setTimeout、setInterval等。可以看到,事件队列中的每一个事件都是一个macro-task, 现在被称为 宏任务队列。
  • micro-task(微任务):Promise、process.nextTick 等

JS 引擎线程首先执行主代码块。每次执行栈执行的代码就是一个宏任务,包括任务队列(宏任务队列)中的,因为执行栈中的宏任务执行完之后,会去取任务队列(宏任务队列)中的任务加入执行栈中,即同样是事件循环的机制。

在执行宏任务时,遇到Promise等,会创建微任务(.then()里面的回调),并加入到微任务队列队尾。micro-task(微任务)必然是在某个宏任务执行的时候创建的,而在下一个宏任务开始之前,浏览器会对页面重新渲染(task >> 渲染 >> 下一个task(从人物队列中取一个))。 同时,再上一个宏任务执行完成后,渲染页面之前,会执行当前为任务队列中的所有微任务。

也就是说,在某一个macro-task(宏任务)执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有micro-task(微任务)都执行完毕。

这样就可以解释"promise1", “promise2” 在"timerover" 之前打印了。“promise1” “promise2” 作为微任务加入到微任务队列中,而“timerover ” 作为宏任务加入到宏任务队列中,它们同时在等待被执行,但是微任务队列中的所有微任务都会在开始下一个宏任务之前都被执行完。

同样,上面有个例子就很好的理解了:

const myPromise = Promise.resolve(Promise.resolve('Promise!'));
function funcOne(){
  myPromise.then(res => res).then(res => console.log(res));
  setTimeout(() => console.log('Timeout!'), 0);
  console.log('Last Line!');
}
function funcTwo() {
  const res = await myPromise;
  console.log(await res);
  setTimeout(() => console.log('Timeout!'), 0);
  console.log('Last Line!');
}

funcOne();
funcTwo();

// 输出: Last Line! -> Promise! -> Promise! -> Last Line! --> Timeout! --> Timeout!

在node环境下,process.nextTick 的优先级高于 Promise, 也就是说:在宏任务结束后会先执行任务队列中 的 nextTickQueue,然后才会执行微任务中的 Promise。

执行机制:

  1. 执行一个宏任务(执行栈中没有就从事件队列中获取)
  2. 执行过程中如果遇到微任务,就将它添加到微任务的队列中
  3. 宏任务执行完毕后,立即执行当前微任务队列中的微任务(依次执行)
  4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  5. 渲染完毕后,JS引擎线程继续,开始下一个宏任务(从宏任务队列中获取)

宏任务 macro-task

一个event Loop(事件循环)有一个或者多个 task 队列。task 任务源非常宽泛,比如ajax的 onload,click 事件,基本上我们经常绑定的各种事件都是 task 任务源,还有数据库操作(IndexedDB),需要注意的是setTimeout、setInterval、setTmmediate也是task任务源。总结来说task任务源:

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • requestAnimationFrame
  • UI rendering
微任务 micro-task

microtask 队列 和task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个event loop里只有一个microtask 队列。另外microtask 执行时机和 macrotasks 也有所差异。

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver
宏任务和微任务的区别:
  • 宏任务队列中可以有多个,微任务队列只有一个,所以每创建一个新的setTimeout都是一个新的宏任务队列,执行完一个宏任务队列后,都回去checkpoint 微任务。
  • 一个事件循环后,微任务队列执行完了,再执行宏任务队列。
  • 一个时间循环中,在执行一个宏队列之后,就会check 微任务队列。
宏任务与微任务示例
1. 主线程上添加宏任务和微任务
console.log('---开始---');
setTimeout(() => {
  console.log('setTimeout!');
}, 0);
new Promise((resolve, reject) => {
  for (let i = 0; i < 5; i++) {
    console.log(i);
  }
  resolve();
}).then(() => {
  console.log('Promise!');
});

console.log('----结束---');

// 输出:
// ---开始---
// 0
// 1
// 2
// 3
// 4
// ----结束---
// Promise!
// setTimeout!

解析:

第一轮事件循环:

  • 整体代码作为第一个宏任务进入主线程,遇到 console.log(), 输出: —开始—
  • 遇到setTimeout, 其回调函数被分发到 宏任务 Event Queue中,我们暂且记为: setTimeout1
  • 遇到 Promise,new Promise直接执行,循环一次输出0、1、 2、 3、 4. then 被分发到微任务Event Queue 中。 我们记为 then1
  • 继续往下,输出:----结束—,到此第一轮事件循环结束,此时,会先查看当前循环有么有产生出微任务,有依次按产生顺序执行。
  • 发现有then1,输出: Promise! , 当前为任务执行完毕,到此,第一轮事件循环结束。

发现setTimeout 宏任务,开始第二轮事件循环:

  • 遇到console.log() , 直接输出 setTimeout,然后微任务执行结束,第二轮循环结束。

所有宏任务执行完毕,整个程序执行完毕。

2. 在微任务中创建微任务
console.log('---开始---');
setTimeout(() => console.log('setTimeout1'));
new Promise(resolve => {
  resolve();
  console.log('Promise1');
}).then(() => {
  console.log('Promise2');
  Promise.resolve().then(() => {
    console.log('beforeTimeout');
  }).then(() => {
    Promise.resolve().then(() => {
      console.log('alse beforeTimeout');
    })
  })
});
console.log('---结束---')

// 输出:
// ---开始---
// Promise1
// ---结束---
// Promise2
// beforeTimeout
// alse beforeTimeout
// setTimeout1

解析:

第一轮事件循环

  • 遇到console.log() ,输出: —开始—
  • 遇到setTimeout ,其回调函数被分发到宏任务Event Queue中,我们记为 setTimeout1
  • 遇到 Promise, new Promise 直接执行,输出 Promise1. then() 被分发到微任务 Event Queue中,我们记为 then1(此时忽略 then 中的内容)
  • 遇到 console.log(), 输出: —结束—
  • 执行微任务 then1, 遇到 console.log(), 输出 Promise2,遇到 Promise.resolve().then , then 又被分发到微任务 Event Queue,我们此时记为then2
  • 继续执行微任务,微任务then2,输出: beforeTimeout, 生成微任务 then3
  • 继续执行微任务,微任务then3, 输出: alse beforeTimeout ,至此微任务执行完毕,第一轮事件循环结束。

发现setTimeout1 宏任务,开始第二次事件循环

  • console.log(), 输出 setTimeout1,无微任务,第二轮事件循环结束
3. 微任务队列中创建宏任务
new Promise(resolve => {
  console.log('new Promise(macro task 1)');
  resolve();
}).then(() => {
  console.log('micro task 2');
  setTimeout(() => { // set2
    console.log('setTimeout1');
  }, 0);
})
setTimeout(() => { // set1
  console.log('setTimeout2');
}, 500)

console.log('----End-----');

// 输出:
// new Promise(macro task 1)
// ----End-----
// micro task 2
// setTimeout1
// setTimeout2

解析:

第一轮事件循环

  • 遇到Promise, new Promise 直接执行,输出 : new Promise(macro task 1). then 被分发到微任务 Event Queue中,我们记为 then1
  • 遇到setTimeout ,其回调函数被分发到宏任务 Event Queue中,记为 宏 set1
  • console.log() 输出: End。
  • 执行微任务 then1, 输出: micro task 2, 然后又遇到setTimeout ,生成宏任务,记为 宏 set2, 至微任务,执行完毕,第一轮事件循环结束

发现有两个定时器宏任务,这里优先执行set2,开始第二轮事件循环(为什么有限制性set2?)

  • console.log() 输出 setTimeout1, 无微任务,第二轮事件循环结束

执行宏 set1, 开始第三轮事件循环

  • console.log() 输出 setTimeout2, 无微任务,第三轮事件循环结束。

为什么优先执行 宏 set2 ?
尽管宏 set1 先被定时器出发线程处理,但是 宏set2 的callback 会先加入消息队列。

上面,宏set2 的延时为0ms, HTML5 标准规定 setTimeout 第二个参数不得小于4, 不足会自动增加,所以" setTimout2 " 会在"setTimeout1" 之后。

就算延时为0 ms, 只是宏set2 的毁掉含税会立即加入消息队列而已,回调的执行还是得等待执行栈为空时执行。

4. 宏任务中创建微任务
setTimeout(() => { // set1
  console.log('timer_1');
  setTimeout(() => { // set3
    console.log('timer_2');
  }, 0);

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

setTimeout(() => { // set2
  console.log('timer_3');
}, 0)

console.log('--End --');

// 输出
// --End --
// timer_1
// Promise1
// Promise2
// timer_3
// timer_2

解析:

第一轮事件循环

  • 创建宏 set1
  • 创建宏 set2
  • console.log() 输出 --End–
  • 无微任务,当前事件循环结束

执行 set1,第二轮事件循环开始

  • console.log() 输出 timer_1
  • 创建宏 set3
  • 执行 new Promise,直接输出 Promise1 创建微任务 then1
  • 执行微任务then1, 输出 Promise2
  • 无微任务,当前事件循环结束

执行 set2 , 第三轮事件循环开始

  • console.log() 输出 timer_3
  • 无微任务,当前事件循环结束

执行 set3, 第四轮事件循环开始:

  • console.log() 输出 timer_2 ,
  • 无微任务,当前事件循环结束
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值