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 包含许多假设使用这种布局的内联函数,我们需要以类似的方式排列我们的 JSC 类型,以确保为 V8 API 预编译的本机模块仍然可以工作。

在 JSC 中模仿 V8 表示

那么,我们如何在 JSC 类型的情况下创建看起来像 V8 的布局呢? 让我们回顾一下内联 V8 函数期望的重要属性

  • 每个 Local 都包含一个指向当前活动的 HandleScope 管理的内存的指针
  • HandleScope 包含标记指针,这些指针可以内联存储有符号 32 位整数,也可以指向堆上的对象
  • 堆上的每个对象都以指向其 map 的标记指针开始
  • 每个 map 在偏移量 12 处都有一个实例类型

标记指针

首先,我们需要表示标记指针。 回想一下,标记指针是一个 64 位地址,它可以表示 32 位有符号整数(“Smi”)、强指针或弱指针。 我编写了一个简单的 struct,它包装了 uintptr_t 并用于表示标记指针,以便我们可以添加辅助方法和构造函数。 该实现并不太复杂。 你可以从指针或 Smi 构造一个标记指针(两者都可以正确处理设置标记位),查询它的类型,并将其作为指针或 Smi 访问。

现在,我们实际上可以在数字是 Smi 的情况下实现 v8::Number,而无需进行太多额外的工作。 我们所需要的只是一个句柄作用域来提供可以分配这些标记指针的内存。 但是,对于对象来说,更困难的情况仍然潜伏着,所以让我们首先解决这个问题(而且,它最终会影响我们如何实现句柄作用域)。

Maps

V8 和 JSC 分别使用 MapStructure 进行重要的优化:如果两个 JavaScript 对象具有相同的属性名称和类型,则可以将它们表示为更像 C struct 的东西,属性一个接一个地位于固定的偏移量,而不是完全动态的哈希表。 这使得属性访问速度更快,并且使 JIT 编译器更容易生成访问属性的代码。 因此,这些类都会被动态分配和多次实例化,因为 JavaScript 正在运行。 幸运的是,对于我们的 V8 功能,我们不需要与设置为匹配每个 Structure 的伪 Map 建立 1:1 的关联。 我们只需要 Map 来覆盖内联 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。 这不仅效率低下,而且容易出错,因为我们需要在没有垃圾回收器帮助的情况下管理所有这些对象的内存。

相反,如果我们将这两个字段都存储在句柄作用域内会怎样? 我们已经需要在句柄作用域中为每个 V8 值留出 8 个字节,以存储标记指针。 如果我们改为留出 24 个字节,我们可以将句柄、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 中,这是一个单独的堆分配(并且包括对象的实际字段,如字符串内容))现在存储在句柄作用域管理的内存中。 但是你也可以看到,从 V8 代码的角度来看,我们的布局应该看起来相似。 例如,该代码仍然可以跟随 to_v8_object 标记指针来查找对象,该对象将 Map 指针作为其第一个字段。 to_v8_object 恰好总是向前指向 8 个字节,这并不重要。

下图显示了为了从 Local 句柄中查找实例类型而跟踪的指针,无论是在 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 的可增长数组,并且必须在其构造函数中将自身标记为当前句柄作用域,并在其析构函数中恢复先前的句柄作用域。 请记住,与我们一直在查看的其他 V8 类型不同,HandleScope 是堆栈分配的,大小为 24 字节。 这实际上只是存储的合适大小

  • 指向 Isolate 的指针(当我们使用 JSC 时,Isolate 甚至是什么?我稍后会解决这个问题)
  • 指向先前 HandleScope 的指针
  • 指向实现句柄实际存储的类 HandleScopeBuffer 的指针

实现 HandleScopeBuffer 的唯一技巧是我们期望每个句柄都在堆栈上的某个位置都有一个指向它的活动指针。 如果某些代码创建了大量句柄,我们需要能够增长数组,而无需移动任何现有句柄。 我们稍后将以可扩展的方式解决此问题。 现在,我们可以使 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 调用,this 是指向句柄中存储的标记指针的指针。 因此我们需要

  • 取消引用 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 中实际使用的版本类似,但对许多这些操作使用了辅助函数,并支持双精度浮点数(我们稍后会看到)。

现在,我们需要在 Number::New() 中做什么?

  1. 断言提供的 double value 适合 int32_t 的范围(因为完整的 double 值是不同的情况,我们尚不打算处理)
  2. 找出 isolate 中当前活动的句柄作用域
  3. 创建一个新句柄
  4. 将句柄的标记指针值 (to_v8_object) 设置为表示 value 的 Smi(句柄中也会有用于 map 指针和 JSCell 指针的空间,但我们不需要设置这些)
  5. 返回一个 Local,其中包含指向句柄中标记指针的指针

在我们执行此操作之前,我们必须弄清楚 Isolate 应该是什么。 许多 V8 函数,尤其是那些分配新对象的函数,都会传递一个指向 Isolate 的指针。 因此,我们应该使其成为对我们自己的使用 JSC 的实现有用的东西。 在我甚至开始在 Bun 工作之前,已经实现了一些基本的 V8 函数,对 isolate 和上下文都使用了指向全局对象的指针,因此我在最初启动更多 V8 API 时坚持使用了它。

现在我们需要一种从全局对象获取当前句柄作用域的方法。 我们可以直接添加一个字段,但是由于我正在为 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 仅在其创建的句柄作用域存在时有效。 我们需要确保对象内的值不会被删除,因为内部字段的常见用例是在你返回给 JavaScript 的对象上分配它们,然后在稍后传递到对象的不同本机函数中读取它们。

至于容器类型,WTF::Vector 是具有固定内联容量的动态数组。 因此,在这种情况下,它可以容纳最多 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,因为它仅用于由内联 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

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 函数所需信息的简便方法,并获取当前句柄作用域。 我们需要句柄作用域,以便此函数可以返回在该句柄作用域内分配的 Local。 一旦我们有了字段容器和句柄作用域,我们就执行边界检查,访问此字段的 JSValue 版本,并使用句柄作用域将其转换为 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 位整数,它创建一个句柄,其中标记指针是 Smi
  • 对于指向对象的指针,它设置一个句柄,其中包含几个不同的 Map 之一,具体取决于对象的类型。 然后,它在 Map 指针之后存储实际的 JSC 对象指针,并使句柄开头的标记指针指向 map 字段,正如 V8 所期望的那样。
  • 我们稍后将讨论如何表示双精度浮点数、truefalsenullundefined

设置句柄后,createLocal 返回指向该句柄的指针,包装在 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,它允许模块访问当前上下文(并扩展为 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 值,如双精度浮点数、布尔值、nullundefined,以及 V8 兼容性层的其他一些杂项部分。 到时见。