-
-
[原创]从调用级并发到 Guest Thread Runtime:unidbg 单后端线程语义的重建
-
发表于: 1天前 665
-
本文讨论的是 unidbg 在单 Unicorn 后端下模拟多线程 native 程序时的通用架构问题。文中的线程、调用、网络回调、状态节点和返回值均为抽象示例,不对应任何具体应用、接口、命令编号或业务数据。
本文承接前文《从时间片轮转到调用级并发:unidbg 单后端多线程架构重构》。上一篇把调度单位从 Task 提升到了 Invocation:每次 native 调用拥有独立 record、generation、context、terminal、outcome 和 JNI local-reference scope,并通过 FIFO 在真实 safe point 上完成 backend 交接。
继续实现后,笔者又从 Invocation-Owned Runtime 改成了 Guest Thread Runtime。
原因不是时间片不能工作,也不是调用级并发方向错误,而是笔者逐渐发现:一次调用有了独立身份,仍然不等于运行这次调用的 Android guest thread 已经被正确表达。
宿主 Java 线程、guest Android 线程、一次 native invocation 和临时驾驶 Unicorn backend 的 carrier,是四种不同身份。只要其中任意两层仍被混用,系统就可能在看似正常的调度日志下共享错误的 TID、TLS、stack、JNIEnv、pending exception,或者把一次临时让路误判成调用已经完成。
这篇文章不再讨论某个调用如何“多跑一会儿”,而是讨论另一件更基础的事:在只有一个 backend 的前提下,如何建立一个作用域正确、失败可隔离、并且能够被合同测试证明的 guest thread runtime。
整个演进可以分成三代。
第一代是 native timeslice。C 后端按照指令预算主动停止,Java 调度器保存 Task 上下文,再选择下一个可运行 Task。它解决的是:多个 guest Task 如何获得执行机会。
第二代是 Invocation-Owned Runtime。它引入调用 record、generation、FIFO admission、exact terminal、immutable context 和 invocation-scoped JNI local references。它解决的是:多个重叠 native 调用如何不串结果、不串上下文。
第三代是 Guest Thread Runtime。它进一步引入 Run、GuestThreadIncarnation、TaskThreadBinding、ThreadBirthRecord、StackRegion、InvocationContinuation、CarrierLease、AdmissionReceipt、RetirementReceipt、StateStore 和 ReadinessAuthority。它要解决的是:一次调用究竟运行在哪个 guest thread 上,谁临时拥有 backend,线程级状态属于谁,调用结束后又由什么证据证明资源可以释放。
核心结论可以压缩成四个不等式:
新的设计不试图在单 backend 上伪造真 SMP,而是用确定性的交错执行,重建线程身份、调用栈、JNI 作用域、事务边界和跨节点证据。
最早的 unidbg 多线程问题,是 worker 虽然已经创建,却很难获得稳定的执行机会。协作式调度依赖 syscall、JNI 回调和函数返回等偶然 safe point;如果当前 Task 长时间不主动让出,其他 Task 就只能等待。
笔者先把它改成 native timeslice:
这一步建立了可预测的停止原因,也让 futex WAIT/WAKE 和 worker 轮转第一次拥有了稳定的调度基础。
但时间片只回答“下一刻跑谁”。它不知道当前 Task 承载的是哪一次调用,也不知道一个返回值应该交给哪个宿主提交者。

继续处理重叠调用时,笔者发现 command、last result、active context 和异常状态散落在多个全局槽位中。同一种调用连续出现两次时,仅凭 command 类别无法区分前后 generation;一个调用释放全局 JNI local refs,还可能清掉另一个暂停调用正在使用的对象。
于是笔者把系统改成 Invocation-Owned Runtime:
backend 仍然只有一个,但不同宿主提交者必须排队申请 admission。旧 owner 在真实 safe point 保存上下文并让路,新 invocation 才能进入;一次 backend stop 只表示交通交接,不能产生 COMPLETED。
这一步解决了调用级身份。两次同类 invocation 不再因为 command 相同而共用结果槽,旧调用也不能拿新 generation 的 terminal;一个 backend stop 只是 handoff,不再自动等于调用完成。
但第二代模型仍然默认了一件事:只要 invocation 拿到了 carrier、private context 和 local-reference scope,这次调用的“线程语义”似乎也就随之成立了。
继续实现后,笔者发现这个默认前提并不成立。

第二版完成后,单条 native 调用已经能够拥有独立的 generation、context 和 outcome。只看一条调用,系统的行为是合理的:提交、排队、进入 backend、返回、发布终态,各个步骤都有明确对象。
真正的问题出现在一次同步重入场景里。为了脱离具体应用,可以把它描述成下面这条完全匿名的运行:
把对象身份展开后,第二版的循环等待是:
这不是 backend 没有时间片,而是第二版把 child 当成普通 Invocation 处理,却没有把它建模成同一 GuestThread 上的可重入 child frame。A 必须等 B,B 又必须等 A 释放当前 owner,于是 parent/child 形成循环等待。宿主最终只能看到 future 超时或进程长期无响应。
更麻烦的是,host timeout 只结束了等待,不会自动结束 guest 执行。若调用者在同一个 Run 中立即 retry,旧的 lease-A#1、parent continuation、callback mailbox 和 JNI local frame 可能仍然存活;新的 invocation-A#2 又使用相同 command category 进入队列。此时卡死会继续演化成 carrier 饥饿、同类节点 receipt 混淆和资源滞留:

