6

一、为什么JavaScript必须是单线程

所谓单线程,就是同一个时间只能做一件事。JavaScript从诞生之初就是作为浏览器的一种脚本语言,其主要用途是与用户互动,以及操作DOM,而这就决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容另一个线程删除了这个节点,这个时候浏览器就不知道该如何处理了,到底是应该在节点上添加内容还是应该删除这个节点呢
虽然为了利用CPU的多核计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制且不得操作DOM,所以,这个新标准并没有改变JavaScript单线程的本质

二、为什么单线程还能实现异步

对于大多数语言而言,实现异步可以通过启动额外的进程线程协程来实现,也就是说仅仅靠一个进程或线程是无法实现异步功能的。而我们的JavaScript实现异步功能也不例外,也是将一些异步操作交给其他线程进行处理,比如最常用的setTimeout,就是要将异步操作交给定时器线程进行处理。然后通过事件循环机制来处理异步操作的结果。发起异步操作的时候通常会传入一个回调函数,当执行异步操作的线程处理完成后,会将回调函数放入到任务队列中,最后在未来的某轮事件循环tick中获取并执行这个回调函数

三、浏览器中的事件循环

浏览器中的事件循环会涉及到一个任务队列(task queue)的概念。所有的异步任务执行完毕后都会将对应的回调函数放到任务队列中,并进行排队依次处理。而任务队列又分为宏任务队列微任务队列,并且只有在主线程的调用栈被清空的时候,才会执行任务队列中的任务,这也就是所谓的JavaScript的运行机制


浏览器宏任务主要有setTimeout()setInterval()
浏览器微任务主要有Promise.then()requestAnimationFrame()

并且微任务优先级要高于宏任务。当主线程调用栈空闲时,就会检测任务队列中是否有任务要执行,首先看一下微任务队列中是否有任务要执行,如果有则执行微任务队列中的任务,直到微任务队列清空为止,然后接着开始执行宏任务队列中的任务,宏任务清空后,又接着检测微任务队列中是否有任务,如此往复下去形成事件循环。

这里需要注意的就是浏览器是多线程的,主要为UI渲染线程JS引擎线程GUI线程(主要用于处理事件交互),其中,JS引擎线程和UI渲染线程是互斥的,即,如果JS引擎主线程在执行,那么UI将无法进行渲染,因为JS引擎线程是可以进行DOM操作的,只有互斥才能保证不会出现UI引擎在渲染的同时,JS引擎线程同时在修改DOM,如页面中有一个按钮,点击按钮后会开始一段耗时比较长的计算,这里要求实现点击按钮后按钮文字显示"计算中",计算完成后,按钮文字显示"计算完成"。

<body>
    <button id="btn">点我</button>
</body>
let btn = document.getElementById("btn");
function long_running() {
    console.log("long_running");
    var result = 0;
    for(var i = 0; i < 1000; i++) {
        for(var j= 0; j < 1000; j++) {
            for(var k=0; k< 1000; k++) {
                result = result + i + j + k;
            }
        }
    }
    btn.innerHTML = "计算完成";
}
btn.addEventListener("click", (e) => {
    btn.innerHTML = "计算中...";
    long_running();
});

运行如上代码,我们可以发现点击按钮后并没有先变成"计算中",然后再变成"计算完成",而是点击之后无变化,然后等计算完成后直接变成了"计算完成"。因为btn.innerHTML = "计算中...";是进行DOM操作使用的UI渲染线程,此时,JS引擎线程调用栈还未清空(还需要往下执行long_running()方法),所以还不能立即执行任务队列中的方法,然后执行long_running(),long_running()不是异步任务,不进入到任务队列中,直接进入到主线程的调用栈中执行,由于耗时比较长,等long_running()中的耗时计算执行完成后,接着执行btn.innerHTML = "计算完成",同样是UI渲染,此时long_running()执行完毕,主线程调用栈被清空,开始检测是否有微任务需要执行,发现没有,接着将线程执行权限交给UI渲染线程UI渲染需要执行btn.innerHTML = "计算中..."; btn.innerHTML = "计算完成";两个任务执行速度非常快,所以直接显示"计算完成"了,此时UI渲染线程工作执行完毕,接着将线程执行权限交回给JS引擎线程,接着检测是否有宏任务要执行。要实现btn.innerHTML = "计算中..."先执行,再进行计算,最后执行btn.innerHTML = "计算完成",我们需要给long_running()添加一个延时让计算过程进入到宏任务队列中,不再占用主线程调用栈,用户点击之后发起了UI渲染和一个宏任务,click事件结束后发现没有微任务,线程权限交给UI渲染线程,渲染出计算中...,UI渲染线程工作完毕,线程权限交回JS引擎线程,发现有宏任务要执行,执行长时间的计算过程后,又发起了一个UI渲染,long_running执行完毕,发现没有微任务,线程权限再次交给UI渲染引擎,渲染出计算完成,线程权限再次交回JS引擎线程。如:

btn.addEventListener("click", (e) => {
    btn.innerHTML = "计算中...";
    setTimeout(() => {
        long_running();
    }, 0);
});

添加延时后,long_running();也进入到了任务队列中,所以会先执行btn.innerHTML = "计算中...";再执行long_running();等计算完成后再更新为"计算完成"。

四、NodeJS中的事件循环

NodeJS的运行环境与浏览器的运行环境有些许不同,所以事件循环也有些许不同。同样的NodeJS的核心是V8,V8本身没有异步运行的能力,需要借助其他线程来实现,NodeJS的异步能力是依赖于libuv库。我们先来看看NodeJS每一轮事件循环经历的过程。

 ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  • timers: 计时器阶段,用于处理setTimeout以及setInterval的回调函数
  • pending callbacks: 用于执行某些系统操作的回调,例如TCP错误
  • idle, prepare: Node内部使用,不用做过多的了解
  • poll: 轮询阶段,执行队列中的 I/O 队列,并检查定时器是否到时
  • check: 执行setImmediate的回调
  • close callbacks: 处理关闭的回调,例如 socket.destroy()

我们平时经常用到的也就是timerspollcheck三个阶段,这三个阶段都有对应的宏任务队列,并且在进入下一个阶段之前要先检测微任务队列中有没有微任务只有先清空微任务中的任务才能进入事件循环的下一个阶段,注意不是下一轮事件循环而是事件循环的下一个阶段
NodeJS中宏任务和微任务类型也有所不同:
NodeJS中的宏任务主要有setTimeoutsetIntervalsetImmediate
NodeJS中的微任务主要有Promise.thenprocess.nextTick,并且nextTick的优先级要高于Promise.then。因为nextTick微任务有一个单独的队列,称为 next tick queue,所以可以做到先清空next tick queue。

我们先看一个例子,以便了解promise.then,process.nextTick, setTimeout以及setImmediate的执行顺序。如:

// NodeJS运行环境
setImmediate(function(){ // 宏任务1
    console.log(1);
},0);
setTimeout(function(){ // 宏任务2
    console.log(2);
},0);
new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){ // 微任务1
    console.log(5);
});
console.log(6);
process.nextTick(function(){ // 微任务2
    console.log(7);
});
console.log(8);

根据上面提到的NodeJS中的事件循环过程可知,首先promise.then和process.nextTick属于微任务,setTimeout和setImmediate属于宏任务,并且process.nextTick的优先级要高于promise.thensetTimeout的优先级高于setIImmediate
首先执行整体代码,产生了两个宏任务,然后创建Promise执行同步代码输出3和4,此时产生了一个微任务1,接着输出6,然后再产生了一个微任务2,接着输出8,此时整体代码执行完毕,然后检测微任务队列并执行(进入事件循环各阶段前需要先清空微任务队列),此时微任务队列中有两个,虽然微任务2后面添加进去,但是微任务2是由process.nextTick创建具有更高优先级,所以先执行微任务2,依次输出7和5,接着再执行宏任务,由于setTimeout比setImmediate具有更高优先级,所以先执行宏任务2,依次输出2和1,故最终结果为3、4、6、8、7、5、2、1。

