基础系列-事件循环与异步

JS为什么要有事件循环机制

JS被设计为单线程机制,为了避免出现竞态条件,例当线程A中正在操作DOM,但线程B中删除了此DOM,会发生错误。

所以为了避免发生竞态条件,是单线程原因之一。

主线程被长时间占用运行,渲染线程也会被锁死,目的就是避免出现竞态条件

运行时的概念

The_Javascript_Runtime_Environment_Example

函数调用形成了一个由若干帧组成的栈。

function foo(b) {
    let a = 10;
    return a + b + 11;
}

function bar(x) {
    let y = 3;
    return foo(x * y);
}

console.log(bar(7)); // 返回 42

当调用 bar 时,第一个帧被创建并压入栈中,帧中包含了 bar 的参数和局部变量。 当 bar 调用 foo 时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo 的参数和局部变量。当 foo 执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。

队列

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。

在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

事件循环

在操作系统中,通常等待的行为都是一个事件循环。

异步编程的核心:程序中现在运行的部分和将来运行的部分之间的关系。

程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。

JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。

一种来处理程序中多个块的执行,且执行每块时调用JavaScript引擎的机制,这种机制为事件循环。

JavaScript引擎本身并没有事件的概念,只是一个按需执行JavaScript任意代码片段的环境。

最常见的块单位是函数。

伪代码:

// queue.waitForMessage()会同步地等待消息到达(如果当前没有任何消息等待被处理)
while (queue.waitForMessage()) {
  queue.processNextMessage();
}

// 或
while(true) {
    r = wait();
    execute(r);
}

每一个消息完整地执行后,其它消息才会被执行。当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。所以无法中途停止函数执行,如果一个函数处理时间过长,在这段时间内用户无法进行其他的操作。

在浏览器里,每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。所以当一个带有点击事件处理器的元素被点击时,就会像其他事件一样产生一个类似的消息。

一个JavaScript引擎会常驻于内存中,它等待着宿主把JavaScript代码或者函数传递给它执行。

JS引擎的术语,我们把宿主发起的任务称为宏观任务,把JavaScript引擎发起的任务称为微观任务

宏观任务与微观任务

宏观任务的队列(先进先出原则)就相当于事件循环。

在宏观任务中,JavaScript的Promise还会产生异步代码,JavaScript必须保证这些异步代码在一个宏观任务中完成,因此,每个宏观任务中又包含了一个微观任务队列。

一个宏观任务 = 函数执行 + N个微观任务

有了宏观任务和微观任务机制,我们就可以实现JS引擎级和宿主级的任务了,如:Promise永远在队列尾部添加微观任务。setTimeout等宿主API,则会添加宏观任务。

宏观任务包括有:整体代码块、setTimeoutsetIntervalsetImmediate、I/O、用户交互操作,UI渲染

微观任务包括有:Promise.then()(重点)、process.nextTick(nodejs)Object.observe(不推荐使用)、MutationObserver(前端的回溯)

执行顺序:当前事件循环需要执行的宏观任务,执行完毕后检查微观任务队列,如果存在微观任务,则顺序执行微观任务,然后进行下一轮的事件循环。

在宏观任务中,JavaScript的Promise还会产生异步代码,JavaScript必须保证这些异步代码在一个宏观任务中完成,因此,每个宏观任务中又包含了一个微观任务队列。

为什么要有微任务?如果需要执行高优先级的任务,只有宏任务的话,会遵循队列的先进先出原则,无法进行高优先级任务插队。

Promise

Promise包裹的函数会立即执行(宏观任务),后续的then(...)会加入到微观任务队列中。

var r = new Promise(function(resolve, reject){
    console.log('a');
    resolve()
}); // 宏观任务队列1
r.then(() => console.log('c')); // 微观任务队列1
console.log('b') // 宏观任务队列2
// a
// b
// c

setTimeout

