Files
jscriptx/docs/lifecycle.md
T
what 6477b27ef4 docs: 校对 README 和 docs,改掉三处跟代码对不上的
不是全面重写——文档里不指向具体源文件,所以上一轮的改名没波及它们。逐条核对
「文档提到的 API」和「实际导出」之后,只有三处不符:

  README 的哨兵错误列表   多了 ErrUnsupportedSignature(这轮删了),
                          少了 ErrInterrupted(一直都有)
  call-flow.md 的 freeze  说白名单「逐层拷贝成只读对象,否则同一个 Go map 会被
                          所有 VM 共享」——那只对 map[string]any 成立。结构体
                          指针和 slice 走 rt.ToValue,是**共享同一个对象**的。
                          扩展走的正是这条路,不该被当成隔离保证。
                          (doc.go 里同一处上一个 commit 已经改过)
  lifecycle.md 的状态图   写着 [*] --> 池中: newVM(),但 newVM 产出的 VM 是
                          直接借出去用的,回池要等 release

错误分类那张表(KindTimeout/KindCanceled/KindPanic/KindRuntime)核对过,没有
KindSignature,不受这轮删除影响。
2026-09-10 15:51:48 +08:00

9.9 KiB
Raw Blame History

生命周期

四层对象,从长到短:Engine → Script → VM → 调用。搞清楚谁活多久,就知道状态该放哪。

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 关闭
VMInstance 到你 Close() 只有你——本库不代管
作用域(ctx 里) 跟着 ctx 没有生命周期,只是数据载体

Engine:只管配置,不追踪实例

Engine 持有白名单、超时策略、Loader、打包选项和编译产物缓存。它不知道任何 Instance 的存在—— 没有会话注册表、没有按 key 复用、没有空闲回收器。

这是有意的:New 出来的实例由你拿着,生命周期归你;Engine 追踪它反而是多余的耦合,还要处理 "调用方忘了 Close 但 Engine 还引用着"导致的泄漏。

Instance 也不直接持有 Engine,它通过 script.engine 拿配置。


Script:热更新时被顶替

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 而在下次调用时报 ErrClosedVM 等 GC 回收。

版本号由 Loader 给。jscriptx/esm 用的是参与打包的所有源文件的 mtime+size 哈希—— 所以改了被 import 的公共模块也会触发重编译。node_modules 里的文件不算在内,改依赖后要 显式 Rebuild()

多层叠放(WithLoader 给多个)时,版本号会带上是第几层给的。这一步不能省:覆盖层的 脚本删掉后会落回下面那层,两层的版本号万一撞上,不带层号引擎就看不出脚本已经换了人, 会继续用旧的编译结果。


VM 池:无状态调用

Script.Call 走这条。VM 从池里借,用完还回去:

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:当普通对象用

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,也不管实例的生死

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 侧和脚本读写的天然是同一份,不用再取回来:

st := store.New()
ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st))
// …脚本里 store.Set("k", v)…
st.Get("k")

扩展的存活期就是你手里那个变量的存活期——obj.Close() 只释放实例的 VM,不碰扩展。


脚本级钩子:跑在临时 VM 里

CallStatic 调的是脚本导出的 class 上的静态方法,它跟实例的生命周期是分开的:

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) 匹配——生命周期钩子 多半是可选的,用它区分"没写"和"写了但炸了"。

状态该放哪

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。