表面上,generation、parent/child record、FIFO handoff 和 JNI local frame 的单项测试都可以变绿。但最终边界真正需要回答的不是“有没有两个调用结果”,而是:
第二版第一次让我们看清:“每次调用互不串线”仍然可能在同步重入时形成进程级死锁;局部 generation 正确,也不能自动推出线程级调度、quiescence 和整条组合链正确。 后面的六个现象,都是这条卡死场景在不同边界上的具体表现。
在上面的场景中,alpha 和 beta 使用同一种 command category。第二版能够用 generation 区分 invocation-A#1 和 invocation-A#2,却还不能说明“这次调用在场景图中承担 alpha 还是 beta”。
于是会出现一种很隐蔽的现象:前置调用全部返回成功,数量也对,最终边界却可能拿到同类节点中较早或较晚的一枚 receipt。表面上没有串 command,也没有串 generation;真正串掉的是 scenario identity。
这让我们认识到,业务图中的主键不能只是:
还必须包含由场景配置预先声明的 typed node identity。调用记录、admission、route、terminal 和后续 consumer 都要核对同一个节点,不能在消费阶段根据“最近一次同类调用”反推。
第二个现象更像“alpha、beta 和 callback 都已经返回,为什么最终边界还是不 ready”。
原因是 native return、dispatcher terminal 和跨调用状态提交是三件不同的事。invocation-A#1 可以已经返回,但 alpha 的 host state 仍停留在 staging;callback-B#1 可以已经离开 Java adapter,但对应的 typed state 还没有和正确 instance 绑定;B 的 carrier 也可能已经停止执行,却尚未完成 dispatcher retirement。
如果最终边界只检查一组 completed=true,它看到的只是调用结束,不是状态闭合。真正的 readiness 至少要同时证明:
这也是为什么第三版不再让 graph 自己拼布尔值,而是要求 authority 从多类 immutable receipt 中计算 exact closure。
场景中的 callback-B#1 由异步会话边界触发。最初观察时,一段会话元数据和后续 callback 输入出现在相邻时间窗口、相同线程附近,很容易把它们理解成“前者经过某种转换生成后者”。
继续核对调用栈和对象身份后,我们发现两者只是相邻,并不是 producer 与 derived value 的关系:一条属于会话元数据查询,另一条来自独立 callback 边界。它们可以由不同对象、不同 instance 和不同生命周期产生。
这个发现改变了第二版中的状态传递方式。callback 不能借用前一条 command record,也不能从相邻元数据猜输入;它必须有自己的:
换句话说,时间相邻只能产生 observation,不能产生 lineage。 没有真实 callback producer 时,正确结果是 fail closed,而不是寻找一个形状相似的值填进去。
第二版主要围绕 invocation 隔离,因此宿主提交线程、carrier Task 和 guest thread 之间仍容易形成隐含绑定。在简单测试里,它们经常一一对应,看不出问题;进入这条组合场景后,thread-A#1 连续承载两次 invocation,parent 又在 callback 期间临时让出 backend,thread-B#1 则使用另一份线程级状态完成 child invocation。
这时,按宿主 Java thread、command role 或 carrier TID 查找状态,就可能让业务表现出下面几类问题:
这些问题不会总是立即崩溃。更常见的表现是前置节点偶发缺状态、最终汇合长期等待,或者同一输入在 fresh run 和 same-run retry 中得到不同路径。
假设 A 在等待 callback-B#1 时触发宿主等待上限。第二版已经能够把 timeout 归给 exact invocation,但我们进一步发现:future 返回 timeout,只说明宿主不再等了,并不说明 A 或 B 的 guest carrier 已经离开 backend,更不说明 continuation、mailbox 和 JNI critical resource 已经释放。
如果把 host timeout 直接写成 guest terminal,下一次重试可能在同一个 Run 中继承旧 worker、旧 wait edge 或旧 pending exception。业务上看到的是“第一次超时后,第二次行为更奇怪”;架构上真正缺少的是 cancellation 与 quiescence 之间的确认阶段。
因此第三版把它拆成:
无法证明静止时,不允许清空几个 Map 后继续复用当前 Run,而是进入 quarantine 并重新创建 emulator、VM 和 memory。
第二版的 model test 可以分别证明 alpha/beta generation 不串、A/B context 不串、FIFO handoff 合法。这些测试都很重要,但上面的完整场景还会经过 dispatcher lifecycle、Task stack allocator、JNI router、callback adapter、StateStore 和 graph join。
如果测试直接调用 authority helper 构造理想 receipt,它证明的是对象模型,不是生产 caller 已经使用同一条事务。于是会出现“状态机测试全部通过,组合场景仍停在最终边界”的反差。
这不是 model test 没有价值,而是证据层级不同。第三版因此把验收拆成 model contract、production-connected contract 和 runtime scenario,并要求每条能力同时具有 identity mismatch、failure injection、cleanup 和 concurrency 负控。
回头看,第二版暴露的并不是某一条业务命令写错,而是组合场景对 runtime 提出了更严格的要求:
这些现象最终把问题的主语从 Invocation 推向了 Guest Thread Runtime。我们不再只问“这是哪一次调用”,而要继续问:它属于哪个 guest thread incarnation,谁暂时拥有 backend,依赖来自哪个 producer,以及用什么不可变证据证明整条链已经真正闭合。
宿主 Java 线程只是调用 unidbg API 的线程。guest thread 的身份应由 guest 可见的 birth、TID、pthread self、TPIDR/TCB 和 lifecycle 共同决定。carrier 只是当前一次执行的载体,它可以被替换、暂停或退役;Invocation 只拥有一次调用的参数和终态,也不应该拥有线程级 errno、JNIEnv attachment 或持久 stack。
因此,新的模型不再继续添加 lane 标签,而是把身份拆成 Run、GuestThread、Invocation 和 Carrier 四层。



组合场景第一次失败时,表面现象并不是“线程模型不对”,而是一些更零碎的问题:某个重复节点恢复后读到了旧异常,异步回调结束后 parent local frame 消失,宿主重试又继承了上一轮尚未退休的 worker。
这些现象最终指向同一个问题:调用、线程、执行载体和整次模拟的生命周期被放进了同一组可变字段。新的设计因此先不讨论调度策略,而是先回答“状态到底属于谁”。


RunContext 表示一次完整模拟,而不是某条命令的一次执行。它拥有 emulator、VM、Memory、dispatcher、guest process shared state、thread registry、StateStore、wait graph、clock policy 和各类 authority。
Run 也是最小故障隔离域。如果发生无法确认的 stack corruption、错误 binding、dependency cycle,或者取消后无法证明 guest 已经静止,正确做法不是清几个 Map 后继续运行,而是让当前 Run 进入 quarantine,随后创建全新的 emulator/VM/memory。
推荐状态机如下:
guest thread 的主键不能只是整数 TID。TID 在线程退出后可以复用,所以真正的身份应当是:
每个 incarnation 至少拥有:
这里最重要的变化,是笔者不再把 role 当成 thread primary key。MAIN-like、worker-like 之类的名称只能作为观测角色;它们必须通过同一 Run 内的结构证据解析到一个既存 GuestThreadIncarnation,不能反过来创建或猜测线程。
Invocation 继续保留上一篇建立的能力:
但它的边界被进一步收紧。Invocation 不再拥有持久 TID、线程 TLS、JNIEnv attachment 或 thread stack。它可以拥有自己的 entry/return boundary 和 continuation,却不能在结束时把整个 thread execution state 一并销毁。
Carrier 表示“谁此刻可以驾驶 Unicorn backend”。它只是一种 capability:
同一 GuestThread 可以在不同时间由不同 carrier 继续执行;一个 synthetic command carrier 也不能因为拥有 TID 和 stack,就冒充 native clone 创建的持久线程。
这张表比类名更重要。任何实现只要让同一个状态同时出现两个 owner,就应该先停下来重新定义边界,而不是继续增加同步块。
在代码上,RunContext 不是一个装 ID 的 DTO,而是整个 runtime 的 composition root。所有会影响线程、调用、fault、state 和 readiness 的 authority 都从这里取得,并且只能服务于同一个 RunId。
下面是按实际设计裁剪后的类骨架:
这里有三个实现约束。
第一,其他对象不能自己 new 一个 RunId 来伪装成同一 Run。ThreadIncarnationId、InvocationId、receipt 和 state key 都必须由当前 Run 的 authority 签发。
第二,close() 不能等价于 maps.clear()。它必须先冻结新 admission,处理 live carrier、waiter、continuation、JNI attachment 和 critical resource;只有确认 guest 不再产生副作用后才能进入 DISPOSED。
第三,mutation epoch 不是普通 telemetry counter。任何可能改变 readiness 的状态迁移,都要先取得 RunMutationPermit,成功后 commit,失败则 abort。
线程 ID 使用强类型,线程对象只聚合持久 thread scope;birth record 则明确区分 process leader、native clone 和 synthetic carrier:
这样做的原因是:process leader 合法地允许 tid == pid;native clone 必须保留 parent、TLS 和 child-tid 语义;synthetic carrier 则只能证明“模拟器创建了一个隔离执行载体”,不能证明 guest native runtime 真正创建了线程。
过去最容易出现的回退,是当前 Task 没有身份时使用 process pid,或者使用宿主 Java thread id。新设计把所有 thread-sensitive API 收敛到一个 provider:
provider 明确拒绝 host thread id、process pid、activeCommand、lastRunningTask 和 role name 等 fallback。
如果当前 Task 没有合法 binding,正确结果是 UNSUPPORTED_FAIL_CLOSED 或 runtime integrity fault,而不是返回一个“看起来像线程 ID”的值。
线程身份拆开以后,下一件必须被显式建模的事情是等待关系。一个 guest thread 可能等待另一个 invocation 的 terminal,也可能等待 futex、条件变量或 callback mailbox。若这些等待只存在于某个 Task 的字段里,调度器就无法判断一个 fault 应该传播给谁,也无法证明取消后已经没有遗留执行者。
因此,等待关系属于 Run,而不是某个 carrier。RunWaitGraph 只保存同一 Run 内的 typed endpoint 和 immutable edge;它不执行 callback,也不直接改变 invocation 状态。状态迁移由 FaultAuthority 或 CancellationAuthority 提交。
addBatch 采用“全部验证、一次发布”的方式。一个 waiter 的多个 dependency 不能被后来的 edge 覆盖;如果第三条边形成环,前两条边也不能已经可见。WaitReason 至少要区分 invocation terminal、futex、join 和 callback mailbox,因为不同原因对应不同的 quiescence 条件。
等待图的节点不是 command 名称,而是 (RunId, InvocationId, generation)。这样同一个 command 的两次重叠调用、同一个 guest thread 的父子 invocation、以及已经被取消但尚未退役的 carrier,都能被准确区分。
根故障处理最忌讳“先清理本地对象,再看看谁在等它”。清理顺序一旦反过来,direct waiter 和 transitive waiter 可能已经失去入口,最后只剩一个看似干净、实际上无法证明静止的 Run。
笔者把故障分成四类:
其中后三类都是 Run 级风险。RootFaultController 负责把一个 root fault 变成不可分叉的 propagation plan:
这里的 freezeNewAdmission() 必须早于 root fault 的对外发布,否则新的 invocation 可能在旧 fault 传播期间进入 graph,形成一个没有稳定父节点的孤儿。snapshotDependents 必须发生在 edge cleanup 之前;DEPENDENCY_FAILED 也必须拥有和 FAULTED、CANCELLED 对称的 stack、JNI、StateStore、registry release 路径。
取消请求只能表达“不要再继续安排新的执行”,不能证明 guest 已经停止。新的状态机把请求、收束和终态分开:
CANCELLED 只有在 carrier 已经取得 retirement receipt、continuation 不可恢复、wait edge 已处理、JNI/critical/state staging 已释放、mailbox 不再写入后才能签发。为此需要一个独立的 QuiescenceBarrier,而不是让调用者自己传入 isStopped=true:
所有 probe 都封存 RunMutationEpoch;确认期间出现新 admission、continuation 或 mailbox write,旧 probe 立即 stale。若 stack、binding、receipt、依赖图或 cancellation 证据无法确认,最安全的边界是当前 Run,而不是某个 Task:
处置顺序固定为:
不能用 Map.clear() 代替这条流程。DISPOSED 只允许由 disposal authority 签发;无法回收的 physical owner 必须保留在 disposal ledger 中。
时间语义也必须拆开:guest 的 clock_gettime、futex timed wait、condvar deadline 和 nanosleep 使用 GuestClock;宿主 API 使用 host clock;watchdog 只负责防止测试失控。
两套时间的终态不能混写:
host watchdog 不能被转换成 guest natural timeout,也不能因为宿主等待结束就直接签发 CANCELLED。是否推进 guest wall clock、其他 thread 获得 backend 服务时是否允许 timer 前进,都必须由同一个 GuestClockPolicy 决定,不能让各个 command 自己读取宿主系统时间。
前面的章节分别解释了身份、载体、回执和就绪权威,但如果只按概念阅读,仍然容易把它们理解成互相独立的类。下面用一条的组合场景来描述,把这些对象按时间顺序串起来。场景只包含两个 Guest 线程、一次嵌套 callback 和一个最终集成边界,不对应任何具体应用或业务协议。
这条流程的主线是“线程 A 发起 native 调用,callback 在 Guest 线程 B 上重入,A 恢复后由 Readiness Authority 确认最终边界”。图中的对象名是稳定身份示意,不是宿主线程号,也不是调用者可以随意填写的字符串。

