引擎V8推出“併發標記”,可節省60%-70%的GC時間

weixin_33724059發表於2018-06-15
引擎V8推出“併發標記”,可節省60%-70%的GC時間

作者|V8 部落格

編輯|覃雲 - 前端之巔公眾號

V8 官方部落格宣佈 V8 引擎在 GC 技術上獲得重大突破,這項技術名為“併發標記( concurrent marking)”,在 GC 掃描和標記活動物件時,它允許 JavaScript 應用程式繼續執行。測試顯示,併發標記技術為主執行緒標記節省了 60%-70%的時間。併發標記是一個用新的平行和併發的 GC 替換舊的 GC 的專案,現在 Chrome 64 和 Node.js v10 已經預設啟用併發標記。

背景介紹

標記是 V8 Mark-Compact GC 工作的一個階段。在這個階段中,收集器發現並標記所有活動物件。標記從一組已知的活動物件開始,如全域性物件和啟用函式,即所謂的 roots,收集器將 roots 標記為活動的物件,並順著指標去尋找發現更多的活動物件。收集器繼續標記新發現的物件並跟隨指標移動,直到沒有發現更多的物件要標記為止。在標記結束時,所有無法讓應用程式訪問的未標記物件,都可以安全地回收。

我們可以將標記視為圖遍歷(Graph traversal)。堆記憶體上的物件是下圖中的節點,指標從一個物件指向另一個物件是圖的邊緣。給定圖中的一個節點,我們可以使用該物件的隱藏類找到該節點的所有外邊緣。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

V8 使用每個物件的兩個 mark-bits 和一個標記工作表來實現標記。兩個 mark-bits 編碼三種顏色:白色(00),灰色(10)和黑色(11)。最初所有物件都是白色的,這意味著收集器還沒有發現它們。當收集器發現它並將其推到標記工作表上時,白色物件變灰。當收集器將它從標記工作列表中彈出並訪問其全部欄位時,灰色物件變黑,這種方案被稱為三色標記法。當沒有灰色物件時,標記結束。所有剩餘的白色物件都可以安全地被回收。

引擎V8推出“併發標記”,可節省60%-70%的GC時間引擎V8推出“併發標記”,可節省60%-70%的GC時間

請注意,上述標記演算法僅適用於在標記進行中應用程式暫停的情況。如果我們允許應用程式在標記過程中執行,那麼應用程式可以更改圖形並最終誘騙收集器釋放活動物件。

減少標記停頓

對大型的堆記憶體來說,可能需要幾百毫秒才能完成一次標記。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

長時間的停頓可能會導致應用程式無法響應,並導致使用者體驗不佳。2011 年,V8 從 stop-the-world 標記切換到增量標誌。在增量標記期間,GC 將標記工作分解為更小的模組,並允許應用程式在模組之間執行:

引擎V8推出“併發標記”,可節省60%-70%的GC時間

GC 決定每個模組中執行多少增量標記以匹配應用程式的分配速率。一般情況下,這極大地提高了應用程式的響應速度。但對於大型堆記憶體來說,收集器試圖跟上應用程式分配速率的過程中,仍然可能會有長時間的停頓。

再者增量標記並不是免費的,應用程式必須通知 GC 關於更改物件圖的所有操作。V8 使用 Dijkstra-style write-barrier 來實現通知,在每次用 JavaScript 寫入 object.field = value 之後,V8 插入 write-barrier 程式碼:

// Called after `object.field = value`. write_barrier(object, field_offset, value) { if (color(object) == black && color(value) == white) { set_color(value, grey); marking_worklist.push(value); } }

增量標記很好地整合了 GC 的閒置時間(idle time)。Chrome 的 Blink 任務排程程式在主執行緒的閒置時間內可以排程小增量標記步驟,而且不會造成混亂。如果閒置時間可用,優化效果會非常好。

由於 write-barrier 會有消耗,增量標記可能會降低應用程式的吞吐量。通過使用額外的 worker threads 可以提高吞吐量和暫停時間。有兩種方法可以在 worker threads 上進行標記:平行標記(parallel marking)和併發標記(concurrent marking)。

平行標記發生在主執行緒和工作執行緒(worker threads)上,應用程式在整個平行標記階段暫停,它是 stop-the-world 標記的多執行緒版本。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

併發標記主要發生在工作執行緒上,當併發標記進行時,應用程式可以繼續執行。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

以下兩節將講述如何在 V8 中新增對平行標記和並行標記的支援。

平行標記

在平行期間,我們可以假定應用程式沒有執行。這大大簡化了實現過程,因為我們可以假定物件圖是靜態的並且不會發生變化。為了平行標記物件圖,我們需要確保 GC 資料結構是執行緒安全的,並找到一種方法有效地線上程之間共享標記工作。下圖顯示了平行標記所涉及的資料結構。箭頭指示資料流的方向,為簡單起見,該圖省略了整理堆記憶體碎片所需的資料結構。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

需要注意的是,執行緒只能從物件圖中讀取並且不會被更改。物件的標記位點和標記工作表必須支援讀取和寫入的訪問。

併發標記

當工作執行緒正訪問堆記憶體上的物件時,併發標記允許 JavaScript 在主執行緒上執行,這為許多潛在的資料競爭(data races) 開啟了大門。例如,當工作執行緒正在讀取欄位時,JavaScript 可能正在寫入物件欄位。資料競爭可能會讓 GC 錯誤地釋放活動物件或將原始值與指標混合在一起。

主執行緒上每個更改物件圖的操作都是資料競爭的潛在來源。由於 V8 是一款高效能引擎,具有許多物件佈局優化功能,因此潛在的資料競爭來源很多。以下是可能導致的部分結果:

  • 物件分配

  • 寫入一個物件欄位

  • 物件佈局更改

  • 從 snapshot 中反序列化

  • Materialization during deoptimization of a function.

  • 在新一代 GC 中疏離(Evacuation)

  • 程式碼修補

主執行緒需要與工作執行緒同步,同步的成本和複雜程度取決於操作。

Write barrier

寫入物件欄位導致的資料競爭,可將寫入操作調整為 atomic write,並調整 write barrier 來解決:

保釋清單(Bailout worklist)

某些操作(例如程式碼修補)需要獨家訪問該物件。早期,我們決定避免物件鎖定,因為它們可能導致優先順序逆轉( priority inversion)問題,在這個過程中,主執行緒必須等待一個因為持有鎖定物件而被取消排程的工作執行緒。我們不鎖定物件,而是允許工作執行緒訪問該物件。工作執行緒通過將物件推入保釋清單來完成該工作,這個過程只能由主執行緒來處理:

引擎V8推出“併發標記”,可節省60%-70%的GC時間

工作執行緒保釋了優化的程式碼物件、隱藏類和 weak collections,因為訪問它們需要鎖定或高昂的同步協議。

回顧過去,保釋清單對增量開發來說非常有用,我們開始使用工作執行緒來釋放所有物件型別並逐個新增併發標記。

更改物件佈局

物件的欄位可以儲存三種值:標記的指標、標記的小整數(也稱為 Smi),或未標記的值(如拆箱的浮點數)。

通過將物件轉換為另一個隱藏類,V8 中將物件欄位從標記的狀態變為未標記的狀態(反之亦然),這種更改物件佈局的方式對併發標記來說是不安全的。

如果在工作執行緒中使用舊的隱藏類訪問物件時發生更改,則可能會出現兩種型別的錯誤。首先,worker 可能會錯過一個指標,認為這是一個沒有標記的值。write barrier 可以防止這種錯誤。其次,worker 可能會將未標記的值視為指標並放棄引用它,這會導致無效的記憶體訪問,通常會導致程式崩潰。為了處理這種情況,我們使用在物件標記位上同步的 snapshotting 協議。協議涉及兩方面:主執行緒將物件欄位從標記變為未標記,然後工作執行緒訪問該物件。在更改欄位之前,主執行緒會確保該物件被標記為黑色,並將其推入保釋清單中供以後訪問:

atomic_color_transition(object, white, grey); if (atomic_color_transition(object, grey, black)) { // The object will be revisited on the main thread during draining // of the bailout worklist. bailout_worklist.push(object); } unsafe_object_layout_change(object);

如下面的程式碼片段所示,工作執行緒首先載入物件的隱藏類,並使用 atomic relaxed 載入操作來快照(snapshots)隱藏類指定物件中的所有指標欄位。然後它會嘗試使用 atomic compare 和 swap 操作將物件標記為黑色。如果標記成功,則意味著快照必須與隱藏類一致,因為主執行緒在更改其佈局之前會將物件標記為黑色。

snapshot = ; hidden_class = atomic_relaxed_load(&object.hidden_class); for (field_offset in pointer_field_offsets(hidden_class)) { pointer = atomic_relaxed_load(object + field_offset); snapshot.add(field_offset, pointer); } if (atomic_color_transition(object, grey, black)) { visit_pointers(snapshot); }

放在一起

我們將併發標記整合到現有的增量標記基礎設施中,主執行緒通過掃描 roots 並填充標記工作表來啟動標記。之後,它會在工作執行緒上釋出併發標記任務。工作執行緒通過合作清空(draining)標記工作表以加快主執行緒標記進度。主執行緒偶爾也會通過處理保釋清單和標記工作表參與標記。標記工作表變空後,主執行緒完成 GC。在最終確定之前,主執行緒重新掃描 roots ,可能會發現更多的白色物件,這些物件在工作執行緒的幫助下被平行標記。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

結 果

測試結果顯示移動和桌面上每個 GC 週期的主執行緒標記時間分別減少了 65%和 70%。

引擎V8推出“併發標記”,可節省60%-70%的GC時間

最後,我們需要說的是 Node.js v10 現已支援併發標記。

原文連結

https://v8project.blogspot.com/2018/06/concurrent-marking.html

相關文章