JavaScript 异步、栈、事件循环、任务队列

59

概览

图片描述
我们经常会听到引擎和runtime,它们的区别是什么呢?

  • 引擎:解释并编译代码,让它变成能交给机器运行的代码(runnable commands)。
  • runtime:就是运行环境,它提供一些对外接口供Js调用,以跟外界打交道,比如,浏览器环境、Node.js环境。不同的runtime,会提供不同的接口,比如,在 Node.js 环境中,我们可以通过 require 来引入模块;而在浏览器中,我们有 window、 DOM。

Js引擎是单线程的,如上图中,它负责维护任务队列,并通过 Event Loop 的机制,按顺序把任务放入栈中执行。而图中的异步处理模块,就是 runtime 提供的,拥有和Js引擎互不干扰的线程。接下来,我们会细说图中的:栈和任务队列。

现在,我们要运行下面这段代码:

function bar() {
    console.log(1);
}

function foo() {
    console.log(2);
    far();
}

setTimeout(() => {
    console.log(3)
});

foo();

它在栈中的入栈、出栈过程,如下图:
图片描述

任务队列

Js 中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?

  • 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.
  • 微任务:process.nextTick, Promise, Object.observer, MutationObserver.

浏览器的 Event Loop

浏览器的 Event Loop 遵循的是 HTML5 标准,而 NodeJs 的 Event Loop 遵循的是 libuv。 区别较大,分开讲。

我们上面讲到,当stack空的时候,就会从任务队列中,取任务来执行。浏览器这边,共分3步:

  1. 取一个宏任务来执行。执行完毕后,下一步。
  2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
  3. 更新UI渲染。

Event Loop 会无限循环执行上面3步,这就是Event Loop的主要控制逻辑。其中,第3步(更新UI渲染)会根据浏览器的逻辑,决定要不要马上执行更新。毕竟更新UI成本大,所以,一般都会比较长的时间间隔,执行一次更新。

从执行步骤来看,我们发现微任务,受到了特殊待遇!我们代码开始执行都是从script(全局任务)开始,所以,一旦我们的全局任务(属于宏任务)执行完,就马上执行完整个微任务队列。看个例子:

console.log('script start');

// 微任务
Promise.resolve().then(() => {
    console.log('p 1');
});

// 宏任务
setTimeout(() => {
    console.log('setTimeout');
}, 0);

var s = new Date();
while(new Date() - s < 50); // 阻塞50ms

// 微任务
Promise.resolve().then(() => {
    console.log('p 2');
});

console.log('script ent');


/*** output ***/

// one macro task
script start
script ent

// all micro tasks
p 1
p 2

// one macro task again
setTimeout

上面之所以加50ms的阻塞,是因为 setTimeout 的 delayTime 最少是 4ms. 为了避免认为 setTimeout 是因为4ms的延迟而后面才被执行的,我们加了50ms阻塞。

NodeJs 的 Event Loop

NodeJs 的运行是这样的:

  • 初始化 Event Loop
  • 执行您的主代码。这里同样,遇到异步处理,就会分配给对应的队列。直到主代码执行完毕。
  • 执行主代码中出现的所有微任务:先执行完所有nextTick(),然后在执行其它所有微任务。
  • 开始 Event Loop

NodeJs 的 Event Loop 分6个阶段执行:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

以上的6个阶段,具体处理的任务如下:

  • timers: 这个阶段执行setTimeout()setInterval()设定的回调。
  • pending callbacks: 上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段执行。
  • idle, prepare: 仅内部使用。
  • poll: 执行 I/O callback,在适当的条件下会阻塞在这个阶段
  • check: 执行setImmediate()设定的回调。
  • close callbacks: 执行比如socket.on('close', ...)的回调。

每个阶段执行完毕后,都会执行所有微任务(先 nextTick,后其它),然后再进入下一个阶段。

Links

你可能感兴趣的

16 条评论
TuringMachine · 5月5日

提出一点质疑:
取一个宏任务来执行。执行完毕后,下一步。
取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
更新UI渲染。

跟我的测试数据有差别,应该是主线程执行完之后,运行micro-task队列的任务直到为空,然后再运行macro-task队列的任务直到为空,重复执行这两步。
下面是我的测试代码:

process.nextTick(()=>{
    console.log('process 1')
})

setTimeout(()=>{
    console.log('setTimeout 1')
    process.nextTick(()=>{
        console.log('process 2')
        setTimeout(()=>{
            console.log('setTimeout 4')
        })
    })
})

setTimeout(()=>{
    console.log('setTimeout 2')
    process.nextTick(()=>{
        console.log('process 4')
        setTimeout(()=>{
            console.log('setTimeout 5')
        })
    })
})

setTimeout(()=>{
    console.log('setTimeout 3')
})

process.nextTick(()=>{
    console.log('process 3')
})

// 结果:
process 1
process 3
setTimeout 1
setTimeout 2
setTimeout 3
process 2
process 4
setTimeout 4
setTimeout 5

+2 回复

0

我也有这样的疑问。按照文章里解释的,先把主线程的任务(包括console.log)执行完后,才开始从任务队列中取第一个宏任务,但是第一个宏任务又是全局代码,那这样不是重复了吗?然后你的解释是“先执行主线程,再取微任务,再取宏任务”,感觉这样也说的通。但是等下一次执行宏任务时第一个宏任务还是全局啊,所以全局代码到底要不要放到任务队列里去啊。希望有老板帮忙解解答~

有猫饼 · 7月9日
0

之所以一开始要从macro中取一个执行,是因为在最开始的时候,只有macro中有(且只有一个)任务,即全局script,而其他子队列都是空的。

Hjai · 7月13日
0

很感谢您的评论。浏览器和NodeJS的 Event Loop,有很大区别。我把 NodeJs 的 Event Loop 也加上去了。

谷雨 作者 · 7月15日
iwsr · 2月6日

感谢 这篇文章给我指点迷津了 对任务队列的认识又加深了一步

回复

益明 · 4月9日

非常好,不仅讲到了event loop机制,还深入了到了任务队列的micro queue和macro queue。解释了promise.then和setTimeout这一经常遇到的面试题的本质。

回复

cabbage · 4月18日

写得太好了

回复

哎呀歇菜了 · 4月19日

空栈之后的执行,应该是先执行完微任务,在去执行的宏任务的吧?

回复

0

博主的意思好像是把全局任务当作第一个宏任务处理,之后再处理所有的微任务。再之后处理剩下的宏任务。

从小就很瘦 · 5月18日
0

@从小就很瘦 嗯,是的,第一个全局任务(script)是宏任务。

谷雨 作者 · 7月15日
0

@谷雨 那第一个任务是不是指的就是js主线程中的所有的同步任务?或者可以理解异步任务队列可以分为宏任务和微任务么?

Hurry_9 · 9月17日
从小就很瘦 · 5月18日

很好的文章,简洁易懂

回复

freesou1 · 7月6日

赞一个 谢谢

回复

晴天 · 8月26日

小菜鸡弱弱地问两句:
①多个宏任务队列,本轮宏任务及微任务队列里所有微任务都执行完之后,下一轮宏任务该取哪个宏任务队列中的任务呢?
②微任务队列里会出现异步事件嘛?

回复

yrjwork · 12月6日

有几个疑问有大佬可以解答一下吗?在此先谢过!
1.宏任务队列可以有多个,不同队列是按任务类型来分,如setTimeout类型和setInterval类型是两个队列。那在执行宏任务时,如何决定先做那个队列里的?
2.是宏任务队列里的任务全部执行完,才执行微任务队列里的任务吗?

回复

载入中...