﻿---
title: "QuickJS 嵌在哪门语言里跑得最好"
description: "同一段 fib(25)，同一个 QuickJS，在 C 里 7 毫秒，在 Go 的 wazero 绑定里 23 毫秒，在 Java 里 61 毫秒；从 Go 调一次 JS 的 add，有的绑定 89 纳秒，有的 34 微秒。我们先看 QuickJS 是什么、怎么被嵌进别的语言，再用同一份脚本实测 C、Rust、Go（四种绑定）、Python、Node、Java，把差距一项项追到引擎版本、执行方式、绑定实现和线程模型上，最后回答 Go 程序该怎么嵌。"
pubDate: 2026-10-25
tags: ["QuickJS", "JavaScript", "Go", "性能", "嵌入式脚本"]
clc: "TP314"
hero: "./hero.svg"
refs:
  - id: bellard
    title: "QuickJS Javascript Engine"
    url: https://bellard.org/quickjs/
    author: Fabrice Bellard
    site: bellard.org
    note: "官网：几个 C 文件、无外部依赖，hello world 367 KiB x86 代码；运行时实例完整生命周期不到 300 微秒；支持 ES2025；引用计数加环检测。2026-06-04 版“比上一版在 bench-v8 上快 42%”。"
  - id: bellard-gh
    title: "bellard/quickjs：Changelog"
    url: https://github.com/bellard/quickjs/blob/master/Changelog
    site: GitHub
    note: "各版本改动。2026-06-04：小块自定义 malloc（bench-v8 快 11%）、微优化（快 30%）。2021-03-27 之后到 2024-01-13 之间没有发布。"
  - id: gcc-regress
    title: "bellard/quickjs 10c81b1：fixed large performance regression with recent GCC versions"
    url: https://github.com/bellard/quickjs/commit/10c81b1989de2d2e8d482d252b160a34f215d3e2
    site: GitHub
    date: 2026-06-03
    note: "`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”。"
  - id: ng
    title: "quickjs-ng/quickjs"
    url: https://github.com/quickjs-ng/quickjs
    site: GitHub
    note: "“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）。"
  - id: buke
    title: "buke/quickjs-go"
    url: https://github.com/buke/quickjs-go
    site: GitHub
    note: "cgo 绑定，文中用 v0.7.7，打包 quickjs-ng v0.15.1。`WithOwnerGoroutineCheck`、`WithMaxStackSize` 两个选项见 runtime.go；默认 `maxStackSize` 为 0。"
  - id: fastschema
    title: "fastschema/qjs"
    url: https://github.com/fastschema/qjs
    site: GitHub
    note: "把 quickjs-ng 编成 WASM（-O3 + wasm-opt），用 wazero 在 Go 里跑，不需要 cgo。文中用 v0.0.6，内嵌 qjs.wasm 为 ng 0.10.1。"
  - id: modernc
    title: "modernc.org/quickjs"
    url: https://gitlab.com/cznic/quickjs
    site: GitLab
    note: "用 ccgo 把 Bellard 版 QuickJS 的 C 源码翻译成 Go。文中用 v0.27.0（libquickjs v0.14.0，对应 2026-06-04 版），linux/amd64 的翻译结果是一个 19.8 万行的 `ccgo_linux_amd64.go`。"
  - id: goja
    title: "dop251/goja"
    url: https://github.com/dop251/goja
    site: GitHub
    note: "纯 Go 写的 ECMAScript 引擎，不是 QuickJS，作为 Go 里的对照。"
  - id: rquickjs
    title: "DelSkayn/rquickjs"
    url: https://github.com/DelSkayn/rquickjs
    site: GitHub
    note: "Rust 绑定，文中用 0.14.0，打包 quickjs-ng 0.16.2；每次调用前执行 `JS_UpdateStackTop`（rquickjs-core 的 `args.rs`）。"
  - id: py-quickjs
    title: "PetterS/quickjs（PyPI：quickjs）"
    url: https://github.com/PetterS/quickjs
    site: GitHub
    note: "Python C 扩展，文中用 1.19.4；sdist 里的 `upstream-quickjs/VERSION` 是 2021-03-27。执行 JS 时 `Py_BEGIN_ALLOW_THREADS` 释放 GIL。"
  - id: py-ng
    title: "genotrance/quickjs-ng（PyPI：quickjs-ng）"
    url: https://github.com/genotrance/quickjs-ng
    site: GitHub
    note: "PetterS 版的分支，换成 quickjs-ng 0.17.0，文中用 0.17.0.2。PyPI 上的 manylinux wheel 由 GCC 14.2.1 编译。"
  - id: emscripten
    title: "justjake/quickjs-emscripten"
    url: https://github.com/justjake/quickjs-emscripten
    site: GitHub
    note: "用 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）。"
  - id: quickjs4j
    title: "roastedroot/quickjs4j"
    url: https://github.com/roastedroot/quickjs4j
    site: GitHub
    note: "Java 绑定，文中用 0.1.1：Javy 插件（内部是 rquickjs）编成 WASM，再在构建期翻译成 JVM 字节码。"
  - id: wazero
    title: "tetratelabs/wazero"
    url: https://github.com/tetratelabs/wazero
    site: GitHub
    note: "纯 Go 的 WebAssembly 运行时，amd64/arm64 默认用编译器后端。文中版本 v1.9.0；`Module.ExportedFunction` 每次调用都会新建一个函数实例。"
  - id: bench-v8
    title: "QuickJS extras：tests/bench-v8"
    url: https://bellard.org/quickjs/quickjs-extras-2026-06-04.tar.xz
    site: bellard.org
    note: "V8 benchmark suite v7 的合并版 combined.js，QuickJS 官网的性能数字就是用它测的。"
---

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

```text
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 月第一次发布[^bellard]。它的目标不是和 V8 比峰值速度，而是小、能嵌进去：几个 C 文件，没有外部依赖，一个 hello world 编出来只有 367 KiB 的 x86 代码；一个运行时实例从创建到销毁不到 300 微秒；语言上支持到 ES2025[^bellard]。

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf1-title qjsf1-desc">
<title id="qjsf1-title">QuickJS 的结构：宿主持有 JSRuntime 和 JSContext，源码一遍编译成字节码再由解释器执行</title>
<desc id="qjsf1-desc">左边是宿主程序里调用的四个 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 指向解释器。</desc>
<defs>
<marker id="qjsf1-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="qjsf1-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="452" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="260" height="420" rx="8"/>
<rect class="dd-zone" x="292" y="16" width="492" height="420" rx="8"/>
<text class="t-zone" x="32" y="44">宿主程序（C）</text>
<text class="t-zone" x="308" y="44">JSRuntime · 一个堆、一个 GC</text>
<path class="dd-edge-acc" d="M260 210 H296 Q304 210 304 218 V222 Q304 230 312 230 H334" marker-end="url(#qjsf1-arrow-acc)"/>
<path class="dd-edge" d="M260 270 H276 Q284 270 284 278 V396 Q284 404 292 404 H334" marker-end="url(#qjsf1-arrow)"/>
<rect class="dd-card" x="32" y="64" width="228" height="52" rx="6"/>
<text class="t-mono" x="48" y="86">JS_NewRuntime()</text>
<text class="t-note" x="48" y="106">一个堆、一个垃圾回收器</text>
<rect class="dd-card" x="32" y="124" width="228" height="52" rx="6"/>
<text class="t-mono" x="48" y="146">JS_NewContext(rt)</text>
<text class="t-note" x="48" y="166">一套全局对象和内置对象</text>
<rect class="dd-card" x="32" y="184" width="228" height="52" rx="6"/>
<text class="t-mono" x="48" y="206"><tspan class="t-acc">JS_Eval</tspan>(ctx, src, …)</text>
<text class="t-note" x="48" y="226">编译并执行一段源码</text>
<rect class="dd-card" x="32" y="244" width="228" height="52" rx="6"/>
<text class="t-mono" x="48" y="266">JS_Call(ctx, fn, this, …)</text>
<text class="t-note" x="48" y="286">调用一个 JS 函数</text>
<text class="t-say" x="32" y="330">宿主拿到的每个 JSValue</text>
<text class="t-say" x="32" y="350">都要自己 JS_FreeValue：</text>
<text class="t-say" x="32" y="370">QuickJS 靠引用计数回收，</text>
<text class="t-say" x="32" y="390">再定期扫一遍环。</text>
<rect class="dd-derived" x="308" y="64" width="460" height="56" rx="6"/>
<text class="t-name" x="324" y="88">JSContext</text>
<text class="t-val" x="324" y="108">globalThis · Object · Array · JSON · RegExp …</text>
<rect class="dd-card" x="336" y="136" width="416" height="44" rx="6"/>
<text class="t-mono" x="352" y="163">function fib(n) { return n &lt; 2 ? n : fib(n-1) + fib(n-2) }</text>
<line class="dd-edge-acc" x1="544" y1="180" x2="544" y2="204" marker-end="url(#qjsf1-arrow-acc)"/>
<rect class="dd-card" x="336" y="208" width="416" height="44" rx="6"/>
<text class="t-name" x="352" y="235">解析器 + 编译器：一遍扫描，<tspan class="t-acc">直接出字节码</tspan></text>
<line class="dd-edge-acc" x1="544" y1="252" x2="544" y2="274" marker-end="url(#qjsf1-arrow-acc)"/>
<rect class="dd-card" x="336" y="278" width="416" height="90" rx="6"/>
<path class="dd-card-head" d="M342 278 H746 Q752 278 752 284 V310 H336 V284 Q336 278 342 278 Z"/>
<text class="t-name" x="352" y="299">字节码 · fib</text>
<text class="t-note" x="736" y="299" text-anchor="end">19 字节，栈深 4</text>
<text class="t-mono" x="352" y="331">get_arg0 · push_2 · lt · if_false8 7 · return</text>
<text class="t-mono" x="352" y="355">get_var_ref0 · push_1 · sub · call1 · … · add</text>
<line class="dd-edge-acc" x1="544" y1="368" x2="544" y2="382" marker-end="url(#qjsf1-arrow-acc)"/>
<rect class="dd-card" x="336" y="386" width="416" height="36" rx="6"/>
<text class="t-name" x="352" y="409">解释器：JS_CallInternal 一条指令一条指令地执行</text>
</svg>
</div>
<figcaption>图 1 · QuickJS 没有 JIT：源码一遍编译成字节码，之后一直由同一个 C 解释器执行</figcaption>
</figure>

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

### 两个分支，很多个版本

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf2-title qjsf2-desc">
<title id="qjsf2-title">QuickJS 两条分支的发布时间线，以及各个绑定打包的是哪一版</title>
<desc id="qjsf2-desc">上面一条是 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。</desc>
<rect class="dd-frame" width="800" height="300" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="268" rx="8"/>
<text class="t-zone" x="32" y="44">发布时间线 · 强调色是本文被测绑定实际打包的版本</text>
<text class="t-name" x="32" y="72">Bellard · bellard/quickjs</text>
<text class="t-name" x="32" y="164">quickjs-ng · quickjs-ng/quickjs</text>
<line class="dd-hair" x1="40" y1="236" x2="40" y2="244"/>
<text class="t-note" x="40" y="262" text-anchor="middle">2019</text>
<line class="dd-hair" x1="100" y1="236" x2="100" y2="244"/>
<text class="t-note" x="100" y="262" text-anchor="middle">2020</text>
<line class="dd-hair" x1="160" y1="236" x2="160" y2="244"/>
<text class="t-note" x="160" y="262" text-anchor="middle">2021</text>
<line class="dd-hair" x1="220" y1="236" x2="220" y2="244"/>
<text class="t-note" x="220" y="262" text-anchor="middle">2022</text>
<line class="dd-hair" x1="280" y1="236" x2="280" y2="244"/>
<text class="t-note" x="280" y="262" text-anchor="middle">2023</text>
<line class="dd-hair" x1="340" y1="236" x2="340" y2="244"/>
<text class="t-note" x="340" y="262" text-anchor="middle">2024</text>
<line class="dd-hair" x1="480" y1="236" x2="480" y2="244"/>
<text class="t-note" x="480" y="262" text-anchor="middle">2025</text>
<line class="dd-hair" x1="620" y1="236" x2="620" y2="244"/>
<text class="t-note" x="620" y="262" text-anchor="middle">2026</text>
<line class="dd-hair" x1="40" y1="240" x2="760" y2="240"/>
<line class="dd-edge" x1="71" y1="112" x2="174" y2="112"/>
<line class="dd-edge dd-dash" x1="174" y1="112" x2="345" y2="112"/>
<line class="dd-edge" x1="345" y1="112" x2="734" y2="112"/>
<text class="t-say" x="259" y="100" text-anchor="middle">近三年没有新版本</text>
<line class="dd-edge" x1="334" y1="204" x2="734" y2="204"/>
<path class="dd-edge dd-dash" d="M325 112 V192 Q325 204 333 204 H328"/>
<text class="t-say" x="335" y="164">2023 年分叉</text>
<circle class="dd-dot" cx="71" cy="112" r="4"/>
<text class="t-mono" x="71" y="100" text-anchor="middle">2019-07</text>
<circle class="dd-fill-acc" cx="174" cy="112" r="6"/>
<text class="t-mono" x="174" y="134" text-anchor="middle">2021-03-27</text>
<text class="t-note t-acc" x="174" y="150" text-anchor="middle">Python quickjs</text>
<circle class="dd-dot" cx="345" cy="112" r="4"/>
<text class="t-mono" x="345" y="100" text-anchor="middle">2024-01</text>
<circle class="dd-dot" cx="524" cy="112" r="4"/>
<text class="t-mono" x="524" y="100" text-anchor="middle">2025-04</text>
<circle class="dd-fill-acc" cx="578" cy="112" r="6"/>
<text class="t-mono" x="578" y="134" text-anchor="middle">2025-09-13</text>
<text class="t-note t-acc" x="578" y="150" text-anchor="middle">emscripten</text>
<circle class="dd-fill-acc" cx="679" cy="112" r="6"/>
<text class="t-mono" x="679" y="100" text-anchor="middle">2026-06-04</text>
<text class="t-note t-acc" x="679" y="84" text-anchor="middle">C · modernc</text>
<circle class="dd-dot" cx="334" cy="204" r="4"/>
<text class="t-mono" x="334" y="226" text-anchor="middle">v0.1</text>
<circle class="dd-fill-acc" cx="531" cy="204" r="6"/>
<text class="t-mono" x="531" y="192" text-anchor="middle">0.10.1</text>
<text class="t-note t-acc" x="531" y="176" text-anchor="middle">fastschema</text>
<circle class="dd-fill-acc" cx="636" cy="204" r="6"/>
<text class="t-mono" x="636" y="226" text-anchor="middle">0.12.1</text>
<text class="t-note t-acc" x="636" y="242" text-anchor="middle">emscripten-ng</text>
<circle class="dd-fill-acc" cx="679" cy="204" r="6"/>
<text class="t-mono" x="679" y="192" text-anchor="middle">0.15.1</text>
<text class="t-note t-acc" x="679" y="176" text-anchor="middle">buke</text>
<circle class="dd-fill-acc" cx="709" cy="204" r="6"/>
<text class="t-mono" x="709" y="226" text-anchor="middle">0.16.2</text>
<text class="t-note t-acc" x="709" y="242" text-anchor="middle">rquickjs</text>
<circle class="dd-fill-acc" cx="720" cy="204" r="6"/>
<text class="t-mono" x="714" y="192" text-anchor="start">0.17</text>
<text class="t-note t-acc" x="714" y="176" text-anchor="start">C · Python</text>
</svg>
</div>
<figcaption>图 2 · 被测的十来个绑定，打包的引擎横跨五年、两个分支</figcaption>
</figure>

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

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

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

### 三条路

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf3-title qjsf3-desc">
<title id="qjsf3-title">QuickJS 进入宿主的三条路：原生链接、编成 WASM、翻译成 Go</title>
<desc id="qjsf3-desc">左边是同一份源码 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 编译器编译。</desc>
<defs>
<marker id="qjsf3-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="184" height="368" rx="8"/>
<rect class="dd-zone" x="216" y="16" width="568" height="112" rx="8"/>
<rect class="dd-zone" x="216" y="144" width="568" height="128" rx="8"/>
<rect class="dd-zone" x="216" y="288" width="568" height="96" rx="8"/>
<text class="t-zone" x="32" y="44">同一份源码</text>
<text class="t-zone" x="232" y="44">① 编成本机机器码，直接链接</text>
<text class="t-zone" x="232" y="172">② 编成 WASM，由宿主的虚拟机执行</text>
<text class="t-zone" x="232" y="316">③ 把 C 翻译成 Go 源码</text>
<path class="dd-edge" d="M184 160 H200 Q208 160 208 152 V88 Q208 80 216 80 H230" marker-end="url(#qjsf3-arrow)"/>
<path class="dd-edge" d="M184 200 H230" marker-end="url(#qjsf3-arrow)"/>
<path class="dd-edge" d="M184 240 H200 Q208 240 208 248 V340 Q208 348 216 348 H230" marker-end="url(#qjsf3-arrow)"/>
<line class="dd-edge" x1="380" y1="80" x2="400" y2="80" marker-end="url(#qjsf3-arrow)"/>
<line class="dd-edge" x1="380" y1="210" x2="400" y2="210" marker-end="url(#qjsf3-arrow)"/>
<line class="dd-edge" x1="380" y1="348" x2="400" y2="348" marker-end="url(#qjsf3-arrow)"/>
<rect class="dd-card" x="32" y="64" width="152" height="232" rx="6"/>
<path class="dd-card-head" d="M38 64 H178 Q184 64 184 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="48" y="85">C 源码</text>
<text class="t-mono" x="48" y="121">quickjs.c</text>
<text class="t-note" x="48" y="139">约 6.5 万行</text>
<line class="dd-hair" x1="32" y1="150" x2="184" y2="150"/>
<text class="t-mono" x="48" y="171">libregexp.c</text>
<line class="dd-hair" x1="32" y1="182" x2="184" y2="182"/>
<text class="t-mono" x="48" y="203">libunicode.c</text>
<line class="dd-hair" x1="32" y1="214" x2="184" y2="214"/>
<text class="t-mono" x="48" y="235">dtoa.c</text>
<line class="dd-hair" x1="32" y1="246" x2="184" y2="246"/>
<text class="t-note" x="48" y="270">无外部依赖</text>
<rect class="dd-card" x="232" y="62" width="148" height="36" rx="6"/>
<text class="t-mono" x="248" y="85">gcc → <tspan class="t-acc">libqjs.a</tspan></text>
<rect class="dd-card" x="404" y="62" width="364" height="52" rx="6"/>
<text class="t-name" x="420" y="84">C · Rust（rquickjs）· Go（cgo）</text>
<text class="t-note" x="420" y="104">Python C 扩展 · 跨语言要经过 FFI</text>
<rect class="dd-card" x="232" y="192" width="148" height="36" rx="6"/>
<text class="t-mono" x="248" y="215">clang → <tspan class="t-acc">qjs.wasm</tspan></text>
<rect class="dd-card" x="404" y="188" width="364" height="68" rx="6"/>
<text class="t-name" x="420" y="210">Go：wazero 把 WASM 再编成机器码</text>
<text class="t-name" x="420" y="230">Node：V8 的 WASM 编译器</text>
<text class="t-name" x="420" y="250">Java：构建期翻译成 JVM 字节码</text>
<rect class="dd-card" x="232" y="330" width="148" height="36" rx="6"/>
<text class="t-mono" x="248" y="353">ccgo → <tspan class="t-acc">*.go</tspan></text>
<rect class="dd-card" x="404" y="326" width="364" height="44" rx="6"/>
<text class="t-mono" x="420" y="353">ccgo_linux_amd64.go · 19.8 万行 Go</text>
</svg>
</div>
<figcaption>图 3 · 同一份 C 代码，可以变成机器码、WASM 或 Go 源码，宿主执行的东西因此完全不同</figcaption>
</figure>

**第一条路，执行的是 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-go[^buke]、wazero 的 fastschema/qjs[^fastschema]、ccgo 翻译的 modernc.org/quickjs[^modernc]，再加一个我们自己写的最薄 cgo 封装；另外加一个不是 QuickJS 的 goja[^goja] 当对照。其他语言是 Rust 的 rquickjs[^rquickjs]、Python 的两个 C 扩展[^py-quickjs][^py-ng]、Node 的 quickjs-emscripten[^emscripten] 和 Java 的 quickjs4j[^quickjs4j]：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="qjst4-title">被测宿主：绑定、打包的引擎版本、走哪条路</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="qjst4-title">
<colgroup><col style="width:22%"><col><col><col style="width:16%"></colgroup>
<thead>
<tr><th scope="col">宿主</th><th scope="col">绑定</th><th scope="col">引擎</th><th scope="col">路</th></tr>
</thead>
<tbody>
<tr><th scope="row">C</th><td>直接链接</td><td class="mono">ng 0.17 / Bellard 2026-06-04</td><td class="c" rowspan="2">① 原生</td></tr>
<tr><th scope="row">Rust</th><td class="mono">rquickjs 0.14.0</td><td class="mono">ng 0.16.2</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">Go</th></tr>
<tr><th scope="row">Go · buke</th><td class="mono">buke/quickjs-go v0.7.7</td><td class="mono">ng 0.15.1</td><td class="c" rowspan="2">① cgo</td></tr>
<tr><th scope="row">Go · 自写</th><td>本文写的最薄 cgo 封装</td><td class="mono">ng 0.17</td></tr>
<tr><th scope="row">Go · fastschema</th><td class="mono">fastschema/qjs v0.0.6</td><td class="mono">ng 0.10.1</td><td class="c">② wazero</td></tr>
<tr><th scope="row">Go · modernc</th><td class="mono">modernc.org/quickjs v0.27.0</td><td class="mono">Bellard 2026-06-04</td><td class="c">③ ccgo</td></tr>
<tr><th scope="row">Go · goja</th><td class="mono">dop251/goja</td><td>纯 Go 引擎，不是 QuickJS</td><td class="c">对照</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">其他语言</th></tr>
<tr><th scope="row">Python</th><td class="mono">quickjs 1.19.4</td><td class="mono">Bellard 2021-03-27</td><td class="c" rowspan="2">① C 扩展</td></tr>
<tr><th scope="row">Python</th><td class="mono">quickjs-ng 0.17.0.2</td><td class="mono">ng 0.17.0</td></tr>
<tr><th scope="row">Node</th><td class="mono">quickjs-emscripten 0.32.0</td><td class="mono">Bellard 2025-09-13 / ng 0.12.1</td><td class="c">② V8</td></tr>
<tr><th scope="row">Java</th><td class="mono">quickjs4j 0.1.1</td><td>Javy（rquickjs）</td><td class="c">② JVM</td></tr>
<tr><th scope="row">Node</th><td>V8 自己</td><td>JIT，不是 QuickJS</td><td class="c">对照</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">机器：4 vCPU 的 Intel Xeon 2.1 GHz 云主机，GCC 13.3、Go 1.26、Rust 1.97、Python 3.13、Node 22、JDK 21。</p>
</div>
<figcaption>图 4 · 十四个被测宿主，覆盖三条路</figcaption>
</figure>

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

**先看最基础的问题：QuickJS 的解释器本身，在不同宿主里跑得一样快吗？**

## 三、解释器本身的速度

### 现象：从 0.8 倍到 12 倍

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 524" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf4-title qjsf4-desc">
<title id="qjsf4-title">同一组纯 JS 负载在各宿主里的耗时，相对 C 直接链接 quickjs-ng 0.17</title>
<desc id="qjsf4-desc">三个负载（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 倍。</desc>
<rect class="dd-frame" width="800" height="524" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="492" rx="8"/>
<text class="t-zone" x="32" y="44">纯 JS 负载耗时（越短越快）· C 直接链接 ng 0.17 = 1×</text>
<line class="dd-edge dd-dash" x1="392" y1="64" x2="392" y2="468"/>
<text class="t-name" x="260" y="91" text-anchor="end">V8 自己（JIT，参照）</text>
<rect class="dd-track" x="272" y="80" width="23" height="16" rx="3"/>
<text class="t-mono" x="400" y="92">0.19×</text>
<text class="t-name" x="260" y="119" text-anchor="end">C · Bellard 2026-06</text>
<rect class="dd-fill-acc" x="272" y="108" width="100" height="16" rx="3"/>
<text class="t-mono" x="400" y="120">0.83×</text>
<text class="t-name" x="260" y="147" text-anchor="end">C · ng 0.17</text>
<rect class="dd-fill-acc" x="272" y="136" width="120" height="16" rx="3"/>
<text class="t-mono" x="400" y="148">1.00×</text>
<text class="t-name" x="260" y="175" text-anchor="end">Go · 自写 cgo</text>
<rect class="dd-fill-acc" x="272" y="164" width="127" height="16" rx="3"/>
<text class="t-mono" x="407" y="176">1.06×</text>
<text class="t-name" x="260" y="203" text-anchor="end">Rust · rquickjs</text>
<rect class="dd-fill-acc" x="272" y="192" width="127" height="16" rx="3"/>
<text class="t-mono" x="407" y="204">1.06×</text>
<text class="t-name" x="260" y="231" text-anchor="end">Python · quickjs（2021 版）</text>
<rect class="dd-fill-acc" x="272" y="220" width="155" height="16" rx="3"/>
<text class="t-mono" x="435" y="232">1.29×</text>
<text class="t-name" x="260" y="259" text-anchor="end">Go · buke（ng 0.15.1）</text>
<rect class="dd-fill-acc" x="272" y="248" width="162" height="16" rx="3"/>
<text class="t-mono" x="442" y="260">1.35×</text>
<text class="t-name" x="260" y="287" text-anchor="end">Python · quickjs-ng wheel</text>
<rect class="dd-fill-acc" x="272" y="276" width="175" height="16" rx="3"/>
<text class="t-mono" x="455" y="288">1.46×</text>
<text class="t-name" x="260" y="315" text-anchor="end">Node · emscripten</text>
<rect class="dd-bar" x="272" y="304" width="188" height="16" rx="3"/>
<text class="t-mono" x="468" y="316">1.57×</text>
<text class="t-name" x="260" y="343" text-anchor="end">Node · emscripten-ng</text>
<rect class="dd-bar" x="272" y="332" width="272" height="16" rx="3"/>
<text class="t-mono" x="552" y="344">2.27×</text>
<text class="t-name" x="260" y="371" text-anchor="end">Go · modernc（翻译成 Go）</text>
<rect class="dd-bar" x="272" y="360" width="289" height="16" rx="3"/>
<text class="t-mono" x="569" y="372">2.41×</text>
<text class="t-name" x="260" y="399" text-anchor="end">Go · goja（不是 QuickJS）</text>
<rect class="dd-track" x="272" y="388" width="426" height="16" rx="3"/>
<text class="t-mono" x="706" y="400">3.55×</text>
<text class="t-name" x="260" y="427" text-anchor="end">Go · fastschema（wazero）</text>
<rect class="dd-bar" x="272" y="416" width="432" height="16" rx="3"/>
<text class="t-mono" x="712" y="428">3.60×</text>
<text class="t-name" x="260" y="455" text-anchor="end">Java · quickjs4j</text>
<rect class="dd-bar" x="272" y="444" width="468" height="16" rx="3"/>
<path class="dd-edge" d="M726 442 l6 20 M732 442 l6 20"/>
<text class="t-mono" x="748" y="456">12.20×</text>
<rect class="dd-fill-acc" x="272" y="480" width="14" height="10" rx="2"/>
<text class="t-note" x="292" y="490">直接把 C 编进宿主</text>
<rect class="dd-bar" x="428" y="480" width="14" height="10" rx="2"/>
<text class="t-note" x="448" y="490">WASM 或翻译成别的语言</text>
<rect class="dd-track" x="612" y="480" width="14" height="10" rx="2"/>
<text class="t-note" x="632" y="490">参照（不是 QuickJS）</text>
</svg>
</div>
<figcaption>图 5 · 直接把 C 编进来的宿主都在 1 倍附近，换了执行方式才会差出几倍</figcaption>
</figure>

图上的条形分成了明显的几组。直接链接 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 重新编一遍，答案就出来了：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="qjst6-title">原生链接的几种情况：同一份引擎源码，换宿主不换速度</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="qjst6-title">
<colgroup><col style="width:40%"><col><col><col></colgroup>
<thead>
<tr><th scope="col">引擎 · 宿主 · 编译方式</th><th scope="col" class="num">fib ms</th><th scope="col" class="num">JSON ms</th><th scope="col" class="num">字符串 ms</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="4" scope="rowgroup">ng 0.17，GCC 13 -O2</th></tr>
<tr><th scope="row">C</th><td class="num">7.2</td><td class="num">21.6</td><td class="num">8.0</td></tr>
<tr><th scope="row">Go 自写 cgo</th><td class="num">7.5</td><td class="num">24.0</td><td class="num">8.3</td></tr>
<tr><th scope="row">Rust rquickjs（ng 0.16.2，-O3）</th><td class="num">7.6</td><td class="num">23.7</td><td class="num">8.2</td></tr>
<tr><th scope="row">Python，本机从 sdist 编译</th><td class="num">7.2–7.7</td><td class="num">23.3–25.2</td><td class="num">8.2–8.4</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">ng 0.15.1（buke 打包的版本）</th></tr>
<tr><th scope="row">Go buke</th><td class="num">7.6</td><td class="num acc">35.7</td><td class="num">11.3</td></tr>
<tr><th scope="row">C，同一份 0.15.1 源码</th><td class="num">7.4</td><td class="num acc">32.9</td><td class="num">10.2</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">其他</th></tr>
<tr><th scope="row">Python quickjs（Bellard 2021-03-27）</th><td class="num">6.7</td><td class="num">32.9</td><td class="num">12.0</td></tr>
<tr><th scope="row">Python quickjs-ng（PyPI wheel，GCC 14.2）</th><td class="num acc">17.9</td><td class="num">23.7</td><td class="num">9.1</td></tr>
<tr><th scope="row">C，Bellard 2026-06-04</th><td class="num good">6.0</td><td class="num good">17.3</td><td class="num good">6.9</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">Python 的“本机编译”是用 pip 的 --no-binary 从同一个 0.17.0.2 源码包装的，两次运行的范围。</p>
</div>
<figcaption>图 6 · buke 慢在 0.15.1 这一版本身，Python wheel 慢在编译它的那个 GCC，都与宿主语言无关</figcaption>
</figure>

**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 下性能大幅退化”的问题[^gcc-regress]，原因是值类型 `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 万次。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="qjst7-title">跨边界调用：每次的平均耗时</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="qjst7-title">
<colgroup><col style="width:30%"><col><col><col></colgroup>
<thead>
<tr><th scope="col">宿主</th><th scope="col" class="num">宿主调 JS</th><th scope="col" class="num">JS 调宿主</th><th scope="col" class="num">一条 JSON 规则</th></tr>
</thead>
<tbody>
<tr><th scope="row">C · Bellard 2026-06</th><td class="num good">17 ns</td><td class="num good">34 ns</td><td class="num good">3.4 µs</td></tr>
<tr><th scope="row">C · ng 0.17</th><td class="num">21 ns</td><td class="num">44 ns</td><td class="num">4.0 µs</td></tr>
<tr><th scope="row">Rust · rquickjs</th><td class="num">82 ns</td><td class="num">113 ns</td><td class="num">4.4 µs</td></tr>
<tr><th scope="row">Go · 自写 cgo</th><td class="num">89 ns</td><td class="num">79 ns</td><td class="num">4.2 µs</td></tr>
<tr><th scope="row">Python · quickjs</th><td class="num">138 ns</td><td class="num">148 ns</td><td class="num">7.0 µs</td></tr>
<tr><th scope="row">Python · quickjs-ng</th><td class="num">163 ns</td><td class="num">209 ns</td><td class="num">4.3 µs</td></tr>
<tr><th scope="row">Go · modernc</th><td class="num">294 ns</td><td class="num">625 ns</td><td class="num">12.4 µs</td></tr>
<tr><th scope="row">Go · goja（对照）</th><td class="num">329 ns</td><td class="num">777 ns</td><td class="num">26.1 µs</td></tr>
<tr><th scope="row">Go · buke 关掉检查</th><td class="num">730 ns</td><td class="num">466 ns</td><td class="num">10.0 µs</td></tr>
<tr><th scope="row">Node · emscripten</th><td class="num">1.6 µs</td><td class="num">3.3 µs</td><td class="num">14.6 µs</td></tr>
<tr><th scope="row">Java · quickjs4j</th><td class="num">—</td><td class="num bad">17.6 µs</td><td class="num">—</td></tr>
<tr><th scope="row">Go · fastschema（wazero）</th><td class="num bad hl">29.9 µs</td><td class="num bad hl">31.1 µs</td><td class="num bad">49.8 µs</td></tr>
<tr><th scope="row">Go · buke 默认</th><td class="num bad hl">34.3 µs</td><td class="num bad">10.9 µs</td><td class="num bad">36.2 µs</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">“JS 调宿主”包含 JS 自己循环一次的开销（C 里约 43 ns）。quickjs4j 只能通过 JSON 序列化调用 JS 函数，没有测宿主调 JS。</p>
</div>
<figcaption>图 7 · 计算速度一样的几个原生宿主，边界开销能差出 400 倍</figcaption>
</figure>

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

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 476" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf8-title qjsf8-desc">
<title id="qjsf8-title">从 Go 调一次 add(acc, 1)：buke 要跨约十次边界，自写封装只跨一次</title>
<desc id="qjsf8-desc">左边是 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 纳秒。</desc>
<defs>
<marker id="qjsf8-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="qjsf8-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="476" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="440" height="316" rx="8"/>
<rect class="dd-zone" x="472" y="16" width="312" height="316" rx="8"/>
<rect class="dd-zone" x="16" y="348" width="768" height="112" rx="8"/>
<text class="t-zone" x="32" y="44">buke：每一步都是一次 cgo 调用</text>
<text class="t-zone" x="488" y="44">自写封装：只过一次 cgo</text>
<text class="t-zone" x="32" y="376">fastschema：每次进 WASM 先查一次导出函数</text>
<rect class="dd-card" x="32" y="60" width="408" height="216" rx="6"/>
<path class="dd-card-head" d="M38 60 H434 Q440 60 440 66 V92 H32 V66 Q32 60 38 60 Z"/>
<text class="t-name" x="48" y="81">Go 方法 → C 函数</text>
<text class="t-note" x="424" y="81" text-anchor="end">每步前：<tspan class="t-acc">runtime.Stack</tspan> 查属主</text>
<text class="t-mono" x="48" y="113">ctx.NewInt32(acc)</text><text class="t-val" x="424" y="113" text-anchor="end">JS_NewInt32</text>
<line class="dd-hair" x1="32" y1="122" x2="440" y2="122"/>
<text class="t-mono" x="48" y="139">ctx.NewInt32(1)</text><text class="t-val" x="424" y="139" text-anchor="end">JS_NewInt32</text>
<line class="dd-hair" x1="32" y1="148" x2="440" y2="148"/>
<text class="t-mono" x="48" y="165">fn.Execute(g, x, y)</text><text class="t-val" x="424" y="165" text-anchor="end">JS_NewUndefined · JS_Call</text>
<line class="dd-hair" x1="32" y1="174" x2="440" y2="174"/>
<text class="t-mono" x="48" y="191">r.ToInt32()</text><text class="t-val" x="424" y="191" text-anchor="end">JS_ToInt32</text>
<line class="dd-hair" x1="32" y1="200" x2="440" y2="200"/>
<text class="t-mono" x="48" y="217">r.Free() · x.Free() · y.Free()</text><text class="t-val" x="424" y="217" text-anchor="end">JS_FreeValue × 3</text>
<line class="dd-hair" x1="32" y1="226" x2="440" y2="226"/>
<text class="t-note" x="48" y="243">约 10 次 cgo，每次 50–70 ns</text>
<line class="dd-hair" x1="32" y1="252" x2="440" y2="252"/>
<text class="t-note" x="48" y="268">约 10 次属主检查，每次约 3 µs</text>
<text class="t-name" x="32" y="300">默认 <tspan class="t-acc">34.3 µs</tspan></text>
<text class="t-name" x="200" y="300">关掉属主检查 730 ns</text>
<rect class="dd-card" x="488" y="60" width="280" height="190" rx="6"/>
<path class="dd-card-head" d="M494 60 H762 Q768 60 768 66 V92 H488 V66 Q488 60 494 60 Z"/>
<text class="t-name" x="504" y="81">Go → C 一次</text>
<text class="t-mono" x="504" y="113">eng_call(e, ADD, acc, 1)</text>
<line class="dd-hair" x1="488" y1="122" x2="768" y2="122"/>
<text class="t-val" x="520" y="139">JS_NewInt32 × 2</text>
<line class="dd-hair" x1="488" y1="148" x2="768" y2="148"/>
<text class="t-val" x="520" y="165">JS_Call</text>
<line class="dd-hair" x1="488" y1="174" x2="768" y2="174"/>
<text class="t-val" x="520" y="191">JS_ToInt64</text>
<line class="dd-hair" x1="488" y1="200" x2="768" y2="200"/>
<text class="t-val" x="520" y="217">JS_FreeValue</text>
<line class="dd-hair" x1="488" y1="226" x2="768" y2="226"/>
<text class="t-note" x="504" y="243">以上都在 C 里完成</text>
<text class="t-name" x="488" y="282">一次 <tspan class="t-acc">89 ns</tspan></text>
<text class="t-note" x="488" y="306">C 自己做同样的事：21 ns</text>
<rect class="dd-card" x="32" y="392" width="360" height="52" rx="6"/>
<text class="t-mono" x="48" y="414">m.ExportedFunction("QJS_Call").Call(…)</text>
<text class="t-note" x="48" y="434">每次新建函数实例和调用栈：<tspan class="t-acc">3075 ns</tspan></text>
<rect class="dd-card" x="408" y="392" width="360" height="52" rx="6"/>
<text class="t-mono" x="424" y="414">f := m.ExportedFunction(…)；f.Call(…)</text>
<text class="t-note" x="424" y="434">查一次、缓存起来反复用：32 ns</text>
</svg>
</div>
<figcaption>图 8 · 慢的不是 cgo 或 WASM 本身，而是绑定把一次逻辑调用拆成了很多次，并且每次都做了昂贵的额外工作</figcaption>
</figure>

buke 提供了 `WithOwnerGoroutineCheck(false)` 关掉这个检查，代价是调用方自己保证一个运行时只在一个 goroutine 上用[^buke]。关掉之后一次 `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` 每次都会新建一个函数实例、分配一块调用栈[^wazero]。我们绕过 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` 的实例时平均每个占多少内存：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="qjst9-title">启动、内存和多线程吞吐</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="qjst9-title">
<colgroup><col style="width:28%"><col><col><col></colgroup>
<thead>
<tr><th scope="col">宿主</th><th scope="col" class="num">新建一个运行时</th><th scope="col" class="num">每个实例内存</th><th scope="col" class="num">1 / 2 / 4 线程吞吐（次/秒）</th></tr>
</thead>
<tbody>
<tr><th scope="row">C · ng 0.17</th><td class="num">104 µs</td><td class="num">170 KB</td><td class="num">—</td></tr>
<tr><th scope="row">Rust · rquickjs</th><td class="num">106 µs</td><td class="num">182 KB</td><td class="num">119 / 243 / 421</td></tr>
<tr><th scope="row">Go · buke 关掉检查</th><td class="num">304 µs</td><td class="num">117 KB</td><td class="num good">129 / 254 / 496</td></tr>
<tr><th scope="row">Go · modernc</th><td class="num">198 µs</td><td class="num">165 KB</td><td class="num bad hl">46 / 24 / 21</td></tr>
<tr><th scope="row">Go · fastschema（wazero）</th><td class="num bad">3.4 ms，进程内首次 0.77 s</td><td class="num bad">660 KB</td><td class="num">33 / 53 / 78</td></tr>
<tr><th scope="row">Go · goja（对照）</th><td class="num good">11 µs</td><td class="num good">35 KB</td><td class="num">34 / 68 / 115</td></tr>
<tr><th scope="row">Python · quickjs</th><td class="num">176 µs</td><td class="num">55 KB</td><td class="num good">134 / 261 / 459</td></tr>
<tr><th scope="row">Python · quickjs-ng</th><td class="num">115 µs</td><td class="num">167 KB</td><td class="num">56 / 108 / 211</td></tr>
<tr><th scope="row">Node · emscripten</th><td class="num">209 µs</td><td class="num">54 KB</td><td class="num">—</td></tr>
<tr><th scope="row">Java · quickjs4j</th><td class="num bad">9.0 ms</td><td class="num">—</td><td class="num">—</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">吞吐：1、2、4 个线程（goroutine）各自持有一个运行时，循环跑 fib(25)，4 vCPU。内存是 200 个实例的 RSS 增量平均值，各版本内置对象多少不同，只看量级。</p>
</div>
<figcaption>图 9 · 原生的几种都能一个请求一个运行时；wazero 的首次编译和 modernc 的并发是两个例外</figcaption>
</figure>

原生的几种新建一个运行时都在 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`）[^py-quickjs]，所以多个线程各持一个 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 当时在哪。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 356" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf10-title qjsf10-desc">
<title id="qjsf10-title">goroutine 换了线程，QuickJS 按旧线程的栈顶判断，误报栈溢出</title>
<desc id="qjsf10-desc">实测输出：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。</desc>
<defs>
<marker id="qjsf10-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="356" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="372" height="264" rx="8"/>
<rect class="dd-zone" x="404" y="16" width="380" height="264" rx="8"/>
<text class="t-zone" x="32" y="44">OS 线程 13632（主线程）</text>
<text class="t-zone" x="420" y="44">OS 线程 13635</text>
<path class="dd-edge-acc" d="M372 195 H388 Q396 195 396 187 V112 Q396 104 404 104 H418" marker-end="url(#qjsf10-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="340" height="88" rx="6"/>
<path class="dd-card-head" d="M38 60 H366 Q372 60 372 66 V92 H32 V66 Q32 60 38 60 Z"/>
<text class="t-name" x="48" y="81">JS_NewRuntime()</text>
<text class="t-note" x="356" y="81" text-anchor="end">记下栈顶</text>
<text class="t-mono" x="48" y="113">栈地址</text>
<text class="t-val" x="356" y="113" text-anchor="end"><tspan class="t-acc">0x7ffdcbd576a4</tspan></text>
<line class="dd-hair" x1="32" y1="122" x2="372" y2="122"/>
<text class="t-mono" x="48" y="139">上限</text>
<text class="t-val" x="356" y="139" text-anchor="end">往下 1 MB 以内</text>
<rect class="dd-card" x="32" y="164" width="340" height="62" rx="6"/>
<text class="t-name" x="48" y="186">第 0 次调用</text>
<text class="t-mono" x="48" y="210">sp 0x7ffdcbd5767c</text>
<text class="t-val" x="356" y="210" text-anchor="end">ok</text>
<text class="t-say" x="32" y="256">goroutine 被调度器换到别的线程</text>
<rect class="dd-card" x="420" y="60" width="348" height="88" rx="6"/>
<path class="dd-card-head" d="M426 60 H762 Q768 60 768 66 V92 H420 V66 Q420 60 426 60 Z"/>
<text class="t-name" x="436" y="81">第 1 次起的每次调用</text>
<text class="t-note" x="752" y="81" text-anchor="end">同一个运行时</text>
<text class="t-mono" x="436" y="113">sp</text>
<text class="t-val" x="752" y="113" text-anchor="end">0x7f23f199dddc</text>
<line class="dd-hair" x1="420" y1="122" x2="768" y2="122"/>
<text class="t-mono" x="436" y="139">离记下的栈顶</text>
<text class="t-val" x="752" y="139" text-anchor="end">约 0.85 TB</text>
<rect class="dd-derived" x="420" y="164" width="348" height="40" rx="6"/>
<text class="t-mono" x="436" y="189"><tspan class="t-acc">Maximum call stack size exceeded</tspan></text>
<text class="t-say" x="420" y="232">虚线：并没有递归，是按错的</text>
<text class="t-say" x="420" y="252">栈顶算出来的“溢出”</text>
<rect class="dd-zone" x="16" y="296" width="768" height="44" rx="8"/>
<text class="t-name" x="32" y="323">解决：运行时所在 goroutine 调 <tspan class="t-acc">runtime.LockOSThread()</tspan>，或者每次调用前 JS_UpdateStackTop</text>
</svg>
</div>
<figcaption>图 10 · 栈深检查假设运行时一直在同一个线程上，Go 的 goroutine 不满足这个假设</figcaption>
</figure>

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

### 现成的 Go 绑定怎么处理：一个关了检查，一个崩成 panic

**那 buke 为什么没有这个问题？** 因为它默认把栈深上限设成了 0，也就是不检查[^buke]。代价是真的无限递归时，没有人拦着：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="qjst11-title">执行 function r(n){ return r(n+1) + 1 } 之后</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="qjst11-title">
<colgroup><col style="width:34%"><col></colgroup>
<thead>
<tr><th scope="col">宿主</th><th scope="col">结果</th></tr>
</thead>
<tbody>
<tr><th scope="row">Go · buke 默认</th><td class="bad hl">SIGSEGV，整个进程退出</td></tr>
<tr><th scope="row">Go · buke + WithMaxStackSize(1&lt;&lt;20)</th><td class="good">JS 里抛 RangeError，可以 catch</td></tr>
<tr><th scope="row">Go · fastschema（wazero）</th><td class="bad">Go panic：wasm error: out of bounds memory access</td></tr>
<tr><th scope="row">Go · modernc</th><td class="good">JS 里抛 InternalError: stack overflow</td></tr>
<tr><th scope="row">Rust · rquickjs、Python 两个扩展</th><td class="good">JS 里抛异常，可以 catch</td></tr>
<tr><th scope="row">Node · emscripten</th><td class="warn">宿主侧抛 RangeError（V8 的栈先满了）</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">脚本用 try/catch 包住了递归，“可以 catch”指脚本自己能接住，宿主继续正常运行。</p>
</div>
<figcaption>图 11 · 同一段恶意脚本，有的绑定当作普通异常，有的直接让进程退出</figcaption>
</figure>

