我们用同一份 JavaScript 文件,在同一台机器上,让 QuickJS 跑了一遍 fib(25),只是每次换一个宿主语言(C 直接链接,其余三个是各语言的绑定):
C quickjs-ng 0.17 7.2 msRust rquickjs 0.14 7.6 msGo fastschema/qjs 22.8 msJava 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,源码进来之后只走一条很短的路:
这张图里有两件事和后文直接相关。第一,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。各语言的绑定打包的是哪一边、哪一版,全凭作者什么时候更新过:
版本之间的差距并不小。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 的各个绑定三种都有:
第一条路,执行的是 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 | ① 原生 |
| Rust | rquickjs 0.14.0 | ng 0.16.2 | |
| Go | |||
| Go · buke | buke/quickjs-go v0.7.7 | ng 0.15.1 | ① cgo |
| Go · 自写 | 本文写的最薄 cgo 封装 | ng 0.17 | |
| Go · fastschema | fastschema/qjs v0.0.6 | ng 0.10.1 | ② wazero |
| Go · modernc | modernc.org/quickjs v0.27.0 | Bellard 2026-06-04 | ③ ccgo |
| Go · goja | dop251/goja | 纯 Go 引擎,不是 QuickJS | 对照 |
| 其他语言 | |||
| Python | quickjs 1.19.4 | Bellard 2021-03-27 | ① C 扩展 |
| Python | quickjs-ng 0.17.0.2 | ng 0.17.0 | |
| Node | quickjs-emscripten 0.32.0 | Bellard 2025-09-13 / ng 0.12.1 | ② V8 |
| Java | quickjs4j 0.1.1 | Javy(rquickjs) | ② JVM |
| Node | V8 自己 | 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。
所有宿主跑的是同一份 bench.js,里面有三类纯计算负载(fib(25)、5000 个对象的 JSON 序列化加排序、5000 行文本的正则和字符串处理)、两个用来测边界的函数,以及一条读 JSON 的业务规则。每个宿主跑 3 轮取中位数。
先看最基础的问题:QuickJS 的解释器本身,在不同宿主里跑得一样快吗?
三、解释器本身的速度
现象:从 0.8 倍到 12 倍
把三个纯计算负载的耗时取几何平均,以“C 直接链接 quickjs-ng 0.17”为 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 ms | JSON ms | 字符串 ms |
|---|---|---|---|
| ng 0.17,GCC 13 -O2 | |||
| C | 7.2 | 21.6 | 8.0 |
| Go 自写 cgo | 7.5 | 24.0 | 8.3 |
| Rust rquickjs(ng 0.16.2,-O3) | 7.6 | 23.7 | 8.2 |
| Python,本机从 sdist 编译 | 7.2–7.7 | 23.3–25.2 | 8.2–8.4 |
| ng 0.15.1(buke 打包的版本) | |||
| Go buke | 7.6 | 35.7 | 11.3 |
| C,同一份 0.15.1 源码 | 7.4 | 32.9 | 10.2 |
| 其他 | |||
| Python quickjs(Bellard 2021-03-27) | 6.7 | 32.9 | 12.0 |
| Python quickjs-ng(PyPI wheel,GCC 14.2) | 17.9 | 23.7 | 9.1 |
| C,Bellard 2026-06-04 | 6.0 | 17.3 | 6.9 |
Python 的“本机编译”是用 pip 的 --no-binary 从同一个 0.17.0.2 源码包装的,两次运行的范围。
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 万次。
| 宿主 | 宿主调 JS | JS 调宿主 | 一条 JSON 规则 |
|---|---|---|---|
| C · Bellard 2026-06 | 17 ns | 34 ns | 3.4 µs |
| C · ng 0.17 | 21 ns | 44 ns | 4.0 µs |
| Rust · rquickjs | 82 ns | 113 ns | 4.4 µs |
| Go · 自写 cgo | 89 ns | 79 ns | 4.2 µs |
| Python · quickjs | 138 ns | 148 ns | 7.0 µs |
| Python · quickjs-ng | 163 ns | 209 ns | 4.3 µs |
| Go · modernc | 294 ns | 625 ns | 12.4 µs |
| Go · goja(对照) | 329 ns | 777 ns | 26.1 µs |
| Go · buke 关掉检查 | 730 ns | 466 ns | 10.0 µs |
| Node · emscripten | 1.6 µs | 3.3 µs | 14.6 µs |
| Java · quickjs4j | — | 17.6 µs | — |
| Go · fastschema(wazero) | 29.9 µs | 31.1 µs | 49.8 µs |
| Go · buke 默认 | 34.3 µs | 10.9 µs | 36.2 µs |
“JS 调宿主”包含 JS 自己循环一次的开销(C 里约 43 ns)。quickjs4j 只能通过 JSON 序列化调用 JS 函数,没有测宿主调 JS。
这张表的排序和图 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 微秒。
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 纳秒。封装的核心只有这么几行:
/*#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.17 | 104 µs | 170 KB | — |
| Rust · rquickjs | 106 µs | 182 KB | 119 / 243 / 421 |
| Go · buke 关掉检查 | 304 µs | 117 KB | 129 / 254 / 496 |
| Go · modernc | 198 µs | 165 KB | 46 / 24 / 21 |
| Go · fastschema(wazero) | 3.4 ms,进程内首次 0.77 s | 660 KB | 33 / 53 / 78 |
| Go · goja(对照) | 11 µs | 35 KB | 34 / 68 / 115 |
| Python · quickjs | 176 µs | 55 KB | 134 / 261 / 459 |
| Python · quickjs-ng | 115 µs | 167 KB | 56 / 108 / 211 |
| Node · emscripten | 209 µs | 54 KB | — |
| Java · quickjs4j | 9.0 ms | — | — |
吞吐:1、2、4 个线程(goroutine)各自持有一个运行时,循环跑 fib(25),4 vCPU。内存是 200 个实例的 RSS 增量平均值,各版本内置对象多少不同,只看量级。
原生的几种新建一个运行时都在 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 当时在哪。
两个地址相差约 0.85 TB,自然“超过了 1 MB 的上限”。解决办法有两个:让持有运行时的 goroutine 调 runtime.LockOSThread() 钉在一个线程上;或者像 rquickjs 那样,每次调用前执行一次 JS_UpdateStackTop,按当前线程重新记栈顶9。Rust 里没有 goroutine 调度,rquickjs 这样做是为了支持运行时在线程间移动,这也是它的单次调用比 C 多出几十纳秒的原因之一。
现成的 Go 绑定怎么处理:一个关了检查,一个崩成 panic
那 buke 为什么没有这个问题? 因为它默认把栈深上限设成了 0,也就是不检查5。代价是真的无限递归时,没有人拦着:
| 宿主 | 结果 |
|---|---|
| Go · buke 默认 | SIGSEGV,整个进程退出 |
| Go · buke + WithMaxStackSize(1<<20) | JS 里抛 RangeError,可以 catch |
| Go · fastschema(wazero) | Go panic:wasm error: out of bounds memory access |
| Go · modernc | JS 里抛 InternalError: stack overflow |
| Rust · rquickjs、Python 两个扩展 | JS 里抛异常,可以 catch |
| Node · emscripten | 宿主侧抛 RangeError(V8 的栈先满了) |
脚本用 try/catch 包住了递归,“可以 catch”指脚本自己能接住,宿主继续正常运行。
如果脚本来自用户,这张表比前面所有性能数字都重要。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:
能用 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 不行。
小结
- QuickJS 是什么? 一个几个 C 文件、无依赖、没有 JIT 的 JavaScript 引擎:源码一遍编译成字节码,由 C 解释器执行;宿主通过
JS_NewRuntime、JS_Call这些 C 函数和它打交道。Bellard 原版和 quickjs-ng 两个分支并行发版,各绑定打包的版本横跨五年。 - 同一份 C 代码怎么进入别的语言? 三条路:编成机器码直接链接(C、Rust、Go cgo、Python 扩展),编成 WASM 由宿主执行(Go wazero、Node、Java),翻译成 Go 源码(modernc)。
- 解释器速度和宿主语言有关吗? 走第一条路时无关,差别来自引擎版本(ng 0.15.1 比 0.17 的 JSON 慢 50%,Python quickjs 还是 2021 年的引擎)和编译器(同一个源码包,PyPI wheel 的 fib 慢 2.4 倍)。走 WASM 或翻译时,慢 1.6 到 12 倍,取决于执行它的虚拟机。
- 边界调用为什么能差 400 倍? cgo 往返约 70 纳秒、wazero 调用约 32 纳秒,本身都不贵;buke 的属主检查(
runtime.Stack,每次约 3 微秒)和 fastschema 每次ExportedFunction(约 3 微秒)把一次逻辑调用变成了 30 多微秒。 - 启动和并发呢? 原生绑定新建一个运行时 0.1 到 0.3 毫秒,可以一个请求一个;wazero 进程内首次要 0.77 秒;modernc 的 libc 有全局分配锁,多 goroutine 反而变慢;Python 扩展执行 JS 时释放 GIL,能多线程扩展。
- 还有什么会让进程挂掉? QuickJS 按创建时的线程记栈顶,Go 的 goroutine 换线程就会误报栈溢出,要
LockOSThread或JS_UpdateStackTop;buke 默认关掉栈深检查,一行无限递归就能让进程段错误退出。 - 嵌在哪里最好? 只看引擎,C 和 Rust 最好,Python 也不差;在 Go 里优先用 cgo 绑定并改掉两个默认值,边界密集时自己封一层;不能用 cgo 时按是否需要并发在 wazero、modernc 和 goja 之间取舍。
参考资料
Footnotes
-
QuickJS Javascript Enginebellard.org ↗官网:几个 C 文件、无外部依赖,hello world 367 KiB x86 代码;运行时实例完整生命周期不到 300 微秒;支持 ES2025;引用计数加环检测。2026-06-04 版“比上一版在 bench-v8 上快 42%”。https://bellard.org/quickjs/ ↩ ↩2 ↩3
-
quickjs-ng/quickjsgithub.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 ↩
-
bellard/quickjs:Changeloggithub.com ↗各版本改动。2026-06-04:小块自定义 malloc(bench-v8 快 11%)、微优化(快 30%)。2021-03-27 之后到 2024-01-13 之间没有发布。https://github.com/bellard/quickjs/blob/master/Changelog ↩
-
QuickJS extras:tests/bench-v8bellard.org ↗V8 benchmark suite v7 的合并版 combined.js,QuickJS 官网的性能数字就是用它测的。https://bellard.org/quickjs/quickjs-extras-2026-06-04.tar.xz ↩
-
buke/quickjs-gogithub.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 -
fastschema/qjsgithub.com ↗把 quickjs-ng 编成 WASM(-O3 + wasm-opt),用 wazero 在 Go 里跑,不需要 cgo。文中用 v0.0.6,内嵌 qjs.wasm 为 ng 0.10.1。https://github.com/fastschema/qjs ↩
-
modernc.org/quickjsgitlab.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 ↩ -
dop251/gojagithub.com ↗纯 Go 写的 ECMAScript 引擎,不是 QuickJS,作为 Go 里的对照。https://github.com/dop251/goja ↩
-
DelSkayn/rquickjsgithub.com ↗Rust 绑定,文中用 0.14.0,打包 quickjs-ng 0.16.2;每次调用前执行
JS_UpdateStackTop(rquickjs-core 的args.rs)。https://github.com/DelSkayn/rquickjs ↩ ↩2 -
PetterS/quickjs(PyPI:quickjs)github.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 -
genotrance/quickjs-ng(PyPI:quickjs-ng)github.com ↗PetterS 版的分支,换成 quickjs-ng 0.17.0,文中用 0.17.0.2。PyPI 上的 manylinux wheel 由 GCC 14.2.1 编译。https://github.com/genotrance/quickjs-ng ↩
-
justjake/quickjs-emscriptengithub.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 ↩ -
roastedroot/quickjs4jgithub.com ↗Java 绑定,文中用 0.1.1:Javy 插件(内部是 rquickjs)编成 WASM,再在构建期翻译成 JVM 字节码。https://github.com/roastedroot/quickjs4j ↩
-
bellard/quickjs 10c81b1:fixed large performance regression with recent GCC versionsgithub.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 ↩ -
tetratelabs/wazerogithub.com ↗纯 Go 的 WebAssembly 运行时,amd64/arm64 默认用编译器后端。文中版本 v1.9.0;
Module.ExportedFunction每次调用都会新建一个函数实例。https://github.com/tetratelabs/wazero ↩