我們正在舊金山招聘系統工程師,一同打造 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);
}
由於 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 分別使用 Map
和 Structure
進行重要的最佳化:如果兩個 JavaScript 物件具有相同的屬性名稱和類型,則它們可以表示為更像 C struct
的東西,屬性位於一個接一個的固定偏移量處,而不是完全動態的雜湊表。 這使得屬性存取速度更快,並且使 JIT 編譯器更容易產生存取屬性的程式碼。 因此,這些類別都會在 JavaScript 執行時動態分配和實例化多次。 幸運的是,對於我們的 V8 功能,我們不需要與設定為匹配每個 Structure
的偽造 Map
建立 1:1 的關聯。 我們只需要 Map
來涵蓋 inline V8 函數期望從實例類型中獲得某些東西的情況。 例如,我們有一個由所有物件共用的 Map
,其唯一目的是強制 V8 呼叫 SlowGetInternalField
,而不是嘗試直接查找內部欄位。 我們出於各種原因建立了一些其他 Map
,但我們不需要動態建立它們中的任何一個,因此它們都只是靜態常數。
以下是 Map
的粗略定義
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 物件指標全部彼此相鄰地儲存。
struct ObjectLayout {
TaggedPointer m_taggedMap;
JSCell* m_ptr;
};
struct Handle {
TaggedPointer m_toV8Object;
ObjectLayout m_object;
};
使用此方案,Local
持有指向 to_v8_object
欄位的指標。 如果 to_v8_object
是整數,則忽略 ObjectLayout
。 如果它是指標,它會指向 ObjectLayout
的 tagged_map
欄位,以便 V8 程式碼可以找到它期望的 map 指標。
以下是完成後此佈局的外觀,使用本文頂部的相同程式:
與 V8 下佈局運作方式的主要區別在於 ObjectLayout
的內容(在 V8 中,這是一個單獨的堆積分配(並包含物件的實際欄位,例如字串內容))現在儲存在由 handle scope 管理的記憶體中。 但您也可以看到,從 V8 程式碼的角度來看,我們的佈局應該看起來相似。 例如,該程式碼仍然可以追蹤 to_v8_object
標記指標,以找到一個物件,該物件將 Map
指標作為其第一個欄位。 to_v8_object
恰好總是向前指向 8 個位元組這一點並不重要。
下圖顯示了為了從 Local
handle 中查找實例類型而追蹤的指標,無論是在 V8 佈局(上方)還是我們在 Bun 中實作的偽造 V8 佈局(下方)中。 雖然在這種情況下實例類型不同,但兩者都被視為字串,這就是我們所關心的。
Handle
有幾個不同的建構子。 必須維護的不變量是,如果 to_v8_object
不是 Smi,它必須包含 object
的位址。 我實作了 C++ 複製建構子和賦值運算子,以幫助確保這一點。
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
的定義,使其更清楚地表明它實際代表什麼
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
函數。
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
讓我們試試看
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()
中做什麼?
- 斷言提供的
double value
符合int32_t
的範圍(因為完整的double
值是我們尚未嘗試處理的不同情況) - 找出
isolate
中目前活動的 handle scope - 建立新的 handle
- 將 handle 的標記指標值 (
to_v8_object
) 設定為表示value
的 Smi(handle 也將在其中具有 map 指標和JSCell
指標的空間,但我們不需要設定這些) - 傳回
Local
,其中包含指向 handle 中標記指標的指標
在我們可以執行此操作之前,我們必須弄清楚 Isolate
應該是什麼。 許多 V8 函數,尤其是那些分配新物件的函數,都會傳遞指向 Isolate
的指標。 因此,我們應該使其成為對我們使用 JSC 的實作有用的東西。 在我甚至開始在 Bun 工作之前,已實作了一些基本的 V8 函數,使用指向全域物件的指標作為 isolate 和 context,因此我在最初讓更多 V8 API 啟動時堅持使用它。
現在我們需要一種從全域物件取得目前 handle scope 的方法。 我們可以直接新增欄位,但由於我正在為 V8 新增許多欄位,我將它們全部放入 v8::GlobalInternals
類別中,以追蹤 Bun 的 V8 API 支援特定的狀態。 這確保了如果未使用 V8 API,我們不會過度膨脹全域物件。 全域物件只具有指向全域內部結構的延遲初始化指標。
考慮到這些細節,我們終於可以實作正確版本的 Number::New()
了
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
實作建立該類型的物件。
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
而無需分配,如果我們需要儲存更多,它將在堆積上分配空間。
存取內部欄位
現在讓我們看看如何向原生模組公開內部欄位。 這些是我們需要實作的函數:
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
的實作
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
。 這是只能處理整數或指標的初始版本(實際實作現在更複雜):
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
?
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
。
為了以防萬一,這是一個圖表,顯示了此函數為了找到 InternalFieldObject
和 HandleScope
而遍歷的指標:
這是我們第一次看到函數 HandleScope::createLocal
。 目前許多 V8 API 都使用此函數來產生其傳回值。 它根據傳入的 JSValue
類型處理幾種情況:
- 對於 32 位元整數,它會建立一個 handle,其中標記指標是 Smi。
- 對於指向物件的指標,它會設定一個 handle,其中包含幾個不同的
Map
之一,具體取決於物件的類型。 然後,它將實際的 JSC 物件指標儲存在Map
指標之後,並使 handle 開頭的標記指標指向 V8 期望的 map 欄位。 - 我們稍後將討論 double、
true
、false
、null
和undefined
如何表示。
設定 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> exports
和Local<Value> module
,它們對應於 CommonJS 模組中的exports
和module
,以及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 例外) - 宣告模組的原始碼檔案名稱,以及模組本身的名稱
- 兩個函式指標,因為模組可以在傳遞或不傳遞上下文到註冊函式的情況下註冊。
exports
和module
參數與node_register_module_vXYZ
版本中的相同,context
在其中一個函式簽名中被省略,並且兩者也都具有void *priv
,允許將額外資料傳遞到註冊函式中 - 一個不透明的指標,它被傳遞到註冊函式中,以允許攜帶額外資料
- 一個
大多數原生模組本身不處理上述繁瑣的細節;相反地,它們使用來自 Node.js 公開標頭的巨集,這些巨集恰好使用靜態建構子版本而不是預定的函式名稱。因此,到目前為止,我只實作了靜態建構子路徑來載入模組,儘管要新增對另一種方式的支援也不會非常困難。
接下來
今天時間就到這裡!在本系列的最後一部分,我們將看看如何與 JSC 的垃圾回收器好好相處(劇透:到目前為止我展示的實作存在嚴重缺陷),如何表示其他 JavaScript 值,例如 doubles、booleans、null
和 undefined
,以及 V8 相容性層的其他雜項部分。到時見。