TinyActor 中文 · English

设计决策记录(PR #15 review 对齐)

与用户在 PR #15 review comments 对齐过程中确认的设计决策。 每个决策:结论 + 理由 + 影响面。实现前以此为准。

D1. 外部 C 模块类型声明机制(external fn)

结论:独立声明文件 lib/<mod>.ta(与 dylib 同名),语法 external fn + 可选绑定。

// lib/mymod.ta —— mymod.dylib 的声明文件(外部模块作者只写这个 + .c)
external fn double(x: int) -> int = "mymod" "double"   // 显式绑定(C 侧名不同时)
external fn greet(name: string) -> string              // 缺省 = 模块名.函数名同名

D2. 布尔逻辑对齐 Gleam

结论:外部语法 &&/|| 中缀,AST 保持 ('and a b) / ('or a b)

D3. 模块级编译(B 方案:编译期链接)

结论:模块预编译为 .tabc 段 + .sig 签名表,主程序 import 时编译期拼接(链接)进输出,仍是单文件 .tabc。

D4. float 类型(规划,不实现)

结论:float 进路线图但当前不实现,只修正文档。

D6. char 字面量 = 码点 int(语法糖)

结论'a' 字面量 = Unicode 码点 int(与 97 完全等价,零类型系统改动)。

D7. GC 心智模型文档澄清(c-module.md §2/§3 已更新;其中 root 部分已被 D11 取代)

结论:纯文档修正,回答用户三个问题:

  1. 符号表并发(:67):symbols 是 VM 级共享数组,vm_intern_symbol 无锁(线性扫描+追加);多 worker 线程下运行期并发 intern 存在 data race 窗口(重复 strdup/数组竞争)。实际风险小(符号集中编译期 intern)。C 模块应初始化阶段预 intern,运行期避免并发新建。不加锁(YAGNI,真出问题再说)。
  2. C 里构造的 Val 怎么被 root 保护(:76):root = 进程 TA 栈 + gc_roots 数组;C 局部变量不可见。构造即返回安全原理 = 构造函数先分配后写值,无「持有旧 Val 又分配」窗口。
  3. 单次分配完下次分配前不触发 GC?(:76):。GC 同步、仅分配时堆栈碰撞触发、per-proc 独立堆互不影响、无后台 GC 线程。风险只在跨分配持有

D8. 代码风格重构(主题 8,全做)

结论:else if 已支持(澄清,不改);三处重构全做(用户:代码整洁度是需要的):

  1. parser.ta:202:kw 处理嵌套 if → 扁平化 else if 链(纯样式不动逻辑;不推 match——parser 主循环风险高)
  2. tokenizer.ta:113:is_kw 的 str.eq 链(×11)→ 提取公共函数(one_of/查表 contains)
  3. typecheck.ta:1984:make_builtin_env 的 extend 嵌套 let → 表格驱动 alist 遍历(与主题 1 的 http 删除一起做,避免两次动 extend 链)

三个重构都是不改语义的编译器改动 → 走 FIXED POINT 验证 + make test 100 绿。

D9. 测试/文档疑问(主题 9)

结论

  1. 无注释能否推导出错误(type-annot-errors.ta:42):不能(实验证明:无注释 map(inc, "nope") 通过 exit=0,带注释才报 cannot unify string with List(int))。原因:match 推断不反推参数类型,参数无标注 = fresh var。保持现状(标注 = 显式检查点,光谱设计;lib 大量无标注函数,反推会全爆)
  2. 类型大写是命名习俗(type-annot-hof.ta:4):——gleam/OCaml/Haskell 惯例(类型构造器大写、变量/函数小写),tinyactor 沿用
  3. len/list_ref 文档残留(layered-type-model.md:39/60,Copilot):已确认 typecheck 无注册(已移除),文档两处已删

D10. check-modules.sh 删除(主题 10)

结论:删除 tools/check-modules.sh(用户明确「我不需要这种东西啊…这个方向就不对!」)。

实现计划(全部主题对齐后,按依赖排序进 PR #15)

  1. P1 布尔逻辑 gleam 化(D2):&&/|| tokenizer + 砍 and/or + if 严格 Bool + logic-and-or.ta 重写 + 负测试
  2. P2 char 字面量(D6):tokenizer lookahead 消歧
  3. P3 代码风格重构(D8):parser 扁平化 + is_kw 公共函数 + typecheck 表格化
  4. P4 external fn 声明文件(D1):typecheck 加载声明文件 + demo/http 迁移 + 删 http extend 行 + 删 check-modules
  5. P5 模块级编译 B1’(D3):模块产物 + 签名表 + import 链接(在 P4 声明文件机制上做)

每个编译器改动 commit:FIXED POINT VERIFIED + make test 100 绿。

已在 D2 覆盖:if 条件 typecheck 要求 Bool,非 bool 编译期报错;运行时 OP_JUMP_IF_FALSE 的 falsy={nil,false} 语义保留(防御性)。

待对齐主题(review comments 剩余)

D11. GC 分配时就地收集 + per-proc 门 + arena 固定预留(issue #136;#119 phase2)

结论:GC 的触发点从”每条指令边界的 gc_pending 检查”(vm_run_proc 循环顶) 改为分配时proc_heap_alloc 在堆超过 gc_trigger(或 TA_GC_STRESS 要求)时 就地收集,但仅在 Proc.gc_gate == 0(”当前没有不可根化的活引用”)时。VM 主循环 内不再有任何 GC 检查。arena 仍是固定预留TA_ACTOR_HEAP,默认 64 MiB),由此 继续不需要 rootinggc_root_push/gc_root_pop/GC_ROOTS_SCOPE/Proc.gc_roots*proc_growgc_fixup_heap_pointers(及其 fixup_buffer/fixup_val_in_range)依旧 不存在。

安全点定义(替代旧的”唯一 opcode 边界”):

  1. 门开时的分配点proc_heap_allocgc_pending 或堆超 trigger 且 gc_gate == 0 ⇒ 就地 gc_collect。GC 的 root 集只有 TA 栈,所以调用这个 分配点的瞬间,该 proc 的所有活引用都必须在栈上(或在 gate 罩住、不会被收集的 区域里)。
  2. 门关时的延迟 + 补收:无法满足上面前提的区段——把多个活堆引用放在 C 局部 / malloc worklist 里横跨分配(C 模块回调、val_deep_copy 的迭代 worklist、嵌套 val_pair 链、将死的 proc)——用 proc_gc_enter/proc_gc_leavegc_gate 加/减一。门关时分配只置 gc_pending;门重新开为 0 且区段的活值已根化后,由 proc_gc_drain 补收(如 OP_CCALL_NAME/OP_RECV* 在把结果压栈之后)。
  3. 延迟收:区段出来时若活值还没根化(val_deep_copy 的返回值、将死 proc 的堆), 就不补收,让请求挂着——下一个门开的分配点的那次 proc_gc_drain 自然会消费它。

能重写为栈纪律就不套门OP_CONS 过去先 pop 到 C 局部再 val_pair,现在改为 “先分配 → 再从栈槽 proc_peek 读 car/cdr → sp += 2 → 压回新 pair”(GC 只转发栈 槽,按值读即安全)。OP_CLOSURE/OP_PUSH_STRING 本就”先分配、后从栈或字节码读”, 无需门;OP_PUSH_FLOAT 只是从字节码取操作数,不分配,更无需门。

一条保证(仍成立):arena 不移动Val绝对指针,但 buffer 只允许在 heap_ptr == 0(无对象 ⇒ 无指针指向它)时扩容/搬迁,之后终生冻结。收集只是把存活 对象在两个半区间复制、并转发 TA 栈;因此指向堆内缓冲区的裸指针 (char * into HeapString->data)只要在安全点不在 C 局部里横跨分配,就仍然有效。 C 模块的”不需要 root”承诺不变:OP_CCALL_NAME 用门罩住整个回调,回调期间不发生 收集,回调返回后结果先压栈再补收。

代价 / 取舍(明确记录):

影响面:ta.h / ta_inline.h / gc.c / vm.c / val.c / scheduler.c / api.c / lib/http.c / lib/demo.c + docs/c-module.md §3 重写 + 新增 test/crash/arena-exhausted.ta(fatal 路径回归);phase2 追加 test/run_gc_tests.shTA_GC_STRESS=1 轮(门纪律执行器)。

D12. actor primitive 从专用 opcode 迁到 OP_BUILTIN 表(PR E / phase 4a+4b)

结论spawn / spawn_main / spawn_clos / send / recv / recv_peek / recv_commit / recv_after / monitor9 个 actor primitive 不再是独立 opcode,改为 OP_BUILTIN(opcode 49)+ 1 字节静态索引,由 builtin_table[]src/builtin.c) 分派。指令发射编码:

OP_BUILTIN idx [原操作数…]        // spawn/spawn_main 保留 4 字节 fn_id

动机

代价 / 取舍(明确记录):

新 opcode 的准入门槛(本 PR 之后的规则):加 opcode 必须满足 (a) 在每条指令都跑的热路径上,且 (b) 明确比一次间接调用更便宜(带内联的 一两个字节操作、单次数组索引/算术、无函数调用的栈操作)。不满足就加进 builtin_table[]——那里只花一次间接调用,且不占需要同步维护的四个位置( codegen.ta 镜像常量、vm.c dispatch_table 槽位、vm.c CASE_OP_* label、 api.c instr_len 行)。反例即本次迁走的 9 个原语(冷、且重); 正例是留在表里的 cons / car / cdr / 类型判断 / 算术 / OP_SELF

影响面ta.h(枚举改名 + 保留洞 + TABC_VERSION)/ lib/bootstrap/codegen.ta (镜像常量 + emit_builtin + 12 处发射点 + serialize_tabc 版本号)/ src/vm.cdispatch_table 槽位 → 保留 path、删 9 个旧 handler、保留 OP_SELF/OP_BUILTIN)/ src/api.cinstr_len 注释、rebase_codeOP_SPAWN* 分支、版本检查)/ docs/ta-language-spec.md 不变(语法没动,只动了字节码编码)/ test/run_cli_tests.sh 新增”v2 .tabc 被拒”回归。