函数 setTimeout 接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息并且为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout 消息会在延迟时间到达后加入等待队列并等待其它消息都处理完毕之后执行。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。

(function() {

    console.log('这是开始');

    setTimeout(function cb() {
        console.log('这是来自第一个回调的消息');
    });

    console.log('这是一条消息');

    setTimeout(function cb1() {
        console.log('这是来自第二个回调的消息');
    }, 0);

    console.log('这是结束');

})();

// "这是开始"
// "这是一条消息"
// "这是结束"
// "这是来自第一个回调的消息"
// "这是来自第二个回调的消息"

setTimeout(.., 0)进行异步调度,表示“把这个函数插入到当前时间循环队列的结尾处”。

Promise + setTimeout

var r = new Promise(function(resolve, reject){
    console.log('a');
    resolve()
}); // 第1轮宏观任务队列1
setTimeout(() => console.log('d'), 0) // 第2轮宏观任务队列1
r.then(() => console.log('c')); // 第1轮微观任务队列1
console.log('b') // 第1轮宏观任务队列2
// a
// b
// c
// d

不论代码顺序如何,d必定发生在c之后,因为Promise产生的是JavaScript引擎内部的微任务,而setTimeout是浏览器API产生宏任务。

Promise是异步函数表示异步完成后生成微任务直接在当前事件循环队列末尾插队,不会像setTimeout到整个事件循环队列末尾排队。

总结一下如何分析异步执行的顺序:

  • 首先我们分析有多少个宏任务;
  • 在每个宏任务中,分析有多少个微任务;
  • 根据调用次序,确定宏任务中的微任务执行次序;
  • 根据宏任务的触发规则和调用次序,确定宏任务的执行次序;
  • 确定整个顺序。

并发

并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行。但是JavaScript是单线程的。

单线程事件循环时并发的一种形式。

Node中的事件循环机制

node事件循环中宏任务执行顺序:

  1. timers定时器:执行已经安排的setTimeout 和 setInterval 的回调函数
  2. pending callback 待定回调:执行延迟到下一个循环迭代的 I/O 回调
  3. idle,prepare:仅系统内部使用。
  4. poll:检索新的I/O事件,执行与I/O相关的回调
  5. check:执行setImmediate 回调函数
  6. close callback:socket.on(‘close’, () => ())

node中的事件循环与浏览器中的事件循环有什么区别:

node v10及之前:1. 执行完一个阶段中的所有任务;2. 执行nextTick队列里的内容;3. 执行完微任务队列的内容;

node v10版本以后,和浏览器事件循环机制相同:执行完宏任务后,清空微任务队列。

测试

以下代码段的执行顺序

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}

async function async2() {
    console.log('async2')
}

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

// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout
console.log('start')
setTimeout(() => {
    console.log('children2')
    Promise.resolve().then(() => {
        console.log('children3')
    })
}, 0)
new Promise(function (resolve, reject) {
    console.log('children4')
    setTimeout(function() {
        console.log('children5')
        resolve('children6')
    }, 0)
}).then((res) => {
    console.log('children7')
    setTimeout(() => {
        console.log(res)
    }, 0)
})

// start     -- 第一轮宏任务
// children4
// children2 -- 第二轮宏任务
// children3
// children5
// children7
// children6 -- 第三轮宏任务
const p = function() {
    return new Promise((resolve, reject) => {
        const p1 = new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(1)
            }, 0)
            resolve(2) // promise状态一旦改变,就不再改变状态
        })
        p1.then((res) => {
            console.log(res)
        })
        console.log(3)
        resolve(4)
    })
}

p().then(res => {
    console.log(res)
})
console.log('end')

// 3
// end
// 2  -- 开始清空微任务队列
// 4

参考链接

并发模型与事件循环:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop

《你不知道的JavaScript(中卷)》

极客时间 –《重学前端》

B站爪哇教育2021年面试题讲解

复习推荐:setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop – 掘金