摘要:极客时间《图解 Google V8》事件循环和垃圾回收笔记,消息队列、异步编程、垃圾回收
回调函数
当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,这个函数被称为回调函数。回调函数分为同步回调和异步回调两种,同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。
异步回调
在页面线程中,当一个事件被触发时,系统需要将该事件提交给 UI 线程来处理,UI 线程运即行窗口的线程。大部分情况下 UI 线程并不能立即响应和处理这些事件,因而要将待执行的事件添加到消息队列中,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件,UI 线程每次从消息队列中取出事件,执行事件的过程被称为一个任务。
常见的异步回调有:
1、setTimeout
原理就是在 setTimeout 函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程会在合适的时候从消息队列中取出并执行回调事件。查看《浏览器工作原理与实践》笔记(四)
2、XMLHttpRequest
下载任务一般比较耗时,直接在 UI 线程上执行就会造成页面卡顿,因此主线程从消息队列中取出来这类下载任务之后,会将其分配给网络线程,这样就不会阻塞 UI 线程。查看《浏览器工作原理与实践》笔记(四)。具体过程:
1、UI 线程从消息队列中取出一个任务,并分析该任务
2、UI 线程分析得到该任务是下载任务,将此任务转发给网络进程,主线程继续执行下面的任务
3、网络线程接收到下载任务后,与服务器建立连接,并发起下载请求
4、网络线程在不断的接收数据过程中,会将一些中间信息和回调函数封装成新的消息,并将其添加进消息队列中
5、主线程从消息队列中取出回调事件,并执行回调函数,直到最后接收到下载结束事件,UI 线程会显示该页面下载完成。
宏任务与微任务
宏任务就是消息队列中的等待被主线程执行的事件,每个宏任务在执行时,V8 都会重新创建栈,栈会随者宏任务中函数调用而变化,宏任务执行完毕时,栈会被清空。但主线程执行消息队列中宏任务的时间颗粒度太粗,无法胜任一些对精度和实时性要求较高的场景,由此引入微任务,微任务可以在实时性和效率之间做一个有效的权衡。微任务可以看成是一个异步执行函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。栈的大小是有限制的,调用次数过多就存在栈溢出的问题。使用 setTimeout 将同步函数调用封装成异步函数调用,可以避免栈溢出,但是宏任务需要先被放到消息队列中,而宏任务的执行时间是不确定的,这就会影响到新添加到消息队列中的宏任务的执行。而微任务依然会在当前任务执行结束之前被执行,但触发的微任务不会在当前的函数中被执行,因而微任务不会导致栈溢出。如果在微任务中循环触发新的微任务,那么将会一直执行微任务,当前的宏任务也就无法退出,会导致消息队列中的其他任务没有机会被执行。
async/await
异步回调函数如果嵌套太多,会导致代码难以理解,产生回调地狱。使用 Promise+then 可以解决回调地狱的问题,但是如果逻辑比较复杂,那么代码中就会出现大量的 then 方法,代码的语义化不明显,执行流程不够清晰。后面使用 Generator 函数使逻辑更加线性化,Generator 本质是协程的一种实现。当执行到异步请求的时候,暂停当前函数,等异步请求返回了结果,再恢复该函数。但是生成器 Generator 需要额外的执行器来驱动,这很不友好,因此 ES7 引入了 async/await。
async/await 提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。async/await 原理就是 Promise 和生成器应用,往底层说,就是微任务和协程的应用。总的来说,使用了 async 声明的函数在执行时,是一个单独的协程,可以使用 await 来暂停该协程,由于 await 等待的是一个 Promise 对象,可以用 resolve 来恢复该协程。其中,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。如果在 async 函数里面使用了 await,那么 async 函数就会暂停执行,并等待合适的时机来恢复执行。await 可以等待两种类型的表达式:任意普通的表达式或者一个 Promise 对象的表达式。如果 await 等待的是一个 Promise 对象,它就会暂停执行生成器函数,将控制权交给父协程,直到 Promise 的状态变成 resolve,那么 async 函数会恢复执行,然后将得到的 resolve 的值作为 await 表达式的运算结果。如果 await 等待的是一个非 Promise 对象,那么 V8 会隐式地将得到的值包装成一个已经 resolve 的对象。如果出错调用了 reject 方法,而又没有捕获错误,那么 await 之后的代码就不会执行了
。
例如:
1 | function HaveResolvePromise(){ |
整个执行过程:
1、执行到 getResult 函数,发现有 async 标识,创建子协程并执行 getResult 函数
2、执行到 HaveResolvePromise 返回一个 Promise 对象(await 从右到左运算)
3、接着遇到 await 关键字,暂停当前协程,将控制权交给父协程
4、等到 Promise 执行 resolve 方法,子协程恢复执行,继续执行 getResult 函数
垃圾回收
垃圾回收的流程:
1、通过 GC Root 标记空间中活动对象和非活动对象
V8 采用的可访问性(reachability)算法来判断堆中的对象是否是活动对象,从 GC Roots 对象出发,遍历 GC Root 中的所有对象。能遍历到的对象即为活动对象,不能遍历到到的对象为非活动对象。常见的 GC Root 有 window、DOM 树、栈上的变量等。
2、回收非活动对象所占据的内存
3、内存整理
频繁回收对象后,内存中就会存在大量不连续空间的内存碎片,如果需要分配较大的连续内存时,就有可能出现内存不足的情况。
目前 V8 采用了两个垃圾回收器,主垃圾回收器 Major GC 和副垃圾回收器 Minor GC (Scavenger)。查看《浏览器工作原理与实践》笔记(三)
提高垃圾回收效率
目前 V8 垃圾回收器为了提高回收效率,使用了并行、并发和增量等垃圾回收技术。这些技术主要是从两方面来提高垃圾回收效率:
1、将一个完整的垃圾回收的任务拆分成多个小的任务,防止一次回收时间过长
2、将标记对象、移动对象等任务转移到后台线程进行,减少主线程暂停的时间
并行回收
并行回收就是圾回收器在主线程上执行的过程中,会同时开启多个协助线程一起执行标记整理工作。并行回收所消耗的时间等于辅助线程数量乘以单个线程所消耗的时间,再加上一些同步开销的时间,它仍然是一种全停顿的垃圾回收方式。V8 的副垃圾回收器所采用的就是并行策略,辅助线程在进行垃圾整理的同时会将对象空间中的数据移动到空闲区域,此时对象地址发生了改变,因此还需要同时更新引用这些对象的指针。
增量回收
增量式垃圾回收是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行,这样每次执行的只是整个垃圾回收过程中的一小部分工作。但这样算法就比较复杂:垃圾回收要可以随时被暂停和重启,并且在暂停期间如果被标记的垃圾数据被修改了,那么垃圾回收期要能正确处理这些数据。仅靠数据是否标记无法满足需求,因此 V8 采用了三色标记法,由黑白灰三种颜色来表示状态:
1、黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成
2、灰色表示这个节点被 GC Root 引用到了,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点
3、白色表示这个节点没有被访问到,如果本轮遍历完仍然是白色,那么表明这块数据需要被回收
垃圾回收器会根据当前内存中有没有灰色结点来判断下面要做的工作:如果有灰色的标记,那么就从灰色节点处继续执行;如果没有灰色标记了,那么可以开始执行垃圾整理。
此外,节点还要通过写屏障 (Write-barrier) 机制满足强三色不变性,即:已经被标记为黑色的节点又被续上一个白色的节点,写屏障机制会强制将这个白色节点变成灰色。当一个结点被标记为黑色后,即使主线其他任务又给他修改或添加新的未检测过的白色结点,也能保证垃圾回收器可以正确地回收数据。
并发回收
并发回收是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。并行回收虽然将一部分回收任务交给辅助线程,但它仍然会阻塞主线程。增量回收也是在主线程上工作,同样会降低主线程处理任务的吞吐量。而使用并发回收,辅助线程负责所有标记整理的任务,主线程则可以一直执行其他任务。由于主线程和辅助线程同时工作,可能会同时操作同一个对象,而且主线程的操作也可能使辅助线程之前的垃圾回收工作全部无效,因此并发回收的实现难度要比其他两种更大,但并发回收的效率还是远高于其他方式。
V8 的主垃圾回收器同时使用了并行、并发和增量:
1、主垃圾回收器主要使用并发标记,标记是在辅助线程中完成的
2、主线程和多个辅助线程会并行执行整理操作
3、使用增量回收将整理任务分割并穿插在其他任务之间执行