同一块 QuickJS 芯片,插进六种不同的宿主 中间是一块芯片,代表同一份 quickjs.c;六根长短粗细不同的线把它连到周围六个形状各异的插座上,代表 C、Rust、Go、Python、Node 和 Java 六种宿主,其中最短的一根是强调色。左边写着 7 ms、59 ms 和 fib(25),表示同一段代码在不同宿主里跑出的时间。 7 ms … 61 ms fib(25) same quickjs.c

我们用同一份 JavaScript 文件,在同一台机器上,让 QuickJS 跑了一遍 fib(25),只是每次换一个宿主语言(C 直接链接,其余三个是各语言的绑定):

C quickjs-ng 0.17 7.2 ms
Rust rquickjs 0.14 7.6 ms
Go fastschema/qjs 22.8 ms
Java quickjs4j 0.1.1 60.6 ms

再让宿主调 20 万次脚本里的 function add(a, b) { return a + b },每次调用的平均耗时差得更远:C 是 21 纳秒,Go 的 buke/quickjs-go 默认配置下是 34274 纳秒。

QuickJS 是一个 C 写的引擎,这几次跑的 JavaScript 一个字都没变,按理说宿主只是“把它装进来”。那慢出来的时间是谁花掉的?是 Go、Java 这些语言本身慢,还是装进来的方式不同?如果要在自己的程序里嵌一个 QuickJS,选哪门语言、哪个绑定最合适?

我们先弄清楚 QuickJS 是什么、它是怎样被装进别的语言里的,再一项一项把这些差距拆开。

一、QuickJS 是什么

一个能整个塞进别的程序里的引擎

QuickJS 是 Fabrice Bellard(FFmpeg、QEMU 的作者)和 Charlie Gordon 写的 JavaScript 引擎,2019 年 7 月第一次发布1。它的目标不是和 V8 比峰值速度,而是小、能嵌进去:几个 C 文件,没有外部依赖,一个 hello world 编出来只有 367 KiB 的 x86 代码;一个运行时实例从创建到销毁不到 300 微秒;语言上支持到 ES20251。

小是靠取舍换来的。它没有 JIT,源码进来之后只走一条很短的路:

QuickJS 的结构:宿主持有 JSRuntime 和 JSContext,源码一遍编译成字节码再由解释器执行 左边是宿主程序里调用的四个 C API:JS_NewRuntime、JS_NewContext、JS_Eval、JS_Call。右边是 JSRuntime 内部:一个 JSContext 持有全局对象和内置对象;fib 函数的源码经过一遍扫描的解析器直接变成 19 字节的字节码,列出了 get_arg0、push_2、lt、if_false8、call1、add、return 等指令,最后交给 JS_CallInternal 里的解释器执行。JS_Eval 指向编译器,JS_Call 指向解释器。 宿主程序(C) JSRuntime · 一个堆、一个 GC JS_NewRuntime() 一个堆、一个垃圾回收器 JS_NewContext(rt) 一套全局对象和内置对象 JS_Eval(ctx, src, …) 编译并执行一段源码 JS_Call(ctx, fn, this, …) 调用一个 JS 函数 宿主拿到的每个 JSValue 都要自己 JS_FreeValue: QuickJS 靠引用计数回收, 再定期扫一遍环。 JSContext globalThis · Object · Array · JSON · RegExp … function fib(n) { return n < 2 ? n : fib(n-1) + fib(n-2) } 解析器 + 编译器:一遍扫描,直接出字节码 字节码 · fib 19 字节,栈深 4 get_arg0 · push_2 · lt · if_false8 7 · return get_var_ref0 · push_1 · sub · call1 · … · add 解释器:JS_CallInternal 一条指令一条指令地执行
图 1 · QuickJS 没有 JIT:源码一遍编译成字节码,之后一直由同一个 C 解释器执行

这张图里有两件事和后文直接相关。第一,JS 跑得快不快,全看这个 C 写的解释器循环被编译成了什么样的机器码。 它不会在运行中生成新代码,所以宿主语言既帮不上忙,也拖不了后腿,除非宿主改变了“这段 C 怎样变成机器码”。第二,宿主和引擎之间的每一次往来都是一个 C 函数调用:建一个数字要 JS_NewInt32,调函数要 JS_Call,用完要 JS_FreeValue。宿主语言调用 C 的代价,会乘上这些调用的次数。

两个分支,很多个版本

同一个名字下面其实有两份代码。 Bellard 在 2021 年 3 月发布一版之后,将近三年没有新版本。2023 年,Ben Noordhuis 和 Saúl Ibarra Corretgé 分叉出 quickjs-ng,按自己的节奏发版2;2024 年 1 月 Bellard 又恢复了发布,此后两边各走各的3。各语言的绑定打包的是哪一边、哪一版,全凭作者什么时候更新过:

QuickJS 两条分支的发布时间线,以及各个绑定打包的是哪一版 上面一条是 Bellard 的 QuickJS:2019-07 首发,2021-03-27 之后近三年没有发布,2024-01-13 恢复,之后 2025-04-26、2025-09-13、2026-06-04。下面一条是 quickjs-ng:2023-11 发布 v0.1.0,之后 v0.10.1、v0.12.1、v0.15.1、v0.16.2、v0.17.0。强调色圆点标出本文被测绑定实际打包的版本:Python quickjs 用 2021-03-27,quickjs-emscripten 用 2025-09-13,modernc 和 C 用 2026-06-04;fastschema 用 ng 0.10.1,quickjs-emscripten-ng 用 0.12.1,buke 用 0.15.1,rquickjs 用 0.16.2,Python quickjs-ng 和 C 用 0.17.0。 发布时间线 · 强调色是本文被测绑定实际打包的版本 Bellard · bellard/quickjs quickjs-ng · quickjs-ng/quickjs 2019 2020 2021 2022 2023 2024 2025 2026 近三年没有新版本 2023 年分叉 2019-07 2021-03-27 Python quickjs 2024-01 2025-04 2025-09-13 emscripten 2026-06-04 C · modernc v0.1 0.10.1 fastschema 0.12.1 emscripten-ng 0.15.1 buke 0.16.2 rquickjs 0.17 C · Python
图 2 · 被测的十来个绑定,打包的引擎横跨五年、两个分支

