如何使用 V8 並安全地開啟 concurrent GC

Wu Yuwei published on
10 min, 4103 words

Categories: Research

用 Rust 接 V8 本身不難,rusty_v8 已經做好大部分的 binding。真正麻煩的是 GC:只要少 trace 一個指標,或是在錯的時間點讀了一個指標,就可能變成 use-after-free。如果再打開 concurrent marking,讓 GC 在背景 thread 上一邊走訪物件、main thread 一邊修改,事情又更複雜了。

這篇文章以一個用 Rust 寫的 DOM 為例,說明怎麼把 Rust 的物件放進 V8 的 heap、怎麼把 GC 的規則盡量交給 Rust 編譯器檢查,以及為了讓 concurrent marking 在 Rust 裡也站得住腳,rusty_v8 需要改哪些東西。

為什麼 DOM 要放在 V8 的 heap 上

DOM 節點和 JS 物件會互相引用。節點指向自己的 JS wrapper,也指向 event listener 的 function;JS 這邊則透過 wrapper 指回節點。如果 DOM 用 Rust 自己的 Rc 管理、JS 物件由 V8 管理,兩邊的循環就沒有人能回收:V8 看不到 Rust 那一半的引用,Rust 的引用計數也看不到 JS 那一半。

Blink 的解法是 unified heap:DOM 配置在 cppgc(也就是 Blink 的 Oilpan)的 heap 上,這個 heap 接在 V8 的 isolate 上,一次 GC 同時 trace JS 物件和 DOM 節點,循環自然就能回收。rusty_v8 也支援這種用法,建立 isolate 時把一個 CppHeap 交給它就好:

let heap = v8::cppgc::Heap::create(
    v8::V8::get_current_platform(),
    v8::cppgc::HeapCreateParams {
        marking_support: v8::cppgc::MarkingType::Incremental,
        sweeping_support: v8::cppgc::SweepingType::IncrementalAndConcurrent,
    },
);
let isolate = v8::Isolate::new(v8::CreateParams::default().cpp_heap(heap));

接著 DOM 節點就可以是一個 cppgc 的 GC 物件:

#[derive(GcObject)]
struct Node {
    // 被 trace 的區域
    parent: Edge<Node>,
    first_child: Edge<Node>,
    last_child: Edge<Node>,
    previous_sibling: Edge<Node>,
    next_sibling: Edge<Node>,
    first_listener: Edge<Listener>,  // event listener 的 linked list
    wrapper: WrapperSlot,            // JS 的 wrapper(JsEdge)
    kind: NodeKindData,              // 種類、不可變的 per-kind 資料、per-kind 的 Edge

    // 不被 trace 的區域
    #[no_trace] state: RefCell<NodeState>,  // attribute、computed style、文字內容
    #[no_trace] id: NodeId,
    #[no_trace] canary: Canary,             // debug build 的 use-after-free 偵測
}

Edge<T> 是指向另一個 GC 物件的強指標,底層是 cppgc 的 Member;JsEdge<T> 是指向 JS 值的指標,底層是 V8 的 TracedReference。後面會一直提到這兩個型別。

GC 會在什麼時候出事

在進入 concurrent 之前,先列出 GC 和 Rust 放在一起時,有哪些地方會出錯:

  1. 少 trace 一個指標:GC 以為那個物件沒人用,把它回收掉,之後再讀就是 use-after-free。
  2. 在錯的時間點持有指標:手上拿著一個 &Node,中間呼叫了某個會配置 GC 物件的函式,GC 剛好在這時執行,這個 reference 就懸空了。
  3. 漏掉 write barrier:incremental marking 分好幾段做完,如果在兩段之間把一個還沒標記的物件接到已經標記完的物件上,GC 不會再回頭看那個父節點,新的物件就會被當成垃圾。
  4. Finalizer 做太多事:cppgc 的 finalizer 會在任意的配置點執行,可能剛好在一個 DOM 修改操作的中間。如果 finalizer 去碰 DOM,就會看到修改到一半的樹。
  5. Concurrent marking 時的 data race:marker thread 在背景讀節點,main thread 同時在寫。

C++ 的 Blink 靠的是 coding convention、clang plugin 和大量的測試。Rust 則有機會做得更好:讓上面這些規則盡量由編譯器檢查,剩下編譯器管不到的,明確寫成文件,並用執行期檢查和測試守住。

先處理單 thread 的部分

Scope:GC 只能在沒人持有指標時執行

所有 DOM 存取都要先從持有 isolate 的 DomIsolate 拿一個 scope:只讀的 DomReadScope,或可以修改的 DomMutationScope。兩種 scope 都借用 &mut DomIsolate:

impl DomIsolate {
    pub fn read(&mut self) -> DomReadScope<'_> { ... }
    pub fn mutate(&mut self) -> DomMutationScope<'_> { ... }
    pub fn evaluate_classic_script(&mut self, ...) { ... }
    pub fn run_platform_tasks(&mut self) { ... }
}

重點在於,所有可能觸發 GC 的入口也都要 &mut DomIsolate:建立節點、執行 script、跑 V8 的 platform task。所以只要你手上還有一個 scope,borrow checker 就不會讓你呼叫任何會 GC 的東西。

從 scope 讀出來的節點是 NodeRef<'a>,它借用 scope,scope 結束它就不能再用:

let escaped = {
    let scope = dom.read();
    document.get(&scope).first_child()
}; // error: `scope` does not live long enough

要在 scope 之外持有節點,就用 NodeHandle,它是一個 cppgc 的 Persistent,也就是 root。這樣一來,在 DOM 的操作以外,不存在任何沒被 root 的 GC 指標,GC 不管什麼時候執行、掃不掃 stack 都是安全的。

修改操作也只接受 &NodeHandle,不接受 NodeRef,所以「一邊讀一邊改」會編譯失敗。常見的寫法是先讀、收集 handle,再改:

let children: Vec<NodeHandle> = {
    let scope = m.read();
    parent.get(&scope).children().map(|c| c.to_handle()).collect()
};
for child in &children {
    m.remove(child);
}

Stack 掃描和 platform task

cppgc 一般的 GC 會保守地掃描 stack 和 register,所以在修改操作裡,剛配置、還沒接到樹上的節點只要放在 stack 的區域變數裡就不會被回收。但 V8 的 incremental marking task 是從 platform task 發起的,這時 V8 假設 stack 上沒有 heap 指標,不掃描 stack。

也就是說,platform task 只能在 event loop 的最外層執行。可以用兩個方式守住這點:包一層要 &mut self 的 run_platform_tasks(),並在 clippy.toml 裡直接禁止 rusty_v8 原本的 API:

disallowed-methods = [
    { path = "v8::platform::Platform::pump_message_loop", reason = "use the &mut self wrapper" },
    { path = "v8::platform::Platform::run_idle_tasks", reason = "use the &mut self wrapper" },
]

Binding 和 JS 的執行互斥

JS 呼叫 binding 時,binding 要能拿到 DOM。一個做法是:執行 script 的入口(evaluate_classic_script 等)先把 DOM 狀態的指標放進 isolate 的一個私有 slot,binding 再從 BindingContext 取出來。BindingContext 的 mutate()、read() 和拿 JS scope 的 scope() 都借用整個 context,所以持有 DOM scope 時拿不到 JS scope:

let mut cx = BindingContext::new(scope).unwrap();
let m = cx.mutate();
let _ = args.get(0).to_rust_string_lossy(cx.scope());
// error[E0499]: cannot borrow `cx` as mutable more than once at a time

這看起來有點嚴格,但很必要:to_rust_string_lossy 可能呼叫物件的 toString,執行任意 JS,而那段 JS 又可能呼叫另一個 binding、開出另一個 DOM scope。所以 binding 的寫法一律是先轉換參數、再開 DOM scope,關掉之後才建立回傳值。

Finalizer 不准有 Drop

因為 finalizer 會在任意的配置點執行,derive macro 可以直接禁止 GC 型別實作 Drop。用的是 pin-project 的技巧:替所有 T: Drop 實作一個 trait,再替這個型別實作一次,型別有 Drop 就會衝突:

