我们正在旧金山招聘系统工程师,共同打造 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 包含许多内联函数,这些函数假定使用了这样的布局,因此我们需要以类似的方式排列我们的 JSC 类型,以确保为 V8 API 预编译的原生模块仍然有效。
在 JSC 中模拟 V8 的表示
那么,我们如何在使用 JSC 类型的同时创建类似于 V8 的布局呢?让我们回顾一下内联 V8 函数期望的重要属性:
- 每个
Local都包含一个指向当前活动的HandleScope管理的内存的指针。 HandleScope包含标记指针(tagged pointers),这些指针要么内联存储一个 32 位有符号整数,要么指向堆上的一个对象。- 堆上的每个对象都以一个指向其 map 的标记指针开始。
- 每个 map 在偏移量 12 处都有一个实例类型。
标记指针(Tagged Pointers)
首先,我们需要表示标记指针。回想一下,标记指针是一个 64 位地址,它可以表示一个 32 位有符号整数(“Smi”)、一个强指针或一个弱指针。我编写了一个简单的 struct 来包装一个 uintptr_t 并用于表示标记指针,这样我们就可以添加辅助方法和构造函数。实现并不复杂。你可以从指针或 Smi(两者都能正确设置标签位)构造一个标记指针,查询它的类型,并将其作为指针或 Smi 访问。
现在,我们可以实际实现 v8::Number,当数字是 Smi 时,无需太多额外工作。我们所需要的只是一个 Handle Scope 来提供这些标记指针的分配内存。然而,对象更难处理的情况仍然存在,所以我们先处理它(而且,它最终会影响我们如何实现 Handle Scope)。
Map(映射)
V8 和 JSC 分别使用 Map 和 Structure 来实现一项重要的优化:如果两个 JavaScript 对象具有相同的属性名称和类型,它们就可以表示得更像 C 的 struct,属性在连续的固定偏移量处,而不是一个完全动态的哈希表。这使得属性访问速度更快,并使 JIT 编译器更容易生成访问属性的代码。因此,这些类在 JavaScript 运行时会被动态分配并实例化很多次。幸运的是,对于我们的 V8 功能,我们不需要与一个假的 Map 一一对应,该 Map 被设置为匹配每个 Structure。我们只需要 Map 来覆盖内联 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 的要求,我们需要将指向 Map 的标记指针存储在每个对象的最后 8 个字节中。然而,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 中实际使用的版本类似,但使用了许多这些操作的辅助函数,并且支持 doubles(我们稍后会看到如何)。
现在,在 Number::New() 中我们需要做什么?
- 断言提供的
double value在int32_t的范围内(因为完整的double值是另一种情况,我们暂时不处理)。 - 在
isolate中找出当前活动的 Handle Scope。 - 创建一个新的 Handle。
- 将 Handle 的标记指针值(
to_v8_object)设置为代表value的 Smi(Handle 中还有 Map 指针和JSCell指针的空间,但我们不需要设置它们)。 - 返回一个包含 Handle 中标记指针的指针的
Local。
在我们能做到这一点之前,我们必须弄清楚 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 是一个具有固定内联容量的动态数组。所以在这个例子中,它可以存储多达 2 个 JSValues 而无需分配,或者如果我们需要存储更多,它将分配堆空间。
访问内部字段
现在让我们看看如何将内部字段暴露给原生模块。我们需要实现这些函数:
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,因为它仅用于由内联 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::localToJSValue 来处理 data,以便我们得到一个可以存储在内部字段中的 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 呢?
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。然后它在Map指针之后存储实际的 JSC 对象指针,并使 Handle 开头的标记指针指向 Map 字段,就像 V8 所期望的那样。 - 稍后我们将讨论 doubles、
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,它允许模块在需要时访问当前上下文(进而访问 isolate)。大多数模块只需使用v8::Object函数即可在exports上分配属性。模块可以使用静态构造函数,这是一个在模块加载时由系统动态链接器自动调用的函数。这是一个存在的功能,用于支持调用 C++ 类静态实例上的构造函数以正确初始化它们。当调用此静态构造函数时,它会反过来调用
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 的垃圾回收器友好相处(剧透:到目前为止我展示的实现都有严重缺陷),如何表示双精度浮点数、布尔值、null 和 undefined 等其他 JavaScript 值,以及 V8 兼容层的一些其他杂项部分。下次见。