版本之间的差距并不小。Bellard 在 2026-06-04 版的发布说明里写的是“在 bench-v8 上比上一版快 42%”1;我们在本机用官方的 bench-v84 跑了一下两个分支的最新版,Bellard 2026-06-04 得 1333 分,quickjs-ng 0.17 得 1131 分。后面比较宿主时,这是一个必须先剥离的变量。

那么,同一份 C 代码是怎么进到 Go、Python、Java 这些语言里的?

二、同一份 C 代码进入宿主的三条路

三条路

一个 C 库要被别的语言用,只有三种办法:把它编译成本机机器码直接链接进去,把它编译成一种可移植的中间格式再由宿主执行,或者把 C 源码翻译成宿主语言的源码。QuickJS 的各个绑定三种都有:

QuickJS 进入宿主的三条路:原生链接、编成 WASM、翻译成 Go 左边是同一份源码 quickjs.c(约 6.5 万行)加 libregexp、libunicode、dtoa。第一条路用 gcc 或 cc 编成机器码 libqjs.a,直接链接进 C 程序、Rust(rquickjs)、Go(cgo)和 Python 的 C 扩展。第二条路用 clang 或 emscripten 编成 qjs.wasm,由 Go 的 wazero、Node 里的 V8,或者翻译成 JVM 字节码后由 Java 执行。第三条路用 ccgo 把 C 翻译成 19.8 万行的 Go 源码 ccgo_linux_amd64.go,再由 Go 编译器编译。 同一份源码 ① 编成本机机器码,直接链接 ② 编成 WASM,由宿主的虚拟机执行 ③ 把 C 翻译成 Go 源码 C 源码 quickjs.c 约 6.5 万行 libregexp.c libunicode.c dtoa.c 无外部依赖 gcc → libqjs.a C · Rust(rquickjs)· Go(cgo) Python C 扩展 · 跨语言要经过 FFI clang → qjs.wasm Go:wazero 把 WASM 再编成机器码 Node:V8 的 WASM 编译器 Java:构建期翻译成 JVM 字节码 ccgo → *.go ccgo_linux_amd64.go · 19.8 万行 Go
图 3 · 同一份 C 代码,可以变成机器码、WASM 或 Go 源码,宿主执行的东西因此完全不同

第一条路,执行的是 C 编译器生成的机器码,和 C 程序自己用没有区别;代价在“跨语言调用”这一步,每种语言调 C 的机制(Go 的 cgo、Python 的扩展 API、Rust 的 FFI)各有开销。第二条路,执行的是宿主的 WASM 引擎再生成的机器码,好处是不需要 C 工具链、天然隔离,代价是 WASM 引擎的代码质量通常不如 GCC,而且每次进出 WASM 都要转换参数。第三条路是 Go 特有的:ccgo 把 C 逐句翻译成 Go,不用 cgo 也不用 WASM,但 C 里的指针运算、malloc 都要由一个用 Go 写的 libc(modernc.org/libc)来模拟。

被测的宿主

按这三条路,我们把各语言里常见的 QuickJS 绑定都找了出来。Go 是我们的主力语言,所以 Go 的四种都测:cgo 的 buke/quickjs-go5、wazero 的 fastschema/qjs6、ccgo 翻译的 modernc.org/quickjs7,再加一个我们自己写的最薄 cgo 封装;另外加一个不是 QuickJS 的 goja8 当对照。其他语言是 Rust 的 rquickjs9、Python 的两个 C 扩展1011、Node 的 quickjs-emscripten12 和 Java 的 quickjs4j13:

被测宿主:绑定、打包的引擎版本、走哪条路
宿主绑定引擎路
C直接链接ng 0.17 / Bellard 2026-06-04① 原生
Rustrquickjs 0.14.0ng 0.16.2
Go
Go · bukebuke/quickjs-go v0.7.7ng 0.15.1① cgo
Go · 自写本文写的最薄 cgo 封装ng 0.17
Go · fastschemafastschema/qjs v0.0.6ng 0.10.1② wazero
Go · moderncmodernc.org/quickjs v0.27.0Bellard 2026-06-04③ ccgo
Go · gojadop251/goja纯 Go 引擎,不是 QuickJS对照
其他语言
Pythonquickjs 1.19.4Bellard 2021-03-27① C 扩展
Pythonquickjs-ng 0.17.0.2ng 0.17.0
Nodequickjs-emscripten 0.32.0Bellard 2025-09-13 / ng 0.12.1② V8
Javaquickjs4j 0.1.1Javy(rquickjs)② JVM
NodeV8 自己JIT,不是 QuickJS对照

机器:4 vCPU 的 Intel Xeon 2.1 GHz 云主机,GCC 13.3、Go 1.26、Rust 1.97、Python 3.13、Node 22、JDK 21。

图 4 · 十四个被测宿主,覆盖三条路

所有宿主跑的是同一份 bench.js,里面有三类纯计算负载(fib(25)、5000 个对象的 JSON 序列化加排序、5000 行文本的正则和字符串处理)、两个用来测边界的函数,以及一条读 JSON 的业务规则。每个宿主跑 3 轮取中位数。

