跳转至

Part 3 阅读材料:从 wait() 推演所有权、状态查询与一次性结果消费语义

阅读目标

  • wait() 的真实语义出发,理解异步 AI service 为什么必须补上所有权检查、状态查询以及一次性结果消费;
  • 能够把“谁能等待、谁能查询、结果何时回收”这些语义边界映射到最小可行的请求接口设计上;
  • 为完成 Hw2 中与 ai_requestai_statusai_waitai_query 相关的语义设计题做好准备。

阅读范围

本部分主要围绕以下内容展开:

  • kernel/core/proc.c 中的 wait()
  • user/sh.c 中的 fork + exec + wait
  • “长期驻留 worker”这一类异步服务场景

为了完成 Part 3 题目,下文会在这些机制的基础上做一层合理抽象,使用 reqidai_waitai_queryai_status 等记号来说明异步请求语义。你给出的答案不要求与后续实验最终代码完全一致,但必须做到字段选择和状态语义自洽。

阅读建议

回答 Part 3 时,只需要抓住“所有权、状态查询、一次性消费”这三条主线,不需要覆盖本页列出的每一个边界场景。

与前文的分工

Part 1 阅读材料的 4.4 / 4.5 已经介绍了当前 base 的运行路径与 llmrun_* 的初始化方式。本页不再重复这些背景,只把“长期驻留 worker”当作一个应用场景,用来说明为什么异步请求必须补上所有权、状态查询和一次性结果消费语义。

1. 先看 wait() 的核心语义

请阅读 kernel/core/proc.c 中的 wait()。关键片段如下:

int wait(uint64 addr) {
    struct proc *p = myproc();
    ...

    acquire(&wait_lock);
    for (;;) {
        int havekids = 0;

        for (int i = 0; i < NPROC; i++) {
            struct proc *pp = &procs[i];
            if (pp->parent != p) {
                continue;
            }

            havekids = 1;
            acquire(&pp->lock);
            if (pp->state == ZOMBIE) {
                int pid = pp->pid;
                int status = pp->xstate;

                if (addr != 0 &&
                    copyout(p->pagetable, addr, (char *)&status, sizeof(status)) < 0) {
                    release(&pp->lock);
                    release(&wait_lock);
                    return -1;
                }

                freeproc(pp);
                release(&pp->lock);
                release(&wait_lock);
                return pid;
            }
            release(&pp->lock);
        }

        if (!havekids || p->killed) {
            release(&wait_lock);
            return -1;
        }
        sleep(p, &wait_lock);
    }
}

这段代码里,至少有三条可以直接迁移到异步 AI service 的关键语义:

  1. 只能等待“属于自己关系范围内”的对象
  2. 对象未完成时,可以阻塞等待
  3. 成功取走结果后,对象会被回收,而不是永久保留

2. 第一条语义:不是所有人都能等待任何对象

wait() 中最关键的一句是:

if (pp->parent != p) {
    continue;
}

它表示:

当前进程只能等待自己的子进程,而不能拿任意一个 pid 去回收别人的退出状态。

这个边界在 user/sh.c 里非常清楚:

int pid = fork();
if (pid == 0) {
    exec(path, argv);
    exit(127);
}

int status = 0;
wait(&status);

这里的所有权关系是:

  • shell 创建子进程;
  • 子进程执行用户程序;
  • shell 作为父进程负责 wait() 回收它。

把这个思想迁移到 AI service,就会得到一个自然要求:

  • 每个请求都必须知道“是谁提交的”;
  • 后续 wait/query 时,系统必须检查当前调用者是否就是这个请求的拥有者。

也就是说:

知道 reqid 不是权限;拥有这个请求,才是权限。


3. 第二条语义:wait() 是阻塞等待,但它只等“完成”

wait() 做的事情并不是查询各种中间进度,而是:

  • 子进程还没结束时就睡眠;
  • 子进程结束后取走退出状态;
  • 然后完成回收。

因此,wait() 很适合表达“一个对象什么时候整体结束”,但不适合表达“中间进度走到了哪一步”。

如果把这个逻辑迁移到 AI 请求,就会发现一个差别:

  • 子进程状态通常很简单,重点是“活着”还是“退出”
  • AI 请求的生命周期更细,至少可能经历“排队中”“运行中”“完成”“失败”等阶段

这正是异步 AI service 为什么通常还需要一个 ai_query()
因为仅有 ai_wait() 并不足以表达中间状态。


4. 第三条语义:结果成功取走后,应当一次性消费并回收

wait() 的成功路径里,取走退出状态之后会执行:

freeproc(pp);

这说明:

  • 退出结果不是永久快照;
  • 父进程成功消费一次后,子进程对象就应该被回收;
  • 第二次再对同一个对象 wait(),不应继续返回旧结果。

把这个思想迁移到 AI service,会得到完全平行的语义:

  • 请求完成后,拥有者可以成功 ai_wait(reqid, out, cap) 一次;
  • 一旦结果被成功取走,请求槽就应该回收;
  • 同一个 reqid 不能被无限次重复消费。

