Bun

Bun 如何在不使用 V8 的情況下支援 V8 API (第二部分)


Ben Grant · 2024 年 11 月 5 日

我們正在舊金山招聘系統工程師,一同打造 JavaScript 的未來!

本系列的第一部分中,我們比較了用於與 JavaScriptCore 和 V8 互動的 C++ API,它們分別是 Bun 和 Node.js 使用的 JavaScript 引擎。在文末,我們概覽了 V8 用於表示程式中物件的記憶體佈局。

void foo(const FunctionCallbackInfo<Value>& info) {
    Isolate* isolate = info.GetIsolate();
    HandleScope foo_scope(isolate);
    Local<String> foo_string = String::NewFromUtf8(isolate, "hello").ToLocalChecked();
    bar(isolate);
}

void bar(Isolate* isolate) {
    Local<Number> bar_number = Number::New(isolate, 2.0);
    baz(isolate);
}

void baz(Isolate* isolate) {
    HandleScope baz_scope(isolate);
    Local<Number> baz_number = Number::New(isolate, 3.5);
}

Diagram of memory layout in the previous C++ program. Each function's local variables hold the addresses of handles in a handle scope. The handle for foo_string contains a strong pointer to a string on the heap. The handle for bar_number contains an integer. The handle for baz_number contains a strong pointer to a heap number. Each heap object starts with a pointer to its map, one for each type. Each map has a pointer to the map used by maps, which points to itself.

由於 V8 的 API 包含許多假設使用這種佈局的 inline 函數,我們需要以類似的方式安排我們的 JSC 類型,以確保為 V8 API 預先編譯的原生模組仍然可以運作。

在 JSC 中模擬 V8 表示法

那麼,我們如何在 JSC 類型中建立一個看起來像 V8 的佈局呢? 讓我們回顧一下 inline V8 函數期望的重要屬性:

  • 每個 Local 都包含一個指向目前活動 HandleScope 管理的記憶體的指標。
  • HandleScope 包含標記指標,它可以 inline 儲存帶正負號的 32 位元整數,或指向堆積上的物件。
  • 堆積上的每個物件都以指向其 map 的標記指標開始。
  • 每個 map 在偏移量 12 處都有一個實例類型。

標記指標

首先,我們需要表示標記指標。回想一下,標記指標是一個 64 位元位址,它可以表示 32 位元帶正負號的整數("Smi")、強指標或弱指標。我編寫了一個簡單的 struct,它封裝了 uintptr_t,用於表示標記指標,以便我們可以新增輔助方法和建構子。實作並不複雜。 您可以從指標或 Smi 建構標記指標(兩者都正確處理設定標記位元),查詢其類型,並將其作為指標或 Smi 存取。

現在,如果數字是 Smi,我們實際上可以實作 v8::Number,而無需進行太多額外工作。 我們只需要一個 handle scope 來提供可以分配這些標記指標的記憶體。 然而,物件更困難的情況仍然潛伏著,所以讓我們首先解決這個問題(此外,它最終會影響我們如何實作 handle scope)。

Map

V8 和 JSC 分別使用 MapStructure 進行重要的最佳化:如果兩個 JavaScript 物件具有相同的屬性名稱和類型,則它們可以表示為更像 C struct 的東西,屬性位於一個接一個的固定偏移量處,而不是完全動態的雜湊表。 這使得屬性存取速度更快,並且使 JIT 編譯器更容易產生存取屬性的程式碼。 因此,這些類別都會在 JavaScript 執行時動態分配和實例化多次。 幸運的是,對於我們的 V8 功能,我們不需要與設定為匹配每個 Structure 的偽造 Map 建立 1:1 的關聯。 我們只需要 Map 來涵蓋 inline V8 函數期望從實例類型中獲得某些東西的情況。 例如,我們有一個由所有物件共用的 Map,其唯一目的是強制 V8 呼叫 SlowGetInternalField,而不是嘗試直接查找內部欄位。 我們出於各種原因建立了一些其他 Map,但我們不需要動態建立它們中的任何一個,因此它們都只是靜態常數。

以下是 Map 的粗略定義

Map.h
enum class InstanceType : uint16_t {
    // ...
};

struct Map {
    // the structure of the map itself (always points to map_map)
    TaggedPointer m_metaMap;
    // TBD whether we need to put anything here to please inlined V8 functions
    uint32_t m_unused;
    InstanceType m_instanceType;

