Skip to content

【Zig 日报】Lightpanda 为什么选择 Zig? #289

@jiacai2050

Description

@jiacai2050

TL;DR
坦白说,我开始做 Lightpanda 时,选择 Zig 是因为我不够聪明,无法用 C++ 或 Rust 构建一个大型项目。
Francis Bouvier,Lightpanda 联合创始人兼 CEO

Image

我喜欢简单的语言。我喜欢 Zig 的原因和我喜欢 Go、C 和 KISS 原则的原因一样。不只是因为我信奉这个理念,而是因为我没有能力大规模地处理复杂的抽象概念。

在 Lightpanda 之前,我做了很多 Go。但是从头开始构建一个 Web 浏览器需要一种底层的系统编程语言,以确保出色的性能,所以 Go 不是一个选项。对于这样的项目,我想要比 C 更安全、更现代的工具。

为什么我们用 Zig 构建 Lightpanda

我们的需求是性能、简单性和现代工具。Zig 似乎是完美的平衡:比 C++ 和 Rust 更简单,顶级的性能,以及比 C 更好的工具和安全性。

当我们构建浏览器的第一个迭代版本并深入研究该语言时,我们开始欣赏 Zig 特别擅长的特性:编译时元编程,显式内存分配器,以及一流的 C 互操作性。更不用说正在进行的编译时间方面的工作。

当然这是一个很大的赌注。Zig 是一种相对较新的语言,生态系统很小。它是 pre-1.0 的,有规律的破坏性更改。但是我们非常看好这种语言,而且我们不是唯一的:Ghostty、Bun、TigerBeetle 和 ZML 都在用 Zig 构建。随着 Anthropic 最近收购了 Bun,大型科技公司正在关注。

以下是我们学到的。

Lightpanda 需要一种什么样的语言

在深入细节之前,让我们谈谈构建一个用于 Web 自动化的浏览器需要什么。

首先,我们需要一个 JavaScript 引擎。没有它,浏览器只能看到静态 HTML:没有客户端渲染,也没有动态内容。我们选择了 V8,Chrome 的 JavaScript 引擎,因为它技术先进、被广泛使用(Node.js、Deno),并且相对容易嵌入。

V8 是用 C++ 编写的,并且没有 C API,这意味着任何与它集成的语言都必须处理 C++ 的边界。Zig 不直接与 C++ 互操作,但它有一流的 C 互操作,C 仍然是系统编程的通用语言。我们使用主要从 rusty_v8 生成的 C 头文件(Deno 项目的一部分)来桥接 V8 的 C++ API 和我们的 Zig 代码。

除了集成之外,性能和内存控制至关重要。当您抓取数千个页面或大规模运行自动化时,每一毫秒都很重要。我们还需要精确控制诸如 DOM 树、JavaScript 对象和解析缓冲区之类的短生命周期分配。Zig 的显式分配器模型完美地满足了这一需求。

为什么不用 C++?

C++ 是显而易见的选择:它为几乎所有主要的浏览器引擎提供动力。但是,以下是我们犹豫的原因。

  • 四十年的功能: C++ 多年来积累了巨大的复杂性。几乎所有事情都有多种方法可以做到:模板元编程,多重继承模式,各种初始化语法。我们想要一种用一种清晰的方式做事的语言。
  • 内存管理: 控制伴随着持续的警惕。用后释放错误、内存泄漏和悬空指针是真正的风险。智能指针有所帮助,但它们增加了复杂性和运行时开销。Zig 通过显式传递分配器的方式使内存管理更清晰,并能更自然地实现诸如 arenas 之类的模式。
  • 构建系统: 任何与 CMake 斗争过或处理过头文件依赖关系的人都知道这种痛苦。对于一个试图快速行动的小团队来说,我们不想浪费时间调试构建配置问题。

我们不是说 C++ 不好。它为令人难以置信的软件提供动力。但是对于一个从头开始的小团队来说,我们想要更简单的东西。

为什么不用 Rust?

很多人接下来会问这个问题。这是一个合理的挑战。Rust 是一种比 Zig 更成熟的语言,提供内存安全保证,具有出色的工具,并且生态系统不断增长。