为什么这一点很重要

如果成功 wait 后还永久保留旧请求,就会带来三类问题:

  • 请求槽迟迟不释放,系统资源会被耗尽
  • 同一结果可能被重复取走,语义变得混乱
  • reqid 和新请求更容易发生混淆

5. 把 wait() 映射到异步 AI request

从系统设计角度,可以把两者做一个很直接的类比:

wait() / 进程语义 AI request 语义
子进程 pid 请求 reqid
父子关系 owner_pid 或等价的所有权信息
ZOMBIE DONE / FAILED
xstate 错误码、结果长度等完成信息
wait() ai_wait(reqid, ...)
freeproc() 回收请求槽

这张表想说明的核心点是:

  • wait() 不是凭空工作的;
  • 它依赖“对象归属”“对象状态”“成功消费后回收”这三件事同时成立;
  • 异步 AI service 也必须补齐这三件事,接口语义才会完整。

6. 为什么异步 AI service 还必须提供 ai_query()

如果一个请求只是“有”和“没有”两种状态,那么也许只靠 submit + wait 就勉强够用。
但 AI 请求往往更像这样一条生命周期:

enum ai_req_state {
    AIREQ_UNUSED = 0,
    AIREQ_QUEUED,
    AIREQ_RUNNING,
    AIREQ_DONE,
    AIREQ_FAILED,
};

此时,调用者经常需要知道的并不是“立刻取结果”,而是:

  • 还在排队,还是已经开始运行?
  • 是已经成功完成,还是已经失败?
  • 当前是否已经有结果可以等待取回?

这就是 ai_query() 的职责:

  • ai_query 是观察 只返回状态,不消费结果
  • ai_wait 是消费 等待完成,并在成功时取走结果

把这两者区分开,接口语义才会清楚。


7. 一种最小可行的字段设计

Part 3 任务 A 不是要你抄写唯一标准答案,而是要你给出一组最小但自洽的字段。下面是一种压缩到较少字段的设计示意。

7.1 ai_request 元数据字段

字段名 用途
reqid 请求的唯一标识,供后续 wait/query 查找
owner_pid 记录这个请求属于谁,用于所有权检查
state 记录请求当前处于排队、运行、完成还是失败
err 失败时保存错误码,成功时可为 0
result_len 结果长度,便于 wait/query 判断输出规模

7.2 ai_status 对外可见字段

字段名 用途
state 告诉调用者当前生命周期阶段
err 如果失败,告诉调用者失败原因
result_len 如果已完成,告诉调用者结果规模

为什么这里没有再放 reqid

ai_query(reqid, st) 的调用者本来就已经持有 reqid,因此在“最小设计”里,ai_status 不再重复携带它也是合理的。如果你希望保留 reqid 作为调试字段,也可以,只要能解释其必要性。


8. 一组自洽的 query / wait 语义表

下面这张表几乎可以直接作为 Part 3 任务 C 的参考骨架。

场景 期望行为 理由
提交者在请求尚未完成时调用 ai_query(reqid, st) 返回成功,并在 st 中写入 QUEUEDRUNNING 等状态;不消费结果 query 的职责是观察状态,而不是取走结果
提交者在请求完成后第一次调用 ai_wait(reqid, out, cap) 阻塞结束后成功返回,复制结果,并回收请求槽 第一次 wait 才是合法的结果消费时刻
同一个提交者第二次对同一 reqid 调用 ai_wait(...) 返回失败 结果已经被成功消费,请求对象已经回收或标记失效
其他进程或不具有所有权的子进程拿到 reqid 后调用 ai_query(...) 返回失败 知道 reqid 不等于拥有访问权限
请求已经被成功 wait 取走后,再次 ai_query(reqid, st) 返回失败 请求对象已经不再有效,旧状态不应被继续暴露

这张表背后的核心原则其实只有三条:

  1. 有所有权边界
  2. query 只观察,wait 才消费
  3. 成功消费后,请求对象应被回收

9. 回到 Hw2,应该抓住哪三个核心问题

如果你要回答 Part 3,最重要的不是记住某个固定结构体,而是先把下面三件事想清楚:

  1. 为什么异步请求必须记录“属于谁”
  2. 为什么异步请求不能只有 wait,还需要 query
  3. 为什么结果成功取走一次后,请求对象应当被回收

只要这三件事讲清楚,你给出的字段和语义表通常就会比较稳固。


10. 读到这里,你应该已经能回答的四个问题

  1. 为什么一个 AI 请求必须记录所有权信息
  2. 为什么“知道 reqid”不等于“有权限 wait/query
  3. 为什么 ai_queryai_wait 不能混成同一种操作
  4. 为什么成功 wait 一次后,请求对象通常应被回收

如果你已经能说清这些问题,就可以进入 Part 3 题目了。