trait MustNotImplDrop {}
impl<T: ::core::ops::Drop> MustNotImplDrop for T {}
impl MustNotImplDrop for #name {}
error[E0119]: conflicting implementations of trait `MustNotImplDrop` for type `G`

#[no_trace] 欄位裡的型別還是可以有 Drop(例如 String),但它們必須實作 unsafe trait NoTrace,契約是「不含 GC 指標,Drop 只釋放記憶體,不碰 DOM、scope、root 或 thread-local,也不 panic」。

每個欄位都要歸到一個區域

#[derive(GcObject)] 和 #[derive(Trace)] 會替每個欄位產生一個編譯期斷言:沒標 #[no_trace] 的欄位必須實作 Trace,標了的必須實作 NoTrace。所以漏 trace 一個指標是不可能的,因為 Edge 沒有實作 NoTrace;Vec<Edge>、Option<Edge>、RefCell<Edge> 也都沒有實作 Trace,直接編譯失敗:

error[E0277]: the trait bound `RefCell<Edge<G>>: Trace` is not satisfied
 --> tests/ui/refcell_edge.rs:8:11
  |
8 |     next: RefCell<Edge<G>>,
  |           ^^^^^^^^^^^^^^^^ the trait `Trace` is not implemented for `RefCell<Edge<G>>`

為什麼連 RefCell<Edge> 都不行?因為它可以讓你拿到 &mut Edge,繞過 write barrier。Edge 唯一的寫入方式是 Edge::set,而它只在 DOM 的 crate 內部可見,並且要一個 &mut DomMutationScope:

pub(crate) fn set(
    &self,
    _scope: &mut DomMutationScope<'_>,
    target: &impl GetRustObj<T>,
) {
    // SAFETY: on the heap's thread (the scope borrows the isolate), and
    // `target` is on the same heap.
    unsafe { self.slot.assign(target) }
}

它最終呼叫 C++ 的 Member::operator=,也就是 atomic store 加上 write barrier。沒有 &mut 存取、沒有 Clone、沒有帶值的建構子,barrier 就繞不過去。

到這裡為止,incremental marking 下的安全性已經大致成立:marking 分段做,但每一段都在 main thread 上執行。

打開 concurrent marking 之後

Concurrent marking 時,V8 會在背景的 worker thread 上呼叫我們的 trace(),而 main thread 同時在修改 DOM。這時有兩件事要成立:

  • marker thread 讀到的東西,不會和 main thread 的寫入發生 data race;
  • 在 Rust 的語意下,這件事也不違反 aliasing 規則。

第二點是 C++ 不用煩惱、Rust 卻必須面對的問題。

Trace: Sync

第一步是讓型別系統表達「被 trace 的區域可以被另一個 thread 讀」:

pub unsafe trait Trace: Sync {
    fn trace(&self, visitor: &mut Visitor);
}

Trace 要求 Sync,所以 #[derive(Trace)] 的型別整個都必須是 Sync。GC 物件只能透過 & 存取,所以被 trace 的區域除了 slot 本身以外都是不可變的;需要修改的資料,例如 attribute 或 computed style,一律放進 #[no_trace] 的 RefCell 或 Cell,marker 從來不讀它們。

換句話說,marker thread 唯一會和 main thread 同時碰到的東西,就是 slot。只要 slot 的讀寫是安全的,整個 trace() 就是安全的。

上游 Member 的三個問題

問題是 slot 本身。上游 rusty_v8 的 Member、WeakMember 和 TracedReference 在 concurrent 下有三個問題:

1. Aliasing。 上游的寫入方法是 Member::set(&mut self)、TracedReference::reset(&mut self),trace 則是 &self。concurrent marking 時,marker thread 正在 trace 一個 slot、main thread 同時寫入它,等於兩個 thread 同時持有同一塊記憶體的 & 和 &mut。即使 C++ 那邊兩邊都是 atomic 存取,這在 Rust 裡仍然是 undefined behavior。

2. 對齊。 上游把 slot 存成 [u8; N],對齊只有 1,而 C++ 把它當成 std::atomic 存取,必須對齊。這不是理論上的問題:我實際碰過一個放在 enum variant 裡的 Edge,被編譯器排在 offset 1。在 ARM64 上,沒對齊的 atomic 存取不保證是 atomic。

3. 發布新物件。 cppgc 配置一個物件時,會先跑建構子,再用 release store 把物件標記成「已建構完成」;marker 在 trace 一個物件之前,一定先用 acquire 讀這個位元。但上游的 make_garbage_collected 是在標記完成之後才寫入 Rust 的資料,包括 trace() 要用的 dynamic 指標。這形式上是 data race,在 ARM64 上 marker 甚至可能讀到舊的 dynamic 指標然後 crash。

自己的 slot 型別

我在 rusty_v8 的 fork 裡,在上游的型別旁邊加了 MemberSlot、WeakMemberSlot 和 TracedSlot。它們呼叫一樣的 C 函式,只是換了在 Rust 這邊的表示方式:

pub struct MemberSlot<T: GarbageCollected> {
    storage: UnsafeCell<MemberStorage>,
    _phantom: PhantomData<T>,
}

// SAFETY: the only data is the C++ object in the `UnsafeCell`, and no
// Rust reference to it is ever created: every access is a C++ call
// through a raw pointer. Of those, only `trace` may run off the heap's
// thread (on a marker thread), and it only reads, with an atomic load
// (`GetAtomic`). Writes (`assign`, `clear`, `copy_from`) are atomic
// stores (`operator=`'s `SetRawAtomic`) on the heap's thread, ...
unsafe impl<T: GarbageCollected> Sync for MemberSlot<T> {}

設計的重點有三個:

  • bytes 放在 UnsafeCell 裡,從不建立指向它們的 Rust reference。每次寫入和 trace 都只把 raw 指標交給 C++,和標準函式庫的 AtomicPtr 是同一個模式。這樣 Rust 這邊就沒有 &mut 和 & 同時存在的問題,所有同步都交給 C++ 的 atomic。

  • 對齊等於大小。Member 在開了 cppgc pointer compression 時是 4 bytes,沒開時是 8 bytes。大小從 build 時產生的常數讀,再用 const assert 檢查:

    #[repr(C)]
    struct MemberStorage {
      _align: [<Bytes<MEMBER_SIZE> as AlignedAs>::Unit; 0],
      _bytes: [u8; MEMBER_SIZE],
    }
    const _: () = assert!(
      size_of::<MemberStorage>() == MEMBER_SIZE
        && align_of::<MemberStorage>() == MEMBER_SIZE
    );
  • 接 &self 的方法全都是 unsafe,契約寫明它能在哪個 thread 上執行:assign、clear、get 只能在 heap 的 thread 上,trace 只能從擁有這個 slot 的 GC 物件的 trace 呼叫。這是 unsafe impl Sync 成立的前提。如果哪天有人加了一個 safe 的 &self 寫入方法,Sync 就不再成立,而編譯器完全不會發現。這也是整個設計裡最需要人工審查的一條。

DOM 的 Edge 只是包一層 MemberSlot,再加上 DOM 自己的規則(寫入要 DomMutationScope、只在 DOM 的 crate 裡),所以它自動是 Sync。為了避免有人不小心用回上游的型別,clippy.toml 也禁止它們:

disallowed-types = [
    { path = "v8::cppgc::Member", reason = "use v8::cppgc::MemberSlot (our fork)" },
    { path = "v8::cppgc::WeakMember", reason = "use v8::cppgc::WeakMemberSlot (our fork)" },
    { path = "v8::TracedReference", reason = "use v8::cppgc::TracedSlot (our fork)" },
]

在建構子裡寫入 Rust 的資料

第三個問題最早是用兩個 fence 補的:寫入 slot 時一個 release fence,trace 時一個 acquire fence。後來我直接改了 RustObj 的建構子,讓它接一個初始化 callback:

// Called from the constructor, so the Rust data is written before
// cppgc marks the object fully constructed (a release store, after the
// constructor returns). Concurrent markers trace an object only after
// reading that bit with acquire, so they see the Rust data without any
// further synchronization. It must not allocate on the cppgc heap.
using RustObjInit = void (*)(RustObj* self, void* data);

class RustObj : public v8::Object::Wrappable {
 public:
  RustObj(RustObjInit init, void* data) { init(this, data); }
  ...
};

這樣 Rust 的資料就在 cppgc 的 release store 之前寫好,搭上 cppgc 本來就有的 acquire,marker 一定讀得到完整的物件。fence 就可以拿掉了。

這個問題有個討厭的地方:測試完全測不出來。x86-64 不會重排寫入,所以在 x86 上本來就不會出事;ARM64 會重排,但 stress 也很難碰上。我試過在 ARM64 上把 release fence 拿掉,測試照樣通過。所以這裡只能靠對照 cppgc 的原始碼來確認。

TracedReference 和 JS 值

JsEdge 包的是 TracedSlot,用在節點的 wrapper 和 listener 的 callback 上。它的情況比 Member 簡單一些:slot 只存一個指向 V8 traced handle 節點的指標,讀寫都是 relaxed atomic(SetSlotThreadSafe / GetSlotThreadSafe);節點的內容則由 V8 自己用 release / acquire 發布。marking 中 destroy 一個 handle 時,V8 只把物件清掉,節點留到最後的 pause 才釋放,所以 marker 就算讀到舊的 slot,指向的也還是活著的節點。Blink 依賴的是同一套機制,TracedSlot 不需要自己再做同步。

寫入時的 barrier 也由 V8 負責:marking 進行中寫入 TracedReference 時,V8 會當場標記新的值。

守住對 V8 的假設

上面的論證有很大一部分建立在「V8 是這樣實作的」之上:Member::operator= 是 atomic store 加 barrier、marker 用 atomic load 讀、有 finalizer 的物件不會在背景 thread 上被釋放、cppgc 不會搬動一般的物件……這些都是讀 V8 原始碼得到的結論,我們證明不了,而且升級 V8 時可能改變。

build.rs 比對 V8 的原始碼

可以讓 build script 讀 V8 的原始碼,檢查論證依賴的那幾段還在不在。例如 cppgc 的 allocation.h 是不是還「先建構、再標記」,TracedReference::Reset 是不是還是 kAssigningStore。找不到就編譯失敗:

assert!(
    text.contains(&normalize(snippet)),
    "{} no longer contains `{snippet}`; review the safety notes against it",
    ...
);

錯誤訊息直接要你回頭審查文件。這不是要你把字串換成新的內容就結束,而是要重讀對應的 C++ 函式,確認語意沒有變。

釘死的 V8 flag

讀 V8 原始碼時發現了一條很隱蔽的路徑:一輪 GC 如果在 efficiency mode 下開始,V8 之後會呼叫 CppHeap::ReEnableConcurrentMarking,不檢查 heap 的設定,就把一個 incremental 的 CppHeap 升級成 concurrent。也就是說,就算你建立 heap 時說只要 incremental,V8 還是可能自己開 concurrent。

所以初始化 V8 時,一律在所有 flag 的最後面加上兩個 flag,讓使用者傳入的 flag 也蓋不掉:

const PINNED_FLAGS: &str = "--no-single-threaded-gc-in-background --no-cppgc-young-generation";

let flags = format!("{flags} {user_flags} {PINNED_FLAGS}");
v8::V8::set_flags_from_string(&flags);
  • --no-single-threaded-gc-in-background 關掉上面那條升級路徑。CppHeap::SelectMarkingType 只會降級、不會升級,所以這是唯一的升級路徑。
  • --no-cppgc-young-generation 確保 cppgc 不會開 young generation。V8 開了 caged heap 時(x64 和 arm64 的預設),young generation 會一起編進 cppgc,只要一個實驗性的 flag 就能在執行時打開它,帶來 minor GC、generational barrier,以及按位址記住 TracedReference slot 的行為。整個安全論證只涵蓋 full GC,所以它一律關掉。

這樣一來,concurrent marking 就只會在建立 heap 時明確要求才打開:

v8::cppgc::HeapCreateParams {
    marking_support: v8::cppgc::MarkingType::IncrementalAndConcurrent,
    ...
}

執行期的自我檢查

有些假設在編譯期檢查不了,就在啟動時檢查。例如 wrap 的 tag:V8 在 unwrap 時會檢查 tag,用一個 tag wrap 的物件用另一個 tag unwrap 只會拿到 null。但這只有開了 pointer compression 的 V8 才檢查,rusty_v8 預設的 prebuilt 沒開,會直接回傳物件裡的欄位。所以在建立第一個 isolate 時,先用一個 tag wrap、再用另一個 tag unwrap,拿到東西就 panic。換成不檢查的 V8 會在啟動時就停下,而不是一路跑下去。這也是為什麼最好連結自己編的、開了 pointer compression 的 V8。

怎麼驗證

這整套東西是論證,不是證明。Miri 不能執行 V8 的 C++ 程式碼,把 cppgc 的行為形式化又不切實際。所以做法是把假設和不變式明確寫成一份 soundness 文件(我的版本列了十條對 V8 的假設、十四條不變式,以及 concurrent 額外需要的四條假設),再分別用不同的方式守住:

  • 編譯期:每條規則一個 compile-fail 測試,用 trybuild 比對錯誤訊息;Trace: Sync;clippy 的 disallowed-types、disallowed-methods 和 undocumented_unsafe_blocks;workspace 的 unsafe_code = "deny",只有負責 GC 的 crate 和 rusty_v8 的 fork 能寫 unsafe。

  • 執行期:debug build 的每個節點有一個 canary,讀到已經被 finalize 的節點就 panic;計數器記錄 finalizer 和 trace() 在哪個 thread 上執行,背景 thread 上的 finalizer 數量必須是 0。

  • 一步一步控制的 marking:在沒接 isolate 的 heap 上手動推進 marking,把修改放在 marking 的特定時間點。concurrent 的測試會關掉 main thread 的 marking,讓背景 thread 在 main thread 修改的同時 trace,最後還斷言背景 thread 真的 trace 過,避免「測試跑了,但其實沒有 concurrent」:

    dom.start_incremental_gc_for_testing();
    dom.set_main_thread_marking_for_testing(false);
    for _ in 0..50 {
        work.batch(&mut dom.mutate(), 50);
        dom.marking_step_for_testing();
    }
    dom.set_main_thread_marking_for_testing(true);
    dom.finalize_gc_for_testing();
    work.check(&mut dom);
    // ...
    assert!(after > before, "no edge was traced on a marker thread");
  • Stress 和 fuzz:每次配置都強制 GC,隨機修改 DOM 並對照 Rust 端的模型;--stress-incremental-marking 讓修改和 marking 交錯;CI 再隨機組合種子、thread 數、marking 模式和 GC flag。寫這類測試有個陷阱:不能用 root 保住要檢查的節點,因為 GC 最後的 pause 會從 root 重新標記,漏掉 write barrier 也不會出錯。

  • V8 自己的 DCHECK:連結 debug 版的 V8,讓 V8 內部的檢查也一起跑。它抓到過一個測試在建立 heap 之前就建立 slot。

  • Mutation test:暫時把實作弄壞,例如讓 MemberSlot::trace 什麼都不做、拿掉 barrier、把 weak 當成強指標、讓 derive 漏掉一個 enum variant,確認一定有測試失敗。

結語

把 GC 接進 Rust,最困難的部分其實不是 FFI,而是 GC 的規則和 Rust 的所有權模型是兩套不同的語言。cppgc 假設你會遵守它的規則:什麼時候能持有指標、什麼時候要寫 barrier、finalizer 能做什麼。我們要做的,就是把這些規則一條一條翻譯成 Rust 的型別和 lifetime,讓 borrow checker 替我們檢查。

Concurrent marking 則多了一層:C++ 只要求 atomic,Rust 還要求 aliasing 正確。這需要重新設計 slot 型別,並修正上游 rusty_v8 的三個問題:slot 的對齊、&mut self 寫入造成的 aliasing,以及新物件發布前的同步。之後我會整理成 issue 或 PR 回報給上游,希望其他用 rusty_v8 cppgc 的專案也能受益。