从时间片轮转到调用级并发:unidbg 单后端多线程架构重构
本文讨论的是 unidbg 在单 Unicorn 后端下模拟多线程 native 程序时的通用架构问题。文中的命令、线程、回调与返回值均为抽象示例,不对应任何具体应用、接口或业务数据。
本文为作者本人原创的合并修订版,完整整理了从第一版原生时间片到最终调用级并发架构的演进过程。
摘要
本文第一部分的原生时间片方案解决了一个最直观的问题:让主任务和 native worker 能够按原生时间片轮流执行。它把 unidbg 从依赖 syscall、JNI 回调等偶然 safe point 的协作式调度,推进到了可被指令预算稳定打断的抢占式轮转模型。
但是,“线程能够轮流执行”并不等于“多个 native 调用能够安全重叠”。当两个 Java 宿主线程向同一个 Unicorn backend 提交命令时,系统还必须回答一组更困难的问题:当前是谁在驾驶 backend?新调用如何请求通行权?旧调用暂停后由谁恢复?返回值和终态属于哪一代调用?一个调用释放 JNI local reference 时,会不会清掉另一个暂停调用仍在使用的对象?
第二版方案开始引入 backend owner、handoff、command result 和超时终态,但仍然保留了大量全局槽位和按命令号匹配的兼容逻辑。它能处理部分交接,却不能从结构上保证调用身份、运行上下文、JNI 引用和终态的一致性。
最终方案不再把“任务”作为唯一调度单位,而是在任务之上增加一层 invocation-owned runtime:每次 native 调用都拥有独立的 record、generation、submitter、carrier、immutable context、terminal 和 JNI reference scope。backend 仍然只有一个,但调用可以通过 FIFO 申请、在真实 safe point 交接、暂停和显式恢复。
可以把整个演进理解成一座单车道桥:
- 第一版给桥装上红绿灯,让车辆能够轮流前进;
- 第二版增加了临时交警,但通行记录仍写在几块共享白板上;
- 最终版给每辆车发放独立通行证、流水号、货单和回执,并规定只有真正取得桥权的车辆,其上下文才对桥上的设施可见。
一、第一版:给 unidbg 装上原生时间片
第一版首先解决的是最基础的调度能力:让单 Unicorn backend 中的 main task 与 native worker 不再完全依赖偶然 safe point,而是能够按照明确的指令预算被打断、保存、恢复和轮转。本章保留第一版方案的完整实现,并在末尾说明它通过了哪些验收,以及为什么这些验收仍不足以支撑重叠 native invocation。
1.1 问题来源:native worker 线程池
一个真实的逆向场景
在逆向某个 SO 时,我们发现它的核心逻辑依赖多线程协作:
主线程(producer)
↓ 入队工作项
共享队列
↓ 出队工作项
worker 线程池(3-5 个 consumer)
↓
执行业务回调 → 生产新 item → signal → 入队
主线程负责把工作项放入队列,worker 线程负责消费。消费者没有工作时通过 futex / pthread_cond_wait 挂起,生产者通过 pthread_cond_signal 唤醒它们。业务逻辑只有在 worker 消费了特定工作项之后才会被触发。
在真机上,操作系统调度器在线程之间公平分配 CPU 时间,这套模型完美运转。在 unidbg 上,所有代码共享同一个 CPU 核心,问题立刻暴露。
观察到的症状
| 症状 |
说明 |
pthread_create 返回了 tid |
线程确实创建了 |
| worker 从不执行 |
对应的函数从未被调用 |
futex wait 永远超时 |
FUTEX_WAIT 设了条件,但同一个地址上的 FUTEX_WAKE 要么不来,要么来的时候没有等待者 |
为什么是线程调度的问题
排查过程大致如下:
第一步:确认资源没问题。 文件系统、环境变量、JNI 回调路径全部正确,SO 确实读到了所有必要数据。
第二步:确认控制流没问题。 目标函数的调用链在 unidbg 和真机上完全一致,条件分支都走了正确的路径。
第三步:确认 worker 确实创建了。 pthread_create syscall 返回了正确的 tid,Java 层的线程对象也正确创建。
第四步:确认 worker 没有执行。 通过 Frida 在 worker 入口函数下断点,真机上断下来了,unidbg 上断不下来。
此时问题范围已经收窄到「线程创建了但没有被调度执行」。在单核模拟器上,这只能是调度模型的问题。
1.2 为什么既有补丁都无效
unidbg 的协作式调度模型
unidbg 的 UniThreadDispatcher 是一个协作式调度器。它的默认行为是:
1. 主线程开始执行
2. 主线程跑到一个 safe-point(syscall 入口、callback 返回等)
3. safe-point 代码把主线程 rotate 到队尾,尝试调度 worker
4. worker 跑一小段,进入下一个 safe-point
5. 回到步骤 2
这个模型在大部分场景下工作良好。但有两个根本性缺陷:
缺陷一:safe-point 依赖主动让出。 worker 想被调度,必须先跑到一个 safe-point。但如果 worker 代码在到达 safe-point 之前就进入了 futex wait——而主线程此时不在 WAKE 的代码路径上——worker 就会永远等在那里。
缺陷二:时间窗口不重叠。 真机上是并发的,WAIT 和 WAKE 在时间轴上天然重叠。在 unidbg 的串行模型下,要么先 WAKE(此时没有等待者),要么先 WAIT(此时没有人来 WAKE)。这个时间窗口错位在协作式调度下是常态,不是例外。
为什么打补丁无效:五条路线的逐一复盘
这一节不是流水账。我们把每条路线走到的边界说清楚——包括它为什么局部有效,以及它的天花板在哪里。
路线一:窗口式 drain
思路:在业务生命周期的特定节点(如"插件加载完成"、"某命令返回后"),一次性把 pending 队列里的 worker 全部拉起来跑一段。相当于在串行执行的主线上开了几个"排水窗口"。
为什么局部有效:如果 drain 窗口恰好开在主线程刚往共享队列里扔了工作项之后,worker 被唤醒后确实能消费那个工作项,输出会增加一小截。这是我们最早看到的"worker 终于动了"的现象。
为什么不够用:真实的多线程程序中,生产者和消费者的执行窗口天然重叠——主线程可能在 worker 还没来得及取走数据之前就已经修改了那块内存。而 drain 窗口是固定时间点,开早了 worker 还没准备好,开晚了主线程的上下文(寄存器、栈帧、TLS)已经变化,worker 拿到的状态不是正确的执行起点。
更根本的问题是:drain 是单次快照,不是调度策略。Worker 跑完一段进入 futex wait 之后,下一个 drain 窗口如果不来碰它,它就永远等在那里——而下一个 drain 窗口的开与不开,取决于主线程的控制流,不是 worker 的需求。
路线二:全局 handoff flag
思路:设一个布尔值 flag,每当业务逻辑到达某个关键点时设 flag,调度器主循环每次迭代检查这个 flag。如果 flag 为真,主动把当前任务切到队尾,调度下一个。
为什么局部有效:在那些"关键点"之后的下一次调度确实发生了——worker 获得了更多的执行机会。
为什么不够用:当业务逻辑中存在多个这样的关键点(service done、callback returned、handoff armed、clone first wait……),flag 的组合状态开始爆炸:
flag_A=true, flag_B=false → 行为 X
flag_A=false, flag_B=true → 行为 Y
flag_A=true, flag_B=true → 行为 ???
更危险的是:flag 是全局共享的,而业务逻辑是状态机。设 flag 的代码和读 flag 的调度代码不在同一个执行上下文里,race condition 从设计层面就存在。一旦出现"flag 刚被检查完但业务状态已变化",调度器会切到一个错误的上下文。
从实现层面说,handoff flag 本质上是在把"业务调度决策"编码到"调度器"里——两个本应正交的层被耦合在一起。维护成本随 flag 数量线性增长,行为不可预测。
路线三:safe-point yield
思路:在回调函数返回点、JNI 入口、syscall 入口等"安全位置"插入 yield,强制当前任务让出 CPU。
为什么局部有效:在有回调返回点的场景下,yield 确实让调度器有机会切到其他线程。
为什么不够用:这个路线的根本前提是 worker 必须首先到达某个 safe-point。但如果 worker 从一开始就没被调度过——也就是说它从未获得过 CPU 时间片——那 safe-point 永远不会触发。
在单核模拟器上,主线程一直在跑。只有当主线程主动让出(比如执行了一个 syscall 或触发了 hook),调度器才有机会切到 worker。而 safe-point yield 本身就是在等这个让出动作。这是一个循环依赖:worker 需要被调度才能到达 safe-point,但 safe-point 是触发调度的条件。
其次,safe-point 的位置取决于 native 代码结构,不是调度器能控制的。有些函数可能执行数千条指令才到达一个 syscall,中间的所有状态都是黑盒。
路线四:PC redirect
思路:在 code hook 里直接读取目标函数的地址,然后修改 PC(程序计数器)寄存器,让 CPU 跳转到目标函数去执行。
为什么局部有效:在纯计算场景下(比如让 worker 直接执行某个不依赖线程上下文的函数),PC redirect 能跑通,并产生输出。
为什么不够用:PC redirect 跳过去的函数,其执行依赖于完整的线程上下文:TLS 寄存器、栈指针、thread-local 变量。hook 执行的上下文里,这些值是不完整的或错误的。当目标函数内部调用了 pthread_create 或 futex_wait 时,这些调用会静默失败——函数返回了,但没有创建线程,没有正确的 errno,也没有更新任务队列。
更深层的问题是:PC redirect 跳进去的代码,在调度器眼里是"不存在的"。调度器不知道这个任务正在执行什么,它只是在某个 hook 回调里偷偷改了 PC 寄存器。一旦目标函数内部再触发一个 hook,调度器没有这个函数的栈帧信息,无法正确保存和恢复上下文。
路线五:单点 futex 修复
思路:针对某个具体的 futex 条件变量,分别修复 WAIT 和 WAKE 的实现,确保它们的语义正确。
为什么局部有效:针对那些已经被识别出来的关键条件变量(通过日志分析发现其 WAIT/WAKE 总是错位),逐个修复确实能让那一对 WAIT/WAKE 正确配对。
为什么不够用:futex 的语义要求 WAIT 和 WAKE 在时间窗口上必须重叠。修复一个条件变量只能解决这一对的问题,而一个 worker 线程池可能涉及数十个不同的条件变量。更关键的是,即使所有条件变量都各自配对正确了,主线程和 worker 线程的执行顺序仍然是由主线程控制的——worker 只是在"被唤醒"后才能跑,而不是"主动"获得 CPU。
而且,单点修复是在追赶症状。修复了 A 地址的 WAIT/WAKE,B 地址的问题可能就暴露出来了。这变成了一场永无止境的打地鼠游戏。
五条路线的共同教训
把它们放在一起看,问题的根源逐渐清晰:
| 路线 |
试图解决 |
实际假设 |
为什么站不住 |
| 窗口式 drain |
worker 执行机会 |
drain 窗口能对齐业务状态 |
drain 是快照,业务是流 |
| 全局 handoff flag |
调度时机 |
flag 组合状态可控 |
业务状态机 + 全局 flag = 组合爆炸 |
| safe-point yield |
worker 被调度 |
worker 能先到达 safe-point |
循环依赖:需要调度才能到 safe-point |
| PC redirect |
worker 执行入口 |
hook 上下文有完整 TLS |
PC redirect 绕过了任务调度上下文 |
| 单点 futex 修复 |
WAIT/WAKE 配对 |
只有这一个条件变量有问题 |
条件变量有 N 个,且执行顺序仍由主线程控制 |
每个补丁局部有效,但补丁之间没有统一的执行契约。 协作式调度失败的根本原因是:所有这些补丁都在回答"什么时候该切",但没有任何机制保证"什么时候能切"。没有可预期的指令级中断,所有的让出动作都是在猜——猜主线程什么时候会停,猜 worker 什么时候准备好,猜下一次 drain 窗口开的时候上下文还对不对。
1.3 解决思路:把“为什么停”和“下一个跑谁”拆开
正确的做法是:让模拟器在每 N 条指令后自动停下来,把停止原因告诉 Java 层,然后由 Java 调度器根据任务状态决定下一个执行者。
设计原则只有三条:
- C 层只做一件事:跑 N 条指令后停下来,告诉上层「为什么停」
- Java 调度器只做一件事:根据停止原因决定「下一个跑谁」
- 两层之间的接口只有一个枚举值 + 一个 PC 值
1.4 C 后端实现:用最小代码建立明确停止原因
停止原因枚举
public enum BackendStopReason {
NONE(0), // 未启动/未知
NORMAL(1), // 正常执行到 until 地址
TIMESLICE(2), // 指令预算耗尽 ← 核心新增
EMU_STOP(3), // 显式调用 emu_stop()
FAULT(4); // uc_emu_start 返回错误
}
有了明确的停止原因,Java 层不需要再猜「是正常返回还是预算耗尽」。
Backend 接口
public interface Backend {
// 新增接口
BackendStopReason getLastStopReason();
long getLastStopPc();
void clearLastStopReason();
boolean supportsNativeTimeslice();
void configureNativeTimeslice(long instructionBudget);
void setNativeTimesliceEnabled(boolean enabled);
}
supportsNativeTimeslice() 让其他后端返回 false,完全不受影响。
时间片 hook(核心代码)
// unicorn.c
static void native_timeslice_cb(struct uc_struct *uc,
uint64_t address,
uint32_t size,
void *user_data) {
t_unicorn unicorn = (t_unicorn) user_data;
if (!unicorn->timeslice_enabled) return;
if (unicorn->timeslice_budget == 0) return;
if (++unicorn->timeslice_counter >= unicorn->timeslice_budget) {
unicorn->timeslice_counter = 0;
unicorn->last_stop_pc = address;
unicorn->last_stop_reason = STOP_TIMESLICE;
uc_emu_stop(uc);
}
}
这段代码的核心逻辑:每执行一条指令,计数器加一;达到预算时,设置停止原因,然后调用 uc_emu_stop() 让 Unicorn 停下来。
没有 JNI 调用,没有复杂的逻辑,只有两个条件判断和一个 uc_emu_stop()。在指令 hook 里调 JNI 会导致 GC 风险和性能损失,所以这里的做法是只设置 flag——Java 层在下一次 emu_start 返回后读取这些 flag。
emu_start 包装
JNIEXPORT void JNICALL
Java_com_github_unidbg_arm_backend_unicorn_Unicorn_emu_1start
(JNIEnv *env, jclass cls, jlong handle,
jlong begin, jlong until, jlong timeout, jlong count) {
t_unicorn unicorn = (t_unicorn) handle;
unicorn->timeslice_counter = 0;
unicorn->last_stop_reason = STOP_NONE;
uc_err err = uc_emu_start(eng, begin, until, timeout, count);
if (err != UC_ERR_OK) {
unicorn->last_stop_reason = STOP_FAULT;
throwException(env, err);
} else if (unicorn->last_stop_reason == STOP_TIMESLICE) {
return; // 时间片耗尽,正常返回
} else if (unicorn->last_stop_reason == STOP_EMU_STOP) {
return; // 显式停止,正常返回
} else {
unicorn->last_stop_reason = STOP_NORMAL;
}
}
关键点:uc_emu_start 返回 UC_ERR_OK 时,不一定代表「正常执行完毕」——它也可能是因为时间片耗尽或者被显式停止。这段代码把两种情况区分开来,Java 层通过 getLastStopReason() 拿到真实原因。
为什么不用 Unicorn 自带的 count 参数
Unicorn 的 uc_emu_start 第四个参数就是指令计数预算。但它有一个致命缺陷:到达预算后 uc_emu_start 仍然返回 UC_ERR_OK,Java 层无法区分「正常执行到 until 地址」和「预算耗尽」。
我们的 hook 给出了一个确定的 TIMESLICE 原因,不需要从 PC 来反推。
1.5 Java 桥接层:把停止原因交给调度器
在 AbstractEmulator.emulate() 中,时间片路径和旧路径并存:
boolean timesliceEnabled = enableNativeTimeslice();
BackendStopReason reason = BackendStopReason.NONE;
try {
backend.emu_start(begin, until, 0, 0);
} finally {
if (timesliceEnabled) {
reason = backend.getLastStopReason();
set(TIMESLICE_REASON_KEY, reason);
set(TIMESLICE_STOP_PC_KEY, backend.getLastStopPc());
disableNativeTimeslice();
}
}
if (timesliceEnabled && reason == BackendStopReason.TIMESLICE) {
set(EMU_TIMESLICE_KEY, Boolean.TRUE);
throw new ThreadContextSwitchException()
.setReason(ThreadContextSwitchException.Reason.TIMESLICE);
}
enableNativeTimeslice() 根据当前运行的任务类型选择 budget:
private long getTimesliceBudget() {
RunnableTask runningTask = threadDispatcher.getRunningTask();
boolean isWorker = runningTask instanceof Task
&& !((Task) runningTask).isMainThread();
return isWorker ? 12000L : 50000L; // worker 预算小,主线程预算大
}
默认值是 bootstrap 值,后续可以通过 instruction-count sampling 校准。
任务切换后的寄存器隔离:TPIDR 和 SP 的污染问题
时间片中断带来了协作式调度不会遇到的隐患:任务切换后,前一个任务的寄存器残留会污染下一个任务。
在真实的操作系统上,每个线程有独立的 TPIDR_EL0(线程本地存储寄存器)和栈指针,切换线程时硬件或内核自动保存/恢复。但在 unidbg 的单引擎模型下,所有任务共享同一个 CPU 寄存器组。调度器做 context save/restore 时,TPIDR_EL0 的恢复时机和 Native 代码的预期不一致——这会导致一个隐蔽的 bug。
问题现象:主线程执行某个命令时,读到了一个本属于 worker TLS 区域的地址。这说明主线程恢复执行时,TPIDR_EL0 仍残留着 worker 的 TLS 基址,而不是主线程自己的。
根因:调度器保存的是任务被时间片中断时的 PC/SP 等通用寄存器,但 TPIDR_EL0 是一个特殊的系统寄存器。在时间片耗尽的 hook 点,TPIDR_EL0 的值没有随着任务切换一起更新。当 worker park 后调度器切回主线程,主线程拿到的 TPIDR 仍是 worker 的。
这在单次调度时不会触发——第一次调度时 TPIDR 由线程初始化代码正确设置。但随着调度轮次增加,worker 和 main 反复切换,TPIDR 的残留值就会在某个时刻恰好被业务代码读取,产生错误的内存寻址。
修复方案分两处:
第一处:Native 入口显式写回 SP。 Function64 / NativeWorkerTask64 在每个 JNI/native 函数入口处,显式把当前入口的 SP(栈指针)写回到任务上下文。这确保了每次进入 native 代码时,栈指针是当前任务的正确值,而不是上一个任务残留的。
// Function64 / NativeWorkerTask64
// 在 JNI/native 入口处
public void onNativeEntry(long sp) {
// 写回当前入口的 SP,保证下一个任务拿到的 SP 是正确的
currentTask.setEntrySp(sp);
}
第二处:主线程 TPIDR 污染时恢复。 调度器在切回主线程时,检查 TPIDR_EL0 是否被 worker 污染。如果发现主线程的 TPIDR 指向了 worker TLS 区域,就从最早捕获的主线程 TPIDR 快照中恢复。
// UniThreadDispatcher 任务切换逻辑
private void restoreMainTpidrIfPolluted() {
long currentTpidr = backend.reg_read(UC_ARM64_REG_TPIDR_EL0);
long mainBase = getMainTlsBase();
long workerBase = getWorkerTlsBase();
// 如果主线程的 TPIDR 指向了 worker TLS 区域,说明被污染了
if (currentTpidr >= workerBase && currentTpidr < workerBase + TLS_SIZE) {
// 从快照恢复主线程 TPIDR
backend.reg_write(UC_ARM64_REG_TPIDR_EL0, mainTpidrSnapshot);
}
}
为什么这个 patch 不算"业务补丁":它修复的是调度基础设施的隔离性,不是某个业务函数的返回值或某个算法参数。任何使用 worker 线程池的 SO 在 unidbg 上跑,都可能遇到同样的 TPIDR 污染问题。这和修复某个 syscall 偏移或某个 JNI 函数路径属于同一层——都是让模拟器正确模拟 pthread runtime,而不是替 SO 算业务结果。
1.6 Java 调度器:任务状态机
状态定义
enum TaskState {
NEW, // 刚创建,未被调度过
RUNNABLE, // 可以被调度器选中
RUNNING, // 正在emu_start执行中
WAITING, // 因futex wait阻塞
FINISHED, // 线程退出
}
每个任务有一个元数据对象保存状态和统计信息:
static class TaskMeta {
TaskState state = TaskState.NEW;
ThreadContextSwitchException.Reason lastReason;
long lastPc, lastSp;
long slices; // 已获得的调度次数
}
调度主循环
private Number runWithTimeslice(long timeout, TimeUnit unit) {
long start = System.currentTimeMillis();
Task previous = null;
while (true) {
// 1. 从pending队列推进到taskList
promoteRunnableThreads();
cleanupFinishedTasks();
if (taskList.isEmpty()) return null;
// 2. 选下一个可调度的任务
Task task = pickNextRunnableTask();
if (task == null) return null;
emulator.set(Task.TASK_KEY, task);
previous = task;
// 3. 恢复上下文(首次调度时初始化)
if (task.isContextSaved()) {
task.restoreContext(emulator);
}
// 4. dispatch
this.runningTask = task;
Number ret = task.dispatch(emulator);
// 5. 任务完成
if (ret != null) {
task.destroy(emulator);
if (task.isMainThread()) return ret;
taskList.remove(task);
continue;
}
// 6. 任务被中断——保存上下文
task.saveContext(emulator);
ThreadContextSwitchException.Reason reason = consumeEmuReason();
TaskMeta meta = getOrCreateTaskMeta(task);
meta.slices++;
// 7. 根据中断原因更新状态
if (reason == ThreadContextSwitchException.Reason.FUTEX_WAIT) {
meta.state = TaskState.WAITING;
taskList.remove(task); // 阻塞的任务移出运行队列
} else {
meta.state = TaskState.RUNNABLE;
rotateTaskToEnd(task); // 时间片耗尽,轮转到队尾
}
// 8. wall-clock guard
if (System.currentTimeMillis() - start >= timeout) {
return null;
}
}
}
关键设计:WAIT 状态的任务从 taskList 中移除,直到被 WAKE 重新唤醒。 这样调度器不会在一个永远阻塞的任务上浪费选择。
任务选择策略:FIFO + wake priority
private Task pickNextRunnableTask() {
// 第一优先级:刚被futex WAKE唤醒的任务
for (Task t : taskList) {
TaskMeta meta = taskMetaMap.get(t);
if (meta != null && meta.lastReason == FUTEX_WAKE
&& meta.state == TaskState.RUNNABLE) {
meta.state = TaskState.RUNNING;
return t;
}
}
// 第二优先级:FIFO轮转
for (Task t : taskList) {
TaskMeta meta = getOrCreateTaskMeta(t);
if (meta.state == TaskState.RUNNABLE
|| meta.state == TaskState.NEW) {
meta.state = TaskState.RUNNING;
return t;
}
}
return null;
}
刚被唤醒的任务优先获得 CPU,这是 futex 语义的正确实现——WAKE 发生时应该立即执行等待者。
为什么不用优先级队列
因为真实的多线程程序中,worker 线程池通常按 FIFO 从队列取任务执行。用 FIFO + 一个 wake priority 标记,比多级优先级队列更容易调试,也更接近实际行为。
1.7 Futex:WAIT 负责停车,WAKE 负责恢复资格
WAIT:不直接等待,而是标记状态
case FUTEX_WAIT: {
RunnableTask rt = emulator.getThreadDispatcher().getRunningTask();
if (old != val) {
return -UnixEmulator.EAGAIN; // 经典futex语义
}
if (rt != null) {
rt.setWaiter(emulator, new FutexNanoSleepWaiter(uaddr, val, ts));
throw new ThreadContextSwitchException()
.setReason(ThreadContextSwitchException.Reason.FUTEX_WAIT);
}
break;
}
这里的关键理解:throw 不是「立即切换线程」,而是「表明当前任务不可继续执行」。 ThreadContextSwitchException 从 emulate() 向上传播,dispatch() 返回 null,调度器在主循环中把任务标记为 WAITING。
WAKE:不直接跑 woken 任务,只做状态迁移
case FUTEX_WAKE: {
int woken = 0;
for (Task t : getAllTasks()) { // 同时搜索active和pending队列
Waiter w = t.getWaiter();
if (w instanceof FutexWaiter && w.wakeUp(uaddr)) {
markTaskRunnable(t); // WAITING → RUNNABLE
if (++woken >= val) break;
}
}
return woken;
}
markTaskRunnable 只是把任务状态从 WAITING 改为 RUNNABLE,加入可运行队列。不调用 emu_start,不尝试在 syscall handler 里启动任务调度。
这是最重要的设计决策之一。如果 WAKE 在 syscall handler 里直接 emu_start woken worker,就相当于在 syscall 里嵌套了一个 emu_start。Unicorn 不支持嵌套 emu_start,而且 hook 上下文里没有正确的 TLS 和 errno 状态——这正是之前大量补丁崩溃的根本原因。
1.8 分阶段验证结果
Phase A:C 后端 stop reason
[native_timeslice.backend] budget=1 reason=TIMESLICE pc=0x10000
[native_timeslice.backend] budget=50000 reason=NORMAL pc=0x1001c
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
时间片耗尽时 stop reason 正确为 TIMESLICE,正常执行到 until 地址时为 NORMAL。
Phase B:Java 桥接层
[native_timeslice.bridge] backendReason=TIMESLICE
exceptionReason=TIMESLICE
savedContext=true stopPc=0x10000
[native_timeslice.bridge.off] ret=0x0 reason=NORMAL
Tests run: 2, Failures: 0, Errors: 0
BUILD SUCCESS
Backend 的 TIMESLICE reason 正确传递为 ThreadContextSwitchException,主线程正常返回时 reason 为 NORMAL。
Phase C:调度器 FIFO 轮转
[native_timeslice.pick] from=<none> to=main tid=2688 saved=false
[native_timeslice.slice] task=main tid=2688 reason=TIMESLICE
state=RUNNABLE slices=1 pc=0x10000 sp=0xbffff710
[native_timeslice.pick] from=main to=worker tid=2689 saved=false
[native_timeslice.slice] task=worker tid=2689 reason=TIMESLICE
state=RUNNABLE slices=1 pc=0x11000 sp=0xbffff710
[native_timeslice.scheduler] mainSaved=true workerSaved=true taskCount=2
[native_timeslice.pick] from=worker to=main tid=2688 saved=true
[native_timeslice.slice] task=main tid=2688 reason=TIMESLICE
state=RUNNABLE slices=2 pc=0x1003c sp=0xbffff710
Tests run: 3, Failures: 0, Errors: 0
BUILD SUCCESS
main 和 worker 交替被选中,FIFO 轮转生效,context save/restore 正确。
Phase D:线程创建
[native_timeslice.clone] tid=2690 parent=2688 fn=0x197438 arg=0x40296400
action=runnable lr=0x11000
[native_timeslice.pick] from=main to=worker tid=2690 saved=false
[native_timeslice.slice] task=worker tid=2690 reason=TIMESLICE
state=RUNNABLE slices=1
Tests run: 2, Failures: 0, Errors: 0
BUILD SUCCESS
pthread_create 创建的 child task 正确加入 runnable 队列,调度器在下一轮 pick 中选中它。
Phase E:Futex wait/wake 重叠
[native_timeslice.futex.wait] cmd=WAIT task=26880 uaddr=0x20000
val=0x2 old=0x2 timeoutMs=-1 action=park
[native_timeslice.futex.wake] cmd=WAKE uaddr=0x20000 val=0x1 woken=1
[native_timeslice.pick] from=main to=worker tid=26881 saved=false
[native_timeslice.futex.test] waitSaved=true wakeRet=1 canDispatch=true
Tests run: 2, Failures: 0, Errors: 0
BUILD SUCCESS
WAIT 正确 park 任务,WAKE 正确唤醒,调度器在下一轮 pick 中选中 woken worker——这是协作式调度下永远无法实现的 wait/wake 时间窗口重叠。
1.9 几个反直觉的设计决策
1. C hook 里绝对不能调 Java。
JNI 回调在每个指令 hook 上触发会导致数十倍的性能损失,而且 GC、死锁、JNI 引用管理在 hook 上下文里完全不可预测。设一个 flag、uc_emu_stop(),足够了。
2. 30 行 C 代码 ≠ 小改。
它改变的是信息传递方式:从「Java 层靠猜」,变成「C 层明确告诉 Java 为什么停」。这个接口契约变了,所有上层逻辑才能建立在这个确定性的基础上。
3. Phase gate 是防止调参循环的唯一手段。
没有分阶段验收,你很容易在 C 后端还没稳定时就跳去调业务参数,然后陷入「为什么还没好」的循环。用独立测试逐阶段验收,才能在每个阶段确认「这一层是对的」。
4. 时间片不是银弹。
它只解决「worker 能被调度」的问题。如果 worker 的业务逻辑本身有其他问题(比如 TLS 上下文错误、queue 消费路径有 bug),调度器帮不了你。
1.10 第一版不是“做错了”,而是验收目标太窄
回头看第一版,最容易产生的误解是:既然后来又做了架构重构,那么 native timeslice 一定是失败的。事实恰好相反——时间片本身完成了它被设计出来时的目标。
第一版已经能够验证以下能力:
- C 后端可以按照 guest 指令预算主动停止,而不是只能等 syscall、hook 或函数返回;
- Java 层能够区分“自然返回”和“时间片耗尽”这两类停止原因;
- Task 被打断后,寄存器、栈和程序计数器能够保存,并在下一次调度时恢复;
- 多个
RUNNABLE Task 可以轮流取得 backend;
FUTEX_WAIT 能让任务离开可运行队列,对应的唤醒又能让它重新入队;
- worker 不只是被创建出来,而是真的能够获得时间片并执行 guest 指令。
这些验证共同证明了一件重要的事:单 backend 上的 native 协作式抢占已经成立。
问题在于,我们当时把这个结论向前多推了一步:
已经证明:多个 Task 可以被打断、保存、恢复和轮转
错误外推:多个重叠 native invocation 因此一定能正确完成
这两句话看起来只差半步,实际上跨过了一条架构边界。Task 是调度对象,回答“下一刻让谁运行”;Invocation 是调用对象,回答“谁发起、谁拥有、谁等待、谁接收结果、谁负责清理”。时间片只解决了前一个问题。
如果把单 backend 比作一座一次只能通过一辆车的窄桥,第一版做成的是红绿灯系统:灯会按时变色,车辆也确实能够交替前进。但“能轮流上桥”不代表整套运输系统已经完备,因为我们还没有为每一趟运输建立独立车牌、货单、收件人和签收回执。
所以,第一版“不对”的准确含义不是时间片算法错误,而是:它通过的是 Task 轮转验收,却被拿去承担 Invocation 正确性。验收标准比真实并发语义窄了一层。
1.11 问题是怎样被发现的:worker 在运行,carrier 却不能自然返回
真正推动第二次重构的,不是某一处代码看起来不够优雅,而是一组无法用“线程没有跑起来”解释的运行现象。
一次 native 调用进入调度器后,可以把承载其入口和最终返回的主任务称为 carrier,把它依赖的其他 guest 任务称为 worker。最初的怀疑很自然:carrier 长时间没有返回,是不是 worker 根本没有创建、没有入队,或者一直没拿到 backend?
被动观测很快排除了这种简单解释:
- worker 已经完成创建并进入任务表;
- worker 的状态确实从等待转为可运行;
- 调度日志中能看到 worker 被选中并取得 backend;
- worker 的程序计数器和上下文持续变化,说明它不是“空调度”;
- 常见的等待与唤醒路径已经发生,不能把问题概括为漏掉一次普通 WAKE。
换句话说,桥上的红绿灯在工作,辅助车辆也确实开上了桥。可是 carrier 仍可能长期停留在这样的状态:
carrier.state = RUNNABLE
carrier.waitAddress = 0
carrier.finished = false
observation = expired
这四个条件同时出现非常关键:
RUNNABLE 表示 carrier 并未睡死;
waitAddress = 0 表示它当时不在一个可直接归因的 futex 等待点;
finished = false 表示 guest 函数还没有自然走到返回边界;
observation = expired 表示宿主的等待窗口先结束了。
如果只看最终的 timeout,很容易得到“native 卡死”的结论;如果把任务状态、实际派发和 wall time 放在一起看,则会发现另一种可能:调用不是不可返回,而是在单 backend 的共享调度窗口中,还没获得足够且连续的推进机会,宿主就先取消了观察。
这也是第一版第一次暴露出模型裂缝的地方。Task 状态都可以是“合法的”,调度器也可以持续工作,但调用整体仍然没有一个能够被准确描述的生命周期。
1.12 如何逐层排除看似合理的解释
发现“worker 已运行、carrier 未完成”以后,不能立刻把原因归结为 timeout,更不能直接把 timeout 调大。我们按照因果位置逐层排除了几类解释。
排除一:不是 worker 没有创建,也不是队列完全失效
任务创建记录、可运行状态变化、实际 dispatch 以及上下文前进共同证明,worker 已经进入了调度闭环。若 worker 从未运行,修复方向应当是线程创建或队列;但证据表明问题发生在这些步骤之后。
排除二:不能让 carrier 独占 backend
另一个直觉方案是提高 carrier 优先级,甚至只调度 carrier,期待它尽快返回。受控验证显示,carrier 会走到一个真实的依赖等待点:它需要某个 worker 更新共享状态或释放同步条件才能继续。
这说明 carrier 和 worker 不是“主任务与噪声”的关系,而是协作关系。只让 carrier 上桥,就像让货车独占桥面,却把负责开闸的工程车永远挡在桥外;货车得到再多绿灯,也只能停在关闭的闸门前。
因此,问题不能通过“把所有时间都给 carrier”解决。正确目标不是单方面提高优先级,而是在保持协作推进的同时,准确计算每一次调用的等待与完成。
排除三:队列调序和强制唤醒只能改变表象
我们还验证过几类常见调度补丁的思路:提高某类任务的选中概率、改变队列顺序、强制把任务改成可运行、在退出前增加 drain window,或者在观察即将到期时再多跑若干轮。
这些措施可能改变某一次运行的路径,甚至让失败出现得更早或更晚,但它们无法建立稳定的因果闭环:
如果任务本来就在 RUNNABLE,强制 wake 没有增加新信息;
如果 carrier 依赖 worker,永久优先 carrier 反而会破坏协作;
如果完成权没有归属,调整队列也不能保证结果交给正确调用;
如果 wall timeout 的含义错误,增加 drain 只是延后同一个问题。
一个补丁只有在回答“它修复了因果链的哪一层”时才是架构修复。仅仅让某次运行通过,不足以证明队列顺序或某个额外唤醒就是根因。
排除四:不是缺少一个神奇的 timeout 数字
被动记账揭示了最容易被忽视的一点:第一版的 deadline 是全局 wall deadline。它从宿主进入调度循环时开始计时,期间不只计算 carrier,还计算全部 worker、任务切换、hook、日志和 Java 调度开销。
因此,在一个看起来很长的观察窗口里,carrier 实际得到的 backend 执行份额可能只占一小部分。宿主时钟已经走完,并不等于 carrier 真正执行了同等时长。
一次受控验证中,放宽全局 wall cutoff 后,原本被判定超时的 carrier 能够继续推进并自然返回。这证明了一个必要事实:旧 deadline 确实可能杀死一个本来可返回的调用。
但这仍不意味着“把固定时间改大”就是最终答案。换一组任务组合或调度顺序后,同一个固定额度仍可能失效。于是结论进一步收窄:
不是旧数值太小;
也不是新数值还不够大;
而是用所有 Task 共享的 wall time,表达某一次 Invocation 的生命周期,本身就不成立。
这一区分很重要。延长 timeout 是参数修补;把提交等待、backend 占用、协作调度和调用终态分别建模,才是语义修复。
1.13 真正暴露出来的问题:Task 调度正确,不等于 Invocation 正确
把上述证据合在一起,可以得到比“调度偶尔超时”更准确的结论:
| 观测事实 |
能证明什么 |
不能证明什么 |
| worker 被创建、入队并实际执行 |
Task 调度闭环已经工作 |
调用一定能完成 |
carrier 仍为 RUNNABLE |
carrier 不是简单睡死 |
它已经获得足够执行机会 |
| 常规 wait/wake 路径发生 |
基础同步事件可被处理 |
所有调用生命周期都被正确建模 |
| 放宽 wall cutoff 后能够自然返回 |
旧 deadline 会过早终止可返回调用 |
某个更大的固定数值永远正确 |
| carrier 独占时反而进入依赖等待 |
worker 是必要协作者 |
提高 carrier 优先级可以替代并发语义 |
第一版真正缺少的,是位于 Java 提交者与 native Task 之间的调用实体。没有这个实体,系统无法可靠回答:
- 当前 carrier 属于哪一次提交;
- 同一个入口连续出现时,前后两代调用如何区分;
- 哪个调用有资格把自己的上下文发布给 backend;
- 某个结果、异常或 timeout 应该交给哪个等待者;
- 调用被暂停后,它创建的 JNI local reference 应由谁保管;
- backend stop 是一次交通让路,还是调用真正结束;
- 观察者停止等待时,guest 调用究竟已经终止,还是仍可继续推进。
桥梁系统的故障至此才算被准确描述:红绿灯没有坏,车辆也不是完全不动;缺的是每趟运输的身份与所有权。没有车牌,两辆同型号车会混淆;没有独立货单,后车可能覆盖前车的货物记录;没有准确回执,桥中央的一次临时停车也可能被误报成“已经送达”;所有车辆共用一个倒计时,则某辆车还没真正开多久,就可能因为别的车占桥而被判超时。
后面四个缺陷,正是这个总问题在代码中的具体表现:调度单位只有 Task、预算使用全局 wall time、交通状态与调用终态混淆,以及 JNI local reference 仍采用 VM-wide 生命周期。
1.14 调度单位只有 Task,没有 Invocation
第一版调度器认识的是 Task:
main task
worker task #1
worker task #2
scheduled function task
每个任务具有 RUNNABLE、WAITING、FINISHED 等状态,调度器按照时间片和 futex 状态轮转任务。
但是一次 Java 调用和一个 Task 并不总是一一对应:
- 同一个命令可能被连续调用多次;
- 同一个宿主线程可能提交多个 generation;
- 一个调用的 carrier 暂停期间,另一个调用可能进入 backend;
- worker 可能是某次调用的间接依赖,却不是调用结果的拥有者;
- carrier 的生命周期可能长于 Java 方法的一次等待窗口。
如果系统只有 Task,没有 Invocation,就无法精确表达:
这个 scheduled task 属于哪一次调用?
这个 TIMEOUT 是当前 generation 的,还是上一轮残留的?
这个 worker fault 应该交给哪个 submitter?
同一个 command 再次出现时,能否覆盖旧结果?
第一版常见的做法是以 command number 或当前 active command 作为关联键。这在严格串行时看起来可用,一旦允许重叠就会产生歧义。
桥梁比喻中,这相当于调度器能识别“卡车”“轿车”,却没有车牌和通行证。两辆同型号车辆先后上桥,收费站只能看到它们都叫 command-X,无法判断回执应该交给谁。
1.15 一个全局 wall timeout 同时计算了所有人的时间
第一版通常以一次调度循环的 wall clock 作为命令预算:
long start = System.currentTimeMillis();
while (hasRunnableTask()) {
Task task = pickNextTask();
task.dispatch(emulator);
if (System.currentTimeMillis() - start >= timeoutMillis) {
return null;
}
}
这段逻辑简单,但它混合了至少四类时间:
- command carrier 自己实际执行的时间;
- 依赖 worker 消耗的时间;
- 无关 background task 消耗的时间;
- Java 调度器、日志、hook 和上下文切换消耗的时间。
在真机上,多个线程能够并发运行;在单 backend 中,它们必须串行分享同一段 wall time。如果把总 wall time 直接当成 carrier 的生命周期预算,就可能出现如下情况:
命令总窗口:8 秒
carrier 实际获得 backend:1.2 秒
worker 与框架开销:6.8 秒
结果:carrier 被判 TIMEOUT
从宿主视角看已经过去 8 秒,从 carrier 视角看它可能刚执行了一小部分。于是 timeout 不再表达 native 生命周期,只表达“所有任务共同消耗了多少宿主时间”。
简单增大 timeout 也不能成为可靠修复,因为不同运行中的 worker 数量、等待路径和共享状态演化不同。某次运行在更大窗口内自然返回,并不能证明某个固定数值就是正确的 native 生命周期。
1.16 停车、让路和到达终点被混为一谈
时间片方案增加了多种 backend 停止原因:
enum BackendStopReason {
NORMAL,
TIMESLICE,
EMU_STOP,
FAULT
}
这解决了“emu_start() 为什么返回”的问题,但上层很容易错误地把“backend 已停止”理解成“native 命令已完成”。
实际上:
TIMESLICE:当前任务只是用完了本轮时间片;
EMU_STOP:另一个线程要求 backend 回到 Java safe point;
FUTEX_WAIT:任务暂时不可运行;
NORMAL 或真实 guest RET:才可能代表自然返回;
FAULT:执行失败;
- host observation 到期:只代表观察被取消。
一辆车在桥中央被红灯拦停,不等于它已经抵达终点。EMU_STOP 应该触发保存现场和交接,而不是生成一个“已完成”的业务回执。
1.17 VM-wide JNI local reference 无法支持暂停调用
传统顺序 JNI 调用常用一个 VM 级 local-reference map:
private final Map<Integer, ObjRef> localObjectMap = new HashMap<>();
void deleteLocalRefs() {
for (ObjRef ref : localObjectMap.values()) {
ref.obj.onDeleteRef();
}
localObjectMap.clear();
}
顺序执行时,这个模型没有问题:调用开始时加入参数对象,调用结束时统一清理。
重叠调用下会出现严重污染:
调用 A:把 Object[] 参数加入 localObjectMap
调用 A:在 safe point 暂停
调用 B:把自己的参数加入同一个 localObjectMap
调用 B:完成并调用 deleteLocalRefs()
调用 A:恢复执行
调用 A:通过原 handle 读取 Object[],得到 null
这种错误很隐蔽,因为它最终可能表现为 JNI 空指针、数组访问失败,甚至被兼容路径包装成普通数值返回。表面看像业务参数错误,根因却是 local reference 的生命周期仍停留在单调用时代。
二、第二版命令服务方案的缺陷
第二版在时间片调度之上增加了 command service:引入 backend owner、handoff、显式 result、watchdog 和 command terminal。相比第一版,它已经意识到“谁在驾驶 backend”和“命令是否完成”是两个独立问题。
但这一版仍处于从 task-centric 向 invocation-centric 过渡的中间态。许多正确概念被放进了全局字段、单槽 Map 和日志字符串中,因此能够处理单次交接,却难以保证重叠调用的严格隔离。
2.1 Backend owner 有了,但调用所有权仍然分散
第二版通常会增加类似字段:
private Thread backendOwnerThread;
private CommandResult lastCommandResult;
private CommandResult commandTerminalResult;
private Map<Integer, CommandResult> terminalByCommand;
private Task pendingFaultTask;
private CommandResult pendingFaultResult;
这些字段分别解决某一个局部问题:
- owner thread 防止多个宿主线程同时驾驶 backend;
- last result 让 caller 能看到最近一次终态;
- terminal map 让 watchdog 发布 TIMEOUT;
- pending fault 让 safe point 消费 backend exception。
问题在于它们没有被同一个 invocation 对象统一拥有。一次调用的身份散落在:
当前 task
当前 command number
当前 submitter thread
某个 generation 字段
全局 last result
某个 keyed terminal map
日志 detail 中的 cmd=...
当同一 command 发生第二次调用时,旧 terminal、旧 watchdog 或旧 fault 可能覆盖新调用。代码不得不增加越来越多的判断:
if (result.getDetail().contains("cmd=" + cmd)) {
// 假定结果属于当前命令
}
这类字符串匹配只能作为 legacy fallback,不能作为并发所有权模型。
2.2 Map<Command, Result> 无法表达同命令多代调用
第二版常见的数据结构是:
Map<Integer, CommandRecord> recordsByCommand;
Map<Integer, CommandResult> terminalsByCommand;
它隐含了一个假设:同一 command 同时最多只有一条记录。
现实中可能出现:
command-X / generation 7 / submitter A
command-X / generation 8 / submitter A
command-X / generation 9 / submitter B
如果新 record 覆盖旧 record,会产生三类问题:
- 旧 watchdog 到期,把 TIMEOUT 写进新调用;
- 新调用 natural return,被旧 submitter 消费;
- fault 只能按 command 匹配,无法确定属于哪个 carrier。
即使把 value 改成 List<Record>,如果消费时仍只传 command,也无法解决歧义。真正的索引必须包含 carrier identity、generation 和 original submitter。
2.3 reserve 与 bind 分两步,暴露中间竞态
为了在 carrier 创建前预留 generation,第二版可能采用:
reserve invocation
create scheduled carrier
bind invocation to carrier
顺序调用时没有问题,并发 caller 会看到一个危险窗口:reservation 已进入 FIFO,但 carrier 尚未绑定。此时另一个线程也可能 reserve、bind 或取消,导致 FIFO head 与真实 carrier 关系不稳定。
如果 command、context、generation 和 carrier 不是在同一把 dispatcher 锁内建立,就不能保证:
context.command == record.command == carrier.command
第二版虽然有 reservation 概念,但还没有把 atomic create-and-bind 作为生产路径的不变量。
2.4 运行上下文仍依赖全局 active fields
许多 native stub、JNI router 或环境模拟逻辑需要知道当前执行的 command、origin 和 detail。第二版往往通过全局字段或 System property 暴露:
activeCommand = nextCommand;
activeOrigin = nextOrigin;
System.setProperty("runtime.command", String.valueOf(nextCommand));
问题是 caller 创建了 B,不等于 B 已经取得 backend admission。A 可能仍在 guest 中运行。如果 B 在提交时就写全局字段,A 的 JNI stub 会突然读到 B 的上下文。
ThreadLocal 也不是完整答案,因为 guest task、backend owner 和 Java submitter 不一定运行在同一个宿主线程上。运行上下文必须绑定 logical admission,而不是绑定“最近一次谁写了变量”。
2.5 返回值与终态仍可能来自两条路径
第二版虽然定义了 CommandResult,但 caller 仍可能这样工作:
CommandResult before = dispatcher.getLastCommandResult();
Number value = callNative(...);
CommandResult after = dispatcher.getLastCommandResult();
CommandResult result = after != before ? after : null;
这里的 value 来自一次 native 调用,result 来自全局槽位。重叠时二者可能不属于同一 generation。
真正的 exact outcome 必须是一个不可拆分的整体:
value
+ terminal
+ command
+ generation
+ original submitter
任何一项无法匹配,都不应构造成功 outcome。
2.6 异常可能被兼容路径包装成“成功返回 -1”
传统兼容路径经常在 native exception 后返回 -1:
catch (RuntimeException e) {
log.warn("native call failed", e);
return -1;
}
随后 scheduled task 看到一个非空 Number,可能把它记录为:
COMPLETED value=-1
这样 caller 会认为命令自然完成,只是返回了一个特殊数值。对 tracked invocation 而言,这是错误的终态传播。异常必须成为该 invocation 自己的 FAULT,不能借用 legacy 数值返回通道。
三、最终方案:Invocation-Owned Runtime
最终重构不再继续增加全局 gate、优先级或 timeout 例外,而是改变系统的核心抽象:
Task 负责“代码在哪里继续执行”,Invocation 负责“这次调用是谁、拥有什么、最终结果交给谁”。
3.1 六条架构不变量
最终方案以六条不变量为设计起点。
不变量一:同一时刻只有一个 backend 驾驶者
Unicorn backend 不是可重入服务。任何时刻只有一个 owner 可以调用 emu_start() 或继续 guest 执行。其他 submitter 必须通过 handoff 请求获得 logical admission。
不变量二:每次调用都有独立身份
调用身份至少由以下字段构成:
carrier task identity
command
generation
original submitter thread identity
command number 只是业务类别,不是调用身份。
不变量三:运行上下文只属于 ADMITTED invocation
创建、绑定或排队都不能让 context 对 guest runtime 可见。只有取得 logical admission 的 invocation 才能投影 context;暂停和终态必须立即撤销。
不变量四:value 与 terminal 必须同源
exact outcome 只能由 record 自己保存的 terminal 构造。不得从全局 last-result 补 identity,也不得把 caller 看到的 value 和另一个槽位的 terminal 拼起来。
不变量五:JNI local reference 跟随 invocation 生命周期
每次 tracked 调用拥有独立 local-reference scope。scope 只有在 carrier 已退役且 caller 已解析 outcome 后才释放。
不变量六:非终态永远不能伪装成终态
PARKED、YIELDED、BACKEND_STOP 和 HANDOFF_REQUIRED 都是交通状态,不是 command terminal。TIMEOUT、FAULT、CANCELLED 也不能被转换为 COMPLETED。
3.2 三层模型
最终架构可以拆成三层。
调用层 Invocation
command / generation / submitter / context / terminal / refs
↓
桥权层 Admission & Capability
FIFO request / exact safe point / suspend / resume / owner session
↓
任务层 Task Scheduler
timeslice / context save-restore / futex / flat round-robin
三层的职责必须严格分离:
- Task scheduler 不猜业务 command;
- Invocation 不直接改变 FUTEX 或 picker;
- Capability 只管理 backend 驾驶权,不制造业务完成;
- Context projection 不参与 terminal 决策;
- Reference scope 不决定调度顺序。
最终方案的三层关系如下:
最下层仍然保留第一版文章中的 native timeslice 和 Task 调度器;最终版不是推翻第一版,而是在它之上补齐调用所有权和生命周期。
3.3 通行证模型
用桥和车的比喻,可以把主要对象映射如下:
| 架构对象 |
比喻 |
职责 |
| Native timeslice |
红绿灯 |
定期让车辆停到安全位置 |
| Backend capability |
桥梁驾驶权 |
保证同一时刻只有一个执行者 |
| CommandInvocationRecord |
独立通行证 |
记录车辆身份、状态和回执 |
| Generation |
通行证流水号 |
区分同一 command 的多次调用 |
| Handoff FIFO |
桥头排队车道 |
防止后来的请求插队 |
| HandoffYield |
交警签发的让行凭证 |
证明旧 owner 在哪个 safe point 让路 |
| CommandInvocationContext |
车辆货单 |
只在车辆真正上桥时可见 |
| CommandInvocationOutcome |
出桥回执 |
value 与 terminal 的不可拆分结果 |
| InvocationReferenceScope |
独立行李舱 |
隔离 JNI local references |
四、具体代码实现
这一章按照代码落点说明最终方案如何实现。示例保留核心逻辑,省略日志、兼容分支和非关键参数。
4.1 用强类型结果分离终态与交通状态
首先要避免一个枚举同时表达“命令是否结束”和“为什么暂停”。最终结果模型把 State 与 Kind 分开:
public final class CommandResult {
public enum State {
COMPLETED,
FAULT,
TIMEOUT,
CANCELLED,
NON_TERMINAL
}
public enum Kind {
VALUE,
NULL_VALUE,
FAULT,
TIMEOUT,
OPERATION_CANCELLED,
PARKED,
YIELDED,
BACKEND_STOP,
HANDOFF_REQUIRED
}
private final State state;
private final Kind kind;
private final Number value;
private final CommandOwnership ownership;
}
State 回答“是否已经形成 command terminal”,Kind 回答“具体发生了什么”。
public boolean isCommandTerminal() {
return state == State.COMPLETED
|| state == State.FAULT
|| state == State.TIMEOUT
|| state == State.CANCELLED;
}
public boolean isYielded() {
return state == State.NON_TERMINAL && kind == Kind.YIELDED;
}
这个拆分非常重要。若把 BACKEND_STOP 放进 completed 状态,handoff 会被错误地包装成自然返回;若把 CANCELLED 当 TIMEOUT,则一次宿主观察结束会被误写成 guest 生命周期失败。
4.2 CommandOwnership:结果必须携带调用身份
public static final class CommandOwnership {
private final int command;
private final long generation;
private final long submitterThreadId;
private final String submitterThread;
}
结果匹配不能只比较 command:
public boolean isForInvocation(
int command,
long generation,
long submitterThreadId) {
return ownership != null
&& ownership.command == command
&& ownership.generation == generation
&& ownership.submitterThreadId == submitterThreadId;
}
这样即使两个调用使用相同 command,也不会串槽。
4.3 CommandInvocationRecord:一次调用的一等对象
最终 record 聚合一次调用的全部关键身份:
public final class CommandInvocationRecord {
public enum State {
RESERVED,
BOUND,
ADMITTED,
SUSPENDED,
TERMINAL,
CANCELLED
}
private final int command;
private final long generation;
private final Thread submitterThread;
private final String origin;
private final CommandInvocationContext context;
private final CommandInvocationReferenceScope referenceScope;
private final long deadlineMillis;
private volatile Task carrier;
private volatile CommandResult terminalResult;
private volatile State state;
}
为什么这些字段必须在一个对象里?因为它们的生命周期一致:
- record 创建时确定 generation 和 submitter;
- bind 时确定 carrier;
- admit 时 context 可见;
- terminal 时结果锁定;
- carrier retirement 与 outcome acknowledgement 共同关闭 reference scope。
如果把它们拆进多个全局 Map,系统只能靠约定维持一致,很难形成可验证的不变量。
4.4 Context 与 command 在构造阶段强制一致
CommandInvocationRecord(
int command,
Task carrier,
Thread submitterThread,
CommandInvocationContext context,
long generation) {
if (context.getCommand() != command) {
throw new IllegalArgumentException(
"context command does not match invocation command");
}
this.command = command;
this.carrier = carrier;
this.submitterThread = submitterThread;
this.context = context;
this.generation = generation;
this.state = carrier == null ? State.RESERVED : State.BOUND;
}
carrier 创建后还要检查 guest argument 中的 command:
Integer carrierCommand = scheduledCommandArg(task);
if (!Integer.valueOf(command).equals(carrierCommand)) {
throw new IllegalArgumentException(
"carrier command does not match invocation command");
}
最终建立三个相等关系:
context.command
== record.command
== carrier command argument
错误在进入 backend 前失败,比运行几秒后再从异常日志猜 command 污染可靠得多。
4.5 原子 create-and-bind 消除 reservation 窗口
对于已能创建 carrier 的 active mailbox 路径,不再先 reserve 再 bind,而是在 dispatcher 同一把锁中完成:
public CommandInvocationRecord createBoundCommandInvocation(
int command,
CommandInvocationContext context,
Task carrier,
long deadlineMillis,
CommandInvocationReferenceScope referenceScope) {
validateCommandContextAndCarrier(command, context, carrier);
synchronized (this) {
if (recordsByCarrier.containsKey(carrier)) {
throw new IllegalStateException(
"carrier already has a command invocation");
}
CommandInvocationRecord record =
new CommandInvocationRecord(
command,
carrier,
Thread.currentThread(),
context,
++invocationGeneration,
referenceScope);
recordsByCarrier.put(carrier, record);
recordsByCommand
.computeIfAbsent(command, ignored -> new ArrayList<>())
.add(record);
notifyAll();
return record;
}
}
这里 carrier identity 是权威索引:
private final Map<Task, CommandInvocationRecord> recordsByCarrier =
new IdentityHashMap<>();
private final Map<Integer, List<CommandInvocationRecord>> recordsByCommand =
new HashMap<>();
recordsByCommand 只负责查询候选集合,不再假设一个 command 只有一个 record。真正完成、fault 和 terminal 消费优先按 carrier 找到精确 record。
4.6 Handoff 请求使用 FIFO,而不是全局布尔 flag
private final Deque<CommandInvocationRecord> handoffRequests =
new ArrayDeque<>();
public boolean requestCommandHandoff(
CommandInvocationRecord invocation,
String operation) {
synchronized (this) {
if (invocation.getSubmitterThread() != Thread.currentThread()) {
return false;
}
if (!invocation.requestHandoff(
operation, ++handoffRequestSequence)) {
return false;
}
handoffRequests.addLast(invocation);
notifyAll();
return true;
}
}
只有 FIFO head 可以 admitted:
public boolean admitCommandHandoff(
CommandInvocationRecord invocation) {
synchronized (this) {
if (admittedInvocation != null) {
return false;
}
if (handoffRequests.peekFirst() != invocation) {
return false;
}
if (!invocation.admitHandoffRequest()) {
return false;
}
handoffRequests.removeFirst();
admittedInvocation = invocation;
contextProjection.activate(invocation.getContext());
notifyAll();
return true;
}
}
FIFO 解决的是 logical admission 顺序,不等同于 Task 调度器的 round-robin。一个 invocation 一旦 admitted,普通时间片轮转不会释放它的调用级身份。
4.7 用 HandoffYield 锁定真实 safe point
仅仅收到 EMU_STOP 还不够。系统必须证明:旧 owner 在当前 owner session 的某个真实 safe point 保存了现场,并同意让路。
public static final class HandoffYield {
private final Task ownerTask;
private final long ownerSessionGeneration;
private final long safePointSequence;
private final String operation;
private final CommandResult result;
}
记录凭证时进行精确检查:
synchronized boolean recordHandoffYield(
Thread requester,
Task requesterCarrier,
Task ownerTask,
long ownerSessionGeneration,
long safePointSequence,
CommandResult result) {
if (requester != submitterThread) {
return false;
}
if (requesterCarrier != carrier) {
return false;
}
if (!result.isYielded()) {
return false;
}
if (!result.isForInvocation(
command, generation, submitterThread.getId())) {
return false;
}
handoffYield = new HandoffYield(
ownerTask,
ownerSessionGeneration,
safePointSequence,
operation,
result);
return true;
}
在真正 suspend A 时,再次校验 owner session 和 safe-point sequence:
if (yield.getOwnerSessionGeneration() != session.generation) {
return false;
}
if (yield.getSafePointSequence() != session.safePointSequence) {
return false;
}
contextProjection.deactivate(admitted.getContext());
admitted.suspendAdmission();
admittedInvocation = null;
这防止旧 handoff 凭证在新的 owner session 中被重复使用。
4.8 Backend stop 只用于回到 Java safe point
新 requester 只有在以下条件同时成立时才请求 emu_stop():
- requester 是 FIFO head;
- 当前 backend 有 foreign owner;
- 当前 owner 本身属于一个 admitted invocation;
- 同一个 owner 尚未被本 requester 请求过 stop。
if (handoffRequests.peekFirst() == invocation
&& admittedInvocation != null
&& admittedInvocation.getSubmitterThread() == owner
&& owner != stopRequestedForOwner) {
emulator.getBackend().emu_stop();
stopRequestedForOwner = owner;
}
emu_stop() 返回不生成 COMPLETED。它只促使 owner 的 emu_start() 回到 Java,随后 dispatcher 才能:
save guest context
issue exact HandoffYield
suspend old admission
admit FIFO head
4.9 Immutable CommandInvocationContext
public final class CommandInvocationContext {
private final int command;
private final String origin;
private final String detail;
private final String contextKey;
private final Map<String, String> projectedProperties;
}
构造后不允许修改:
this.projectedProperties = Collections.unmodifiableMap(
new LinkedHashMap<>(projectedProperties));
context 的可见性由 admission 控制,而不是由 caller 写入时间控制:
public CommandInvocationContext getAdmittedCommandContext() {
CommandInvocationRecord invocation = admittedInvocation;
return invocation != null
&& invocation.getState() == State.ADMITTED
? invocation.getContext()
: null;
}
因此:
B 已创建,但 A 仍 ADMITTED -> runtime reader 仍读 A
A SUSPENDED -> runtime reader 回到 legacy fallback
B ADMITTED -> runtime reader 读 B
B TERMINAL -> B context 撤销
A 显式恢复 -> runtime reader 再读 A
4.10 Legacy System property 只作为投影适配器
现有项目可能已经有大量 System.getProperty() reader,无法一次性全部改造。可以使用一个严格的 projection adapter:
public synchronized void activate(
CommandInvocationContext context) {
if (activeContext != null) {
throw new IllegalStateException(
"command context already active");
}
Map<String, String> previous = new LinkedHashMap<>();
for (Map.Entry<String, String> entry
: context.getProjectedProperties().entrySet()) {
previous.put(entry.getKey(),
System.getProperty(entry.getKey()));
setOrClear(entry.getKey(), entry.getValue());
}
previousValues = previous;
activeContext = context;
}
撤销时精确恢复之前的“存在状态和值”:
public synchronized void deactivate(
CommandInvocationContext context) {
if (activeContext != context) {
throw new IllegalStateException(
"command context is not active");
}
for (Map.Entry<String, String> entry
: previousValues.entrySet()) {
setOrClear(entry.getKey(), entry.getValue());
}
previousValues = Collections.emptyMap();
activeContext = null;
}
它不是新的全局状态源,而是 legacy reader 的迁移桥。长期目标仍然是让行为代码通过 admitted context reader 取值。
4.11 Context 不进入 JNI ABI
context 是 Java-side ownership token,不应该塞进 guest Object... args,否则会改变 native ABI。
调用链采用 overload 显式传递:
public JniInvocationOutcome<?> callStaticJniMethodObjectOutcome(
CommandInvocationContext context,
Emulator<?> emulator,
String method,
long timeout,
TimeUnit unit,
Object... args) {
CommandInvocationOutcome outcome = callJniMethodOutcome(
emulator,
vm,
this,
context,
method,
timeout,
unit,
args);
return resolveInvocationOutcome(outcome);
}
底层仍按原 JNI positional ABI 编组:
x0 = JNIEnv
x1 = jclass / jobject
x2... = 原有 native arguments
CommandInvocationContext 只附着到 scheduled mailbox record,不进入 guest 寄存器。
4.12 Exact CommandInvocationOutcome
public final class CommandInvocationOutcome {
private final Number value;
private final CommandResult result;
private final int command;
private final long generation;
private final CommandInvocationReferenceScope referenceScope;
}
构造器必须拒绝任何拼接结果:
public CommandInvocationOutcome(
CommandInvocationRecord invocation,
CommandResult result) {
if (invocation == null
|| result == null
|| invocation.getTerminalResult() != result
|| !result.isCommandTerminal()
|| !result.isForInvocation(
invocation.getCommand(),
invocation.getGeneration(),
invocation.getSubmitterThread().getId())) {
throw new IllegalArgumentException(
"result does not belong to command invocation");
}
this.value = result.getValue();
this.result = result;
this.command = invocation.getCommand();
this.generation = invocation.getGeneration();
this.referenceScope = invocation.getReferenceScope();
}
这里使用对象 identity:
invocation.getTerminalResult() == result
即使另一个结果拥有完全相同的 command、generation 和 submitter,只要它不是 record 实际保存的 terminal,也不能冒充本次 outcome。
4.13 Terminal 完成只写入一次
synchronized boolean complete(CommandResult result) {
if (!result.isCommandTerminal()) {
return false;
}
if (terminalResult == null) {
terminalResult = result;
state = State.TERMINAL;
}
return true;
}
dispatcher 完成 record 时统一补 ownership:
result = result.withCommandOwnership(
record.getCommand(),
record.getGeneration(),
record.getSubmitterThread().getId(),
record.getSubmitterThread().getName());
record.complete(result);
后续重复 terminal producer 只能得到已有 terminal,不得覆盖:
if (record.getTerminalResult() != null) {
return record.getTerminalResult();
}
这关闭了 watchdog TIMEOUT、natural completion 和 fault retirement 之间的双发布竞争。
4.14 Invocation-scoped JNI local references
先定义一个与具体 VM 解耦的生命周期接口:
public interface CommandInvocationReferenceScope {
void bindToCarrier();
void markCarrierRetired();
void acknowledgeOutcome();
void discardUnbound();
boolean isClosed();
}
Android DVM 提供具体实现:
final class InvocationLocalReferenceScope
implements CommandInvocationReferenceScope {
private final BaseVM vm;
private final Map<Integer, ObjRef> references =
new HashMap<>();
private boolean bound;
private boolean carrierRetired;
private boolean outcomeAcknowledged;
private boolean closed;
}
tracked JNI 参数不再进入 VM-wide map:
private static void addPreparedLocalObject(
VM vm,
CommandInvocationReferenceScope scope,
DvmObject<?> object) {
if (scope == null) {
vm.addLocalObject(object); // legacy 顺序路径
return;
}
((BaseVM) vm).addInvocationLocalObject(scope, object);
}
guest JNI 运行时也根据当前 running carrier 找到 scope:
private InvocationLocalReferenceScope
currentInvocationReferenceScope() {
CommandInvocationReferenceScope scope =
dispatcher.getRunningCommandInvocationReferenceScope();
return scope instanceof InvocationLocalReferenceScope
? (InvocationLocalReferenceScope) scope
: null;
}
因此 addLocalObject() 和 getObject() 自动路由:
public int addLocalObject(DvmObject<?> object) {
InvocationLocalReferenceScope scope =
currentInvocationReferenceScope();
if (scope != null) {
return scope.addLocalObject(object);
}
return addObject(object, false, false);
}
4.15 Reference scope 的双闩锁释放
真正释放必须同时满足:
carrierRetired == true
outcomeAcknowledged == true
private void releaseWhenReady(
boolean retired,
boolean acknowledged,
boolean unbound) {
Map<Integer, ObjRef> released;
synchronized (this) {
if (closed) {
return;
}
carrierRetired |= retired;
outcomeAcknowledged |= acknowledged;
if (unbound && bound) {
return;
}
if (!unbound
&& (!carrierRetired || !outcomeAcknowledged)) {
return;
}
closed = true;
released = new HashMap<>(references);
references.clear();
}
vm.deleteInvocationLocalRefs(released);
}
为什么两个条件缺一不可?
- 只有
carrierRetired:Java 还没通过 native handle 解析返回对象;
- 只有
outcomeAcknowledged:carrier 可能因 TIMEOUT 返回给 caller,但 guest 仍未真正退役;
- 两者都满足:guest 不再使用参数,caller 也已经解析结果,可以安全执行
onDeleteRef()。
4.16 DVM outcome 先解析对象,再 acknowledge
CommandInvocationReferenceScope scope = null;
try {
CommandInvocationOutcome outcome = callJniMethodOutcome(...);
scope = outcome.getReferenceScope();
Number handle = outcome.getValue();
DvmObject<?> value = handle == null
? null
: vm.getInvocationObject(scope, handle.intValue());
return new JniInvocationOutcome<>(value, outcome);
} finally {
if (scope != null) {
scope.acknowledgeOutcome();
}
}
这与旧的 finally { vm.deleteLocalRefs(); } 有本质区别:旧代码清空整个 VM 的 local refs,新代码只确认本 invocation 的 outcome 已解析;实际释放仍等待 carrier retirement。
4.17 Tracked RuntimeException 发布 exact FAULT
在 emulator 的 RuntimeException 处理路径中,先判断当前 running task 是否拥有 tracked invocation:
catch (RuntimeException e) {
if (dispatcher.publishCommandInvocationFault(e)) {
set(EMU_REASON_KEY, Reason.FAULT);
set(EMU_FAULT_KEY, e);
return null;
}
return legacyHandleException(e);
}
dispatcher 将 fault 绑定到 carrier:
public boolean publishCommandInvocationFault(
RuntimeException fault) {
Task task = currentRunningTask();
synchronized (this) {
if (task == null || recordsByCarrier.get(task) == null) {
return false;
}
}
pendingFaults.put(task,
CommandResult.fault("tracked backend fault", fault));
return true;
}
原 submitter 后续只消费自己 record 的 pending fault:
CommandResult fault =
consumePendingFaultForInvocation(invocation);
if (fault != null) {
return retireFaultedCarrierAndComplete(invocation, fault);
}
Legacy runtime exception 仍走旧兼容路径,避免一次重构破坏所有历史调用。
4.18 显式 Route,禁止通过运行状态猜路径
是否进入 tracked mailbox 应由 call site 明确声明,而不是根据“dispatcher 当前是否活跃”猜测:
public enum CommandInvocationRoute {
LEGACY_DIRECT,
TRACKED_ASYNC_MAIN_MAILBOX,
TRACKED_ASYNC_WORKER_MAILBOX
}
switch (route) {
case LEGACY_DIRECT:
return invokeLegacyCommand(...);
case TRACKED_ASYNC_MAIN_MAILBOX:
return invokeTrackedCommand(
context,
CommandCarrierPolicy.SCHEDULED_MAIN,
...);
case TRACKED_ASYNC_WORKER_MAILBOX:
return invokeTrackedCommand(
context,
CommandCarrierPolicy.SCHEDULED_WORKER,
...);
}
显式 route 有三个好处:
- initial/direct 调用不会因为 backend 恰好活跃而误入 mailbox;
- worker carrier policy 不需要从 origin、线程名或环境变量推断;
- code review 能直接看到哪些调用被允许参与 tracked overlap。
4.19 持久异步 worker:每条 command 独立 record
真实程序经常使用 persistent single-thread executor:
final class NativeEventWorker implements AutoCloseable {
private final ExecutorService worker =
Executors.newSingleThreadExecutor();
private final CommandExecutor commandExecutor;
}
一个 job 可以包含多条命令:
private void execute(EventJob job) {
for (EventCommand command : job.getCommands()) {
CommandResult result = commandExecutor.execute(command);
if (!isExactCompletedTerminal(command, result)) {
fail(job, command, result);
return;
}
}
}
注意:persistent worker identity 不等于 persistent backend admission。
正确模型是:
同一个 Java worker
command #1 -> generation N -> terminal N
command #2 -> generation N+1 -> terminal N+1
command #3 -> generation N+2 -> terminal N+2
每条 native command 都创建独立 record,worker FIFO 只保证业务提交顺序。不能把整个 job 合并成一个大 record 或长时间独占 backend,否则会抹掉主分支与 worker 分支之间真实存在的 safe-point overlap。
4.20 Durable terminal ledger
调度架构正确之后,还需要保证运行结果可评价。JUnit PASS 只代表测试方法结束,不代表 native transaction 完整。
一个简单可靠的账本使用 UTF-8 JSONL:
final class TransactionTerminalLedger
implements AutoCloseable {
private final ExecutorService writer =
Executors.newSingleThreadExecutor();
private final AtomicReference<IOException> writerFailure =
new AtomicReference<>();
private long nextSequence;
}
创建文件时使用 CREATE_NEW,防止覆盖旧运行:
Files.newBufferedWriter(
file,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE);
每条事件通过单 writer 顺序写入并立即 flush:
public void writeLine(String line) throws IOException {
writer.write(line);
writer.newLine();
writer.flush();
}
典型事件包括:
RUN_START
COMMAND_TERMINAL
TRANSACTION_TERMINAL
RUN_END
完整性判定只看最后一条:
static boolean isCaptureComplete(Path file)
throws IOException {
List<String> lines = Files.readAllLines(
file, StandardCharsets.UTF_8);
return !lines.isEmpty()
&& lines.get(lines.size() - 1)
.contains("\"event\":\"RUN_END\"");
}
如果 writer 失败,finish() 或 close() 必须向测试抛出 IOException,不能出现“控制台日志看起来正常,但权威账本没有落盘”的假成功。
五、完整运行链:一辆车让路,另一辆车通过,再恢复原车
代码结构只有在完整时序中才能看清。下面用 A、B 两次调用说明最终模型。
这张时序图刻意把 emu_stop() 和 natural return 画成两种不同事件:前者只负责把 owner 拉回 safe point,后者才有资格形成 COMPLETED。
5.1 A 首先取得 admission
A caller 创建 immutable context
A createBound invocation
A request handoff
backend 当前空闲
A 成为 FIFO head
A -> ADMITTED
A context projection activate
A carrier 取得 backend capability
A guest 开始执行
此时:
admittedInvocation = A
backendOwner = A.submitter
runtimeContext = A.context
A.state = ADMITTED
5.2 B 在另一个宿主线程提交
B caller 创建自己的 context
B 原子 create-and-bind
B 加入 handoff FIFO
B 仍然只是 BOUND:
B.state = BOUND
runtimeContext 仍然是 A
这是 admitted-only 语义最重要的时刻。B 的 Java caller 已经存在,但 guest 仍在执行 A,所以任何 runtime reader 都必须继续看到 A。
5.3 B 请求 backend stop
B 是 FIFO head,并发现 backend owner 属于已 admitted 的 A,于是调用:
backend.emu_stop();
这不是终态,只是让 A 的 active emu_start() 回到 Java。
5.4 A 在 safe point 暂停
dispatcher 获得当前 owner-session generation 和 safe-point sequence:
save A PC/SP/register context
create exact HandoffYield for B
deactivate A context
A ADMITTED -> SUSPENDED
admittedInvocation = null
A 的 carrier 没有 destroy,reference scope 也没有释放。A 只是暂时离开桥面。
5.5 B admitted 并自然返回
B 成为 FIFO head
B BOUND -> ADMITTED
activate B context
B carrier acquire backend capability
B guest 执行
B natural return
B record stores exact COMPLETED terminal
deactivate B context
B carrier retired
B caller receives CommandInvocationOutcome
B caller resolves DVM return handle
B outcome acknowledged
B reference scope closes
B 的 terminal 必须带有:
B.command
B.generation
B.submitterThreadId
任何全局 last result 都不能替代它。
5.6 A 只能由原 submitter 显式恢复
A 不会被 dispatcher 自动塞回 FIFO。原 submitter 再次请求 admission:
A SUSPENDED -> handoff request
A becomes FIFO head
A -> ADMITTED
activate A context
restore A PC/SP/registers
A guest 从原 safe point 后继续
最终形成可验证序列:
guest runtime context: A -> B -> A
backend owner: A -> B -> A
terminal ownership: B terminal only belongs to B
saved CPU context: A resumes from exact saved point
5.7 为什么不能把整个异步 job 当成一辆超长卡车
假设一个 persistent worker 需要提交六条 native command,同时主分支可能在中间提交一条 command。
错误模型:
worker 获得一次 admission
连续执行六条 command
完成后才释放 backend
这会抹掉真实 overlap,使主分支无法在 worker command 之间取得执行机会。
正确模型:
persistent Java worker identity
-> command 1 独立 invocation
-> command 2 独立 invocation
-> main command 可在 safe point 插入
-> command 3 独立 invocation
-> ...
Java executor FIFO 与 backend handoff FIFO 是两个正交层:
- executor FIFO 保证事件内部顺序;
- handoff FIFO 保证并发 caller 申请 backend 的顺序;
- 每条 command 都拥有独立 generation 和 terminal;
- 任一 command 失败后,executor 停止后续 job,但不会把失败伪装成完成。
六、验证、迁移策略与能力边界
架构重构最危险的地方,是 synthetic 测试通过后立刻宣称复杂业务已经修好。正确做法是分层验证,每层只证明自己的合同。
6.1 第一层:纯状态机测试
应覆盖:
- reservation 只能由 original submitter bind 或 cancel;
- context command 与 carrier command 不一致时拒绝;
- 两个并发 caller 原子 create-and-bind,不暴露 unbound reservation;
- 三个 submitter 按真实请求顺序进入 FIFO;
- 非 FIFO head 不得 admitted;
- stale owner-session token 不得 suspend 当前 invocation;
- stale safe-point sequence 不得 suspend;
- suspended invocation 不会自动恢复;
- same command、different generation 不串槽;
- foreign terminal 不得构造 outcome。
6.2 第二层:真实 Unicorn backend handoff
使用最小 AArch64 guest 程序验证:
A 进入循环并保持可观测寄存器
B 从另一个 Java 线程提交
B 请求 emu_stop
A emu_start 返回
A 保存上下文并 SUSPENDED
B 写入标志并 RET
B 获得 exact terminal
A 原 submitter显式恢复
A 从原 PC/SP/X0 继续并 RET
必须断言:
- cross-thread
emu_stop() 使 active emulation 返回;
- A 的 PC/SP/关键寄存器恢复一致;
- B 执行期间 A 不继续运行;
- B terminal 不污染 A;
- runtime context 现场观察为 A→B→A;
- SUSPENDED A 不被普通 picker 选中。
6.3 第三层:DVM/JNI outcome 测试
应覆盖:
- context 不进入 JNI argument ABI;
- exact DVM API 能返回 value + owned terminal;
- 注入同 command、错误 generation 的 global terminal,不影响本次 outcome;
Object[] 参数在另一个 invocation 完成后仍可读取;
- reference scope 只有双条件满足才释放;
- fake handle 或 JNI RuntimeException 形成 exact FAULT,而不是 COMPLETED
-1。
6.4 第四层:Persistent worker FIFO
应覆盖:
- 同一 Java worker 执行多条命令;
- 每条命令拥有不同 generation;
- worker command 全部使用 worker carrier policy;
- main command 能在 worker 序列中通过真实 safe point handoff;
- main suspend 期间保持现场;
- worker job 不持有一个跨命令的大 admission;
- TIMEOUT、FAULT、CANCELLED 立即停止当前与排队 job。
6.5 第五层:持久终态证据
一次长运行只有满足以下条件才可评价:
文件成功 CREATE_NEW
RUN_START 已写入
每条 command terminal 带 exact ownership
transaction terminal 已写入
最后一条为 RUN_END
writer failure 能让测试失败
如果 stdout 被截断,而 JSONL 没有 RUN_END,结论只能是:
CAPTURE_INCOMPLETE
不能用 JUnit PASS 或日志前半段推断业务成功、失败或架构充分性。
6.6 从旧代码迁移时不要一次性删除 legacy 路径
推荐迁移顺序:
- 保留第一版 timeslice、futex 和 Task 调度;
- 增加 Invocation record,但仅用于新 tracked route;
- 将 exact outcome API 接入 synthetic caller;
- 迁移真正影响运行行为的 context reader;
- 纯 observation reader 暂时保留 legacy fallback;
- 明确哪些 caller 使用 direct route,哪些使用 tracked mailbox;
- 为每个异步 branch 做完整 route closure,禁止只迁移其中一条命令;
- tracked 路径稳定后,再缩减全局 last-result 和 active fields;
- 最后才清理 legacy 单槽、旧 ENV 和历史日志分支。
不建议全仓机械替换 activeCommand。某些读取只是日志标签,某些读取会改变 JNI 返回形状,两者的迁移风险完全不同。
6.7 第一版、第二版与最终版对比
| 维度 |
第一版时间片 |
第二版命令服务 |
最终 Invocation Runtime |
| 核心目标 |
让 Task 轮流运行 |
避免多人同时驾驶 backend |
让重叠调用拥有精确生命周期 |
| 调度单位 |
Task |
Task + command |
Task + Invocation |
| backend 停止 |
timeslice / futex |
stop + owner handoff |
exact safe-point yield |
| backend 权限 |
调度循环隐式拥有 |
owner thread |
OwnerSession + logical admission |
| 调用身份 |
command 或 active field |
command + 若干 generation 槽 |
carrier + command + generation + submitter |
| 请求顺序 |
Task round-robin |
全局 handoff 状态 |
Invocation FIFO |
| 运行上下文 |
全局 active fields |
global/ThreadLocal 混合 |
immutable admitted-only context |
| 返回结果 |
Number/null |
value + global result heuristic |
record-owned exact outcome |
| 异常 |
可能兼容为 -1 |
pending global fault |
invocation-owned FAULT |
| JNI local ref |
VM-wide map |
VM-wide map |
invocation scope |
| scope 释放 |
JNI finally 全清 |
carrier/task 清理 |
carrier retired AND outcome acknowledged |
| timeout |
总 wall time |
watchdog + 多个全局槽 |
exact terminal,operation cancellation 分离 |
| 可评价证据 |
stdout |
stdout + JUnit |
durable terminal ledger |
6.8 这次重构解决了什么
它解决的是 unidbg 单 backend 多线程模拟的通用基础设施问题:
- backend 可以在不同宿主 submitter 之间安全交接;
- 调用可以在真实 safe point 暂停和恢复;
- 同 command 多 generation 不再覆盖;
- caller context 不会在未 admitted 时污染当前 guest;
- value 与 terminal 不再通过全局槽拼接;
- 暂停调用的 JNI handles 不会被其他调用清除;
- RuntimeException 不再被伪装成成功数值;
- 长运行具有明确的完整性判据。
6.9 它没有承诺什么
这套架构没有承诺:
- 自动还原所有目标程序的业务状态;
- synthetic handoff 通过就意味着复杂业务输出正确;
- 单 backend 能获得真机级别的真正并行;
- 任意 timeout 都可以通过延长时间解决;
- 所有 legacy caller 都应该改成 tracked mailbox;
- 一个 persistent worker 应该长期独占 backend;
- 可以用 timeout、null 或
EMU_STOP 冒充自然完成。
多线程基础设施正确,只说明“车辆、桥权、货单和回执没有串错”。车辆为什么没有装上某批货物,仍然可能是更上层的业务状态问题。
七、把工程记忆写进仓库:Agent可继承的协作闭环
我个人是从 AI 时代才开始接触逆向的,分享一点自己的实践心得,并且本文也在ai的帮助下撰写。
如果只把 AI 当成即时问答工具,协作很容易陷入循环。第一轮花很长时间解释背景,第二轮因为上下文增长开始遗忘旧约束,第三轮又提出已经验证失败的方案,几轮之后,历史假设可能被误写成当前事实。代码确实改了不少,但没有人能准确回答每一次修改究竟证明了什么。这并不完全是模型是否会写代码的问题,更关键的是工程有没有建立可继承的记忆系统。聊天记录适合讨论,却不适合作为唯一事实源;它会被截断和压缩,重要信息也会散落在不同轮次中。真正可持续的做法,是把协作所需的状态、证据、禁区和结论写进仓库,让任何一轮新的分析都从同一个基线出发。
因此,我们没有维护一篇包揽所有内容的“万能说明书”,而是让不同文档承担不同职责。 AGENTS.md 保存协作规则,说明进入任务前要读什么、哪些文件能够修改、哪些操作需要额外授权,以及实验产物应放在哪里。它更像施工现场的安全规范,解决的是“应该怎样工作”,而不是“今天具体做到了哪里”。
progress.md 是按时间增长的工程账本。一次实验修改了什么、没有修改什么,观察到了哪些原始现象,结论能够覆盖到哪一层,下一步停在哪里,都应当在这里留下记录。它保存的不是一份经过修饰的成功故事,而是完整的决策轨迹。后来的协作者不必依靠一句“这里以前试过”,而是可以查到当时试了什么、控制条件是否成立,以及为什么那个结果不能继续外推。
experiment_outcomes_and_pivots.md 专门保存负向知识。一般的开发记录喜欢写“最后怎样成功”,却很少认真记录“哪些路为什么不通”。在复杂重构中,失败实验并不是垃圾,它会缩小候选空间。提高优先级、延长 timeout、强制 wake、扩大全局锁或者增加兼容 fallback,这些都是面对并发异常时很容易再次想到的方案。如果历史已经证明它们只能改变表象,转向账本就应明确写出原假设、反证和新的方向。这样,新一轮 AI 在读取状态后,需要提出能够区分新解释的验证,而不是把旧补丁换一个参数再做一遍。
CURRENT_STATE.md 承担的角色更接近总工程师确认后的最新图纸。时间线账本可以不断追加观察,但稳定状态不应随着每次未经复核的实验来回摆动。把“实验记录”和“当前可信结论”分开,是为了防止一条单样本结果立刻改写整个架构叙事。它也明确提醒 AI:发现新的可能性并不等于已经获得修改全局结论的权力。
随着进展账本越来越长,仅靠读取文件末尾也会逐渐丢失阶段脉络,截至今天,我项目的 progress.md 已经30多万行,如果想让一个从来没接触过项目的ai进站了解项目去读这个文件,显然并不可能,所以我们又用 summarization/07 这类按月份和日期组织的文档保存阶段检查点。它们的核心不是压缩当天日志,而是建立一种可继承的推理架构——每篇文献都遵循一套稳定的结构契约:
- 承接链:开篇声明承接哪一篇、覆盖
progress.md 的哪一行区间,让每一轮分析都能精确回到上一轮的停点,而不是从头猜上下文;
- 概览锚点:当日覆盖的实验阶段列表、核心认知转折、以及一句话收口,相当于状态寄存器的快照;
- 主线演进(而非流水账):按认知转折分组叙述,区分"发现了什么"和"它改变了什么假设",避免日记体式的平铺;
- 正向实验:每个 P 编号段记录原假设、修改范围、观察结果和收窄结论,形成可逐层追溯的因果链;
- 等价禁区:每篇日终都会显式列出当日新增和强化的"已证伪/禁止回退方向"。这不是附注,而是架构的关键组成部分——没有它,负向知识就会在下一轮被遗忘,曾经失败过的方案会再次被提出。把这个列表和实验记录一起持久化,是防止路线循环的唯一机制;
- 多层根因:根因不是一句笼统的结论,而是按层次拆解——对照层、当前修复状况层、仍暴露的窄层、以及长期线。每一层独立演进,不因下层修复而自动闭合上层;
- 显式前沿与下一步:每篇结束前写明"当前最窄根因"和排序后的下一步方向,让下一篇可以从精确的前沿而非全局重启;
- 一句话收口:强制性的单句压缩,确保即使只读第一页也能恢复整篇的核心判断。
这些文档组成了一套分层的工程记忆。规则文档负责限制行为,当前状态负责提供稳定基线,进展账本负责保存时间线,转向账本负责阻止路线循环,阶段摘要负责跨越上下文边界,对外文章则负责把内部经验脱敏后重新组织为可传播的方法。它们并不是重复记录同一件事,而是在不同粒度上回答不同问题。新一轮协作也不需要把整个仓库和全部历史一次性塞给 AI,只要从稳定状态、近期进展和相关阶段摘要恢复现场,再根据当前问题有方向地读取源码即可。
有了这套记忆系统,每次开始工作都可以执行同一个“进站”过程。先读取最新状态和历史转向,再对照禁止清单,随后确认本轮问题位于因果链的哪一层,明确允许修改的文件和能够接受的证据,最后才开始分析或实现。完成后,代码通过测试只是第一层检查,还要确认测试究竟证明了什么、哪些结论仍未覆盖,并在明确停点结束。这样做可以抑制一种很常见的冲动:看到某处出现差异,就自动向上游追踪;看到一个 timeout,就马上延长等待;看到一个全局字段,就顺手做全仓替换。每一步都必须先回答,它修复或验证的是因果链中的哪一层。
结语
第一版原生时间片方案解决了 unidbg 多线程模拟最基础的问题:没有稳定 safe point,就谈不上可控调度。它让主任务和 worker 不再完全依赖偶然 syscall 才能轮转,是从补丁式 drain 走向调度基础设施的关键一步。
第二版命令服务进一步意识到 backend 不能被多个宿主线程同时驾驶,也开始区分 timeout、fault、handoff 和 completion。但只要调用身份仍散落在全局 result、command 单槽、active fields 和日志字符串中,重叠调用就没有严格的隔离边界。
最终重构真正改变的是系统的主语:过去所有逻辑都在问“下一个 Task 跑谁”,现在系统还会问“正在运行的是哪一次 Invocation,它为什么有资格进入 backend,它暂停时应保存什么,它的结果和 JNI 引用最终交给谁”。
红绿灯只能让车轮流过桥。要让一座单车道桥长期、安全地承载复杂交通,还需要通行证、流水号、排队规则、货单隔离、交接凭证和不可抵赖的出桥记录。
对于 unidbg 的单后端多线程模拟来说,真正困难的从来不是“多跑几个 worker”,而是让每一次暂停、恢复、返回、超时和失败,都精确地属于正确的那次调用。
而对于长期的人机协作来说,真正困难的也不是让 AI 多生成几段代码,而是让每一次观察、假设、修改、验证和转向,都精确地写入正确的工程记忆,并能被下一轮工作完整继承。
以上是我个人在 unidbg 调度模型上的一点粗浅思考,受限于水平和经验,难免有疏漏或偏颇之处,欢迎读者批评指正。
本文同步发表在:https://bbs.kanxue.com/homepage-1072248.htm
本文仅讨论 unidbg 开源框架自身调度模型的通用架构设计。文中所有命令编号、回调与返回值均为抽象占位符,不对应任何特定程序、接口或业务数据。本文不提供、不指引、不协助绕过任何具体安全防护措施。