V8系列三

生存还是毁灭,这是个问题。
To be or not to be, that is the question.

回调函数

函数作为参数传给宿主环境,然后在宿主环境被调用,称为 回调函数;

  1. 同步回调: 在函数内部执行;
  2. 异步回调:在函数外部执行;
var myArray = ["water", "goods", "123", "like"]; function handlerArray(indexName,index){ 
console.log(index + 1 + ". " + indexName);
}
myArray.forEach(handlerArray)

// 遇到setTimeout立刻返回,扔到任务队列,之后V8执行函数
function foo() {
alert("Hello");
}
setTimeout(foo, 3000)

UI线程架构

早期浏览器的页面是运行在一个单独的 UI 线程中的,所以要在页面中引入 JavaScript,那 么 JavaScript 也必须要运行在和页面相同的线程上,这样才能方便使用 JavaScript 来操纵 DOM,所以从一开始,JavaScript 就被设计成了运行在 UI 线程中。

UI 线程,是指运行窗口的线程.

使用操作页面时由UI线程处理,由于任务由先后,所以引入消息队列,UI线程将事件放入消息队列; 然后UI线程不停的从消息队列中取出来事件执行。

任务: UI 线程每次从消息队列中取出、执行事件的过程

function foo() { 
alert("Hello");
}
setTimeout(foo, 3000)
  1. 宿主将foo函数封装成一个事件;添加到消息队列,然后setTimeout执行结束;
  2. 主线程不断从消息队列取任务,等到时机成熟?,就执行foo函数。

什么时候才时机成熟?怎么判断时机成熟?

微任务

宏任务很简单,就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。

微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

任务是指:UI 线程每次从消息队列中取出、执行事件的过程
有了任务为啥还需微任务??

  1. 由于主线程执行消息队列中宏任务的时间颗粒度太粗
  2. 可以使用同步形式的代码来编写异步调用

主线程、调用栈、消息队列

调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系;

问题一:针对栈溢出,解决方案:可以将函数封装成宏任务, 如 timeout
问题二:如果宏任务时间过长,会影响后续任务。不可控任务的时间长短;解决方案: 引入微任务。

微任务执行时机: 会在当前的任务快要执行结束时执行,你就能比较精准地控制你的回调函数的执行时机。

通俗地理解,V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。
当你通过 Promise.resolve 生成一个微任务,该微任务会被 V8 自动添加进微任务队列,等整段代码 快要执行结束时,该环境对象也随之被销毁,但是在销毁之前,V8 会先处理微任务队列中 的微任务。

微任务是处于宏任务之前执行的.

function bar() {
console.log("bar-1");
Promise.resolve().then((str) => console.log("micro-bar-2"));
setTimeout((str) => console.log("macro-bar-3"), 0);
}

function foo() {
console.log("foo-1");
Promise.resolve().then((str) => console.log("micro-foo-2"));
setTimeout((str) => console.log("macro-foo-3"), 0);
bar();
}
foo();
console.log("global-1");
Promise.resolve().then((str) => console.log("micro-global-2"));
setTimeout((str) => console.log("macro-global-3"), 0);
/** 
返回值:
foo-1
bar-1
global-1
micro-foo-2
micro-bar-2
micro-global-2
macro-foo-3
macro-bar-3
macro-global-3
*/

从微任务中一次性取出,顺序执行。

function foo() { 
return Promise.resolve().then(foo)
}
foo()

为何会卡死?

  1. 当执行 foo 函数时,由于 foo 函数中调用了 Promise.resolve(),这会触发一个微任务,
  2. 退出当前 foo 函数的执行;
  3. V8 在准备退出当前的宏任务之前,会检查微任务队列,发现微任务队列中有一个微任务,于是先执行微任务。
  4. 由于这个微任务就是调用 foo 函数本身,又会执行第一步,进入死循环
  5. 导致 消息队列中的事件无法执行页面卡死

await / async

JavaScript 社区探索并推出了一系列的方案,从“Promise 加 then”到“generator 加 “co”方案,再到最近推出“终极”的 async/await 方案,完美地解决了回调地狱所造成的问题。

使用 Promise 可以解决回调地狱中编码不线性的问题,但这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,异步逻辑之间依然被 then 方法打断了,因此这种方式的语义化不明显,代码不能很好地表示执行流程

function getResult(){ 
let id = getUserID()
let name = getUserName(id)
return name
}

使用 Generator 函数实现更加线性化逻辑; 执行到异步请求的时候,暂停当前函数,等异步请求返回了结果,再恢复该函数

这个模型的关键就是实现函数暂停执行和函数恢复执行,而生成器就是为了 实现暂停函数和恢复函数而设计的。

生成器函数是一个带星号函数,配合 yield 就可以实现函数的暂停和恢复

V8 是怎么实现生成器函数的暂停执行和恢复执行的呢?

背后的魔法就是协程

1. 一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程;
2. 协程是一种比线程更加轻量级的存在;
3. 可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程
4. 如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。每一时刻,该线程只能执行其中某一个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

为了让你更好地理解协程是怎么执行的,我结合上面那段代码的执行过程,画出了下面 的“协程执行流程图”,你可以对照着代码来分析:

async/await:异步编程的“终极”方案

由于生成器函数可以暂停,因此我们可以在生成器内部编写完整的异步逻辑代码,不过生成 器依然需要使用额外的 co 函数来驱动生成器函数的执行,这一点非常不友好。

基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进, 它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的 能力。你可以参考下面这段使用 async/await 改造后的代码:

async function getResult() {
try {
let id_res = await fetch(id_url);
let id_text = await id_res.text();
console.log(id_text);

let new_name_url = name_url + "?id=" + id_text;
console.log(new_name_url);

let name_res = await fetch(new_name_url);
let name_text = await name_res.text();
console.log(name_text);
} catch (err) {
console.error(err);
}
}
getResult();

虽然这种方式看起来像是同步代码,但是实际上它又是异步执行的,也就是说,在执行到 await fetch 的时候,整个函数会暂停等待 fetch 的执行结果,等到函数返回时,再恢复该函数,然后继续往下执行。

其实 async/await 技术背后的秘密就是 Promise 和生成器应用,往底层说,就是 微任务和 协程应用。要搞清楚 async 和 await 的工作原理,我们就得对 async 和 await 分开分析。

function NoResolvePromise() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(100);
}, 0);
});
}
async function getResult() {
console.log(1);
// promise 对象
let a = await NoResolvePromise();
console.log(a);
console.log(2);
}
console.log(0);
getResult();
console.log(3);
✗ node test.js
0
1
3
100
2
// 如果后边是 非promise对象
let a = await 100
// 转化为:
let a = await new Promise((resolve, reject) => {resolve(100})

垃圾回收

垃圾回收是怎么实现过程

  1. 通过 GC Root 标记空间中活动对象和非活动对象。
  2. 回收非活动对象所占据的内存
  3. 做内存整理。

V8 采用了两个垃圾回收器,主垃圾回收器-Major GC副垃圾回收器 -Minor GC (Scavenger)。V8 之所以使用了两个垃圾回收器,主要 是受到了代际假说(The Generational Hypothesis)的影响。

代际假说是垃圾回收领域中一个重要的术语,它有以下两个特点:

  1. 第一个是大部分对象都是 “朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束 时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可 访问;

  2. 第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。

其实这两个特点不仅仅适用于 JavaScript,同样适用于大多数的动态语言,如 Java、 Python 等。

副垃圾回收器采用了 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换

主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。

优化效率

由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执 行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做 全停顿(Stop-The-World)

避免卡顿主要从两个方面考虑:

  1. 第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾 回收任务;
  2. 第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的 时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。

并行回收

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作,其工作模式如下图所示:

V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

增量回收

虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿的垃圾回收方式

所谓增量式垃圾回收,是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。

要实现增量,需要满足两个条件

  1. 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收 来了之后,才能继续启动。
  2. 暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理。

垃圾回收器的暂停与恢复

没有采用增量算法之前,V8 使用黑色和白色来标记数据。在执行一 次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据。如下图所示:

为了解决,重启时找到启动点,引入了 灰色。
三色标记法

  1. 黑色:节点被引用,该节点的字节点都已经标记完成
  2. 灰色:节点被引用,但是子节点没有被垃圾回收器标记处理,目前正在处理的节点
  3. 白色:节点没有被引用,如果本轮结束还是白色,就会被回收。
  4. 黑色节点如果引用白色节点,则将白色变为黑色

并发回收

以上的回收仍旧会阻塞主线程,有没有办法在不阻塞主线程的情况下,执行垃圾回收操作呢?

并发回收机制
所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃 圾回收的操作。

  1. 由于主线程随时都又可能更改标记过的内容,会使得辅助线程工作无效
  2. 主线程和辅助线程极有可能在同一时间去更改同一个对象,这就需要额外实现读 写锁的一些功能了。

参考

图解 Google V8