那么是不是setTimeout一定比setImmediate先执行呢?答案是不一定,因为setImmediate属于check阶段,而check阶段在I/O poll阶段之后,所以如果是在I/O poll阶段之后加入任务队列,那么就会先执行setImmediate了。

const fs = require("fs");
fs.readFile("./a.js", (err, data) => { 
    // 执行I/O poll阶段时加入timer和check阶段任务队列
    setTimeout(() => {
        console.log("setTimeout");
    }, 0);
    setImmediate(() => {
        console.log("setImmediate");
    }, 0);
});

当a.js文件读取完毕之后,发现I/O poll阶段任务队列中有任务要执行,于是开始执行readFile的回调函数,在执行过程中产生两个宏任务队列,分别加入到timer是任务队列、check任务队列中,此时I/O poll阶段任务队列清空,接着进入到check阶段的任务队列,发现有任务要执行,所以会先执行setImmediate回调,然后在下一轮事件循环中执行setTimeout回调。

五、常见示例

① 示例1
在宏任务执行过程中产生了微任务,优先执行微任务再执行下一个宏任务

setTimeout(() => console.log('setTimeout1'), 0);  //1宏任务
setTimeout(() => {                              //2宏任务
    console.log('setTimeout2');
    Promise.resolve().then(() => { // 2微任务
        console.log('promise3');
        Promise.resolve().then(() => { // 3微任务
            console.log('promise4'); // 4微任务
        })
        console.log(5)
    })
    setTimeout(() => console.log('setTimeout4'), 0);  //4宏任务
}, 0);
setTimeout(() => console.log('setTimeout3'), 0);  //3宏任务
Promise.resolve().then(() => {//1微任务
    console.log('promise1');
})

首先按顺序执行主线程代码,产生了1、2、3三个宏任务,进入宏任务队列,然后执行到最后一行Promise的时候产生了一个微任务,进入微任务队列,在执行宏任务前会先检查微任务队列中是否有任务,发现有一个,所以首先输出promise1,微任务清空后,接着进入到宏任务队列,执行第一个宏任务,输出setTimeout1,接着执行第二个宏任务,输出setTimeout2,同时产生了一个微任务2和宏任务4,执行宏任务过程中产生了微任务2,所以先执行微任务2,输出promise3,又产生了一个微任务4,接着输出5,还有微任务,继续执行微任务4,输出promise4,此时微任务已清空,可以继续执行宏任务3,输出setTimeout3setTimeout4

② 示例2

new Promise((resolve,reject)=>{ // new创建的Promise对象P0
    console.log("promise1")
    resolve()
}).then(()=>{ // then方法创建的Promise对象P1,微任务1
    console.log("then1-1-start")
    new Promise((resolve,reject)=>{ // // new创建的Promise对象P3
        console.log("promise2")
        resolve()
    }).then(()=>{ // then方法创建的Promise对象P4 微任务3
        console.log("then2-1")
    }).then(()=>{ // then方法创建的Promise对象P5 微任务4
        console.log("then2-2")
    })
    console.log("then1-1-end")
}).then(()=>{ // then方法创建的Promise对象P2 微任务2
    console.log("then1-2")
})

首先执行整体代码,立即输出promise1,之后同步执行了resolve,P0变为完成状态,调用then()方法创建了一个P1,并产生微任务1,接着执行then方法又创建了一个P2,处于等待状态,并产生微任务2,由于P0已经处于完成状态,所以执行微任务1,输出then1-1-start,接着创建P3,输出promise2,同时产生微任务3,接着输出then1-1-end,这里主要分清微任务2和微任务3哪个先执行,虽然微任务2先注册,但是由于注册的时候P1处于pending状态,所以会先将微任务2放到P1的一个数组中(不是微任务队列),而P3已经处于完成状态,调用then的时候会立即将微任务3推到微任务队列中,此时微任务1执行完成,然后根据微任务1的返回值改变P1的状态,因为返回值为undefined,所以可以执行P1的resolve方法变为完成状态,在执行P1的resolve的时候会遍历刚刚放到P1数组中的微任务2,将微任务2推到微任务队列中,所以会先执行微任务3再执行微任务2,输出then2-1then1-2,微任务3执行完成后,执行P4的resolve()方法,遍历P4数组中的微任务4,将微任务4推入微任务队列中,最后输出then2-2