先看最基础的问题:QuickJS 的解释器本身,在不同宿主里跑得一样快吗?

三、解释器本身的速度

现象:从 0.8 倍到 12 倍

把三个纯计算负载的耗时取几何平均,以“C 直接链接 quickjs-ng 0.17”为 1 倍:

同一组纯 JS 负载在各宿主里的耗时,相对 C 直接链接 quickjs-ng 0.17 三个负载(fib、JSON、字符串)耗时的几何平均,以 C 直接链接 quickjs-ng 0.17 为 1 倍。V8 自己(JIT,参照) 0.19 倍;C · Bellard 2026-06 0.83 倍;C · ng 0.17 1.00 倍;Go · 自写 cgo 1.06 倍;Rust · rquickjs 1.06 倍;Python · quickjs(2021 版) 1.29 倍;Go · buke(ng 0.15.1) 1.35 倍;Python · quickjs-ng wheel 1.46 倍;Node · emscripten 1.57 倍;Node · emscripten-ng 2.27 倍;Go · modernc(翻译成 Go) 2.41 倍;Go · goja(不是 QuickJS) 3.55 倍;Go · fastschema(wazero) 3.60 倍;Java · quickjs4j 12.20 倍。原生链接的宿主都在 0.8 到 1.5 倍之间,WASM 和翻译成 Go 的在 1.6 到 3.6 倍,JVM 上 12 倍。 纯 JS 负载耗时(越短越快)· C 直接链接 ng 0.17 = 1× V8 自己(JIT,参照) 0.19× C · Bellard 2026-06 0.83× C · ng 0.17 1.00× Go · 自写 cgo 1.06× Rust · rquickjs 1.06× Python · quickjs(2021 版) 1.29× Go · buke(ng 0.15.1) 1.35× Python · quickjs-ng wheel 1.46× Node · emscripten 1.57× Node · emscripten-ng 2.27× Go · modernc(翻译成 Go) 2.41× Go · goja(不是 QuickJS) 3.55× Go · fastschema(wazero) 3.60× Java · quickjs4j 12.20× 直接把 C 编进宿主 WASM 或翻译成别的语言 参照(不是 QuickJS)
图 5 · 直接把 C 编进来的宿主都在 1 倍附近,换了执行方式才会差出几倍

图上的条形分成了明显的几组。直接链接 C 的宿主(强调色)都在 0.8 到 1.5 倍之间;走 WASM 或翻译成 Go 的在 1.6 到 3.6 倍;JVM 上的 quickjs4j 是 12 倍。V8 自己跑同样的脚本只要 0.19 倍,那是 JIT 的功劳,不在本文的比较范围里。

原生这一组:差别来自引擎版本和编译器

同属第一条路,为什么还有 0.83 倍和 1.46 倍之分? 按宿主语言去解释是解释不通的:Go 的 buke 比自写的 cgo 封装慢,Python 的两个扩展一个慢在 JSON、一个慢在 fib。把它们打包的引擎单独拿出来、用 C 重新编一遍,答案就出来了:

原生链接的几种情况:同一份引擎源码,换宿主不换速度
引擎 · 宿主 · 编译方式fib msJSON ms字符串 ms
ng 0.17,GCC 13 -O2
C7.221.68.0
Go 自写 cgo7.524.08.3
Rust rquickjs(ng 0.16.2,-O3)7.623.78.2
Python,本机从 sdist 编译7.2–7.723.3–25.28.2–8.4
ng 0.15.1(buke 打包的版本)
Go buke7.635.711.3
C,同一份 0.15.1 源码7.432.910.2
其他
Python quickjs(Bellard 2021-03-27)6.732.912.0
Python quickjs-ng(PyPI wheel,GCC 14.2)17.923.79.1
C,Bellard 2026-06-046.017.36.9

Python 的“本机编译”是用 pip 的 --no-binary 从同一个 0.17.0.2 源码包装的,两次运行的范围。

图 6 · buke 慢在 0.15.1 这一版本身,Python wheel 慢在编译它的那个 GCC,都与宿主语言无关

buke 的 JSON 慢,是 0.15.1 慢。 把 buke 打包的那份 0.15.1 源码拿出来用 C 编,JSON 一样是 33 毫秒;换成 0.17,Go 里也是 24 毫秒。

Python quickjs-ng 的 fib 慢,是 PyPI 上那个预编译包慢。 同一个 0.17.0.2 的源码包,在本机用 GCC 13 编出来,fib 回到 7 毫秒左右;PyPI 上的 manylinux wheel 是 GCC 14.2 编的,fib 要 17.9 毫秒。Bellard 在 2026 年 6 月修过一个“新版 GCC 下性能大幅退化”的问题14,原因是值类型 JSValue 里一个只初始化了一半的 union 让新 GCC 生成了更慢的代码。ng 0.17 已经带了同样的写法,所以 wheel 慢的具体原因我们没有完全确认,这里只能推断是编译器版本或编译参数的问题;能确认的是,换宿主语言不会让它变快,换个编译方式会。

Python quickjs 的 JSON 慢,是因为它打包的还是 2021 年的引擎。 它的 fib 不慢,说明 2021 年版的解释器核心并不差,慢在后来几年才优化的 JSON 和字符串处理。

所以第一条路上的结论很干脆:只要 C 代码是被编进来的,JS 执行速度就和宿主语言无关,只和“哪一版引擎、用什么编译器编”有关。 选绑定时,先看它打包的是什么版本。

其他几条路:执行方式决定量级