这条 walkthrough 使用的对象身份如下:
thread-A#1 和 thread-B#1 是持久的 GuestThread incarnation;invocation-A#7 和 invocation-B#3 是发生在这些线程上的调用;lease-A#41 和 lease-B#42 只是短时间获得单一 backend 驾驶权的载体。它们的生命周期不同,不能用一个对象代替另外两个对象。
组合场景开始时,RunContext(run-demo) 创建 emulator、VM、Memory、Dispatcher、ThreadRegistry、StateStore、WaitGraph 和两类 Authority。配置只声明 expected slots,不伪造未来的 receipt:
此时没有任何调用已经完成,ReadinessAuthority 只能返回“证据不足”。它不会因为 slot 数量正确,或者某个 Map 非空,就提前打开最终边界。
宿主提交线程只提交参数和场景节点,不能声明“我就是 Guest Thread A”。Dispatcher 根据当前 Run 中已经出生的 thread-A#1 解析 TaskThreadBinding,然后创建调用记录:
AdmissionReceipt 同时绑定 Run、GuestThread incarnation、Invocation、binding epoch 和 carrier lease。它不是“排队成功”的普通布尔值,后续任何阶段都必须拿同一份 identity 回查。若 binding 已经退役或 Run 已进入 quarantine,入场在这里直接 fail closed。
lease-A#41 获得当前 backend ownership,TaskThreadBinding 装载 thread-A#1 的独立 StackRegion、TLS、JNIEnv attachment、pending exception 和 continuation。backend 开始执行 invocation-A#7:
这里的 stop 只产生 continuation snapshot 和 handoff evidence,不产生 COMPLETED。invocation-A#7 仍然是 RUNNING 或 SUSPENDED,lease-A#41 也仍然是 live,直到 dispatcher 明确完成 retirement。
在 A 的 native 执行过程中,guest 触发一个异步 callback。callback adapter 不读取“最近一次 command”,而是根据当前 dispatcher snapshot 创建独立的 CallbackInstanceId(callback-B#3)。A 的 parent invocation 保留自己的 local frame 和 continuation;B 作为 callback consumer 使用自己的 GuestThread incarnation:
这里的“B”不是宿主 Java thread 的名字,也不是从 A 的 carrier 复制出来的临时标签。B 必须拥有自己的 thread birth、StackRegion、TLS、JNIEnv 和 invocation stack。A 的 local frame 不能由 B 关闭,B 的 pending exception 也不能写入 A 的 thread scope。
lease-B#42 进入唯一 backend 后,dispatcher 恢复 thread-B#1 的现场,执行 callback consumer,并在同一事务中提交三份证据:
只有 StateStore 提交和 callback receipt sealing 都成功,callback slot 才算闭合。若 callback producer 缺失、instance identity 不匹配、state commit 失败或 physical release 失败,B 不得伪造成功结果;它会保留未完成的 owner,或者让当前 Run 进入 quarantine。
callback 返回后,dispatcher 根据 invocation-A#7 保存的 continuation 和 binding-A#12 重新申请 A 的后续阶段,恢复 thread-A#1 的 stack、TLS 和 JNIEnv。它不会按宿主线程、command category 或“当前最后一个 carrier”重新查找线程:
如果此时 binding-A#12 已过期、Run epoch 已变化,或者 A 的 carrier 已被另一个 incarnation 占用,恢复必须拒绝。不能因为参数和调用类型相同,就把 B 的现场或新一代 binding 当成 A 的现场。
A 和 B 都有 terminal 并不等于最终边界可以进入。ReadinessAuthority 在一个 mutation epoch 内读取各个 owner 的 committed ledger,并核对 expected set:
只有 exact Run、node、invocation、thread incarnation、binding epoch 和 authority epoch 全部一致,且没有 live waiter、未退休 carrier 或待提交 state,authority 才签发:
permit#9 只能被一个最终 admission 消费。消费时,dispatcher、readiness ledger 和 carrier reservation 共同发布同一个 commit marker;任一 participant 失败,permit 失效,prepared owner 回滚,无法证明回滚时则 quarantine Run。
最终集成边界拿到的不是一组 ready=true,而是一份绑定 exact evidence digest 的 BoundaryAdmissionPermit。它只做一次性消费:
如果另一个线程试图重复消费 permit#9,或者在签发后改变了 graph、state、callback 或 carrier reservation,消费会失败。系统宁可停在边界之前,也不会用“最新结果”“相同 command”或“数量已经够了”补出一条未经证明的因果链。
这条 walkthrough 可以压缩成一句话:A 的调用可以暂停,B 的 callback 可以插入,backend 的驾驶权可以交接,但每次交接都必须沿着 exact thread incarnation、invocation、binding、lease 和 receipt 传递;最终 readiness 只消费已经闭合的证据,不重新猜测历史。
在重叠场景中,一条 invocation 会暂停等待 callback,另一条 invocation 随后接管 backend。如果二者只保存寄存器 context,却复用同一栈顶或一张可变 binding,后进入者就可能覆盖前者的 return boundary;恢复时 PC 看似正确,SP、TLS 或 owner epoch 已经不属于它。
因此,把四层身份拆开之后,真正进入 backend 的 carrier 仍需要一组完整证据。笔者最终把一次合法执行入口收紧为:
缺少任何一项,都不能仅凭 command、role、host thread 或最近一次 running task 补齐。
单 backend 只有一组 CPU 寄存器。切换 Task 时,如果两个活跃 carrier 使用重叠 stack range,后一个 carrier 的 entry frame、保存寄存器或 nested callback frame,就可能覆盖前一个尚未完成的返回边界。
因此,每个可并发存活的 carrier 必须提供可验证的物理 stack evidence:
验证规则至少包括:
这里要区分物理存储和逻辑所有权。P1500 Task 可以继续保存 backend context、waiter 和 stack allocation;Guest Thread Runtime 则通过 binding 把这些物理对象解释为某个 thread incarnation 的执行状态。一次性 synthetic carrier 可以拥有自己的独立栈,但它仍然只能被标记为 synthetic birth,不能自动升级成 native thread。
TaskThreadBinding 是 Task 与 GuestThreadIncarnation 之间的不可猜测连接。它至少记录:
为什么需要 binding epoch?因为同一个 Task 对象、数值 TID 或 carrier role 都可能被重新使用。一个 continuation 如果只保存 task == oldTask,在 rebind 后仍可能错误恢复旧现场;加入 epoch 后,旧 snapshot 会自然变成 stale。
Binding 证明身份,Lease 证明权限,两者不能合并。
一个 GuestThread 可以已经存在并具有合法 binding,但此刻没有取得 backend;另一个 invocation 也可以已经创建 record,却仍在 FIFO 中等待。只有 carrier 获得 live lease,并且 admission 已提交,它的 context 才能对 guest runtime 可见。
换句话说:
真实 JNI/Java callback 经常形成同线程同步重入:
如果 child 被当作普通 FIFO item 排在 parent 后面,parent 又同步等待 child,系统会形成天然死锁。同线程同步重入必须在同一个 GuestThreadIncarnation 上压入 child invocation,并保存 parent 的 callback return boundary。

非 LIFO resume、跨线程复用 child local frame、binding epoch 不匹配或 child stack 覆盖 parent live range,都应被视为 runtime integrity fault,而不是再尝试一次恢复。
StackRegion 表示 thread scope 的逻辑栈区域,TaskStackEvidence 表示某次 carrier 从 backend allocation 得到的物理证据。两者不应混成一个类。
关键点是 fromBackendAllocation() 只能接受 backend 真实 allocation handle,并且 canary 要从 backend memory 读回。测试中随手传入两段不重叠的数字,只能证明区间算法,不能证明真实 stack 已隔离。
TaskThreadBinding 不释放 stack、不关闭 JNIEnv,也不把 thread 标成 DEAD。它只是一个带 epoch 的非 owning 连接,资源生命周期仍由 GuestThread、dispatcher retirement 和 Run disposal 各自负责。
异步 mailbox item 不走这条 LIFO 路径。同线程同步重入、同线程异步提交和跨线程同步调用必须使用不同 ReentryMode,否则 FIFO 和调用栈会再次混成一个概念。
一个典型的组合失败是:dispatcher 已经收下 carrier,场景节点却在随后绑定 state 或 callback evidence 时失败。如果这个 carrier 此时已经可运行,系统面对的就不再是一次普通 rollback,而是一个可能执行过 guest 指令、却没有完整业务身份的半发布对象。
上一篇已经引入了 admission,但继续实现后,笔者发现“某个对象现在看起来是 live”与“它曾经合法完成某个状态迁移”是两种完全不同的证据。
例如,carrier 退役后,live binding 本来就应该消失。如果 terminal consumer 仍要求读取 live binding,就会出现一个悖论:正确 cleanup 反而让历史终态无法验证。
解决办法是把入场和退场都改成不可变 receipt。
一个完整的 AdmissionReceipt 至少封存:
这些字段必须来自同一个 authority transaction。调用者不能分别传入 thread id、record 和 binding,再依靠字段相等做表面校验;否则多个真实对象只要拥有相同数值,就可能伪造出“看起来一致”的 admission。
如果 carrier 一进入 dispatcher queue 就可运行,那么 graph、role evidence、state producer 或 readiness participant 随后失败时,系统只能尝试撤回一个可能已经执行过 guest 指令的 Task。此时再删除 binding、lease 或 stack,反而可能释放正在运行的 owner。
因此,新的 dispatcher entry 增加了一个关键状态:
它表示 dispatcher 已接受 exact carrier,但 picker 还不能执行它。所有参与者先完成 validate 和 stage,最后由单一 commit marker 共同公开。

这个顺序建立了两个非常强的边界:
退役 receipt 不能由 Task.destroy() 或 Java listener 自行签发。只有 dispatcher 看到以下事实同时成立,才有资格产生它:
最终 route ledger 保存的是双 receipt:
因此,即使 Task、binding 和 lease 已经从 live registry 正确删除,后续 graph、StateStore 和 outcome consumer 仍能验证历史事实。
receipt 必须是 immutable value。它不应该提供 markConsumed()、setValid(false) 之类的可变方法,也不应该自己决定是否还能被使用。
真正的消费状态保存在 authority 私有 ledger 中:
这样,调用者拿到 receipt 只能提交给 authority 验证,不能自行把一张旧票改成“尚未使用”。
很多代码会把“失败后抛异常”称为 fail closed,但真正的 failure atomicity 要求更高:
不能出现 queue 已删除、stack 已释放,但某个 registry 仍认为 lease live;也不能出现 StateStore 已 commit,而 graph 仍停在 submitted。跨 owner 的事务要么有一个共同 commit marker,要么每一步都有 exact rollback,并且 rollback 失败不会继续复用当前 Run。
入场对象按“可回滚 reservation”和“不可变历史 receipt”分成两组:
authorityIdentity 和 dispatcherIdentity 防止“字段完全相同的自建对象”冒充签发证据;普通 caller 没有 mint 权限。
并非每次 admission 都需要 role 或 readiness participant,所以后几项可以是显式 Optional;但 required participant 不得因缺失而静默跳过。
authority 内部的 reservation 顺序大致如下:
这里每一步都可能失败,因此实现需要维护明确 rollback order:lease、record、binding、stack/carrier allocation 按相反顺序撤销。不能先把 reservation 从 pending map 删除,再调用可能抛异常的 participant。
commit() 中最值得强调的不是 synchronized,而是最后一个可失败步骤的位置。所有 participant 必须在 marker 前完成 validation;marker 发布后,dispatcher 只把已匹配 bundle 的 entry 变成 RUNNABLE,不再调用新的业务 participant。
如果 removal 或 receipt issuance 失败,physical owner 必须进入 quarantined-retirement owner,而不是已经从所有索引消失。否则系统既无法继续运行,也无法在 disposal 时找到需要释放的对象。
graph terminal、StateStore commit 和 exact outcome 都消费同一个 sealed ledger entry。这样不会出现 caller 看到一份 outcome、graph 保存另一份 terminal、cleanup 又产生第三份 retirement identity。
Receipt-backed transaction 解决了“谁签发、谁消费、谁可以回滚”,但还没有回答另一个问题:多个宿主线程同时读写这些 owner 时,怎样保证它们看不到半发布状态,也不会因为锁顺序错误而互相等待。
笔者在合同测试中先遇到的并不是数据错,而是锁升级死锁。一个 callback adapter 持有 readiness read permit 时继续调用需要 write lock 的 bindExpectedReceipt;另一个 role-lane lifecycle 又在 lineage monitor 内等待 readiness 写门。单看每个方法都合理,组合起来却形成了:
这类问题不能靠增加超时或把所有方法改成 synchronized 解决。真正需要固定的是:谁可以暂时拥有 backend,谁可以发布状态,谁必须先释放自己的锁,以及失败时哪个 authority 负责回滚。

这张图表达的不是一套全局大锁,而是四个相互配合的互斥边界:
这四个边界对应了近期合同测试中反复验证的几类不变量:
合同测试的正向路径只能证明“合法锁序可以完成”。因此每一条合同还必须有对应的负向、清理和并发路径:
近期测试已经覆盖了发布门竞态、精确 permit claim、callback snapshot authority、callback graph rollback、双 carrier JNI 隔离和真实 Unicorn dispatcher 的合法 suspend/resume 顺序;但这类结果仍应按 targeted contract 解释。合法顺序的正控通过,不等于负向顺序、完整 production lifecycle 和 clean-artifact 一并闭合。
因此,本文中的“同步互斥合同”不是“所有状态都加锁”,而是三条更窄的设计规则:身份由 Binding、Lease 和 Receipt 证明;互斥只保护证据发布和消费;锁顺序必须让失败可以回滚、让 reader 看不到半状态。
Admission 和 readiness 的提交可以做到原子,但如果 Run.close() 或 Run.quarantine() 仍然分步关闭各个 authority,系统依旧可能留下反向窗口。
假设实现先清空 receipt,再发布 Run 的终止状态,那么并发线程可能在两步之间仍看到 ACTIVE,取得新的 mutation permit,并在 Run 已经终止后提交。反过来,如果先发布终止状态,再清空旧 receipt,reader 又可能看到“Run 已不可用,但旧证据仍然有效”的组合。
因此,Run lifecycle 需要一个独立的线性化边界:
关键不是这几步在源码中的先后顺序,而是 begin、commit 和 snapshot 都必须核对同一个 lifecycle marker 与 epoch。只在 beginMutation() 时检查一次 RunState 不够,因为 permit 取得以后,Run 仍可能在另一个宿主线程上进入失效流程。
一个最小接口可以表达为:
LifecyclePermit 必须携带签发时的 epoch;requireCommitAllowed() 在最终发布前重新核对 epoch 和 marker;snapshotIfVisible() 也通过同一 gate 读取。这样,失效开始以后,旧 permit、旧 receipt 和旧 snapshot 会同时失去授权,而不是由每个 registry 各自猜测 Run 是否仍然可用。
这个 gate 也不能演变成全局大锁。Run 只负责发布 lifecycle marker;各个 authority 在锁外执行自己的 exact cleanup,再把不可变结果交回 gate 确认。这样既关闭 TOCTOU,也避免 Run 锁反向调用 StateStore、thread registry 或 callback authority 形成新的锁环。
某场景中有一条同步重入:native parent 进入 Java callback,callback 又调用 native child。第二版已经能为 child 创建独立 invocation,但如果所有 JNI 状态仍跟着 invocation 走,child 返回时可能清掉 parent 的 pending exception;如果所有状态都跟着 thread 走,child local refs 又会泄漏给 parent。
Invocation-scoped local references 是上一篇的重要改动,但完整 JNI 语义不只有 local object map。继续扩展后,笔者把 JNI 状态拆成四级作用域。

同一个 guest thread 在 fresh entry、timeslice、wait/resume 和 nested invocation 中,应看到稳定的 JNIEnv identity。不同 guest thread 的 attachment 和 pending exception 必须隔离。
因此不能用 host Thread.currentThread() 推导 JNIEnv,也不能在某个 invocation 的 finally 中清空整个 VM 的 pending-exception map。
DetachCurrentThread 也必须改变 exact GuestThread 的 attachment state。detach 后 GetEnv 应返回 detached,直到显式 attach;如果 getJNIEnv() 每次都静默创建一个新 wrapper,detach 只是表面行为。
每次 native invocation 至少拥有一个自动 local frame。PushLocalFrame 在当前 invocation 内继续压栈;同线程 child invocation 则拥有独立 child frame stack。
PopLocalFrame(result) 不能只弹一个模型 token。它必须操作真实 DVM reference table:
pinned array/string 同时涉及逻辑 lease 和物理 guest buffer。正确 acquire 顺序是:
如果 lease 注册失败,必须释放刚分配的 buffer。release 则相反:
物理释放失败时,lease 必须保持 live;JNI_COMMIT 只 flush,不结束 lease。只改逻辑 Map、不验证实际 buffer 的测试,无法证明 JNI production path 已经正确。
一次 invocation 的 local refs 只有在两个条件同时满足后才能释放:
只有 carrier retired,host 可能还没通过返回 handle 解析对象;只有 outcome acknowledged,TIMEOUT 或 cancellation 下 guest carrier 可能仍未真正静止。双条件关闭把 guest 使用期和 host 解析期都纳入同一生命周期。
getEnv() 不应隐式调用 attach()。查询和状态改变必须是两个 API,否则 guest 调用 DetachCurrentThread 后,下一次普通查询会悄悄把线程重新 attach。
真实 VM 的 addLocalObject() 和 getObject() 必须根据当前 running carrier 找到这个 scope,而不是维护一个只用于测试的平行 frame model。
Throw、ExceptionOccurred 和 ExceptionClear 都先从当前 running carrier 解析 exact RuntimeJniScope,再访问其中的 GuestThreadIncarnation.jni()。这里没有 Map<Task, Throwable> 的 fallback;Invocation 只能在当前 thread scope 中读取和清理 pending exception。
如果 physical free 抛异常,lease 仍留在 registry 中,Run disposal 能看到这份未释放资源并 quarantine。相反,先删 lease 再 free 会在失败后永久丢失 physical owner。
Guest Thread Runtime 不只解决线程切换,也必须解决跨调用状态到底从哪里产生。
笔者在梳理一条通用网络初始化链时,曾遇到两个看起来高度相关的数据:前一个 native command 返回一段连接 metadata,随后同一宿主执行链又把一段较短的二进制输入交给安全回调。它们出现在线程相邻、时间相邻的位置,因此最初很容易画出这样的边:
如果把这条边直接写进模拟器,下一步通常就是尝试截断、hash、decode、AES 或固定 fallback。但这些方法都在回答“怎样造出一个形状相似的值”,没有回答“谁才是真实 producer”。
进一步检查对象 identity、调用边界、内容摘要和真实 consumer 后,笔者发现两者属于两个独立数据域:
它们共享网络会话上下文,却不存在可证明的直接派生关系。
这个例子带来了三个架构结论。
callback 可能发生在两个 native command 之间,也可能来自一个长期存在的网络 session。它不应借用前一个 completed command 的 record,更不能借用未来 consumer invocation 的 record。
因此需要独立对象:
同一种 callback boundary 在一次 Run 中可以出现多次,也可以属于不同 connection/session。只用一个 enum 或 lastCallback 槽位,会让后来的 callback 覆盖前一实例。
更合理的 identity 是:
consumer 必须携带并消费与当前 session、parent invocation 和 binding 对应的 exact receipt,不能查询“最近一次同类型 callback”。
如果模拟环境并不执行完整 Java framework body,可以在精确的入站 DVM method dispatch 上安装 adapter。它只做三件事:
adapter 不主动调用 callback,不生成固定输入,也不替代原始 decrypt/consumer 逻辑。真实 callback 没有发生时,依赖它的节点应继续 fail closed。
callback authority 不能接受这样的接口:
普通 caller 返回一个 live Task,并不能证明这个 Task 此刻真的是 dispatcher current。正确做法是在 dispatcher 持有 running ownership 的边界签发 sealed snapshot:
authority 消费 snapshot 时还要回查当前 live state:carrier 未退役、binding epoch 未变化、parent invocation 仍处于允许 callback 的状态、dispatcher sequence 仍对应当前 owner session。
callback 参数需要 defensive copy,object identity 则用于防止同一 mutable array 在 commit 后被 caller 改写。是否允许相同对象重复通知,应由 callback boundary policy 明确决定,而不是依赖 byte[].equals()。
如果 graph 也需要镜像 callback milestone,它应作为同一 transaction 的 participant,或者由 coordinator 同时 prepare StateStore、receipt registry、expected slot 和 graph publication。不能先 commit callback state,再把 graph 失败仅仅解释为“最后反正不 ready”。
后续 native consumer 只能从当前 live scope 读取 exact receipt:
scope 在 callback 返回后关闭,receipt 仍可作为历史 evidence 保存在 ledger 中,但不能通过一个全局 lastCallbackAdmission 重新注入其他 invocation。
这里真正可复用的方法不是某个网络协议的实现,而是一条因果纪律:
同线程只能证明调度邻接,同时间只能证明事件邻接;只有 exact object identity、producer boundary 和 consumer receipt 才能证明数据依赖。
在组合场景中,最让人困惑的现象是:每条前置调用都有 terminal,最终集成边界却仍然不能安全进入。反过来,如果只看几个 ready boolean,它又可能在 callback 尚未提交、carrier 尚未退役时过早放行。
当一组线程、native invocation、host state 和网络 callback 共同组成一个稳定前缀时,系统需要在某个 integration boundary 前判断“前置条件是否已经完整”。
最简单的实现是依次查询:
这段代码在并发环境中并不可靠。检查完 graph 后,另一个线程可以创建新 invocation;检查完 callback 后,root fault 可以让整个 Run 失效;检查完 lease map 为空后,一个 prepared admission 又可能进入队列。
Readiness 不是几个 boolean 的 AND,而是跨多个 owner 的一致性证明。

配置期可以知道的是:
配置期不知道未来的 admission sequence、callback sequence 或 receipt object identity。因此 ScenarioConfig 只能预注册 immutable expected slots,不能提前伪造运行时证据。
运行时每个合法 producer 在自己的 commit transaction 中,把 exact receipt 绑定到对应 slot:
签发 readiness 前,authority 需要比较:
比较的是 typed identity 集合,不是 count、非空 Map 或 caller 提供的 digest。missing、extra、duplicate、wrong generation、cross-Run 或 stale binding 任一出现,都不能签发 receipt。
所有会影响 readiness 的 mutation,都必须通过同一个 Run-owned gate:
每次成功 mutation 推进单调 epoch。readiness receipt 封存签发时的 epoch;签发后发生任何相关 mutation,旧 receipt 自动 stale,并允许在新状态稳定后重新 issue。

即使 receipt 在签发时有效,也不能先 receipt.consume(),再单独创建下一阶段 carrier。两步之间仍然可能失败,留下“readiness 已消费,但目标 invocation 没有入场”的半状态。
正确做法是:
因此,integration boundary 只是当前 ScenarioConfig 的一个 typed admission node,不是 runtime 内硬编码的某个 command。换一个场景,boundary 可以改变;Run、GuestThread、JNI、fault 和 receipt 合同不需要随之改变。
ScenarioNodeId 必须从 spec 一路贯穿 record、reservation、admission、route ledger 和 terminal evidence。自由文本 context、command category 或 admission sequence 都不能临时升格为 node identity。
这里使用 Map<Node, List<Receipt>>,而不是 Map<Node, Receipt>,因为某个 slot 的合法 cardinality 可能大于一。更不能使用 Map<Command, Receipt>,同一 command category 可以对应多个不同 scenario node。
permit 在 authority 内部还要维护 claim 状态:
第一次 claim 后,同一 permit 不能再为第二个 carrier prepare admission;即使第二个 reservation 的字段恰好相同,只要对象或 authority identity 不同,也必须拒绝。
这个算法不能只扫描 live admission map,因为合法 readiness 时 live admission 本来就应为空。前序已完成的 native 节点必须从 terminal/retirement ledger 读取历史双 receipt;callback、host state 和 role lineage 也从各自的 committed ledger 提供 actual evidence。
committed=true 必须是最后一步。若先标 committed 或先推进 epoch,再调用可能失败的 owner commit,close() 就无法 rollback,会留下半 mutation。
实际实现可以把 markConsumed 也纳入 marker publication,而不是 marker 后再调用一个可能失败的方法。这里的类设计重点是:readiness permit、exact reservation 和 final AdmissionReceipt 必须共享同一个 transaction identity。
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。