Rust 本来是一个可行的选择。但是对于 Lightpanda 的特定需求(老实说,对于我们团队的经验水平),它引入了我们不想要的摩擦。

不安全的 Rust 问题

当您需要做一些借用检查器不喜欢的事情时,您最终会编写不安全的 Rust,这出奇地困难。来自 Bun 的 Zack 在他的文章 When Zig is safer and faster than Rust 中对此进行了深入探讨。

浏览器引擎和垃圾收集运行时是与借用检查器作斗争的经典代码示例。您不断地处理不同的内存区域:每个页面的 arenas,共享缓存,临时缓冲区,具有复杂相互依赖关系的对象。这些模式不能干净地映射到 Rust 的所有权模型。您最终要么付出性能代价(使用索引而不是指针,不必要的克隆),要么深入研究不安全的代码,其中原始指针的人体工程学很差,而 Miri 成为您始终的伙伴。

Zig 采取了不同的方法。Zig 不是试图通过类型系统来强制执行安全性,然后提供一个逃生舱,而是为你在做内存不安全事情的情况下设计的。它为您提供了使这种体验更好的工具:默认情况下为非空指针,在调试模式下捕获用后释放错误的 GeneralPurposeAllocator,以及具有良好人体工程学的指针类型。

为什么 Zig 适用于 Lightpanda

Zig 位于一个有趣的领域。这是一种简单易学的语言,一切都是显式的:没有隐藏的控制流,没有隐藏的分配。

使用分配器的显式内存管理

Zig 让您通过分配器选择如何管理内存。每次分配都需要您指定使用哪个分配器。乍一看这可能听起来很乏味,但是它可以让您进行精确控制。

以下是它在实践中的样子,使用 arena 分配器:

const arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();

var list = std.ArrayList(u8).init(allocator);
defer list.deinit();

这种模式与浏览器工作负载完美匹配。每个页面加载都有自己的 arena。页面完成后,我们扔掉整个内存块。没有跟踪单个分配,没有引用计数开销,没有垃圾收集暂停。(虽然我们了解到单个页面可能会在内存中增长很大,因此我们也在探索生命周期中的清理策略)。您可以链接 arenas,以在页面生命周期内创建短生命周期的对象。

编译时元编程

Zig 的 comptime 功能使您可以编写在编译期间运行的代码。我们广泛使用它来减少桥接 Zig 和 JavaScript 时的样板代码。

在集成 V8 时,您需要将本机类型暴露给 JavaScript。在大多数语言中,这需要为每种类型编写粘合代码。要生成此粘合代码,您需要一些代码生成,通常通过 Macros(Rust,C,C ++)。Macros 是一种完全不同的语言,有很多缺点。Zig 的 comptime 使我们可以自动执行此操作:

const Point = struct {
    x: f64,
    y: f64,

    pub fn magnitude(self: @This()) f64 {
        return @sqrt(self.x * self.x + self.y * self.y);
    }
};

pub fn registerType(vm: *Vm) void {
    vm.registerType(Point, .{
        .name = "Point",
        .methods = .{"magnitude"},
        .properties = .{
            .x = .{ .getter = getX, .setter = setX },
            .y = .{ .getter = getY, .setter = setY },
        },
    });
}

registerType 函数使用编译时反射来:

  • 查找 Point 上的所有公共方法
  • 生成 JavaScript 包装函数
  • 为 x 和 y 创建属性 getter / setter
  • 自动处理类型转换

这消除了手动绑定代码,并通过在编译时和运行时使用相同的语言来简化添加新类型。

C 互操作,开箱即用

Zig 的 C 互操作是一流的功能:您可以直接导入 C 头文件并调用 C 函数,而无需包装器库。

例如,我们使用 cURL 作为我们的 HTTP 库。我们可以直接在 Zig 中导入 libcurl C 头文件并直接使用 C 函数:

const curl = @cImport({
    @cInclude("curl/curl.h");
});

pub fn httpGet(url: [:0]const u8) ![]u8 {
    // ... use curl.curl_easy_setopt, curl.curl_easy_perform etc.
}

感觉就像使用 C 一样简单,只是您正在用 Zig 编程。

而且使用构建系统也很容易将 C 源代码添加到一起构建所有内容(您的 zig 代码和 C 库):