第二、三条路就不一样了,执行的已经不是 GCC 生成的机器码:

  • V8 跑 WASM(Node)约 1.6 倍。 V8 的 WASM 编译器很成熟,两个变体之间的差距(1.57 和 2.27 倍)主要还是引擎版本:一个是 Bellard 2025-09,一个是 ng 0.12.1。
  • wazero 跑 WASM(Go)约 3.6 倍。 wazero 是纯 Go 写的 WASM 运行时,它的编译器后端比 V8 的简单得多;fastschema 打包的 ng 0.10.1 也比较老。
  • ccgo 翻译成 Go 约 2.4 倍。 翻译出的 Go 代码要经过 Go 编译器,所有内存操作都走 modernc 的 libc 模拟层。
  • JVM 约 12 倍。 quickjs4j 把 WASM 在构建期翻译成 JVM 字节码,翻译出来的方法非常大,HotSpot 默认不编译超过 8000 字节码的方法;不加 -XX:-DontCompileHugeMethods,fib 要 513 毫秒,加上也还有 61 毫秒。

goja 不是 QuickJS,但它是 Go 里最常见的替代品,3.55 倍和 wazero、modernc 在同一个量级。

到这里还只解释了开头那组数字里的一半:fib 慢 3 倍可以归给 WASM,可同样是 cgo,buke 调一次 add 要 34 微秒,比 C 慢了 1600 倍,又是为什么?

四、跨边界调用的代价

现象:一次 add 从 17 纳秒到 34 微秒

嵌入脚本的程序很少只让 JS 闷头算。更常见的是宿主一次次调 JS 函数(每来一个请求跑一次规则),或者 JS 一次次回调宿主提供的函数(查一下配置、读一个字段)。我们测了三个量:宿主调 20 万次 add(acc, 1);JS 里循环 20 万次调宿主注册的 hostAdd;以及宿主把一段 JSON 字符串传进去、让 JS 解析后判断一条规则,跑 5 万次。

跨边界调用:每次的平均耗时
宿主宿主调 JSJS 调宿主一条 JSON 规则
C · Bellard 2026-0617 ns34 ns3.4 µs
C · ng 0.1721 ns44 ns4.0 µs
Rust · rquickjs82 ns113 ns4.4 µs
Go · 自写 cgo89 ns79 ns4.2 µs
Python · quickjs138 ns148 ns7.0 µs
Python · quickjs-ng163 ns209 ns4.3 µs
Go · modernc294 ns625 ns12.4 µs
Go · goja(对照)329 ns777 ns26.1 µs
Go · buke 关掉检查730 ns466 ns10.0 µs
Node · emscripten1.6 µs3.3 µs14.6 µs
Java · quickjs4j—17.6 µs—
Go · fastschema(wazero)29.9 µs31.1 µs49.8 µs
Go · buke 默认34.3 µs10.9 µs36.2 µs

“JS 调宿主”包含 JS 自己循环一次的开销(C 里约 43 ns)。quickjs4j 只能通过 JSON 序列化调用 JS 函数,没有测宿主调 JS。

图 7 · 计算速度一样的几个原生宿主,边界开销能差出 400 倍

这张表的排序和图 5 很不一样。buke 在纯计算上和 C 一样快,在边界上却是最慢的;Python 的计算不算快,边界却只比 Rust 慢一点。边界开销显然另有来源。

原因一:buke 每个 API 都在查“我是不是在原来那个 goroutine 上”

用 pprof 看 buke 调 add 的过程,94% 的时间花在一个叫 currentGoroutineID 的函数里,它调用 runtime.Stack 把当前 goroutine 的栈打印成文本,再从文本里解析出 goroutine 编号。buke 在 Runtime、Context、Value 的几乎每个方法前都做这个“属主检查”,防止别的 goroutine 并发访问同一个运行时5。一次 add 要建两个数、调一次、取结果、释放三个值,一共约十次检查,每次约 3 微秒。

从 Go 调一次 add(acc, 1):buke 要跨约十次边界,自写封装只跨一次 左边是 buke 完成一次 add 的调用序列:NewInt32(acc)、NewInt32(1)、Execute 也就是 JS_Call、ToInt32、三次 Free,以及内部的 NewUndefined、IsUndefined,每一步都是一次 cgo 调用,前面还有一次用 runtime.Stack 做的属主检查。默认 34.3 微秒,关掉检查后 730 纳秒。右边是自写封装:Go 只调一次 eng_call,在 C 里完成建数、JS_Call、取结果、释放,89 纳秒;C 自己做同样的事 21 纳秒。下方是 fastschema 的情况:每次进 WASM 前都调用 ExportedFunction 查一次导出函数,3075 纳秒;把查到的函数缓存起来,一次调用只要 32 纳秒。 buke:每一步都是一次 cgo 调用 自写封装:只过一次 cgo fastschema:每次进 WASM 先查一次导出函数 Go 方法 → C 函数 每步前:runtime.Stack 查属主 ctx.NewInt32(acc)JS_NewInt32 ctx.NewInt32(1)JS_NewInt32 fn.Execute(g, x, y)JS_NewUndefined · JS_Call r.ToInt32()JS_ToInt32 r.Free() · x.Free() · y.Free()JS_FreeValue × 3 约 10 次 cgo,每次 50–70 ns 约 10 次属主检查,每次约 3 µs 默认 34.3 µs 关掉属主检查 730 ns Go → C 一次 eng_call(e, ADD, acc, 1) JS_NewInt32 × 2 JS_Call JS_ToInt64 JS_FreeValue 以上都在 C 里完成 一次 89 ns C 自己做同样的事:21 ns m.ExportedFunction("QJS_Call").Call(…) 每次新建函数实例和调用栈:3075 ns f := m.ExportedFunction(…);f.Call(…) 查一次、缓存起来反复用:32 ns
图 8 · 慢的不是 cgo 或 WASM 本身,而是绑定把一次逻辑调用拆成了很多次,并且每次都做了昂贵的额外工作

buke 提供了 WithOwnerGoroutineCheck(false) 关掉这个检查,代价是调用方自己保证一个运行时只在一个 goroutine 上用5。关掉之后一次 add 是 730 纳秒,剩下的就是约十次 cgo 调用本身,每次 50 到 70 纳秒。

原因二:cgo 一次往返本身多贵

cgo 每次调用要在 Go 的调度器上登记“这个线程进 C 了”,出来时再登记回去,这是它比普通函数调用贵的原因。为了看清这笔开销的下限,我们写了一个最薄的封装:Go 只调一次 C 函数 eng_call,建数、调用、取结果、释放全在 C 里做完。这样一次 add 是 89 纳秒,减去 C 自己的 21 纳秒,一次 cgo 往返大约 70 纳秒。封装的核心只有这么几行:

Go
/*
#include "quickjs.h"
typedef struct { JSRuntime *rt; JSContext *ctx; JSValue fn[8]; } eng;
// 一次逻辑调用在 C 里做完:建参数、调用、取结果、释放
static int64_t eng_call(eng *e, int f, int argc, int32_t a, int32_t b) {
JSValue argv[2] = { JS_NewInt32(e->ctx, a), JS_NewInt32(e->ctx, b) };
JSValue r = JS_Call(e->ctx, e->fn[f], JS_UNDEFINED, argc, argv);
int64_t v = 0;
JS_ToInt64(e->ctx, &v, r);
JS_FreeValue(e->ctx, r);
return v;
}
*/
import "C"
func (e *Engine) Call(fn int, a, b int32) int64 {
return int64(C.eng_call(e.e, C.int(fn), 2, C.int32_t(a), C.int32_t(b)))
}

函数句柄在创建运行时时就取好存进 fn[],Go 这边每次只传一个下标。

反方向也一样:JS 调一个用 Go 写的 hostAdd 要 79 纳秒,把 hostAdd 换成 C 实现是 42 纳秒,C 回调 Go 大约多出 35 到 40 纳秒。

Rust(82 纳秒)和 Python(138 到 163 纳秒)的数字也在这个量级。Rust 调 C 本身没有额外开销,rquickjs 多出来的时间花在它的安全包装上,比如每次调用前都更新一次栈顶(第六节会讲为什么要这么做)。Python 每次调用都要把 Python 的 int 转成 JSValue、再把结果转回来,这在 CPython 里本来就是这个价钱。

原因三:fastschema 每次进 WASM 都查一次函数表

fastschema 的 30 微秒有着类似的来历。它每次调用 WASM 里的函数,都写成 m.ExportedFunction("QJS_Call").Call(...),而 wazero 的 ExportedFunction 每次都会新建一个函数实例、分配一块调用栈15。我们绕过 fastschema、直接用 wazero 加载它的 qjs.wasm 做了对比:同一个导出函数,每次查找再调用是 3075 纳秒,查一次缓存起来是 32 纳秒,差了近 100 倍。一次 add 在 fastschema 里要进出 WASM 十来次,于是成了 30 微秒。

WASM 的边界本身并不贵,32 纳秒比 cgo 还便宜,因为 wazero 调用 WASM 不需要切换到系统栈,也不需要通知调度器。贵的是绑定的写法。Node 的 quickjs-emscripten 每次调用 1.6 微秒则是另一种情况:它的每个参数、返回值都是一个要手动 dispose 的句柄对象,一次调用要经过好几次 JS 和 WASM 之间的转换。

回到业务规则

最后一列“JSON 规则”更接近真实场景:宿主把一个 JSON 字符串传进去,JS 解析、判断,返回 0 或 1。它本身要做 3 到 4 微秒的事,所以边界开销只要在一两百纳秒以内就看不出来:C、Rust、Go 自写封装、Python quickjs-ng 都在 4.0 到 4.4 微秒。buke 默认配置下是 36 微秒,fastschema 是 50 微秒,边界开销比正事还多十倍。

单次调用的成本弄清楚了,下一个问题是:一台机器上要同时跑很多段脚本时,又是什么情况?

五、启动、内存与并发

每来一个请求新建一个运行时,行不行

QuickJS 宣传的“300 微秒内完成一个运行时的生命周期”,是很多人选它的原因:每个请求一个全新的运行时,脚本之间互不干扰。我们测了新建运行时和上下文、执行 1+1、销毁的耗时,以及保持 200 个加载了 bench.js 的实例时平均每个占多少内存:

启动、内存和多线程吞吐
宿主新建一个运行时每个实例内存1 / 2 / 4 线程吞吐(次/秒)
C · ng 0.17104 µs170 KB—
Rust · rquickjs106 µs182 KB119 / 243 / 421
Go · buke 关掉检查304 µs117 KB129 / 254 / 496
Go · modernc198 µs165 KB46 / 24 / 21
Go · fastschema(wazero)3.4 ms,进程内首次 0.77 s660 KB33 / 53 / 78
Go · goja(对照)11 µs35 KB34 / 68 / 115
Python · quickjs176 µs55 KB134 / 261 / 459
Python · quickjs-ng115 µs167 KB56 / 108 / 211
Node · emscripten209 µs54 KB—
Java · quickjs4j9.0 ms——

吞吐:1、2、4 个线程(goroutine)各自持有一个运行时,循环跑 fib(25),4 vCPU。内存是 200 个实例的 RSS 增量平均值,各版本内置对象多少不同,只看量级。