    // the map used by maps
    static const Map map_map;
    // other Map declarations follow...

    Map(InstanceType instance_type)
        : m_metaMap(const_cast<Map*>(&map_map))
        , m_unused(0xaaaaaaaa)
        , m_instanceType(instance_type)
    {
    }
};

const Map Map::map_map(InstanceType::Object);

物件

為了滿足 V8,我們需要在每個物件的前 8 個位元組中保留一個指向 Map 的標記指標。 然而,JSC 也在每個 JSCell 物件的開頭儲存元資料。 它們不能同時佔用相同的記憶體。 我們如何糾正這個問題?

我們可以建立一種新型物件,對 JSC 的垃圾回收器不可見,它結合了 V8 的 map 指標以及 JSCell 指標,以便我們的程式碼可以找到 JSC 物件。

struct ObjectLayout {
    TaggedPointer tagged_map;
    JSCell* ptr;
};

這會有效,但我們必須為每個 V8 物件分配一個新的 ObjectLayout。 這不僅效率低下,而且容易出錯,因為我們需要自己管理所有這些物件的記憶體,而沒有垃圾回收器的幫助。

相反,如果我們將這兩個欄位都儲存在 handle scope 內會怎麼樣? 我們已經需要在 handle scope 中為每個 V8 值預留 8 個位元組,以儲存標記指標。 如果我們改為預留 24 個位元組,我們可以將 handle、map 指標和 JSC 物件指標全部彼此相鄰地儲存。

Handle.h
struct ObjectLayout {
    TaggedPointer m_taggedMap;
    JSCell* m_ptr;
};

struct Handle {
    TaggedPointer m_toV8Object;
    ObjectLayout m_object;
};

使用此方案,Local 持有指向 to_v8_object 欄位的指標。 如果 to_v8_object 是整數,則忽略 ObjectLayout。 如果它是指標,它會指向 ObjectLayouttagged_map 欄位,以便 V8 程式碼可以找到它期望的 map 指標。

以下是完成後此佈局的外觀,使用本文頂部的相同程式:

Diagram of memory layout in the previous C++ program. Each function's local variables hold the addresses of handles in a handle scope. The handle for foo_string contains a pointer to the object layout stored inside that handle. The object layout for foo_string points to a JSString. The handle for bar_number contains an integer. The handle for baz_number contains a pointer to the object layout stored inside that handle. The object layout for baz_number stores the number 3.5. Each object layout starts with a pointer to its map, one for each type. Each map has a pointer to the map used by maps, which points to itself.

與 V8 下佈局運作方式的主要區別在於 ObjectLayout 的內容(在 V8 中,這是一個單獨的堆積分配(並包含物件的實際欄位,例如字串內容))現在儲存在由 handle scope 管理的記憶體中。 但您也可以看到,從 V8 程式碼的角度來看,我們的佈局應該看起來相似。 例如,該程式碼仍然可以追蹤 to_v8_object 標記指標,以找到一個物件,該物件將 Map 指標作為其第一個欄位。 to_v8_object 恰好總是向前指向 8 個位元組這一點並不重要。

下圖顯示了為了從 Local handle 中查找實例類型而追蹤的指標,無論是在 V8 佈局(上方)還是我們在 Bun 中實作的偽造 V8 佈局(下方)中。 雖然在這種情況下實例類型不同,但兩者都被視為字串,這就是我們所關心的。

Comparison of the memory layout in V8 vs. in Bun's implementation of V8. Both have a local variable foo_string which points to a handle. In V8, the handle points to a separate string object, which points to a map used by strings. In Bun, the handle points to the object layout which is inside the handle, and also points to the string map.

Handle 有幾個不同的建構子。 必須維護的不變量是,如果 to_v8_object 不是 Smi,它必須包含 object 的位址。 我實作了 C++ 複製建構子和賦值運算子,以幫助確保這一點。

Handle.cpp
Handle::Handle(const Handle& that)
{
    *this = that;
}

Handle& Handle::operator=(const Handle& that)
{
    object = that.object;
    if (that.m_toV8Object.tag() == TaggedPointer::Tag::Smi) {
        m_toV8Object = that.m_toV8Object;
    } else {
        // calls TaggedPointer(void*), which also sets up the tag bit
        m_toV8Object = &this->m_object;
    }
    return *this;
}

那麼我們的 handle scope 呢? 它必須充當可成長的 Handle 陣列,並且必須在其建構子中將自身標記為目前的 handle scope,並在其解構子中還原先前的 handle scope。 請記住,與我們一直在研究的其他 V8 類型不同,HandleScope 是堆疊分配的,大小為 24 個位元組。 這實際上恰好是儲存以下內容的正確大小:

  • 指向 Isolate 的指標(當我們使用 JSC 時,Isolate 到底是什麼? 我稍後會解決這個問題)
  • 指向先前 HandleScope 的指標
  • 指向實作 handle 實際儲存的類別 HandleScopeBuffer 的指標

實作 HandleScopeBuffer 的唯一技巧是我們期望每個 handle 在堆疊上的某個位置都有指向它的活動指標。 如果某些程式碼建立大量 handle,我們需要能夠在不移動任何現有 handle 的情況下成長陣列。 我們稍後將以可擴展的方式解決這個問題。 目前,我們可以讓 HandleScopeBuffer 保留固定大小的 Handle 陣列,追蹤使用了多少個,如果空間不足則崩潰。 這足以讓其他 V8 功能的簡單測試能夠運作。

實作 V8 函數,第二次嘗試

新的 Local

我們要做的第一件事是稍微更改 Local 的定義,使其更清楚地表明它實際代表什麼

V8Local.h
  template<class T>
  class Local final {
  public:
      T* ptr;
      TaggedPointer* m_location;
      Local(TaggedPointer* slot)
          : m_location(slot)
      {
      }

      T* operator*() const { return ptr; }
      T* operator*() const { return reinterpret_cast<T*>(m_location); }
};

在實作 V8 類別的函數時,我們必須謹慎,因為 this 實際上不會指向該類別的實例。 幸運的是,V8 類別實際上不包含任何欄位,因此我們不可能透過引用欄位來意外取消引用 this。 V8 使用單獨的內部類別來儲存這些類型的實際資料,這也是我所做的。

v8::Number 實作

讓我們再次思考如何實作這些 v8::Number 函數。

V8Number.h
class Number : public Primitive {
public:
    BUN_EXPORT static Local<Number> New(Isolate* isolate, double value);

    BUN_EXPORT double Value() const;
};

此外,讓我們只擔心 Smi 數字。 我們將從更簡單的 Value() 開始。 請記住,它將被呼叫,其中 this 是指向儲存在 handle 中的標記指標的指標。 所以我們需要:

  • 取消引用 this 以取得標記指標
  • 檢查標記指標是否為 Smi
  • 將 Smi 表示法轉換為原生整數,然後轉換為 double

讓我們試試看

V8Number.cpp
double Number::Value() const {
    TaggedPointer tagged = *reinterpret_cast<const TaggedPointer*>(this);
    int32_t smi;
    ASSERT(tagged.getSmi(smi));
    return smi;
}

而且... 它運作了! 好吧,如果沒有 Number::New,我們實際上看不到它在運作,所以您必須相信我。 Bun 中實際使用的版本類似,但對許多這些操作使用了輔助函數,並且支援 double(我們稍後會看到如何)。

現在,我們需要在 Number::New() 中做什麼?

  1. 斷言提供的 double value 符合 int32_t 的範圍(因為完整的 double 值是我們尚未嘗試處理的不同情況)
  2. 找出 isolate 中目前活動的 handle scope
  3. 建立新的 handle
  4. 將 handle 的標記指標值 (to_v8_object) 設定為表示 value 的 Smi(handle 也將在其中具有 map 指標和 JSCell 指標的空間,但我們不需要設定這些)
  5. 傳回 Local,其中包含指向 handle 中標記指標的指標

在我們可以執行此操作之前,我們必須弄清楚 Isolate 應該是什麼。 許多 V8 函數,尤其是那些分配新物件的函數,都會傳遞指向 Isolate 的指標。 因此,我們應該使其成為對我們使用 JSC 的實作有用的東西。 在我甚至開始在 Bun 工作之前,已實作了一些基本的 V8 函數,使用指向全域物件的指標作為 isolate 和 context,因此我在最初讓更多 V8 API 啟動時堅持使用它。

現在我們需要一種從全域物件取得目前 handle scope 的方法。 我們可以直接新增欄位,但由於我正在為 V8 新增許多欄位,我將它們全部放入 v8::GlobalInternals 類別中,以追蹤 Bun 的 V8 API 支援特定的狀態。 這確保了如果未使用 V8 API,我們不會過度膨脹全域物件。 全域物件只具有指向全域內部結構的延遲初始化指標。

考慮到這些細節,我們終於可以實作正確版本的 Number::New()

V8Number.cpp
Local<Number> Number::New(Isolate* isolate, double value) {
    // 1.
    double int_part;
    // check that there is no fractional part
    RELEASE_ASSERT_WITH_MESSAGE(std::modf(value, &int_part) == 0.0, "TODO handle doubles in Number::New");
    // check that the integer part fits in the range of int32_t
    RELEASE_ASSERT_WITH_MESSAGE(int_part >= INT32_MIN && int_part <= INT32_MAX, "TODO handle doubles in Number::New");
    int32_t smi = static_cast<int32_t>(value);

    // 2.
    Zig::GlobalObject* globalObject = reinterpret_cast<Zig::GlobalObject*>(isolate);
    HandleScope* handleScope = globalObject->V8GlobalInternals()->currentHandleScope();

    // 3.
    Handle& handle = handleScope->createEmptyHandle();

    // 4.
    handle.to_v8_object = TaggedPointer(smi);

    // 5.
    return Local<Number>(&handle.to_v8_object);
}

這就運作了! 此程式碼執行與 Bun 目前實作相同的操作,只是實際版本由於使用了輔助函數而更簡單。

一些 v8::Object 函數

我不會深入研究每個 V8 函數的實作。 但是,至少讓我們看看一些處理物件的函數,因為這些函數是上次給我們帶來如此多麻煩並迫使我們重新考慮如何在 JSC 中表示 V8 值的函數。

使用內部欄位表示物件

還記得之前的內部欄位嗎? 概括來說:物件可以使用固定數量的欄位建立,這些欄位可以使用整數索引快速存取。 這些欄位僅對原生程式碼可見,並且可以由原生附加元件使用,以將內部狀態與 JavaScript 物件關聯,而無需讓 JavaScript 程式碼干擾該狀態。

預設情況下,普通的 V8 物件沒有內部欄位。 建立具有內部欄位的 V8 物件的唯一方法是在 ObjectTemplate 上配置內部欄位計數,這將應用於您使用該範本建立的所有物件。 這對我們來說很幸運,因為這意味著我們不需要在每個 JSC 物件上支援內部欄位。 相反,我們可以僅為具有內部欄位的物件建立一個特殊的類別,並讓 ObjectTemplate 實作建立該類型的物件。

InternalFieldObject.h
class InternalFieldObject : public JSC::JSDestructibleObject {
public:
    // ...
    using FieldContainer = WTF::Vector<JSC::JSValue, 2>;

    FieldContainer* internalFields() { return &m_fields; }
    // ...
private:
    FieldContainer m_fields;
};

我們使用 JSValue 來表示每個內部欄位。 這比使用 Local 等 V8 類型更好,因為每個 Local 僅在建立它的 handle scope 存在時才有效。 我們需要確保物件內的值不會被刪除,因為內部欄位的常見用例是在您傳回給 JavaScript 的物件上分配它們,然後在稍後傳遞物件的不同原生函數中讀取它們。

至於容器類型,WTF::Vector 是一個具有固定 inline 容量的動態陣列。 因此,在這種情況下,它可以容納最多 2 個 JSValue 而無需分配,如果我們需要儲存更多,它將在堆積上分配空間。

存取內部欄位

現在讓我們看看如何向原生模組公開內部欄位。 這些是我們需要實作的函數:

V8Object.h
namespace v8 {

class Object : public Value {
public:
    // ...
    BUN_EXPORT void SetInternalField(int index, Local<Data> data);
private:
    BUN_EXPORT Local<Data> SlowGetInternalField(int index);
};

}

Data 是 V8 中堆積上任何事物的基底類別。 V8 將 SlowGetInternalField 宣告為 private,因為它僅存在於被 inline GetInternalField 呼叫,而 GetInternalField 給我們帶來了很多麻煩。 我們的實作實際上也必須是私有的,因為否則 名稱修飾符號 在 Windows 上會錯誤。

// public: class v8::Local<class v8::Data> __cdecl v8::Object::SlowGetInternalField(int) __ptr64
// (incorrect)
?SlowGetInternalField@Object@v8@@QEAA?AV?$Local@VData@v8@@@2@H@Z
                                 ^
// private: class v8::Local<class v8::Data> __cdecl v8::Object::SlowGetInternalField(int) __ptr64
// (correct)
?SlowGetInternalField@Object@v8@@AEAA?AV?$Local@VData@v8@@@2@H@Z
                                 ^

在 Linux 和 macOS 上,修飾名稱始終是 _ZN2v86Object20SlowGetInternalFieldEi,無論如何,它都不包含可見性或傳回類型資訊。

讓我們看看 SetInternalField 的實作

V8Object.cpp
void Object::SetInternalField(int index, Local<Data> data)
{
    FieldContainer* fields = getInternalFieldsContainer(this);
    RELEASE_ASSERT(fields, "object has no internal fields");
    RELEASE_ASSERT(index >= 0 && index < fields->size(), "internal field index is out of bounds");
    fields->at(index) = data->localToJSValue(Isolate::GetCurrent()->globalInternals());
}

我們呼叫一個輔助函數來存取內部欄位的向量,我將在稍後展示。 如果物件沒有內部欄位(也就是說,如果物件不是 InternalFieldObject 的實例),則它會傳回 nullptr。 在 V8 中,設定不存在的內部欄位是一個致命錯誤,因此我們包含斷言來檢查這種情況。

如果索引正確,我們在 data 上呼叫 Data::localToJSValue,以便我們有一個可以儲存在內部欄位中的 JSValue。 這是我實作的一個函數,假設它是對 Local 呼叫的,它可以處理不同的 V8 類型並產生 JSValue。 這在許多地方使用,並且可以從我們的 V8 類型實作中輕鬆呼叫,因為它們都繼承自 Data。 這是只能處理整數或指標的初始版本(實際實作現在更複雜):

V8Data.h
class Data {
    JSC::JSValue localToJSValue(GlobalInternals* globalInternals) const
    {
        // access the tagged pointer that the Local contains a pointer to
        TaggedPointer root = *reinterpret_cast<const TaggedPointer*>(this);
        if (root.tag() == TaggedPointer::Tag::Smi) {
            // integer
            return JSC::jsNumber(root.getSmiUnchecked());
        } else {
            // pointer, so we have to skip over the V8 map pointer to find the actual JSCell pointer
            ObjectLayout* v8_object = root.getPtr<ObjectLayout>();
            return JSC::JSValue(v8_object->ptr);
        }
    }
};

SlowGetInternalField 呢? SlowGetInternalField

V8Object.cpp
Local<Data> Object::SlowGetInternalField(int index)
{
    FieldContainer* fields = getInternalFieldsContainer(this);
    JSObject* js_object = localToObjectPointer<JSObject>();
    HandleScope* handleScope = Isolate::fromGlobalObject(JSC::jsDynamicCast<Zig::GlobalObject*>(js_object->globalObject()))->currentHandleScope();
    if (fields && index >= 0 && index < fields->size()) {
        JSValue field = fields->at(index);
        return handleScope->createLocal<Data>(field);
    }
    return handleScope->createLocal<Data>(JSC::jsUndefined());
}

根據 V8,如果物件沒有內部欄位,或索引超出範圍,則此函數應傳回 undefined。 我們很快就會看看如何表示像 undefined 這樣的 JavaScript 值。

對於函數的其餘部分,有很多間接性使事情看起來很複雜,但實際操作並不太棘手。 範本 localToObjectPointer 為我們提供了一個指向特定 JSCell 子類別的指標,在本例中是一個物件。 從那裡我們可以存取全域物件(它是通用的 JSGlobalObject)、將其轉換為 Bun 特定的全域物件、將該物件轉換為 Isolate,這是一種輕鬆存取 V8 函數所需資訊的方法,並取得目前的 handle scope。 我們需要 handle scope,以便此函數可以傳回在該 handle scope 內分配的 Local。 一旦我們有了欄位容器和 handle scope,我們就會執行範圍檢查,存取此欄位的 JSValue 版本,並使用 handle scope 將其轉換為 Local

為了以防萬一,這是一個圖表,顯示了此函數為了找到 InternalFieldObjectHandleScope 而遍歷的指標:

this points to the to_v8_object field at the start of a Handle object. to_v8_object points to the object layout stored inside that same Handle. The object layout's contents points to an InternalFieldObject. The InternalFieldObject points to the global object. The global object points to the V8 global internals. The V8 global internals point to the current handle scope.

這是我們第一次看到函數 HandleScope::createLocal。 目前許多 V8 API 都使用此函數來產生其傳回值。 它根據傳入的 JSValue 類型處理幾種情況:

  • 對於 32 位元整數,它會建立一個 handle,其中標記指標是 Smi。
  • 對於指向物件的指標,它會設定一個 handle,其中包含幾個不同的 Map 之一,具體取決於物件的類型。 然後,它將實際的 JSC 物件指標儲存在 Map 指標之後,並使 handle 開頭的標記指標指向 V8 期望的 map 欄位。
  • 我們稍後將討論 double、truefalsenullundefined 如何表示。

設定 handle 後,createLocal 會傳回指向該 handle 的指標,封裝在 Local 類別中。

Node.js 風格的模組註冊

在這一點上,我能夠開始在基本類型上實作更多 V8 函數。 但所有測試仍然使用奇怪的混合配置,函數透過 Node-API 註冊,但呼叫特定的 V8 函數而不是 Node-API 函數。 請記住,Node-API 是引擎無關的方式來編寫原生附加元件,而不是直接使用 V8 API。 為了修正這種情況,我新增了對以與 Node.js 中相同的方式註冊原生附加元件的支援,這既使測試的結構更清晰,當然對於任何真正的 V8 模組工作也是必要的。

我不會深入探討實作細節,因為它與 Node-API 沒有太大區別,但為了後代,我將描述如何載入 Node.js 原生模組。 有兩種方法:

  • 模組可能會公開一個名為 node_register_module_vXYZ 的函數,其中 XYZ 是模組編譯所針對的 Node.js 的 ABI 版本(請參閱 此表;Bun 目前使用 127 來匹配 Node.js 22)。 此函數傳遞三個參數:Local<Object> exportsLocal<Value> module,它們對應於 CommonJS 模組中的 exportsmodule,以及 Local<Context> context,它允許模組在需要時存取目前的 context(並透過擴充存取 isolate)。 大多數模組只是使用 v8::Object 函數將屬性分配到 exports 上。

  • 模組可以使用靜態建構子,這是一個在模組載入時由系統的動態連結器自動呼叫的函式。此功能旨在支援在 C++ 類別的 static 實例上呼叫建構子,以正確地初始化它們。當這個靜態建構子被呼叫時,它會接著呼叫 void node_module_register(void* mod),這個函式由 Node.js 公開,現在也由 Bun 公開。靜態建構子會傳遞一個指向 struct module 的指標到 node_module_register,以描述正在載入的模組的詳細資訊並提供實作。該結構包含

    • 一個 int,指示預期的 ABI 版本(載入為錯誤版本編譯的模組會拋出 JavaScript 例外)
    • 宣告模組的原始碼檔案名稱,以及模組本身的名稱
    • 兩個函式指標,因為模組可以在傳遞或不傳遞上下文到註冊函式的情況下註冊。exportsmodule 參數與 node_register_module_vXYZ 版本中的相同,context 在其中一個函式簽名中被省略,並且兩者也都具有 void *priv,允許將額外資料傳遞到註冊函式中
    • 一個不透明的指標,它被傳遞到註冊函式中,以允許攜帶額外資料

大多數原生模組本身不處理上述繁瑣的細節;相反地,它們使用來自 Node.js 公開標頭的巨集,這些巨集恰好使用靜態建構子版本而不是預定的函式名稱。因此,到目前為止,我只實作了靜態建構子路徑來載入模組,儘管要新增對另一種方式的支援也不會非常困難。

接下來

今天時間就到這裡!在本系列的最後一部分,我們將看看如何與 JSC 的垃圾回收器好好相處(劇透:到目前為止我展示的實作存在嚴重缺陷),如何表示其他 JavaScript 值,例如 doubles、booleans、nullundefined,以及 V8 相容性層的其他雜項部分。到時見。