如果脚本来自用户，这张表比前面所有性能数字都重要。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：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 412" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="qjsf12-title qjsf12-desc">
<title id="qjsf12-title">Go 程序嵌入 QuickJS 的选择</title>
<desc id="qjsf12-desc">第一个问题：能接受 cgo 吗。能：用 buke 时一定要加 WithMaxStackSize，并在确认单 goroutine 使用时关掉属主检查，或者自己写一层把一次逻辑调用合成一次 cgo 并 LockOSThread，计算 1 倍，边界约 90 纳秒。不能：再问需要多核并发吗。需要：fastschema 计算约 3.6 倍、边界 30 微秒、首次启动 0.77 秒，或者不坚持 QuickJS 用 goja。不需要：modernc 计算约 2.4 倍、边界约 300 纳秒，但多 goroutine 会变慢。</desc>
<defs>
<marker id="qjsf12-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="qjsf12-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="412" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="380" rx="8"/>
<text class="t-zone" x="32" y="44">在 Go 里嵌 QuickJS</text>
<path class="dd-edge-acc" d="M200 100 H232 Q240 100 240 108 V128 Q240 136 248 136 H262" marker-end="url(#qjsf12-arrow-acc)"/>
<path class="dd-edge" d="M120 120 V262" marker-end="url(#qjsf12-arrow)"/>
<path class="dd-edge" d="M200 288 H262" marker-end="url(#qjsf12-arrow)"/>
<path class="dd-edge" d="M120 312 V340 Q120 348 128 348 H262" marker-end="url(#qjsf12-arrow)"/>
<rect class="dd-card" x="32" y="80" width="168" height="40" rx="6"/>
<text class="t-name" x="48" y="105">能接受 cgo 吗？</text>
<text class="t-note" x="208" y="92"><tspan class="t-acc">能</tspan></text>
<text class="t-note" x="128" y="196">不能</text>
<rect class="dd-card" x="32" y="264" width="168" height="48" rx="6"/>
<text class="t-name" x="48" y="285">需要多个 goroutine</text>
<text class="t-name" x="48" y="303">同时跑脚本吗？</text>
<text class="t-note" x="208" y="280">需要</text>
<text class="t-note" x="128" y="336">不需要</text>
<rect class="dd-card" x="264" y="64" width="504" height="132" rx="6"/>
<path class="dd-card-head" d="M270 64 H762 Q768 64 768 70 V96 H264 V70 Q264 64 270 64 Z"/>
<text class="t-name" x="280" y="85">cgo 绑定</text>
<text class="t-note" x="752" y="85" text-anchor="end">计算 1×，边界 ~90 ns 到 0.7 µs</text>
<text class="t-mono" x="280" y="119">buke：<tspan class="t-acc">WithMaxStackSize(1&lt;&lt;20)</tspan> 必加</text>
<line class="dd-hair" x1="264" y1="128" x2="768" y2="128"/>
<text class="t-mono" x="280" y="145">     单 goroutine 使用时 WithOwnerGoroutineCheck(false)</text>
<line class="dd-hair" x1="264" y1="154" x2="768" y2="154"/>
<text class="t-mono" x="280" y="171">边界很密：自己封一层，一次逻辑调用只过一次 cgo，</text>
<text class="t-mono" x="280" y="187">     并 runtime.LockOSThread()</text>
<rect class="dd-card" x="264" y="236" width="504" height="76" rx="6"/>
<text class="t-name" x="280" y="258">fastschema/qjs（wazero）或干脆用 goja</text>
<text class="t-note" x="280" y="280">fastschema：计算 3.6×，边界 30 µs，进程首次 0.77 s，能多核扩展</text>
<text class="t-note" x="280" y="300">goja：计算 3.6×，边界 0.3–0.8 µs，新建 11 µs，不是 QuickJS</text>
<rect class="dd-card" x="264" y="324" width="504" height="56" rx="6"/>
<text class="t-name" x="280" y="346">modernc.org/quickjs</text>
<text class="t-note" x="280" y="368">计算 2.4×，边界 ~0.3 µs，异常处理正常；多 goroutine 反而变慢</text>
</svg>
</div>
<figcaption>图 12 · Go 里最快的是 cgo，但要自己补上栈深上限；不用 cgo 时，按是否需要并发在 wazero、goja 和 modernc 之间选</figcaption>
</figure>

能用 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 之间取舍。


## 参考资料
