docs: README 与执行流程、生命周期两篇
README 分四段:脚本怎么写、Go 侧怎么调、安全边界与可靠性、性能。 docs/call-flow.md 讲源码怎么变成可执行的、一次调用经过哪些环节; docs/lifecycle.md 讲 Engine / Script / VM / Instance / 作用域各活多久。 性能一节的每一行都标了对应的基准名,数字过时了可以自己重跑。原来有一张 「循环里访问 Go 对象」的表没有对应的基准测试,数字无法复现,换成了 Benchmark绑定_* 的实测结果。内存那张表仍是手工测的,已在旁边注明。 挑第三方库那节记了两条实测结论:esbuild 的 target 只降级语法、不补全局 对象,所以库只要用了 structuredClone 或定时器就是运行期才炸;CommonJS 包摇不动,同一组功能 lodash 打出 419 KB 而 es-toolkit 只要 7 KB。
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 执行流程
|
||||
|
||||
一段脚本从源码走到 Go 侧拿到结果,中间经过三个阶段:**打包编译**、**取 VM**、**调用**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["源码<br/>ESM / TypeScript"] --> B{"从哪来"}
|
||||
B -->|"Engine.Compile(name, src)"| C
|
||||
B -->|"Loader.Load(name)"| P{"实现了 Prepared?"}
|
||||
P -->|"否(默认)"| C["Bundle(filename, source)"]
|
||||
P -->|"是(jscriptx/esm)"| E
|
||||
|
||||
C --> C1["esbuild:TS 转译 · import 内联<br/>· tree-shaking · ESM 格式输出"]
|
||||
C1 --> D["FinalizeBundle<br/>改写 export → 立即执行函数"]
|
||||
D --> E["goja.Compile → *goja.Program"]
|
||||
E --> F["*Script(进 Engine 缓存)"]
|
||||
|
||||
F --> G{"怎么调"}
|
||||
G -->|"Script.Call"| H1["从 VM 池借"]
|
||||
G -->|"Instance.Call"| H2["锁住独占的 VM"]
|
||||
H1 --> I
|
||||
H2 --> I["invoke()"]
|
||||
I --> J["结果 / *Error"]
|
||||
```
|
||||
|
||||
`*goja.Program` 是**无状态的**,同一个脚本的所有 VM 共享同一份编译产物;每个 VM 只是各自
|
||||
执行一遍它,产生自己的函数对象和实例。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:打包编译
|
||||
|
||||
只在**脚本加载和热更新**时发生,不在调用路径上(单段源码约 204 μs,一个目录 9 个入口约 1.5 ms)。
|
||||
|
||||
### 为什么必须打包
|
||||
|
||||
goja 的 ES6+ 支持很完整(class、async、解构、可选链、Proxy、BigInt 实测都能直接跑),
|
||||
但它**没有 ES module**——`import`/`export` 在 goja 的 token 表里是保留字,解析阶段就挂;
|
||||
TypeScript 也不在它的职责范围。打包把这两件事在交给引擎之前抹平。
|
||||
|
||||
### 为什么用 ESM 格式而不是 IIFE
|
||||
|
||||
esbuild 按 IIFE 或 CJS 格式输出时,会附带一整套 CommonJS interop helper:
|
||||
|
||||
```js
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __export = (target, all) => { … };
|
||||
var __copyProps = (to, from, except, desc) => { … };
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
```
|
||||
|
||||
那套东西是为了模拟 `__esModule` 语义,本库根本用不上——我们只要拿到导出对象。而它的代价是
|
||||
实打实的:**每建一个 VM 都要重新创建那 7 个函数、再遍历一遍属性装 getter**。
|
||||
|
||||
只有 ESM 格式是零 helper 的(导出信息就在 `export {...}` 那句声明里,不做任何转换)。
|
||||
代价是那句 goja 不认,得由 `FinalizeBundle` 改写掉:
|
||||
|
||||
```js
|
||||
// 源码 // 产物
|
||||
// p.ts (() => {// p.ts
|
||||
var H = class {…}; ───► var H = class {…};
|
||||
export { return H;})()
|
||||
H as default
|
||||
};
|
||||
```
|
||||
|
||||
改写有两条硬约束:
|
||||
|
||||
- 开头的 `(() => {` **必须紧贴原第一行**,不能另起一行,否则所有行号下移一位,
|
||||
sourcemap 就对不上,报错定位不回 `.ts` 源码
|
||||
- `export` 语句在产物末尾,把它整段换掉不影响前面任何行
|
||||
|
||||
只有命名导出时,改写成对象字面量 `return {a:a, b:b}`——仍然零 helper,而且是普通属性
|
||||
(不是 getter),后续按方法名取的时候更快。
|
||||
|
||||
实测差距:
|
||||
|
||||
| | 产物(代码部分) | 建一个实例 | 每实例常驻 |
|
||||
| --- | --- | --- | --- |
|
||||
| IIFE 格式(带 helper) | 1077 字节 | 22.1 μs / 472 allocs | 25.7 KB |
|
||||
| ESM + 改写 | **74 字节** | **4.8 μs / 110 allocs** | **7.0 KB** |
|
||||
|
||||
### 认不出 export 时的兜底
|
||||
|
||||
改写只认末尾那段 `export {...};`。格式对不上就当作"脚本没有导出",产物求值为 `undefined`,
|
||||
调用时得到明确报错——不会静默产生错误结果。守护测试盯着三件事:产物不含任何 interop helper、
|
||||
包装不让行号偏移(端到端验证第 3 行报错仍是第 3 行)、9 种导出形态都能正确改写。
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:建 VM
|
||||
|
||||
池里没有可用 VM 时才走这条路:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["goja.New()"] --> B["engine.bind:注入白名单"]
|
||||
B --> B1["逐层拷贝成只读对象<br/>+ console + 作用域的扩展/全局"]
|
||||
B1 --> C["装超时哨兵"]
|
||||
C --> D["rt.RunProgram(prog)<br/>执行打包产物"]
|
||||
D --> E["取完成值 v"]
|
||||
E --> F{"v 是什么"}
|
||||
F -->|"class"| G["construct(v, ctorArgs)<br/>→ exports"]
|
||||
F -->|"函数"| H["defFn<br/>(DefaultFunc 调用)"]
|
||||
F -->|"对象"| I["exports<br/>(按方法名调用)"]
|
||||
G --> J["*vmHandle"]
|
||||
H --> J
|
||||
I --> J
|
||||
```
|
||||
|
||||
白名单注入时**逐层拷贝成只读对象**,不是直接 `rt.Set`:否则同一个 Go map 会被所有 VM 共享,
|
||||
脚本一句 `db.C = null` 既污染别的 VM,又是实打实的数据竞争。
|
||||
|
||||
顶层代码同样受超时保护——脚本在顶层写死循环不会把调用方卡住。
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:调用
|
||||
|
||||
所有调用都汇到 `invoke()`,`*Script` 和 `*Instance` 的区别只在"怎么取 VM、怎么还":
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["invoke(ctx, runner, fn, args, do)"] --> B{"脚本已关闭?"}
|
||||
B -->|"是"| Z1["ErrClosed"]
|
||||
B -->|"否"| C["runner.acquire(ctx)<br/>池借 / 加锁"]
|
||||
C --> D["lookup(fn)<br/>找方法 + 绑定 this"]
|
||||
D -->|"找不到"| Z2["ErrFuncNotFound<br/>+ 没写 export 的提示"]
|
||||
D --> E["guard:装中断哨兵"]
|
||||
E --> F["defer recover:兜 panic"]
|
||||
F --> G["do(frame) → 真正调用"]
|
||||
G --> H["unwrapPromise<br/>async 结果解包"]
|
||||
H --> I["Export / ExportTo<br/>转成 Go 值"]
|
||||
I --> J{"出错了?"}
|
||||
J -->|"否"| K["healthy = true"]
|
||||
J -->|"是"| L["classify(err) 分类"]
|
||||
L --> M{"致命?"}
|
||||
M -->|"超时/取消/panic"| N["healthy = false"]
|
||||
M -->|"脚本异常"| K
|
||||
K --> O["runner.finish(inst, healthy)"]
|
||||
N --> O
|
||||
O --> P["结果 / *Error"]
|
||||
```
|
||||
|
||||
### defer 的顺序很关键
|
||||
|
||||
三个 defer 按注册顺序倒着执行,缺一不可:
|
||||
|
||||
```
|
||||
注册:finish → stop → recover
|
||||
执行:recover(置 healthy=false)→ stop(清中断标志)→ finish(归还/丢弃)
|
||||
```
|
||||
|
||||
recover 必须最先跑,否则 panic 时 `healthy` 还是 true,一个状态可疑的 VM 会被放回池子。
|
||||
|
||||
### 超时哨兵
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A{"ctx.Done() 存在?"} -->|"否(快路径)"| B["time.AfterFunc<br/>约 80ns"]
|
||||
A -->|"是"| C["起 goroutine<br/>select ctx.Done()"]
|
||||
B --> D["触发 → rt.Interrupt()"]
|
||||
C --> D
|
||||
D --> E["stop():先关哨兵<br/>再 ClearInterrupt"]
|
||||
```
|
||||
|
||||
`stop()` 里的顺序有讲究:**必须先确保哨兵不会再发信号,再清中断标志**。反过来的话,一个迟到的
|
||||
`Interrupt` 会落在已经清理过的 VM 上,毒死下一次用到它的调用。快路径用互斥量保证 `stop` 之后的
|
||||
`fire` 一律丢弃;goroutine 路径等哨兵真正退出再清。
|
||||
|
||||
高频调用(MQTT 消息级)建议传 `context.Background()` 走快路径——传可取消的 context 会让每次
|
||||
调用多起一个 goroutine,实测 0.45 μs 涨到 4.2 μs。
|
||||
|
||||
### 错误分类
|
||||
|
||||
`classify()` 把 goja 的各种错误翻译成统一的 `*Error`:
|
||||
|
||||
| goja 侧 | Kind | VM 还能用吗 |
|
||||
| --- | --- | --- |
|
||||
| `InterruptedError` + `DeadlineExceeded` | `KindTimeout` | ❌ 丢弃 |
|
||||
| `InterruptedError` + `Canceled` | `KindCanceled` | ❌ 丢弃 |
|
||||
| Go 侧 panic(recover 到) | `KindPanic` | ❌ 丢弃 |
|
||||
| `Exception`(脚本 throw) | `KindRuntime` | ✅ 回池 |
|
||||
| `StackOverflowError` | `KindRuntime` | ❌ 丢弃 |
|
||||
|
||||
脚本里没 catch 的 Go error 会顺着 `Exception.Unwrap()` 取回来挂在 `Cause` 上,
|
||||
调用方的 `errors.Is` 照样能匹配到自己的哨兵错误。
|
||||
|
||||
---
|
||||
|
||||
## 自定义调用约定
|
||||
|
||||
`Call`/`CallInto` 表达不了的模式——典型是「回调 + next」中间件——走 `WithCall`:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["WithCall(ctx, fn, do)"] --> B["借 VM、装哨兵、兜 panic"]
|
||||
B --> C["do(Caller)"]
|
||||
C --> D["c.Arity()<br/>看声明了几个形参"]
|
||||
D --> E["c.Call(value, next)<br/>next 是 Go 闭包"]
|
||||
E --> F["res.IsEmpty() / Into(&out)"]
|
||||
F --> G["归还 VM、分类错误"]
|
||||
```
|
||||
|
||||
`next` 能传给脚本,靠的是 goja 会把 Go 函数包装成 JS 函数——所以不需要单独暴露"值转换"的概念。
|
||||
但这也意味着它**只在这次 VM 借出期间有效**,`Caller` 和 `Result` 都不能存下来跨调用用
|
||||
(`Result` 还会被同一个 `Caller` 的下次 `Call` 复用)。
|
||||
|
||||
完整实现见 `ExampleCaller`。本库不预设脚本回调该长什么样——那是框架的约定,各家不同。
|
||||
|
||||
---
|
||||
|
||||
## 值的跨界规则
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Go
|
||||
A["Go 对象"]
|
||||
D["Go 值"]
|
||||
end
|
||||
subgraph JS["VM 内部"]
|
||||
B["反射包装的对象"]
|
||||
C["返回值"]
|
||||
end
|
||||
A -->|"rt.ToValue<br/>反射包装"| B
|
||||
C -->|"Export / ExportTo"| D
|
||||
C -.->|"函数/闭包<br/>❌ ErrValueEscape"| D
|
||||
```
|
||||
|
||||
**函数和闭包不能跨出脚本边界**:那种值绑在 VM 上,VM 归还池子后再调用会出问题,所以
|
||||
`Call` 的返回值和 `CallInto` 的目标都拒绝函数类型。需要回调语义走 `WithCall`——它在 VM
|
||||
借出期间就把整个交互完成了。
|
||||
|
||||
Go 对象每次传进脚本都要反射包装一次,这是池化调用的主要开销:同一个业务(记录设备消息计数),
|
||||
状态放 Go 侧靠参数传进去要 2.0 μs,状态放 JS 实例里只要 0.45 μs。
|
||||
|
||||
另外 Go 的 `nil` 到脚本里是 **`null` 不是 `undefined`**。
|
||||
|
||||
---
|
||||
|
||||
## 多返回值约定
|
||||
|
||||
Go 函数的多返回值到 JS 的转换规则(goja 的行为,本库沿用):
|
||||
|
||||
| Go 签名 | 脚本里拿到 |
|
||||
| --- | --- |
|
||||
| `func() T` | 裸值 |
|
||||
| `func() (T, error)` | error 为 nil:裸值;非 nil:**抛 JS 异常** |
|
||||
| `func() (A, B)` | 数组 `[A, B]` |
|
||||
| `func() (A, B, error)` | error 为 nil:`[A, B]`;非 nil:抛异常 |
|
||||
|
||||
**error 永远不出现在返回值里**,它只会变成异常。这是脚本作者唯一需要额外理解的"非直觉"行为。
|
||||
@@ -0,0 +1,250 @@
|
||||
# 生命周期
|
||||
|
||||
四层对象,从长到短:**Engine → Script → VM → 调用**。搞清楚谁活多久,就知道状态该放哪。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
E["Engine<br/>进程级"] --> S1["Script<br/>一份编译产物 + VM 池"]
|
||||
E --> S2["Script"]
|
||||
S1 --> P["VM 池<br/>无状态调用复用"]
|
||||
S1 --> I1["Instance<br/>独占 VM,你 Close"]
|
||||
S1 --> I2["Instance"]
|
||||
P --> V1["vmHandle"]
|
||||
P --> V2["vmHandle"]
|
||||
I1 --> V3["vmHandle"]
|
||||
I2 --> V4["vmHandle"]
|
||||
```
|
||||
|
||||
| 层 | 活多久 | 谁结束它 |
|
||||
| --- | --- | --- |
|
||||
| `Engine` | 整个进程 | `Close()` |
|
||||
| `Script` | 到下次热更新 | 被同名脚本顶替,或 `Engine.Close()` |
|
||||
| VM(池里) | 不确定 | 出错被丢弃、池满被丢弃、`Script` 关闭 |
|
||||
| VM(`Instance`) | 到你 `Close()` | **只有你**——本库不代管 |
|
||||
| 作用域(ctx 里) | 跟着 ctx | 没有生命周期,只是数据载体 |
|
||||
|
||||
---
|
||||
|
||||
## Engine:只管配置,不追踪实例
|
||||
|
||||
Engine 持有白名单、超时策略、Loader、打包选项和编译产物缓存。它**不知道任何 Instance 的存在**——
|
||||
没有会话注册表、没有按 key 复用、没有空闲回收器。
|
||||
|
||||
这是有意的:`New` 出来的实例由你拿着,生命周期归你;Engine 追踪它反而是多余的耦合,还要处理
|
||||
"调用方忘了 Close 但 Engine 还引用着"导致的泄漏。
|
||||
|
||||
`Instance` 也不直接持有 Engine,它通过 `script.engine` 拿配置。
|
||||
|
||||
---
|
||||
|
||||
## Script:热更新时被顶替
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 已编译: Compile / Loader 加载
|
||||
已编译 --> 已编译: 版本号没变,复用
|
||||
已编译 --> 新Script: 版本号变了,重新编译
|
||||
新Script --> [*]: 旧 Script.Close()
|
||||
已编译 --> [*]: Engine.Close()
|
||||
|
||||
note right of 新Script
|
||||
旧 Script 上正在跑的调用
|
||||
会用旧版本跑完,不受影响
|
||||
end note
|
||||
```
|
||||
|
||||
热更新时 Engine 造一个**新的** `Script` 顶替旧的,然后关掉旧的。已经拿着旧 `*Script` 指针的
|
||||
调用会继续跑完旧版本;旧 `Script` 池里的 VM 被丢弃。
|
||||
|
||||
从旧 `Script` `New` 出来的实例不在这条链上——它们会因为 `script.closed` 而在下次调用时报
|
||||
`ErrClosed`,VM 等 GC 回收。
|
||||
|
||||
版本号由 `Loader` 给。`jscriptx/esm` 用的是**参与打包的所有源文件**的 mtime+size 哈希——
|
||||
所以改了被 `import` 的公共模块也会触发重编译。`node_modules` 里的文件不算在内,改依赖后要
|
||||
显式 `Rebuild()`。
|
||||
|
||||
多层叠放(`WithLoader` 给多个)时,版本号会带上是第几层给的。这一步不能省:覆盖层的
|
||||
脚本删掉后会落回下面那层,两层的版本号万一撞上,不带层号引擎就看不出脚本已经换了人,
|
||||
会继续用旧的编译结果。
|
||||
|
||||
---
|
||||
|
||||
## VM 池:无状态调用
|
||||
|
||||
`Script.Call` 走这条。VM 从池里借,用完还回去:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 池中: newVM()
|
||||
池中 --> 借出: borrow()
|
||||
借出 --> 池中: release(healthy=true)
|
||||
借出 --> 丢弃: release(healthy=false)
|
||||
池中 --> 丢弃: Script.Close()
|
||||
借出 --> 丢弃: 池满
|
||||
|
||||
note right of 丢弃
|
||||
超时、取消、panic 过的 VM
|
||||
一律不回池
|
||||
end note
|
||||
```
|
||||
|
||||
两个刻意的设计:
|
||||
|
||||
**借不到不阻塞**——池空时直接新建一个。池只是复用缓存,不承担限流职责;并发超过池容量时
|
||||
宁可临时多造几个 VM,也不让请求在这里排队。用完如果池满了,多出来的直接丢弃。
|
||||
|
||||
**坏 VM 不回池**——被中断或 panic 过的 VM 状态不确定,重建远比拖着它划算。
|
||||
|
||||
这条路径下**脚本里的 `this.xxx` 随时可能归零**:同一个业务流程的连续调用大概率落在不同 VM 上。
|
||||
而且丢得没规律——低负载时可能一直命中同一个 VM,看着正常,一上量就出问题。
|
||||
|
||||
---
|
||||
|
||||
## Instance:当普通对象用
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 就绪: New(ctx, args...)
|
||||
就绪 --> 执行中: acquire() 加锁
|
||||
执行中 --> 就绪: finish(healthy=true) 解锁
|
||||
执行中 --> 待重建: finish(healthy=false)
|
||||
待重建 --> 就绪: 下次调用惰性重建
|
||||
就绪 --> [*]: Close()
|
||||
|
||||
note right of 待重建
|
||||
构造参数会重新传一遍
|
||||
但 this 上攒的状态归零
|
||||
Resets() 能查到次数
|
||||
end note
|
||||
```
|
||||
|
||||
`New` 会**立刻建好 VM**,所以 `constructor` 抛异常在 `New` 当场就报(`Func` 字段是
|
||||
`"constructor"`),不用等到第一次调用。
|
||||
|
||||
`acquire` 拿到的锁一直持到 `finish` 才放——这就是"同一实例的调用串行执行"的保证。
|
||||
goja 的 Runtime 不是并发安全的,多个 goroutine 同时调同一个实例会排队,不同实例之间并行。
|
||||
|
||||
**出错会丢状态**:一次超时或 panic 让 VM 被丢弃,下次调用用全新实例重建。这把"状态随时可能丢"
|
||||
降低成"只在脚本出错时丢",**不是绝不丢**——真正不能丢的东西放作用域的扩展里(那是 Go 侧对象,
|
||||
不随 VM 重建)。
|
||||
|
||||
**长期持有由你管**:本库没有按 key 复用和空闲回收。要按设备 ID 存着,业务侧自己一个 `sync.Map`,
|
||||
跟 Go 版 controller 的写法一致。
|
||||
|
||||
---
|
||||
|
||||
## 作用域:没有生命周期的数据载体
|
||||
|
||||
作用域装在 ctx 里,只携带一段业务流程要共享的东西——**它不持有 VM,也不管实例的生死**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CTX["ctx ─ scope"] --> K["key(标识,脚本里 scope.key)"]
|
||||
CTX --> G["ScopeGlobals(专属全局)"]
|
||||
CTX --> X["Extensions(你创建的对象)"]
|
||||
X --> V1["ctrlA 的 VM"]
|
||||
X --> V2["ctrlB 的 VM"]
|
||||
X --> V3["ctrlC 的 VM"]
|
||||
|
||||
note1["同一份 Go 对象引用<br/>注入到各个 VM"]
|
||||
X -.- note1
|
||||
```
|
||||
|
||||
扩展实例**由你创建**,所以 Go 侧和脚本读写的天然是同一份,不用再取回来:
|
||||
|
||||
```go
|
||||
st := store.New()
|
||||
ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st))
|
||||
// …脚本里 store.Set("k", v)…
|
||||
st.Get("k")
|
||||
```
|
||||
|
||||
扩展的存活期就是你手里那个变量的存活期——`obj.Close()` 只释放实例的 VM,不碰扩展。
|
||||
|
||||
---
|
||||
|
||||
## 脚本级钩子:跑在临时 VM 里
|
||||
|
||||
`CallStatic` 调的是脚本导出的 class 上的**静态方法**,它跟实例的生命周期是分开的:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["CallStatic(ctx, \"Startup\")"] --> B["建一个临时 VM"]
|
||||
B --> C["跑模块顶层"]
|
||||
C --> D["调静态方法<br/>**不构造实例**"]
|
||||
D --> E["丢弃 VM"]
|
||||
E --> F["副作用留在 Go 侧扩展里"]
|
||||
```
|
||||
|
||||
两点跟 `Call` 不同,都是"静态"这个语义要求的:
|
||||
|
||||
- **不构造实例**——`constructor` 是"这一次调用的准备",跟脚本级的初始化无关,跑它是白费
|
||||
- **VM 用完就丢**,不回池——静态方法一般只在脚本生命周期里跑一两次
|
||||
|
||||
所以它**建不出能存活的 JS 对象**。用处是把配置写进 Go 侧的扩展,那些副作用留在 Go 那边,
|
||||
后续每次调用都读得到。作用域扩展在静态方法里照常可用,这正是它的落点。
|
||||
|
||||
脚本没写这个静态方法时返回的错误能被 `errors.Is(err, ErrFuncNotFound)` 匹配——生命周期钩子
|
||||
多半是可选的,用它区分"没写"和"写了但炸了"。
|
||||
|
||||
## 状态该放哪
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["要跨调用保持的东西"] --> B{"丢了会怎样"}
|
||||
B -->|"只是慢一点"| C["缓存"]
|
||||
B -->|"会算错"| D["状态"]
|
||||
C --> C1["可以放 this.xxx"]
|
||||
D --> D1{"用哪种调用"}
|
||||
D1 -->|"Script.Call 池化"| D2["必须放 Go 侧<br/>作用域扩展,或当参数传"]
|
||||
D1 -->|"Instance"| D3["可以放 this.xxx<br/>但脚本出错会归零"]
|
||||
D3 --> D4["绝对不能丢的<br/>放作用域扩展或落库"]
|
||||
```
|
||||
|
||||
| | 实例生命周期 | 脚本里的 `this.xxx` |
|
||||
| --- | --- | --- |
|
||||
| `Script.Call` | = VM 生命周期 | 随时可能归零,**只能当缓存** |
|
||||
| `Instance` | 由你 `Close` 决定 | 跨调用保持,出错时归零 |
|
||||
| 作用域扩展 | 你手里的变量活多久 | 不受 VM 重建影响 |
|
||||
|
||||
还有一层容易忽略的:**多个脚本 `import` 同一个模块时,模块级状态也不共享**。模块被内联进各自的
|
||||
产物是一层原因,更根本的是每个实例独占一个 VM,VM 之间不共享任何 JS 状态。公共的纯函数随便
|
||||
`import`,公共的状态放作用域扩展。
|
||||
|
||||
---
|
||||
|
||||
## 并发粒度
|
||||
|
||||
| 场景 | 能并发吗 | 为什么 |
|
||||
| --- | --- | --- |
|
||||
| 不同实例(不同请求、不同设备) | ✅ | 各自独占 VM |
|
||||
| 同一作用域下的不同 controller | ✅ | 各有各的 VM,只共享 Go 侧的扩展对象 |
|
||||
| **同一个实例**的多次调用 | ❌ 串行 | Runtime 非并发安全,且要保住 `this.xxx` |
|
||||
| 不带作用域的池化调用 | ✅ | 池里多个 VM |
|
||||
|
||||
只有第三种是串行的,那是必需的——跟 Go 侧用 `sync.Mutex` 保护 struct 字段是一回事。
|
||||
|
||||
共享数据靠扩展(Go 侧对象)而不是共用 Runtime:共用 Runtime 会让作用域内所有脚本被迫串行,
|
||||
那才是真的并发瓶颈。
|
||||
|
||||
---
|
||||
|
||||
## 内存
|
||||
|
||||
每个实例独占一个 VM,3000 个实例的常驻内存实测:
|
||||
|
||||
| 配置 | 单实例 | 3000 个 |
|
||||
| --- | --- | --- |
|
||||
| 裸 VM | 7.0 KB | 20.5 MB |
|
||||
| + `console` | 12.5 KB | 36.6 MB |
|
||||
| + `console` + store 扩展 | 21.8 KB | 64.0 MB |
|
||||
|
||||
白名单、console、扩展都只在**建 VM 时**注入,对每次调用零影响。但每个实例一个 VM,这些会
|
||||
乘以实例数——实例多又不需要脚本日志时,`WithLogger(nil)` 关掉 console 能省约 44%。
|
||||
|
||||
而且注入是**惰性**的:建 VM 时只装一个只读的访问器属性,脚本第一次读到那个全局名才把方法集
|
||||
转成 JS 对象,转完在这个 VM 里缓存。一个脚本通常只用得上少数几个扩展,用不到的那些不该在
|
||||
每次建 VM 时都付一遍转换成本。所以上表是"全都读到"的上界,实际按脚本用到多少收费。
|
||||
|
||||
建一个实例约 4.8 μs。这个数字对"每请求一个实例"的场景直接相关:QPS 1000、每请求 3 个
|
||||
controller,约 14 ms/s 的 CPU。
|
||||
Reference in New Issue
Block a user