图 9 · 原生的几种都能一个请求一个运行时;wazero 的首次编译和 modernc 的并发是两个例外

原生的几种新建一个运行时都在 0.1 到 0.3 毫秒,每个实例一两百 KB,“一个请求一个运行时”是可行的。表里有三处需要单独解释。

fastschema 进程里第一次创建运行时要 0.77 秒。 wazero 要先把 1 MB 的 qjs.wasm 编译成机器码,这一步在每个进程里做一次(可以配置磁盘缓存),之后每个运行时 3.4 毫秒,因为每个实例都要分配并初始化一块独立的 WASM 线性内存。对命令行工具、Serverless 这类短命进程,这 0.77 秒很可能比脚本本身还长。

modernc 线程越多越慢。 QuickJS 的运行时是单线程的,想用满多核只能开多个运行时,各跑各的;原生绑定基本能线性扩展。modernc 从 1 个 goroutine 的 46 次/秒掉到 4 个的 21 次/秒,原因在它模拟 C 标准库的 modernc.org/libc:malloc 和 free 共用一把全局互斥锁 allocMu,QuickJS 分配又极其频繁,多个运行时就全卡在这把锁上。

Python 的两个扩展都能多线程扩展。 它们在执行 JS 期间释放了 GIL(Py_BEGIN_ALLOW_THREADS)10,所以多个线程各持一个 Context 时真的是并行的。

goja 新建只要 11 微秒,因为它的“运行时”就是普通的 Go 对象,不需要初始化一整套 C 结构;这也是它在 Go 里的主要优势。

性能之外,嵌入一个 C 引擎还有一类问题会直接让进程挂掉,它也和宿主的线程模型有关。

六、栈、线程与崩溃

现象:没有递归,却报“栈溢出”

写自用的 cgo 封装时,我们第一次跑规则测试,5 万次里有 3.6 万次返回了异常:Maximum call stack size exceeded。规则只是解析一段 JSON,根本没有递归。

原因:QuickJS 记住的是创建时那个线程的栈

QuickJS 防止 JS 无限递归把 C 栈耗尽的办法,是在创建运行时时记下当前的栈顶地址,之后每次进入函数都比较“当前栈指针离栈顶有多远”,超过上限(默认 1 MB)就抛异常。这个办法默认了一件事:运行时永远在同一个线程、同一块栈上执行。 在 C 里这是自然而然的,在 Go 里却不是:goroutine 会被调度器换到别的 OS 线程上,cgo 调用发生在哪个线程,取决于这个 goroutine 当时在哪。

goroutine 换了线程,QuickJS 按旧线程的栈顶判断,误报栈溢出 实测输出:JS_NewRuntime 在线程 13632 执行,栈地址 0x7ffdcbd576a4,QuickJS 记下这个栈顶。第 0 次调用也在线程 13632,结果正常。第 1 次调用 goroutine 被换到线程 13635,栈地址 0x7f23f199dddc,与记下的栈顶相差 0xd9da3b98c8 字节(约 0.85 TB),远超 1 MB 的上限,于是抛出 Maximum call stack size exceeded,此后每次都失败。解决办法是 runtime.LockOSThread 或者每次调用前 JS_UpdateStackTop。 OS 线程 13632(主线程) OS 线程 13635 JS_NewRuntime() 记下栈顶 栈地址 0x7ffdcbd576a4 上限 往下 1 MB 以内 第 0 次调用 sp 0x7ffdcbd5767c ok goroutine 被调度器换到别的线程 第 1 次起的每次调用 同一个运行时 sp 0x7f23f199dddc 离记下的栈顶 约 0.85 TB Maximum call stack size exceeded 虚线:并没有递归,是按错的 栈顶算出来的“溢出” 解决:运行时所在 goroutine 调 runtime.LockOSThread(),或者每次调用前 JS_UpdateStackTop
图 10 · 栈深检查假设运行时一直在同一个线程上,Go 的 goroutine 不满足这个假设

两个地址相差约 0.85 TB,自然“超过了 1 MB 的上限”。解决办法有两个:让持有运行时的 goroutine 调 runtime.LockOSThread() 钉在一个线程上;或者像 rquickjs 那样,每次调用前执行一次 JS_UpdateStackTop,按当前线程重新记栈顶9。Rust 里没有 goroutine 调度,rquickjs 这样做是为了支持运行时在线程间移动,这也是它的单次调用比 C 多出几十纳秒的原因之一。

现成的 Go 绑定怎么处理:一个关了检查,一个崩成 panic

那 buke 为什么没有这个问题? 因为它默认把栈深上限设成了 0,也就是不检查5。代价是真的无限递归时,没有人拦着:

执行 function r(n){ return r(n+1) + 1 } 之后
宿主结果
Go · buke 默认SIGSEGV,整个进程退出
Go · buke + WithMaxStackSize(1<<20)JS 里抛 RangeError,可以 catch
Go · fastschema(wazero)Go panic:wasm error: out of bounds memory access
Go · moderncJS 里抛 InternalError: stack overflow
Rust · rquickjs、Python 两个扩展JS 里抛异常,可以 catch
Node · emscripten宿主侧抛 RangeError(V8 的栈先满了)

脚本用 try/catch 包住了递归,“可以 catch”指脚本自己能接住,宿主继续正常运行。

图 11 · 同一段恶意脚本,有的绑定当作普通异常,有的直接让进程退出

如果脚本来自用户,这张表比前面所有性能数字都重要。buke 的默认配置下,一行递归就能让整个服务退出;fastschema 会变成 Go 的 panic,虽然能 recover,但这个运行时的状态已经不可信,只能丢掉。

到这里,开头的几组数字都找到了出处。最后回答那个问题:嵌在哪里最好?

七、回到开头:嵌在哪里最好

先把开头的现象逐一对上:

  • fib 7.2 和 7.6 毫秒:C 和 Rust 执行的是同一类 GCC 机器码,差别在引擎小版本和编译参数。
  • fastschema 的 22.8 毫秒、quickjs4j 的 60.6 毫秒:它们执行的不是 GCC 生成的机器码,而是 wazero 和 JVM 从 WASM 再生成的代码,慢在执行方式。
  • buke 的 34 微秒一次 add:既不是 Go 慢,也不是 cgo 慢,而是绑定在每个 API 上做了一次 runtime.Stack。

所以“QuickJS 嵌在哪门语言里表现最好”的答案是:只要走第一条路,把 C 直接编进去,语言本身几乎不影响 JS 的执行速度;真正拉开差距的是绑定打包的引擎版本、执行方式,以及绑定在边界上怎么写。 只看数字,C 和 Rust 最好,Python 意外地不差(边界一两百纳秒、执行时释放 GIL),Node 里用它只适合需要沙箱的场合,Java 目前的选择还很慢。

对 Go 来说,选择取决于能不能接受 cgo:

Go 程序嵌入 QuickJS 的选择 第一个问题:能接受 cgo 吗。能:用 buke 时一定要加 WithMaxStackSize,并在确认单 goroutine 使用时关掉属主检查,或者自己写一层把一次逻辑调用合成一次 cgo 并 LockOSThread,计算 1 倍,边界约 90 纳秒。不能:再问需要多核并发吗。需要:fastschema 计算约 3.6 倍、边界 30 微秒、首次启动 0.77 秒,或者不坚持 QuickJS 用 goja。不需要:modernc 计算约 2.4 倍、边界约 300 纳秒,但多 goroutine 会变慢。 在 Go 里嵌 QuickJS 能接受 cgo 吗? 能 不能 需要多个 goroutine 同时跑脚本吗? 需要 不需要 cgo 绑定 计算 1×,边界 ~90 ns 到 0.7 µs buke:WithMaxStackSize(1<<20) 必加 单 goroutine 使用时 WithOwnerGoroutineCheck(false) 边界很密:自己封一层,一次逻辑调用只过一次 cgo, 并 runtime.LockOSThread() fastschema/qjs(wazero)或干脆用 goja fastschema:计算 3.6×,边界 30 µs,进程首次 0.77 s,能多核扩展 goja:计算 3.6×,边界 0.3–0.8 µs,新建 11 µs,不是 QuickJS modernc.org/quickjs 计算 2.4×,边界 ~0.3 µs,异常处理正常;多 goroutine 反而变慢
图 12 · Go 里最快的是 cgo,但要自己补上栈深上限;不用 cgo 时,按是否需要并发在 wazero、goja 和 modernc 之间选

能用 cgo,就用 cgo:计算速度和 C 一样,但 buke 的两个默认值都要改,一个关乎性能(属主检查),一个关乎进程会不会被一行脚本打崩(栈深上限)。如果宿主和 JS 之间来往特别频繁,自己写一层薄封装是值得的,一次往返从 730 纳秒降到 89 纳秒。

不能用 cgo(要交叉编译、要静态二进制、不想装 C 工具链)时,三个纯 Go 方案各有一个硬伤:fastschema 的边界开销和首次编译,modernc 的并发,goja 不是 QuickJS(如果需要和 QuickJS 完全一致的行为,比如同一份脚本要在 C 端也跑)。附带一个数字:只执行 1+1 的最小程序,-s -w 编译后 buke 是 2.9 MB,modernc 5.2 MB,fastschema 6.3 MB,goja 10.6 MB;后三者 CGO_ENABLED=0 交叉编译到 arm64 都能直接通过,buke 不行。

小结

  1. QuickJS 是什么? 一个几个 C 文件、无依赖、没有 JIT 的 JavaScript 引擎:源码一遍编译成字节码,由 C 解释器执行;宿主通过 JS_NewRuntime、JS_Call 这些 C 函数和它打交道。Bellard 原版和 quickjs-ng 两个分支并行发版,各绑定打包的版本横跨五年。
  2. 同一份 C 代码怎么进入别的语言? 三条路:编成机器码直接链接(C、Rust、Go cgo、Python 扩展),编成 WASM 由宿主执行(Go wazero、Node、Java),翻译成 Go 源码(modernc)。
  3. 解释器速度和宿主语言有关吗? 走第一条路时无关,差别来自引擎版本(ng 0.15.1 比 0.17 的 JSON 慢 50%,Python quickjs 还是 2021 年的引擎)和编译器(同一个源码包,PyPI wheel 的 fib 慢 2.4 倍)。走 WASM 或翻译时,慢 1.6 到 12 倍,取决于执行它的虚拟机。
  4. 边界调用为什么能差 400 倍? cgo 往返约 70 纳秒、wazero 调用约 32 纳秒,本身都不贵;buke 的属主检查(runtime.Stack,每次约 3 微秒)和 fastschema 每次 ExportedFunction(约 3 微秒)把一次逻辑调用变成了 30 多微秒。
  5. 启动和并发呢? 原生绑定新建一个运行时 0.1 到 0.3 毫秒,可以一个请求一个;wazero 进程内首次要 0.77 秒;modernc 的 libc 有全局分配锁,多 goroutine 反而变慢;Python 扩展执行 JS 时释放 GIL,能多线程扩展。
  6. 还有什么会让进程挂掉? QuickJS 按创建时的线程记栈顶,Go 的 goroutine 换线程就会误报栈溢出,要 LockOSThread 或 JS_UpdateStackTop;buke 默认关掉栈深检查,一行无限递归就能让进程段错误退出。
  7. 嵌在哪里最好? 只看引擎,C 和 Rust 最好,Python 也不差;在 Go 里优先用 cgo 绑定并改掉两个默认值,边界密集时自己封一层;不能用 cgo 时按是否需要并发在 wazero、modernc 和 goja 之间取舍。

参考资料

Footnotes

  1. QuickJS Javascript EngineFabrice Bellard · bellard.orgbellard.org ↗官网:几个 C 文件、无外部依赖,hello world 367 KiB x86 代码;运行时实例完整生命周期不到 300 微秒;支持 ES2025;引用计数加环检测。2026-06-04 版“比上一版在 bench-v8 上快 42%”。https://bellard.org/quickjs/ ↩ ↩2 ↩3

  2. quickjs-ng/quickjsGitHubgithub.com ↗“a fork of the original QuickJS project … after it went dormant”,维护者 Ben Noordhuis、Saúl Ibarra Corretgé。v0.1.0 发布于 2023-11-23,文中 C 宿主用 v0.17.0 之后的 main(7a23d8e)。https://github.com/quickjs-ng/quickjs ↩

  3. bellard/quickjs:ChangelogGitHubgithub.com ↗各版本改动。2026-06-04:小块自定义 malloc(bench-v8 快 11%)、微优化(快 30%)。2021-03-27 之后到 2024-01-13 之间没有发布。https://github.com/bellard/quickjs/blob/master/Changelog ↩

  4. QuickJS extras:tests/bench-v8bellard.orgbellard.org ↗V8 benchmark suite v7 的合并版 combined.js,QuickJS 官网的性能数字就是用它测的。https://bellard.org/quickjs/quickjs-extras-2026-06-04.tar.xz ↩

  5. buke/quickjs-goGitHubgithub.com ↗cgo 绑定,文中用 v0.7.7,打包 quickjs-ng v0.15.1。WithOwnerGoroutineCheck、WithMaxStackSize 两个选项见 runtime.go;默认 maxStackSize 为 0。https://github.com/buke/quickjs-go ↩ ↩2 ↩3 ↩4

  6. fastschema/qjsGitHubgithub.com ↗把 quickjs-ng 编成 WASM(-O3 + wasm-opt),用 wazero 在 Go 里跑,不需要 cgo。文中用 v0.0.6,内嵌 qjs.wasm 为 ng 0.10.1。https://github.com/fastschema/qjs ↩

  7. modernc.org/quickjsGitLabgitlab.com ↗用 ccgo 把 Bellard 版 QuickJS 的 C 源码翻译成 Go。文中用 v0.27.0(libquickjs v0.14.0,对应 2026-06-04 版),linux/amd64 的翻译结果是一个 19.8 万行的 ccgo_linux_amd64.go。https://gitlab.com/cznic/quickjs ↩

  8. dop251/gojaGitHubgithub.com ↗纯 Go 写的 ECMAScript 引擎,不是 QuickJS,作为 Go 里的对照。https://github.com/dop251/goja ↩

  9. DelSkayn/rquickjsGitHubgithub.com ↗Rust 绑定,文中用 0.14.0,打包 quickjs-ng 0.16.2;每次调用前执行 JS_UpdateStackTop(rquickjs-core 的 args.rs)。https://github.com/DelSkayn/rquickjs ↩ ↩2

  10. PetterS/quickjs(PyPI:quickjs)GitHubgithub.com ↗Python C 扩展,文中用 1.19.4;sdist 里的 upstream-quickjs/VERSION 是 2021-03-27。执行 JS 时 Py_BEGIN_ALLOW_THREADS 释放 GIL。https://github.com/PetterS/quickjs ↩ ↩2

  11. genotrance/quickjs-ng(PyPI:quickjs-ng)GitHubgithub.com ↗PetterS 版的分支,换成 quickjs-ng 0.17.0,文中用 0.17.0.2。PyPI 上的 manylinux wheel 由 GCC 14.2.1 编译。https://github.com/genotrance/quickjs-ng ↩

  12. justjake/quickjs-emscriptenGitHubgithub.com ↗用 emscripten 编成 WASM,在 Node / 浏览器里跑。文中用 0.32.0 的两个变体:@jitl/quickjs-wasmfile-release-sync(Bellard 2025-09-13)和 @jitl/quickjs-ng-wasmfile-release-sync(ng 0.12.1)。https://github.com/justjake/quickjs-emscripten ↩

  13. roastedroot/quickjs4jGitHubgithub.com ↗Java 绑定,文中用 0.1.1:Javy 插件(内部是 rquickjs)编成 WASM,再在构建期翻译成 JVM 字节码。https://github.com/roastedroot/quickjs4j ↩

  14. bellard/quickjs 10c81b1:fixed large performance regression with recent GCC versionsGitHub · 2026-06-03github.com ↗JSValueUnion 的 int32 字段改成 uint64:“avoid uninitialized data by using a 64 bit field even if only 32 bits are needed because some compilers generate slower code”。https://github.com/bellard/quickjs/commit/10c81b1989de2d2e8d482d252b160a34f215d3e2 ↩

  15. tetratelabs/wazeroGitHubgithub.com ↗纯 Go 的 WebAssembly 运行时,amd64/arm64 默认用编译器后端。文中版本 v1.9.0;Module.ExportedFunction 每次调用都会新建一个函数实例。https://github.com/tetratelabs/wazero ↩