標記是 V8 Mark-Compact GC 工做的一個階段。在這個階段中,收集器發現並標記全部活動對象。標記從一組已知的活動對象開始,如全局對象和激活函數,即所謂的 roots,收集器將 roots 標記爲活動的對象,並順着指針去尋找發現更多的活動對象。收集器繼續標記新發現的對象並跟隨指針移動,直到沒有發現更多的對象要標記爲止。在標記結束時,全部沒法讓應用程序訪問的未標記對象,均可以安全地回收。html
咱們能夠將標記視爲圖遍歷(Graph traversal)。堆內存上的對象是下圖中的節點,指針從一個對象指向另外一個對象是圖的邊緣。給定圖中的一個節點,咱們可使用該對象的隱藏類找到該節點的全部外邊緣。算法
V8 使用每一個對象的兩個 mark-bits 和一個標記工做表來實現標記。兩個 mark-bits 編碼三種顏色:白色(00),灰色(10)和黑色(11)。最初全部對象都是白色的,這意味着收集器尚未發現它們。當收集器發現它並將其推到標記工做表上時,白色對象變灰。當收集器將它從標記工做列表中彈出並訪問其所有字段時,灰色對象變黑,這種方案被稱爲三色標記法。當沒有灰色對象時,標記結束。全部剩餘的白色對象均可以安全地被回收。安全
請注意,上述標記算法僅適用於在標記進行中應用程序暫停的狀況。若是咱們容許應用程序在標記過程當中運行,那麼應用程序能夠更改圖形並最終誘騙收集器釋放活動對象。markdown
減小標記停頓對大型的堆內存來講,可能須要幾百毫秒才能完成一次標記。數據結構
長時間的停頓可能會致使應用程序沒法響應,並致使用戶體驗不佳。2011 年,V8 從 stop-the-world 標記切換到增量標誌。在增量標記期間,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); } }
複製代碼// 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 中添加對平行標記和並行標記的支持。
平行標記在平行期間,咱們能夠假定應用程序沒有運行。這大大簡化了實現過程,由於咱們能夠假定對象圖是靜態的而且不會發生變化。爲了平行標記對象圖,咱們須要確保 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)問題,在這個過程當中,主線程必須等待一個由於持有鎖定對象而被取消調度的工做線程。咱們不鎖定對象,而是容許工做線程訪問該對象。工做線程經過將對象推入保釋清單來完成該工做,這個過程只能由主線程來處理:
工做線程保釋了優化的代碼對象、隱藏類和 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_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); }
複製代碼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 ,可能會發現更多的白色對象,這些對象在工做線程的幫助下被平行標記。
測試結果顯示移動和桌面上每一個 GC 週期的主線程標記時間分別減小了 65%和 70%。
最後,咱們須要說的是 Node.js v10 現已支持併發標記。
原文連接https://v8project.blogspot.com/2018/06/concurrent-marking.html