Goja 部分基准超 QuickJS:纯 Go 引擎的取舍

掘金热榜文章《纯 Go 写的 JS 引擎,跑分超过了 C 语言的 QuickJS》报道了 Goja 在部分基准中超过 QuickJS。 Goja 在部分基准中超过 QuickJS,说明纯 Go 方案已经能在特定工作负载下形成竞争力,但这属于局部结果,不能据此认定版本兼容、内存占用、长期稳定性和生产效率也全面领先。

需要区分三个问题:Goja 为什么能在某些测试中更快;“ECMAScript 5.1+”能不能承载现代 JavaScript;Go 服务、插件系统和动态脚本应该选 Goja、QuickJS,还是带 JIT 的 V8、JSC。现有资料能支持“Goja 是无 CGO 依赖的纯 Go ECMAScript 5.1+ 引擎”这一定位,但没有提供一套足以复现全部排名的完整 QuickJS 对比环境,因此更可靠的选型方法仍是固定环境、验证等价结果,再测自己的业务负载。

跑赢 QuickJS 究竟比较了什么?

“超过”必须绑定引擎版本、测试套件、输入数据和计时边界。不同版本可能调整解析器、正则、对象模型或内建函数,同一段脚本在不同预热状态下也会得到完全不同的结果。

一份可用于复核的记录,至少应包含以下内容:

类别 必须记录的内容
引擎版本 Goja 使用的模块版本和源码提交;QuickJS 的上游版本、提交号和本地改动
Go 环境 Go 版本、GOOS、GOARCH、CGO_ENABLED、编译参数
宿主环境 操作系统及内核、CPU 型号、核心数、内存容量、是否处于节能或降频状态
QuickJS 构建 C 编译器及版本、配置参数、优化级别、是否启用扩展
测试输入 原始脚本、数据规模、预热输入与正式输入、预期输出
测试协议 预热次数、正式重复次数、超时时间、是否启用并发
输出指标 墙钟耗时、CPU 时间、内存分配、GC 次数或停顿、常驻内存、P95/P99 延迟

这里最容易出错的是“正确性门槛”。两个引擎必须完成相同功能并产生等价结果;如果 QuickJS 无法解析某段语法,而 Goja 能执行,这首先是兼容性和支持范围差异,不能简单记成 Goja 更快。反过来,如果某项测试提前异常退出,也不能把异常处理时间当成正常执行耗时。

运行阶段应分别测量:

阶段 建议计时边界 主要回答的问题
冷启动 进程启动、Runtime 初始化、首次解析和执行 CLI、容器和函数计算场景的启动成本
首次编译 Runtime 已创建,仅测脚本首次准备成本 短任务是否被编译阶段主导
预热后短任务 固定预热次数后重复执行 插件、规则计算等高频短任务
长时间稳态 持续运行并分段采样 分配、GC 和长期吞吐是否稳定
跨语言调用 分别测纯脚本和 Go/JavaScript 往返调用 宿主边界是否吞掉引擎性能差异

参考资料还提到 Goja 相比其他 Go JavaScript 引擎快 6~7 倍。 但这与“部分基准超过 QuickJS”不是同一测试口径:前者比较的是 Go 生态中的引擎,后者比较的是 Goja 与 QuickJS。不能把两个结论拼接成“Goja 比 QuickJS 快 6~7 倍”,现有资料也不足以支持这样的数字。

现有资料未提供完整原始数据,不能据此补写更大的倍数;可复核的记录应采用下面的表格:

场景 结果等价 中位数 P95/P99 分配量 GC 常驻内存 原始输出
冷启动 通过/失败 待填 待填 待填 待填 待填 文件或命令
预热稳态 通过/失败 待填 待填 待填 待填 待填 文件或命令
正则与字符串 通过/失败 待填 待填 待填 待填 待填 文件或命令
Go/JS 边界调用 通过/失败 待填 待填 待填 待填 待填 文件或命令

纯 Go 为什么可能在某些任务中跑得更快?

“纯 Go”首先说明实现和交付方式,不能直接据此推出性能结论。Goja 无需 CGO,可直接进入 Go 项目的构建、依赖和跨平台交付链路。 这能减少构建和交付环节的集成成本,但不能自动证明解释执行、对象分配或正则处理一定更快。

阅读 Goja 源码时,可以从公开入口 RunString 顺着调用链检查脚本如何被解析成内部程序表示,运行时如何处理变量和内建对象,函数调用如何进入求值路径,以及结果如何成为 Goja Value。随后再分别检查正则、严格模式、Go 函数回调和导出转换。不同版本的内部文件与函数命名可能变化,因此不应脱离具体提交讨论固定的内部结构。

一次典型调用可以抽象为:

Go 宿主调用
  -> Runtime.RunString
  -> 脚本解析与内部程序准备
  -> 运行时上下文、变量与内建对象
  -> 表达式求值、函数调用与控制流
  -> Goja Value 结果
  -> ExportTo 导出,或通过 Callable 返回 Go

局部领先的来源需要通过剖析验证,不能只依据语言标签判断。

首先,短任务可能主要消耗在解析、首次准备和固定开销上。此时,某个引擎内部数据结构、对象查找方式或 Go 编译器优化效果,都可能改变结果;预热充分的稳态测试则可能呈现另一套排序。

其次,Go 与 JavaScript 的边界调用会稀释引擎差异。如果一个基准反复把结构体、切片或对象图转成 JavaScript 值,再把结果导回 Go,测到的可能主要是装箱、属性映射和分配,而不是脚本执行本身。

再次,Goja 作为 Go 进程的一部分,其对象分配和生命周期受 Go 垃圾回收器影响。少分配的实现可能获得更稳定的尾延迟;频繁创建临时对象或跨边界复制数据,则可能带来额外 GC 压力。

最后,基准脚本是否真正等价也会影响结果。一个主要计算数值表达式的脚本、一个大量创建对象的脚本,以及一个以正则替换为主的脚本,实际上是在测试完全不同的引擎路径。

Go 侧可以直接使用标准工具链观察 CPU、分配和 GC:

go test -run '^$' -bench . -benchmem -count=10 ./bench
go test -run '^$' -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out ./bench
go tool pprof -top cpu.out
go tool pprof -top mem.out
GODEBUG=gctrace=1 go test -run '^$' -bench . -benchmem ./bench

其中 ./bench 应替换为实际基准包路径。如果 cpu.out 的热点集中在宿主转换、正则或对象分配,那么继续比较“解释器与解释器”的整体速度意义不大,应该先优化边界和数据布局。

QuickJS 不带 JIT;带 JIT 的引擎在不少计算任务上可能明显领先。2021 年的选型文章给出的旧有经验值是平均存在 5~10 倍差距。 这组数据用于说明 JIT 带来的量级差异,不能直接套用到 Goja 与 QuickJS 的比较中。现有资料也没有证明 Goja 属于 JIT 引擎,因此更不应把 Goja 和 V8、JSC 简单归为同一执行模型。

“ES5.1+”的加号究竟覆盖了哪些现代场景?

“ES5.1+”中的加号不是正式的 ECMAScript 版本号,也不代表通过了某个完整的一致性测试套件。参考资料称 Goja 完整支持 ECMAScript 5.1,包括正则表达式和严格模式;“+”具体覆盖了哪些后续能力,则不能仅凭这个标签展开。

QuickJS 的资料口径更明确:支持 ES2023,包括模块、异步生成器、Proxy 和 BigInt,并可选择提供 BigDecimal、BigFloat 与运算符重载等数学扩展。 两者可先按以下矩阵比较:

能力维度 Goja QuickJS 验证重点
ECMAScript 5.1 资料称完整支持。 资料称支持 ES2023。 用项目中的实际语法和错误路径验证
严格模式 资料明确列为支持。 给定资料未单独展开 检查抛错类型、作用域和 this 行为
正则表达式 资料明确列为支持。 给定资料未单独展开 检查替换、捕获组、Unicode 和边界行为
模块 “ES5.1+”资料未给出逐项保证 明确支持。 同时验证语法支持与模块加载器
异步生成器 给定资料未确认 明确支持。 验证迭代协议和宿主调度
Proxy 给定资料未确认 明确支持。 检查代理陷阱、对象身份和异常传播
BigInt 给定资料未确认 明确支持。 检查与 Number 混合运算及序列化
BigDecimal、BigFloat、运算符重载 给定资料未确认 可选扩展支持。 确认构建是否启用以及业务是否依赖
Node.js、DOM、网络、定时器 不属于 ECMAScript 引擎本身,需由宿主提供 同样需要宿主提供 分开测试语言能力和宿主 API

“资料未确认”不等于“不支持”,只表示不能拿现有资料替项目下结论。兼容性验证应使用分层测试:

测试层 应使用的材料 通过标准
语法与语义 项目真实代码片段 输出、异常类型和错误路径一致
内建对象与正则 日期、字符串、数组、对象、正则调用 结果一致,关键边界无差异
现代特性 模块、异步生成器、Proxy、BigInt 等实际依赖 特性可执行,异步顺序符合预期
第三方依赖 锁定版本后的真实 npm 包或内部模块 初始化、主要调用和错误分支均通过
前端产物 实际构建配置生成的 bundle 能加载、执行并产生预期 DOM 或业务结果
宿主 API 文件、网络、定时器、控制台和模块解析 由明确实现的宿主能力满足
故障路径 主动抛出异常、非法输入、超限脚本 JavaScript 异常能映射为可处理的 Go 错误

兼容性验证还应覆盖第三方 npm 包和前端 bundle。一个包可能语法完全受支持,却因为依赖 process、文件系统、定时器或 Node 模块解析器而无法运行;浏览器构建产物也可能依赖 DOM。语言层通过与业务层通过,应当记录成两个不同结论。

Go 与 JavaScript 来回传值时,真实成本在哪里?

Goja 提供的最小集成路径包括:使用 vm.Set 注入 Go 值或函数,使用 vm.RunString 执行脚本,使用 vm.ExportTo 把结果导出到 Go,以及用 goja.AssertFunction 获取可从 Go 调用的 JavaScript 函数。

下面的示例同时展示了三个方向:

package main

import (
    "fmt"
    "log"

    "github.com/dop251/goja"
)

func main() {
    vm := goja.New()

    if err := vm.Set("name", "Goja引擎"); err != nil {
        log.Fatal(err)
    }

    if err := vm.Set("version", "1.0"); err != nil {
        log.Fatal(err)
    }

    if err := vm.Set("add", func(a, b float64) float64 {
        return a + b
    }); err != nil {
        log.Fatal(err)
    }

    if _, err := vm.RunString(`
        function greet(name) {
            return "你好," + name + "!";
        }
    `); err != nil {
        log.Fatal(err)
    }

    result, err := vm.RunString(`greet(name + " " + version) + ",和为 " + add(20, 22)`)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(result.String())

    jsResult, err := vm.RunString("Math.PI * 2")
    if err != nil {
        log.Fatal(err)
    }

    var goResult float64
    if err := vm.ExportTo(jsResult, &goResult); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("JavaScript 计算结果:%f\n", goResult)

    greetValue, err := vm.Get("greet")
    if err != nil {
        log.Fatal(err)
    }

    greetFunc, ok := goja.AssertFunction(greetValue)
    if !ok {
        log.Fatal("greet 不是可调用函数")
    }

    greeting, err := greetFunc(goja.Undefined(), vm.ToValue("开发者"))
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(greeting.String())
}

安装依赖:

go get github.com/dop251/goja

压测应覆盖 Go 与 JavaScript 的边界转换。只测一段纯 JavaScript,无法反映这部分成本:

边界操作 可能产生的成本 常见优化方式
基本类型传入与导出 接口装箱、Value 包装、类型校验 优先传数字、布尔值和字符串,减少包装层
结构体、映射、切片传入 属性映射、反射或对象包装 传稳定 ID,在 JavaScript 侧按需查询
大型对象图反复传入 属性复制、临时分配和 GC 批量处理,或保持数据在主要运行的一侧
Go 函数被 JavaScript 调用 参数转换、返回值包装、异常映射 缓存 Callable,减少调用次数,控制参数数量
JavaScript 异常返回 Go 错误包装、堆栈整理 保留原始错误信息,避免重复解析错误文本
Promise、微任务或异步函数 宿主事件循环和任务调度 明确由谁驱动微任务,避免等待无人推进的任务

如果业务以高频小调用为主,Go 与 JavaScript 的边界成本很可能比两个引擎的执行速度差异更大。比较前可以先做两项优化:一是把对象图转换成 ID 或紧凑数组;二是把多个小调用合并成一次批量调用。优化后仍然存在的差距,才更接近引擎本身的差距。

并发方面,不应假定同一个 Runtime 可以被多个 goroutine 无锁并发复用。稳妥做法是为独立工作单元创建 Runtime,或者在宿主层串行化访问,并用 Go 的 race detector 检查相关路径。若脚本不可信,仅创建一个独立 Runtime 并不等于完成安全隔离;CPU、内存、文件系统和网络权限仍应放在明确的边界内,必要时采用独立进程。

不依赖 CGO 的收益,是否足以抵消生产环境代价?

Goja 无需 CGO,可以直接跟随 Go 项目构建,并减少本地编译和容器交付中的原生工具链依赖。 对 Go 服务而言,这项工程收益通常比局部基准中的速度优势更有价值:

CGO_ENABLED=0 go build -trimpath -o app ./cmd/app

这条命令可以用于验证目标包是否能够在关闭 CGO 的条件下完成构建。./cmd/app 应替换为实际入口路径。

但“不依赖 CGO”和“运行速度更快”是两件事。前者影响构建、交叉编译、静态交付和依赖管理;后者还取决于对象模型、分配策略、垃圾回收、正则、宿主接口以及工作负载。

生产评估至少应同时记录:

指标 为什么重要
二进制大小 影响容器镜像分发和 CLI 下载
冷启动时间 影响短生命周期任务和按需启动
常驻内存 影响多 Runtime 和多租户部署
每操作分配量 决定长期 GC 压力
GC 暂停与尾延迟 决定 P95/P99 是否稳定
稳态吞吐 决定规则、转换和批处理任务的单位时间成本
并发隔离成本 决定能否安全地服务多个脚本执行单元
升级与维护成本 决定规范跟进、安全修复和故障定位的长期投入

JavaScript 引擎选型还要考虑语法支持、调试体验、平台规范、包体积和内存,不能只比较速度。 纯 Go 可以降低一部分集成成本,其他成本仍需单独评估。

移动端还要单独处理平台约束。参考资料提到 iOS 平台对集成带 JIT 的虚拟机存在限制。 这条信息来自 2021 年面向特定平台的资料,不应直接当成今天的通用规则,但足以说明:桌面服务器上的引擎结论不能原样复制到移动应用。当前项目仍应核对目标系统版本、应用商店规则和引擎许可。

维护层面也需要评估错误栈是否可读、profiling 是否顺畅、依赖升级是否稳定,以及 Goja 与 JavaScript 规范之间的同步维护负担。纯 Go 消除了 C/C++ 桥接,并不消除规范兼容性成本。

放到真实项目中,哪些场景会加分?

Goja、QuickJS 和带 JIT 的引擎需要按场景取舍。下表是基于工程目标的初步方向,不构成固定排名:

场景 Goja QuickJS V8、JSC 等 JIT 引擎
Go 服务内嵌动态插件 集成路径直接,适合优先验证 能嵌入,但需要处理 C 工具链和跨语言接口 能力强,但部署与桥接复杂度通常更高
规则、模板扩展、配置计算 适合用真实业务脚本验证 同样是候选方案 只有在规模或语义需求明确时再引入
现代前端工具链 兼容性通过后才可采用 ES2023 语言层覆盖更明确。 可作为浏览器语义和性能参照
长时间 CPU 密集计算 必须观察分配和尾延迟 无 JIT,资料中的选型文章提醒其与 JIT 引擎可能存在明显差距。 应纳入最终基准,不能只与解释器比较
内存敏感服务 以实际 Runtime 数量和对象图测量 以实际构建和宿主开销测量 不能仅凭语言标签判断,必须测常驻内存
短生命周期 CLI 重点看冷启动和交付体积 同时比较原生库交付和启动成本 需把引擎及运行时体积计入总成本
受限平台或无 CGO 环境 值得优先验证 受构建链约束 还需检查平台规则和许可
不可信脚本 必须补齐资源限制和隔离 同样需要隔离 同样不能把引擎对象当成安全沙箱

在 Go 服务内嵌插件、运行规则引擎以及频繁传递数据的场景中,Goja 的优势主要体现为集成摩擦较小;现有证据不足以证明所有脚本都更快。若主要成本来自边界调用,应先优化调用频率和数据布局。

对于依赖模块、异步生成器、Proxy、BigInt 或完整 npm 生态的项目,兼容性门槛应先于性能比较。QuickJS 的资料明确给出了 ES2023 覆盖范围,因此它在这一维度上提供了更清晰的语言层基线;但如果项目只使用 Goja 已经验证通过的功能,也没有必要为了“更新”强行更换。

对于浏览器或移动端替代方案,还必须把 DOM、网络、存储和系统能力单独列项。一个能执行 JavaScript 的引擎,不等于一个浏览器运行环境。JSC 是 WebKit 的默认内嵌引擎,V8 也拥有更完整的长期优化体系;把二者作为参照,有助于校准纯 Go 方案的能力边界。

下一步:怎样用三组实验完成最终选择?

最终选择需要同时完成三类验证:基准实验测量性能,兼容实验确认业务能否正确运行,部署实验评估是否适合长期运行。三组实验应使用同一套业务输入和预期输出。

基准实验应锁定 Goja 模块版本、源码提交、QuickJS 提交、Go 版本、操作系统、CPU、编译参数和测试数据;先验证结果等价,再分别测冷启动、预热稳态、长时间运行、正则、对象分配和 Go/JavaScript 边界调用。保留每次原始输出,不只发布平均值,同时记录分配量、GC、P95/P99 和常驻内存:

go version
go env GOOS GOARCH CGO_ENABLED
git rev-parse HEAD
go test -run '^$' -bench . -benchmem -count=10 ./bench
go test -run '^$' -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out ./bench
go tool pprof -top cpu.out
go tool pprof -top mem.out

兼容实验则从真实项目提取语法、内建对象、正则、第三方依赖、前端产物、错误路径和宿主 API,形成通过/失败清单。对每个失败项记录是语言能力缺失、模块加载器缺失,还是宿主 API 未实现,避免把不同原因混成“Goja 不兼容 JavaScript”。

部署实验验证无 CGO 构建,记录二进制大小、冷启动、常驻内存、GC 和尾延迟;再测试多个 Runtime 的隔离、并发访问、异常传播、超时和资源上限:

CGO_ENABLED=0 go build -trimpath -o app ./cmd/app

Linux 环境下还可以用系统工具观察进程的峰值资源:

/usr/bin/time -v ./app

最终选择要核对三个结果:兼容性是否通过;业务负载下的性能是否在可接受的波动范围内领先;部署与维护成本是否可接受。如果 Goja 只在冷启动上获胜,却在稳态吞吐或尾延迟上持续落后,可以把它用于轻量规则,把重任务交给其他引擎;如果现代特性不通过,也不应依靠大量补丁把语言缺口伪装成兼容性。

应先从真实项目中挑出三到五段代表性脚本,固定版本完成这三组实验。热榜标题只能作为线索,最终选型应由自己的输入、自己的预期输出和可复核的运行数据决定。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容