JS事件循环(Event Loop),浏览器进程

153 阅读13分钟

一道面试题:

//请写出输出内容
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
*/

一、浏览器多进程

  • 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)

浏览器都包含哪些进程?

浏览器的主要进程:

  • Browser进程:浏览器的主进程(负责协调、主控),只有一个。作用有:

    • 负责浏览器界面显示,与用户交互。如前进,后退等

    • 负责各个页面的管理,创建和销毁其他进程

    • 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上

    • 网络资源的管理,下载等

  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建

  • GPU进程:最多一个,用于3D绘制等

  • 浏览器渲染进程(浏览器内核)(Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响。主要作用为:

    • 页面渲染,脚本执行,事件处理等

重点是浏览器内核(渲染进程)

目前最为流行的浏览器为:Chrome,IE,Safari,FireFox,Opera。浏览器的内核是多线程的。

一个浏览器通常由以下几个常驻的线程:

  • 渲染引擎线程(GUI渲染线程):顾名思义,该线程负责页面的渲染
  • JS引擎线程:也称为JS内核,负责JS的解析和执行;JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
  • 事件触发线程:处理DOM事件
    • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
    • 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
    • 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
  • 异步http请求线程:处理http请求
  • 定时触发器线程:
    • 传说中的setInterval与setTimeout所在线程
    • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
    • 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

需要注意的是,GUI渲染线程与JS引擎线程是互斥的,渲染线程和JS引擎线程是不能同时进行的。因为JS可以操作DOM,若在渲染中JS处理了DOM,浏览器可能就不知所措了。当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。

二、javaScript是单线

Javascript语言的执行环境是"单线程"(single thread,就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推)。

js 引擎执行异步代码而不用等待,是因有为有 消息队列事件循环(Event Loop)

  • 消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。

  • 事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。

实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。

三、任务队列

首先我们需要明白以下几件事情:

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。

根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。 setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务。

四、宏任务和微任务

4.1 宏任务

(macro)task(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。

浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:

(macro)task->渲染->(macro)task->...

(macro)task主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)

4.2 微任务

microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。

所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。

microtask主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 环境)

五、流程图

由于JS引擎线程GUI渲染线程是互斥的关系,浏览器为了能够使宏任务DOM任务有序的进行,会在一个宏任务执行结果后,在下一个宏任务执行前,GUI渲染线程开始工作,对页面进行渲染。 而当一个宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完。


宏任务 -> 微任务 -> GUI渲染 -> 宏任务 -> ...

微任务宏任务注意点

  • 浏览器会先执行一个宏任务,紧接着执行当前执行栈产生的微任务,再进行渲染,然后再执行下一个宏任务
  • 微任务和宏任务不在一个任务队列,不在一个任务队列
    • 例如setTimeout是一个宏任务,它的事件回调在宏任务队列,Promise.then()是一个微任务,它的事件回调在微任务队列,二者并不是一个任务队列
    • 以Chrome 为例,有关渲染的都是在渲染进程中执行,渲染进程中的任务(DOM树构建,js解析…等等)需要主线程执行的任务都会在主线程中执行,而浏览器维护了一套事件循环机制,主线程上的任务都会放到消息队列中执行,主线程会循环消息队列,并从头部取出任务进行执行,如果执行过程中产生其他任务需要主线程执行的,渲染进程中的其他线程会把该任务塞入到消息队列的尾部,消息队列中的任务都是宏任务
    • 微任务是如何产生的呢?当执行到script脚本的时候,js引擎会为全局创建一个执行上下文,在该执行上下文中维护了一个微任务队列,当遇到微任务,就会把微任务回调放在微队列中,当所有的js代码执行完毕,在退出全局上下文之前引擎会去检查该队列,有回调就执行,没有就退出执行上下文,这也就是为什么微任务要早于宏任务,也是大家常说的,每个宏任务都有一个微任务队列(由于定时器是浏览器的API,所以定时器是宏任务,在js中遇到定时器会也是放入到浏览器的队列中)

此时,你可能还很迷惑,没关系,请接着往下看

首先执行一个宏任务,执行结束后判断是否存在微任务

有微任务先执行所有的微任务,再渲染,没有微任务则直接渲染

然后再接着执行下一个宏任务

图解完整的Event Loop

图片

首先,整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为同步任务异步任务两部分

同步任务会直接进入主线程依次执行

异步任务会再分为宏任务和微任务

宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中

微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中

当主线程内的任务执行完毕,主线程为空时,会检查微任务的Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务

上述过程会不断重复,这就是Event Loop,比较完整的事件循环

六、Promise和async中的立即执行

我们知道Promise中的异步体现在then和catch中,所以写在Promise中的代码是被当做同步任务立即执行的。而在async/await中,在出现await出现之前,其中的代码也是立即执行的。那么出现了await时候发生了什么呢?

6.1 await做了什么

由于因为async await 本身就是promise+generator的语法糖。所以await后面的代码是microtask。

所以对于本题中的

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

等价于

async function async1() {
	console.log('async1 start');
	Promise.resolve(async2()).then(() => {
                console.log('async1 end');
        })
}

变式一

在第一个变式中我将async2中的函数也变成了Promise函数,代码如下:

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    //async2做出如下更改:
    new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
    });
}
console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)
async1();

new Promise(function(resolve) {
    console.log('promise3');
    resolve();
}).then(function() {
    console.log('promise4');
});

console.log('script end');

结果:

script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout

在第一次macrotask执行完之后,也就是输出script end之后,会去清理所有microtask。所以会相继输出promise2, async1 end ,promise4

变式二

在第二个变式中,我将async1中await后面的代码和async2的代码都改为异步的,代码如下:

async function async1() {
    console.log('async1 start');
    await async2();
    //更改如下:
    setTimeout(function() {
        console.log('setTimeout1')
    },0)
}
async function async2() {
    //更改如下:
	setTimeout(function() {
		console.log('setTimeout2')
	},0)
}
console.log('script start');

setTimeout(function() {
    console.log('setTimeout3');
}, 0)
async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');

可以先自己看看输出顺序会是什么,下面来公布结果:

script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1

在输出为promise2之后,接下来会按照加入setTimeout队列的顺序来依次输出,通过代码我们可以看到加入顺序为3 2 1,所以会按3,2,1的顺序来输出。

变式三

async function a1 () {
    console.log('a1 start')
    await a2()
    console.log('a1 end')
}
async function a2 () {
    console.log('a2')
}

console.log('script start')

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

Promise.resolve().then(() => {
    console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) => {
    resolve('promise2.then')
    console.log('promise2')
})

promise2.then((res) => {
    console.log(res)
    Promise.resolve().then(() => {
        console.log('promise3')
    })
})
console.log('script end')

结果如下:

script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout

调用resolve或reject并不会终结 Promise 的参数函数的执行。

let promise2 = new Promise((resolve) => {
    resolve('promise2.then')
    console.log('promise2')
})

上面代码中,调用 resolve('promise2.then')以后,后面的console.log('promise2')还是会执行,并且会首先打印出来。这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。

new Promise(resolve => {
    console.log('Promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})

new Promise(resolve => {
    console.log('Promise3')
    resolve()
})
.then(function() {
    console.log('promise4')
})
.then(function() {
    console.log('promise5')
})

结果如下

Promise
Promise3
promise1
promise4
promise2
promise5

七、 NodeJS中的运行机制

上面的一切都是针对于浏览器的EventLoop

虽然NodeJS中的JavaScript运行环境也是V8,也是单线程,但是,还是有一些与浏览器中的表现是不一样的

其实nodejs与浏览器的区别,就是nodejs的宏任务分好几种类型,而这好几种又有不同的任务队列,而不同的任务队列又有顺序区别,而微任务是穿插在每一种宏任务之间的。node10版本之后的执行顺序和浏览器一致了。

在node(node10之前版本)环境下,process.nextTick的优先级高于Promise,可以简单理解为在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分

NodeJS的Event Loop相对比较麻烦

Node会先执行所有类型为 timers 的 MacroTask,然后执行所有的 MicroTask(NextTick例外)

进入 poll 阶段,执行几乎所有 MacroTask,然后执行所有的 MicroTask

再执行所有类型为 check 的 MacroTask,然后执行所有的 MicroTask

再执行所有类型为 close callbacks 的 MacroTask,然后执行所有的 MicroTask

至此,完成一个 Tick,回到 timers 阶段

……

如此反复,无穷无尽……

反观浏览器中Event Loop就比较容易理解

先执行一个 MacroTask,然后执行所有的 MicroTask

再执行一个 MacroTask,然后执行所有的 MicroTask

……

如此反复,无穷无尽……

参考: