吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 331|回复: 0
上一主题 下一主题
收起左侧

[Android 原创] 从时间片轮转到调用级并发:unidbg 单后端多线程架构重构

[复制链接]
跳转到指定楼层
楼主
mxyoor 发表于 2026-7-15 08:22 回帖奖励

从时间片轮转到调用级并发: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_createfutex_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 调度器根据任务状态决定下一个执行者。

设计原则只有三条:

  1. C 层只做一件事:跑 N 条指令后停下来,告诉上层「为什么停」
  2. Java 调度器只做一件事:根据停止原因决定「下一个跑谁」
  3. 两层之间的接口只有一个枚举值 + 一个 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 不是「立即切换线程」,而是「表明当前任务不可继续执行」。 ThreadContextSwitchExceptionemulate() 向上传播,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

每个任务具有 RUNNABLEWAITINGFINISHED 等状态,调度器按照时间片和 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,会产生三类问题:

  1. 旧 watchdog 到期,把 TIMEOUT 写进新调用;
  2. 新调用 natural return,被旧 submitter 消费;
  3. 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 后才释放。

不变量六:非终态永远不能伪装成终态

PARKEDYIELDEDBACKEND_STOPHANDOFF_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 用强类型结果分离终态与交通状态

首先要避免一个枚举同时表达“命令是否结束”和“为什么暂停”。最终结果模型把 StateKind 分开:

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 有三个好处:

  1. initial/direct 调用不会因为 backend 恰好活跃而误入 mailbox;
  2. worker carrier policy 不需要从 origin、线程名或环境变量推断;
  3. 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 路径

推荐迁移顺序:

  1. 保留第一版 timeslice、futex 和 Task 调度;
  2. 增加 Invocation record,但仅用于新 tracked route;
  3. 将 exact outcome API 接入 synthetic caller;
  4. 迁移真正影响运行行为的 context reader;
  5. 纯 observation reader 暂时保留 legacy fallback;
  6. 明确哪些 caller 使用 direct route,哪些使用 tracked mailbox;
  7. 为每个异步 branch 做完整 route closure,禁止只迁移其中一条命令;
  8. tracked 路径稳定后,再缩减全局 last-result 和 active fields;
  9. 最后才清理 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 开源框架自身调度模型的通用架构设计。文中所有命令编号、回调与返回值均为抽象占位符,不对应任何特定程序、接口或业务数据。本文不提供、不指引、不协助绕过任何具体安全防护措施。

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-16 07:01

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表