③ 示例3

new Promise((resolve,reject)=>{ // new创建的Promise对象P0
    console.log("promise1")
    resolve()
}).then(()=>{ // then方法创建的Promise对象P1 微任务1
    console.log("then1-1-start")
    return new Promise((resolve,reject)=>{ // new创建的Promise对象P3
        console.log("promise2")
        resolve()
    }).then(()=>{ // then方法创建的Promise对象P4 微任务3
        console.log("then2-1")
    }).then(()=>{ // then方法创建的Promise对象P5 微任务4
        console.log("then2-2")
    })
    console.log("then1-1-end")
}).then(()=>{ // then方法创建的Promise对象P2 微任务2
    console.log("then1-2")
})

示例3和示例2只是在示例2的基础上,在then1中添加了一个return,那么其输出也会跟着发生一些变化,前面还是一样,依次输出promise1then1-1-start,加了return后,那么then1-1的返回值就变成了内部Promise的最后一个then返回的Promise对象P5了,此时返回的P5为Pending状态,所以then1-2回调必须等待P5对象变为resolve状态才能执行。所以依次输出promise2then2-1then2-2,最后输出then1-2,因为then1-1-end在return之后,是不可达代码,所以无法输出then1-1-end。这题的关键点就是,then1-2这个微任务的执行需要等待P1变成完成状态,而P1要想变为完成状态又必须依赖P5,而P5也是一个Promise对象,所以需要根据P5的value来决定是否能够将P1变成完成状态,P5要想拿到value,就必须等待P4变成完成状态。

④ 示例4

console.log(1);
setTimeout(function() { // 宏任务1
    console.log('2');
    process.nextTick(function() { // 微任务3
        console.log('3');
    });
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() { // 微任务4
        console.log('5');
    });
}, 0);

process.nextTick(function() { // 微任务1
 console.log('6');
});

new Promise(function(resolve) {
    console.log('7');
    resolve();
   }).then(function() { // 微任务2
    console.log('8')
   });
setTimeout(() => { // 宏任务2
    console.log('9');
    process.nextTick(function() { // 微任务5
    console.log('10');
    })
    new Promise(function(resolve) {
    console.log('11');
    resolve();
    }).then(function() { // 微任务6
    console.log('12');
    });
}, 0);

首先执行整体代码,输出1,并产生宏任务1,接着产生一个微任务1,接着创建Promise执行同步代码输出7,并产生微任务2,最后产生一个宏任务2;接着清空微任务队列,微任务队列中有两个任务,并且nextTick优先级比Promise.then优先级更高,故依次输出68;然后再执行宏任务队列,先执行宏任务1,输出2,并产生微任务3,接着创建Promise执行同步代码输出4,然后产生微任务4,由于产生了微任务3和微任务4,故输出35,继续执行宏任务2,输出9,产生微任务5,接着创建Promise执行同步代码输出11,并产生微任务6,此时宏任务1和2执行完毕,期间又产生了微任务5和6,接着清空微任务队列,输出1012。故最终输出结果为1、7、6、8、2、4、3、5、9、11、10、12

⑤ 示例5

async function async1() {
    console.log('async1 start')
    await async2();
    console.log('async1 end')
}
async function async2() {
    console.log('async2')
}
console.log('script start')
setTimeout(() => {
    console.log('setTimeout0')
},0)

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

setImmediate(() => {
    console.log("setImmediate");
});
async1();
new Promise((resolve) => {
    console.log("promise1");
    resolve();
    console.log("promise2");
}).then(() => {
    console.log("promise3");
});
process.nextTick(() => {
    console.log("nextTick");
});
console.log("scritp end.");

这道题主要考察的是async函数的执行原理,async函数会返回一个Promise对象,当函数执行的时候,一旦遇到await就会立即返回,但是要等到await后的代码执行完成后才能回到主线程,即接着执行函数外的同步代码,函数外的同步代码执行完成后再回到async函数内接着执行,并且之间如果产生了nextTic微任务,那么需要先执行nextTic微任务,但是Promise.then微任务在await之后执行
首先执行整体代码,输出script start,然后setTimeout0、setTimeout3、setImmediate进入到宏任务队列,接着执行async1函数,输出async1 start,然后遇到await,async1函数立即返回,但是还要等到await之后的代码async2执行完毕,async2执行完成输出async2,此时回到主线程继续执行,即执行Promise中的同步代码,输出promise1promise2,然后产生一个promise3微任务,接着nextTick也进入到微任务对列,接着输出scritp end,此时主线程执行完毕,即主线程调用栈已经被清空,接着检测是否有nextTick微任务队列,发现有,开始执行nextTick微任务队列,输出nextTick,此时再次回到async1()执行剩余的代码,输出async1 end,此时还有Promise.then微任务,清空微任务队列,输出promise3,接着再执行宏任务队列中的代码,setTimeout0和setImmediate时间都是0,并且setTimeout0优先级更高,依次输出setTimeout0setImmediate,最后输出setTimeout3

六、整个JS执行过程

当JS中引入了宏任务和微任务后,渲染引擎的执行时机是介于微任务和宏任务之间的,也就是说微任务执行结束后再执行JS渲染接着再执行宏任务。如图所示:

屏幕快照 2020-03-09 上午11.15.03.png

let total = 100000;
    const startTime = Date.now();
    const container = document.getElementById("container");
    Promise.resolve().then(() => {
        console.log("微任务执行完毕,UI渲染线程开始渲染UI,操作DOM。");
    });
    setTimeout(() => {
        console.log("在长时间计算前加入宏任务队列");
        console.log(`执行时间---: ${Date.now() - startTime}`);
    }, 0);
    let result = 0;
    for(var i = 0; i < 1000; i++) {
        for(var j= 0; j < 1000; j++) {
            for(var k=0; k< 1000; k++) {
                result = result + i + j + k;
            }
        }
    }
    console.log("计算结束");
    for (let i = 0; i < total; i++) {
        const li = document.createElement("li");
        li.innerHTML = i;
        container.appendChild(li);
    }
    console.log("发起UI渲染结束");
    setTimeout(() => {
        console.log("UI渲染线程渲染UI完毕,开始执行宏任务。");
        console.log(`UI渲染线程执行时间: ${Date.now() - startTime}`);
    }, 0);
    console.log(`JS主线程代码执行结束,其主线程JS执行时间: ${Date.now() - startTime}`);

首先JS主线程代码开始执行,遇到一个Promise产生一个微任务,加入到微任务队列中,接着遇到一个宏任务,加入到宏任务队列中,需要注意的是setTimeout时间为0也不是立即进入宏任务队列,而是要等待4ms,为保证该宏任务在UI渲染前加入宏任务队列,引入了一个耗时很长的计算,接着执行长计算,输出计算结束,接着执行for循环操作,发起UI渲染,其中包含了DOM操作,即UI渲染,但此时无法立即渲染,需要等到JS主线程代码和微任务代码执行完毕之后再进行UI渲染,for循环结束后输出发起UI渲染结束,接着遇到一个宏任务,加入到宏任务队列中,此时输出JS主线程代码执行结束,其主线程JS执行时间: 13351,JS主线程代码执行完毕,接着清空微任务队列,输出微任务执行完毕,UI渲染线程开始渲染UI,操作DOM。然后线程执行权限交给UI渲染线程,开始执行UI渲染,等待一段时间后UI渲染完毕,线程执行权限交回给JS引擎线程,开始执行宏任务,此时输出在长时间计算前加入宏任务队列执行时间---: 16334UI渲染线程渲染UI完毕,开始执行宏任务UI渲染线程执行时间: 16475。可以看到不管setTimeout在for循环(UI渲染)前还是后,setTimeout回调都是在UI渲染完成后执行。

有些文章说UI渲染也属于宏任务队列是不对的,从上面的输出结果可以看出,如果UI渲染也属于宏任务队列的话,那么第一次setTimeout肯定比UI渲染先进入宏任务队列,应该先执行第一个setTimeout回调,但是两次setTimeout输出的时间相差不大,所以说UI渲染是在宏任务之前执行的


JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师