《圖解 Google V8》事件迴圈和垃圾回收——學習筆記(三)

uccs發表於2023-02-16

這是《圖解 Google V8》第三篇/共三篇:事件迴圈和垃圾回收

這裡主要講了 2 點:

  1. 事件迴圈:宏任務和微任務

    • 什麼是微任務
    • 微任務的執行時機
  2. 垃圾回收

    • 垃圾回收執行過程
    • 垃圾回收演演算法

透過這個專欄的學習,V8 不在是個陌生的黑盒了,變成了一個熟悉的黑盒,因為這個專欄讓你瞭解了 V8 的大致原理,面試時吹吹牛皮還是可以的,不過也就僅此而已,細節方面還需要自己去深入

17 | 訊息佇列:V8 是怎麼實現回撥函式的?

  • 同步回撥函式是在執行函式內部被執行的
  • 非同步回撥函式是在執行函式外部被執行的

UI 執行緒是執行視窗的執行緒,也叫主執行緒

當滑鼠點選了頁面,系統會將該事件交給 UI 執行緒來處理,但是 UI 執行緒不能立即響應來處理

針對這種情況,瀏覽器為 UI 執行緒提供了訊息佇列,然後 UI 執行緒會不斷的從訊息佇列中取出事件和執行事件,如果當前沒有任何訊息等待被處理,那麼這個迴圈就會被掛起

setTimeout

在執行 setTimeout,瀏覽器會將回撥函式封裝成一個事件,新增到訊息佇列中,然後 UI 執行緒會不間斷的從訊息佇列中取出任務,執行任務,在合適的時機取出 setTimeout 的回撥函式

XMLHttpRequest

UI 執行緒執行 XMLHttpRequest,會阻塞 UI 執行緒,所以 UI 執行緒會將它分配給網路執行緒(是網路程式中的一個執行緒):

  1. UI 執行緒從訊息佇列中取出任務,分析
  2. 發現是一個下載任務,就會交給網路執行緒去執行
  3. 網路執行緒接到下載請求後,會和伺服器建立聯絡,發出下載請求
  4. 網路執行緒不斷從伺服器接收資料
  5. 網路請求在收到資料後,會將返回的資料和回撥函式封裝成一個事件,放在訊息佇列中
  6. UI 執行緒迴圈讀取訊息佇列,如果是下載狀態的事件,UI 執行緒就會執行回撥函式
  7. 直到下載事件結束,頁面顯示下載完成

18 | 非同步程式設計(一):V8 是如何實現微任務的?

宏任務是訊息佇列中等待被主執行緒執行的事件,每個宏任務在執行的時候都會建立棧,宏任務結束,棧也會被清空

微任務是一個需要非同步執行的函式,執行時機是在主函式執行結束之後,當前宏任務結束之前

微任務執行的時機:

  1. 如果當前任務中產生了一個微任務,不會再當前的函式中被執行,所以執行微任務時,不會導致棧的無限擴張
  2. 微任務會在當前任務執行結束之前被執行
  3. 微任務結束執行之前,不會執行其他的任務

參考資料

  1. V8 Promise 原始碼全面解讀
  2. JavaScript Event Loop vs Node JS Event Loop

19 |非同步程式設計(二):V8 是如何實現 async/await 的?

生成器 Generator

帶星號的函式配合 yield 可以實現函式的暫停和恢復,這個叫生成器

function* getResult() {
  console.log("getUserID before");
  yield "getUserID";
  console.log("getUserName before");
  yield "getUserName";
  console.log("name before");
  return "name";
}

let result = getResult();

console.log(result.next().value);
console.log(result.next().value);
console.log(result.next().value);

在生成器內部,如果遇到 yield 關鍵詞,那麼 V8yield 後面的內容返回給外部,並暫停函式的執行

生成器暫停後,外面程式碼開始執行,如果想要繼續恢復生成器的執行,就可以使用 result.next() 方法

在暫停和恢復之間切換,這背後的原理是協程,協程是比執行緒更加輕量級,它是跑線上程上的任務

一個執行緒有多個協程,但只能執行一個協程,如果 A 協程啟動 B 協程,那 A 協程就是 B 協程的父協程

使用生成器編寫同步程式碼

function* getResult() {
  let id_res = yield fetch(id_url);
  console.log(id_res);
  let id_text = yield id_res.text();
  console.log(id_text);

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

  let name_res = yield fetch(new_name_url);
  console.log(name_res);
  let name_text = yield name_res.text();
  console.log(name_text);
}
let result = getResult();
result
  .next()
  .value.then((response) => {
    return result.next(response).value;
  })
  .then((response) => {
    return result.next(response).value;
  })
  .then((response) => {
    return result.next(response).value;
  })
  .then((response) => {
    return result.next(response).value;
  });

把執行生成器程式碼的函式稱為執行器(可參考著名的 co 框架)

function* getResult() {
  let id_res = yield fetch(id_url);
  console.log(id_res);
  let id_text = yield id_res.text();
  console.log(id_text);

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

  let name_res = yield fetch(new_name_url);
  console.log(name_res);
  let name_text = yield name_res.text();
  console.log(name_text);
}
co(getResult());

async/await

async 是非同步執行並隱式返回 Promise 作為結果的函式。

await 後面可以接兩種型別的表示式:

  • 任何普通表示式
  • Promise 物件的表示式

如果 await 等待的是一個 Promise 物件,它會暫停執行生成器函式,直到 Promise 物件變成 resolve 才會恢復執行,然後 resolve 的值作為 await 表示式的運算結果

function NeverResolvePromise() {
  return new Promise((resolve, reject) => {});
}
function ResolvePromise() {
  return new Promise((resolve, reject) => resolve("resolve"));
}
async function getResult() {
  let a = await NeverResolvePromise();
  console.log(a); // 不會輸出
}
async function getResult2() {
  let b = await ResolvePromise();
  console.log(b); // "resolve"
}
getResult();
getResult2();
console.log(0);

async 是一個非同步執行的函式,不會阻塞主執行緒的執行

async 函式在執行時,是一個單獨的協程,可以用 await 來暫停,由於等待的是一個 Promise 物件,就可以用 resolve 來恢復該協程

參考資料

  1. 學習 koa 原始碼的整體架構,淺析 koa 洋蔥模型原理和 co 原理

20 | 垃圾回收(一):V8 的兩個垃圾回收器是如何工作的?

  1. 透過 GC Root 標記記憶體中活動物件和非活動物件。

    • V8 採用可訪問性(reachability)演演算法判斷堆中的物件是是否為活動物件

      • GC Root 能夠遍歷到的物件,是可訪問的(reachable),稱為活動物件
      • GC Root 不能遍歷到的物件,認為是不可訪問的(unreachable),稱為非活動物件
    • 瀏覽器環境中有很多 GC Root

      • window 物件(位於每個 iframe 中)
      • DOM,由可以透過遍歷檔案到達的所有原生 DOM 節點組成
      • 存放棧上變數
  2. 回收非活動物件所佔用的記憶體
  3. 回收後,做記憶體整理(可選,有些垃圾回收器不會產生記憶體碎片,比如副垃圾回收器)

    • 回收結束後,記憶體中會出現大量不連續的空間,這空間被稱為記憶體碎片
    • 如果記憶體碎片太多的話,當需要較大連續的記憶體時,就會出現記憶體不足

V8 受代際假說影響,使用了兩個垃圾回收器:主垃圾回收器(Major GC),副垃圾回收器(Minor GC

代際假說:

  1. 大部分物件都是“朝生夕死”的,也就是說大部分物件在記憶體中存活的時間很短,比如函式內部宣告的變數,或者塊級作用域中的變數,當函式或者程式碼塊執行結束時,作用域中定義的變數就會被銷燬。因此這一類物件一經分配記憶體,很快就變得不可訪問
  2. 不死的物件,會活得更久,比如:windowDOMWeb API

V8 把堆分為兩個區域:

  • 新生代:存放生存時間短的物件

    • 容量小,1~8M
    • 使用副垃圾回收器(Minor GC
    • 使用 Scavenge 演演算法,將新生代區域分成兩部分

      • 物件區域 (from-space)
      • 空閒區域 (to-space)

        1. 物件區域放新加入的物件
        2. 物件區域快滿的時候,執行垃圾清理(先標記,再清理)
        3. 清理的把活動物件複製到空閒區域,並且排序(空閒區域就沒有記憶體碎片了)
        4. 複製完之後,把物件區域和空閒區域進行翻轉
        5. 重複執行上面的步驟
        6. 經過兩次垃圾回收後還存在的物件,移動到老生代中
  • 老生代:存放生存時間久的物件

    • 容量大

      • 物件佔用空間大
      • 物件存活時間長
    • 使用主垃圾回收器(Major GC
    • 使用標記 - 清除演演算法(Mark-Sweep

      • 標記:從根元素開始,找到活動物件,找不到的就是垃圾
      • 清理:直接清理垃圾(會產生垃圾碎片)
    • 或者使用標記 - 整理演演算法(Mark-Compact

      • 標記:從根元素開始,找到活動物件,找不到的就是垃圾
      • 整理:把活動物件向同一端移動,另一端直接清理(不會產生垃圾碎片)

參考資料

  1. Understanding Garbage Collection and hunting Memory Leaks in Node.js
  2. 深入理解 Node.js:核心思想與原始碼分析
  3. When and How JavaScript garbage collector works
  4. V8 引擎的記憶體管理
  5. Trash talk: the Orinoco garbage collector

21 | 垃圾回收(二):V8 是如何最佳化垃圾回收器執行效率的?

JavaScript 是執行在主執行緒上的,一旦執行垃圾回收演演算法,JavaScript 會暫停執行,等垃圾回收完畢後再恢復執行,這種行為成為全停頓(Stop-The-World

V8 團隊向現有的垃圾回收器新增並行、併發、增量等垃圾回收技術

這些技術主要從兩方面解決垃圾回收效率的問題:

  1. 將一個完整的垃圾回收任務拆分成多個小的任務
  2. 將標記物件、移動物件等任務轉移到後端執行緒進行

並行回收(在主執行緒執行,全停頓)

在主執行緒執行垃圾回收的任務時,開啟多個協助執行緒,同時執行回收工作

採用並行回收,垃圾回收所消耗的時間 = 輔助執行緒數 * 單個執行緒所消耗的時間

在執行垃圾標記的過程中,主執行緒不會同時執行 JavaScript 程式碼,因此程式碼不會改變回收過程

假設記憶體狀態是靜態的,因此只要確保同時只有一個輔助執行緒在訪問物件就好了

這是 V8 副垃圾回收器採用的策略

它在執行垃圾回收的過程中,啟動多個執行緒來負責新生代中的垃圾清理,同時將物件空間中的資料移動到空閒區域,這操作會導致資料地址變了,所以還需要同步更新這些物件的指標

增量回收(在主執行緒執行,穿插在各個任務之間)

將標記工作分解為更小的塊,穿插在主執行緒不同的任務之間執行

採用增量回收,垃圾回收器沒必要一次執行完成的垃圾回收流程,每次執行的只是一小部分工作

增量回收是併發的,需要滿足兩點要求:

  1. 垃圾回收可以隨時被暫停和重啟,暫停的時候需要保留掃描結果,等待下一次回收
  2. 在暫停期間,被標記好的垃圾資料,如果被修改了,垃圾回收器需要正確的處理

在垃圾回收的時候,V8 使用三色標記法:

  • 黑色:表示所有能訪問到的資料(活動資料),且子節點已經都標記完成
  • 白色:表示還沒有被訪問到,如果在一輪遍歷結束還是白色,這個資料就會被回收
  • 灰色:表示正在處理這個節點,且子節點還沒被處理

垃圾回收器會根據有沒有灰色的節點來判斷這一輪遍歷有沒有結束

  • 沒有灰色:一輪遍歷結束,可以清理垃圾
  • 有灰色:一輪遍歷還沒結束,從灰色的節點繼續執行

標記為黑色的資料被修改了,也就是說黑色的節點引用了一個白色的節點,但是黑色的節點是已經完成標記的,這是它後面還有一個白色的節點是不會被標記為黑色的

這就需要一個約束條件:不能讓黑色節點指向白色節點

這個約束條件是:寫屏障機制(Write-barrier):

  • 當發生黑色節點引用白色節點,寫屏障機制會強制將這個白色節點變為灰色的,從而保證黑色節點不能指向白色節點

這種方法被稱為強三色不變性

併發回收(不在主執行緒執行)

在主執行緒執行 JavaScript 時,輔助執行緒在後臺執行垃圾回收操作

優點:主執行緒不會被掛起(JavaScript 可以自由執行,同時輔助執行緒可以執行垃圾回收)

但有兩點導致它很難實現:

  1. 主執行緒執行 JavaScript 時,堆中的內容隨時會變化,就會使得輔助執行緒之前的工作白做
  2. 主執行緒和輔助執行緒可能會在同一時間去修改同一個物件,這就需要額外實現讀寫鎖的功能

《圖解 Google V8》學習筆記系列

  1. 《圖解 Google V8》設計思想篇——學習筆記(一)
  2. 《圖解 Google V8》編譯流水篇——學習筆記(二)

相關文章