exe.addCSourceFile(. {
    .path = "src/http.c",
});

导入 C 的简单性缓解了 Zig 生态系统仍然很小的事实,因为您可以使用所有现有的 C 库。

构建系统优势

Zig 包括它自己的用 Zig 本身编写的构建系统。这听起来可能并不起眼,但与 CMake 相比,它令人耳目一新地简单明了。添加依赖项,配置编译标志和管理交叉编译都在一个地方完成,具有清晰的语义。运行时、编译时、构建系统:一切都在 Zig 中,这使得事情变得更容易。

特别是交叉编译通常是一个困难的话题,但是使用 Zig 很容易。诸如 Uber 之类的一些项目主要将 Zig 用作构建系统和工具链。

编译时间很重要

Zig 编译速度很快。我们的完整重建花费不到一分钟。不如 Go 或解释型语言快,但足以拥有一个使开发感觉响应迅速的反馈回路。在这方面,Zig 比 Rust 或 C ++ 快得多。

这是 Zig 团队的重点。他们也是一个小团队,他们需要快速编译来开发该语言,因为 Zig 是用 Zig 编写的(自托管)。为此,他们正在开发本机编译器后端(即不使用 LLVM),这非常雄心勃勃但却很成功:它已经是调试模式下 x86 的默认后端,在构建时间方面有了显着改善(对于 Zig 项目本身快了 3.5 倍)。增量编译正在进行中。

我们学到了什么

在用 Zig 构建 Lightpanda 几个月后,以下是突出的内容。

  • 学习曲线是可控的。 Zig 的简单性意味着您可以在几周内理解整个语言。与 Rust 或 C ++ 相比,这会产生真正的差异。
  • 分配器模型得到了回报。 能够为每个页面加载、每个请求或每个任务创建 arena 分配器,使我们能够进行细粒度的内存控制,而无需跟踪单个分配。
  • 社区很小但乐于助人。 Zig 仍在增长。Discord 社区和 ziggit.dev 活跃,并且该语言足够简单,您通常可以通过阅读标准库源代码来弄清楚事情。

结论

如果没有 Zig 基金会和它背后的社区的工作,Lightpanda 将不存在。Zig 使得可以利用小型团队和清晰的心智模型构建像浏览器这样复杂的东西,而无需牺牲性能。

如果您对 Zig 的设计理念感到好奇,或者想了解其编译器和分配器模型如何工作,官方文档 是最好的起点。

您还可以浏览 Lightpanda 源代码 并在 GitHub 上关注该项目。

注册 以测试云版本。

常问问题

Zig 是否足够稳定以供生产使用?

Zig 仍然是 pre-1.0,这意味着版本之间可能会发生破坏性更改。也就是说,我们发现它对于我们的生产使用来说足够稳定,特别是自从生态系统主要标准化为跟踪最新的标记版本而不是主版本以来。该语言本身设计良好,并且版本之间的大多数更改都是值得适应的改进。只需准备在升级 Zig 版本时更新代码。

学习 Zig 最难的部分是什么?

如果您来自垃圾收集语言,则分配器模型需要进行调整。您需要考虑内存来自何处以及何时释放内存。但是与 Rust 的借用检查器或 C ++的内存管理相比,一旦您了解了这些模式,它就相对简单了。

Zig 真的可以取代 C ++进行浏览器开发吗?

对于构建像 Lightpanda 这样专注的浏览器,是的。对于替换 Chromium 或 Firefox,这不太可能:这些项目拥有数百万行 C ++代码和数十年的优化。随着时间的推移,我们更有可能看到 Rust 在这些项目中补充 C ++,例如 Firefox 如何利用 Servo。但是对于您控制代码库的新项目,Zig 绝对可行。

在哪里可以了解更多有关 Zig 的信息?

官方 Zig 文档 开始。Zig Learn 站点提供 实用的教程。并加入 Discord 上的社区ziggit.dev,开发人员会积极帮助新手。该语言足够简单,阅读标准库源代码也是一种可行的学习方法。

Why We Built Lightpanda in Zig - Blog | Lightpanda

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:

  1. 供稿,分享自己使用 Zig 的心得
  2. 改进 ZigCC 组织下的开源项目
  3. 加入微信群Telegram 群组

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions