JS异步编程:事件队列和事件循环机制

919 阅读9分钟

参考 一篇搞定(Js异步、事件循环与消息队列、微任务与宏任务)

浏览器中的单线程与多线程

JavaScript是一门单线程异步非阻塞、解析类型脚本语言。

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

  • JS引擎线程(渲染解析JS的)
  • 定时器监听等线程
  • HTTP网络线程
  • DOM事件监听触发线程
  • GUI渲染线程

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

JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等...),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由 事件触发线程 将异步对应的 回调函数 加入到消息队列(事件队列)中,消息队列中的回调函数等待事件循环机制进行查询并被执行。

总结: 浏览器是多线程的,但是js的引擎是单线程的,js在同步执行任务的时候,如果遇到异步任务,就会交给别的线程去处理,等待异步任务触发并有了运行结果的时候,把回调函数当作一个任务加入到事件队列中,然后等待js主线程同步任务完成,再依据事件循环机制从事件队列中拿出异步任务进行执行。

那么事件队列机制和事件循环这两个机制具体是如何运作的呢?

事件队列和事件循环

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  3. 一但"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",查找里面的事件与对应的异步任务,如果有异步任务,就结束等待状态,将异步任务放入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

以上就是事件队列和事件循环机制的大概过程。在这个过程中,异步任务分为异步宏任务和异步微任务

异步宏任务(macrotask)

  • 定时器(setTimeout、setInterval)
  • DOM事件
  • HTTP请求(ajax、fetch、jsonp...)

异步微任务(microtask )

  • promise(resolve/reject/then...)
  • async await
  • requestAnimationFrame
  • process。nextTick(node中process。nextTick的优先级高于Promise)

事件循环执行机制具体是这样的:

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

总结:js主线程最开始的同步代码也算是宏任务,在执行宏任务时遇到Promise等,会创建微任务(例如.then()里面的回调),并加入到微任务队列队尾。某一个宏任务执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有微任务都执行完毕,然后开启下一个宏任务。这就是事件循环机制。

举例子说明事件队列和事件循环机制

setTimeout(function () {
    console.log(1);
}, 1000);
console.log(2);
new Promise(resolve => {
    console.log(3);
    resolve();
    console.log(4);
}).then(() => {
    console.log(5);
}).then(() => {
    console.log(6);
});
console.log(7);
const fn = () => {
    console.log(9);
};
(async function () {
    console.log(8);
    await fn();
   
    console.log(10);
    await fn();
    
    console.log(11);
})();
console.log(12);

一步一步地说

setTimeout(function () {
    console.log(1);
}, 1000);

浏览器会设置一个宏任务,我们把它叫做任务1.开始计时,到达1000ms后,通知主线程把回调函数执行。浏览器会把这个任务1放到异步宏任务队列中排队等待,并开启一个监听线程,用来计时。

console.log(2);

立即打印 2 ,同步执行

new Promise(resolve => {
    console.log(3);
    resolve();
    console.log(4);
})
  • new Promise 的时候会立即执行回调函数 executor,所以会输出 3
  • resolve() 会立即把promise实例的状态改为成功 'fulfilled' ,同时在微任务队列中创建一个任务,我们叫他任务2:能够把后期基于 then 存放进来的方法 onfulfilledCallback 通知浏览器执行(前提:方法还没有被执行过)
  • 然后输出 4

以上几步图示:

image。png

.then(() => {
    console.log(5);
})

这里 .then 中的回调也是一个微任务,假设是任务3,因为上面的任务2直接是同步的 resolve() ,所以任务2不会被放到为微任务队列中,会跳过,而是把这个确定状态的任务3放到微任务队列

.then(() => {
    console.log(6);
});

这里 .then 中的回调也是一个微任务,假设是任务4,这里不会立刻把这个微任务放到微任务队列中,因为上面的微任务3还没有执行,可能不知道其状态,所以属于暂存的状态,什么时候等上面为任务3执行了,确定了那个 promise 的状态,才决定是否执行这个微任务

console.log(7);

直接输出7

上面这几步对应的图例:

image。png

const fn = () => {
    console.log(9);
};

声明函数

console.log(8);
    await fn();

遇到立即执行函数,打印8

遇到 await

其实 await 可以写成promise的样子,就把它当作promise理解即可,所以这里相当于把当前上下文下中,第一个await下面所有的代码都当成回调了。所以:

立即把 fn 执行,相当于执行promise中的executor,这里默认是resolve的,所以在当前上下文下,会把 await 下面的所有代码当成一个新的异步微任务(任务5),放到任务队列中。类似于任务3中的.then中的回调

所以现在微任务队列中只有任务3,任务5

所以下面的代码先不会执行,继续往下执行

console.log(12);

输出 12

到这里,同步任务都执行完了,主线程空闲下来了,开始执行异步任务。这里因为js是单线程的,如果同步任务没有执行完,也就是主线程没有空闲下来,不论异步任务是否到达了可执行的阶段(例如 setTimeout(()=>{},0) ),都不能执行。

这一段过程的图示如下 image。png

接下来进入到异步队列,开始执行异步任务。 image。png

image。png

image。png

过程是这样的:

  1. 因为在刚才最开始的同步运行中,任务2直接resolve,所以任务2 会跳过,直接把任务3加到微任务队列中。因为任务3还没运行,任务4是否要加到微任务队列中,要看任务3运行后的状态。下面的fn运行完,resolve了,也是一样的道理,第一个 await 下面所有的代码是任务5,都会放到第一轮微任务队列中,执行时,也会和任务3一样直接执行。那么事件循环机制第一次循环要执行的任务按顺序就是任务3,任务5,所以输出5,10,9(第二个fn)。 .thenawait 其实本质一样

    image。png

  2. 执行任务3时,又会有一个新任务,任务4,是接下来的 then 。执行任务5的时候,会又遇到 await ,所以之后的代码又创建了任务6

  3. 所以第二次事件循环机制,就会执行任务4,任务6,分别输出6,11

  4. 最后执行宏任务,输出1

这样每次进行去事件队列中拿任务,然后执行,执行完之后再去事件队列中查询,这就是事件循环机制

总结一下.then的执行机制

执行p.then(onfulfilledCallback,onrejectedCallback)

  1. 首先把传递进来的onfulfilledCallbackonrejectedCallback存储起来
  2. 其次再去验证当前实例的状态
    • 如果实例状态是'pending',则不做任何的处理('pending'说明修改状态的操作是异步的)
    • 如果已经变为'fulfilled/rejected'(说明修改状态的操作直接是同步的),则会通知对应的回调函数执行。但是不是立即执行,而是把其放置在EventQueue中的微任务队列中。

promise本身不是异步的,是用来管理异步的,但是then方法是异步的「微任务」

一个同步改变状态的例子:

let p = new Promise((resolve, reject) => {
    console.log(1);
    resolve('OK'); //=>同步修改其状态和结果
    console.log(2);
});
console.log(p); //此时状态已经修改为成功
p.then(result => {
    console.log('成功-->', result);
});
console.log(3); 

异步改变状态的例子:

let p = new Promise((resolve, reject) => {
    console.log(1);
    setTimeout(() => {
        resolve('OK');
        // + 改变实例的状态和值「同步」
        // + 通知之前基于then存放的onfulfilledCallback执行「异步的微任务:也是把执行方法的事情放置在EventQueue中的微任务队列中」
        console.log(p);
        console.log(4);
    }, 1000); //=>存储了一个异步的宏任务
    console.log(2);
});
console.log(p);
// 此时接受onfulfilledCallback的时候,状态还是pending,此时只把方法存储起来
p.then(result => {
    console.log('成功-->', result);
});
console.log(3);
// 等1000ms后,执行定时器中的函数「把异步宏任务拿出来执行」 

image.png

  1. setTimeout存储一个异步宏任务。等1000ms后,执行定时器中的函数,即把异步宏任务拿出来执行
  2. 第一个p打印的时候,此时接受onfulfilledCallback的时候,状态还是pending,此时只把方法存储起来
  3. 等1s之后,resolve('OK');的时候:
    • 同步改变实例的状态和值

    • 通知之前基于then存放的onfulfilledCallback执行「异步的微任务:也是把执行方法的事情放置在EventQueue中的微任务队列中」

第二个例子(then链):

const p1 = new Promise((resolve, reject) =>{
    resolve('p1')
})
const p2 = new Promise((resolve, reject) =>{
    setTimeout(()=>{
        resolve('p2')
    },1000)
})
const p3 = new Promise((resolve, reject) =>{
    resolve('p3')
})
p1.then((res)=>{
    console.log(1)
}).then((res)=>{
    console.log(2)
})
p3.then((res)=>{
    console.log(3)
}).then((res)=>{
    console.log(4)
})
p2.then((res)=>{
    console.log(res)
})

image.png

1,3在第一轮微任务执行的时候先执行,等第一轮微任务执行完,2,4又被加到微任务队列,所以2,4又被执行。最后再执行p3宏任务

本文参考 一篇搞定(Js异步、事件循环与消息队列、微任务与宏任务)