Part 2 阅读材料:从 llmrun --stdin 到“请求队列 + 后台 worker”¶
阅读目标¶
- 基于当前作业配套代码中的
llmrun、pipe与进程机制,理解为什么 AI service 很自然会从“用户态直跑”演化成“请求队列 + 后台 worker”; - 能够说明 producer、consumer、请求槽、队列、后台 worker、
wait/query分别承担什么职责; - 为完成
Hw2中与异步接口、阻塞点和结构演化相关的书面分析题做好准备。
阅读范围
本部分主要围绕以下文件展开:
user/llmrun_smol.cuser/llmrun_qwen.cuser/llmrun_support.huser/llmrun_support.ckernel/core/pipe.cuser/sh.c
为了完成 Part 2 题目,下文会在这些代码的基础上再做一层合理抽象,用 ai_submit、ai_wait、ai_query 作为说明记号。它们在本页中是帮助你组织分析的接口,不要求与你后续实验中的最终实现完全一致。
阅读建议
回答 Part 2 时,只需要抓住“为什么会出现队列、worker、阻塞点和异步接口”这条主线,不需要把本页所有示意结构都原样搬进答案。
与 Part 1 阅读材料的衔接
本页默认你已经阅读过 Part 1 阅读材料的 4.4 和 4.5。那两节已经解释了当前 base 中:
- 内核只提供通用 OS 支撑,而不是专门的
ai_service llmrun_*如何装载资产、建立 runtime、接收命令行或--stdin输入
因此,本页不再重复展开 session_init() 的装载细节,而是只抓住这些事实继续往前推:为什么这条路径会自然演化成“请求队列 + 后台 worker”。
1. 先看当前的“用户态直跑”路径¶
在 Part 1 阅读材料的 4.4 / 4.5 基础上,可以把当前最简单的一次推理路径概括成:
这里的“完成初始化”已经包含前一份阅读材料里介绍过的内容,例如:
- 进入
/AI/SMOL或/AI/QWEN; - 装载
CFG.BIN、EMB.BIN、NRM.BIN、Lxx.BIN、ROP.BIN等资产; - 分配
K/V cache、workspace 以及其他运行期缓冲区。
本页不再重复展开这些步骤,而是只保留一个与 Part 2 有关的结论:
当前 base 的确存在一条“用户态直跑”的最小路径,而后续异步服务结构正是从这条路径继续抽象出来的。
2. 为什么 --stdin 已经暴露了“后台 worker”的雏形¶
在不重复前面装载细节的前提下,--stdin 模式只需要抓住下面两件事:
- 初始化很重
资产装载与 cache / workspace 分配都在进程启动阶段完成,因此一次
exec llmrun_*伴随着明显冷启动开销。 - 同一进程可以连续处理多次请求
--stdin模式不是“处理一次就退出”,而是读取一行请求、执行一次、输出一次结果,然后继续等待下一行输入。
把这两点放在一起,就会得到本页最重要的结构性直觉:
如果一个后端初始化成本高、而且可以在同一进程里连续处理多次请求,那么它就天然适合被组织成长期驻留的 worker。
3. 为什么不能长期停留在“同步直跑”¶
如果把 AI 推理始终做成“谁调用,谁同步等到结束”的一次性路径,那么服务结构会面临至少三个问题。
3.1 调用者会被整段计算路径长期占住¶
同步路径通常意味着:
调用者从头到尾都要被这条长路径绑住,中间既不能快速返回,也不方便把“排队”和“执行”两个阶段分开表达。
3.2 冷启动成本会被重复支付¶
如果每条请求都重新:
那么模型装载和缓存分配成本会被重复支付很多次。
而 --stdin 给出的另一种思路是:
这正是“复用”和“长驻”的价值所在。
3.3 同步结构很难表达排队与背压¶
一旦系统同时到来多个请求,就必须回答:
- 谁先执行?
- 请求放在哪里等?
- 满了怎么办?
- 结果何时能被调用者取回?
同步调用不擅长表达这些问题,而“请求槽 + 队列 + worker”正是更自然的系统答案。
4. 从当前路径抽象出异步 AI service¶
为了回答 Part 2 题目,可以把当前可见的 llmrun --stdin 路径进一步抽象成下面这组接口:
int ai_submit(const uint32 *tokens, int token_count, int predict_count);
int ai_wait(int reqid, char *out, int out_cap);
int ai_query(int reqid, struct ai_status *st);
一个合理的系统链路可以表示为:
调用进程
-> ai_submit(...)
-> 在请求槽中登记元数据
-> 把请求槽编号压入循环队列
-> 后台 worker/ai_daemon 取出请求
-> 执行 llmrun 或复用长驻 runtime
-> 写回结果与状态
-> ai_wait(...) / ai_query(...)
这三类接口的职责可以先粗略理解成:
ai_submit负责把一个请求放进系统,并尽快返回一个reqidai_wait负责等待某个具体请求完成,并在成功时取回结果ai_query负责非阻塞地观察这个请求当前处于什么状态
这一步抽象的关键
当前代码已经说明:请求可以被长期驻留的进程处理,输入可以通过字节流送达,结果可以晚于提交时刻才产生。把这三点合在一起,最自然的接口就不再是“同步直跑”,而是“submit + queue + worker + wait/query”。
5. 请求槽和循环队列分别负责什么¶
如果要把“排队”和“执行”分开,就至少需要两类对象:
- 请求槽数组
- 循环队列
下面是一种只用于理解结构的示意:
struct ai_request_preview {
int reqid;
int owner_pid;
int state;
int err;
int result_len;
};
struct ai_service_preview {
struct spinlock lock;
int q[AI_NREQ];
int qhead;
int qtail;
int qcount;
int worker_pid;
struct ai_request_preview reqs[AI_NREQ];
};
可以这样理解这两类对象的分工:
reqs[]负责保存“某个请求是谁提交的、现在是什么状态、结果是否可取”等元数据q[]负责保存“哪些请求还在等待 worker 处理”qhead / qtail / qcount负责管理一个有界循环队列
因此:
- 请求槽承担的是“请求对象本身”的存放;
- 循环队列承担的是“等待执行顺序”的管理。
这正是 Part 2 任务 A 里要求你画出来的两个不同层次。
6. 把 pipe 类比到 AI service¶
kernel/core/pipe.c 是理解这套结构最好的参照物之一。它告诉你:
- producer 可以把数据放进有界缓冲区;
- consumer 可以从缓冲区取数据;
- 缓冲区满时,producer 睡眠;
- 缓冲区空时,consumer 睡眠。
把它类比到 AI service,可以得到下面这张映射表:
pipe 中的角色 |
AI service 中的角色 |
|---|---|
pipewrite |
ai_submit |
piperead |
worker 从队列取请求 |
data[] |
请求槽编号队列 q[] |
| 写端 producer | 调用进程 |
| 读端 consumer | 后台 worker |
| 缓冲区满则阻塞写者 | 队列满或无空槽时阻塞提交者 |
| 缓冲区空则阻塞读者 | 队列空时阻塞 worker |
需要特别注意的一点是:
pipe传的是字节流;- AI service 传的更像是“请求槽编号”或“请求对象引用”。
但它们的并发骨架是相同的,都是 producer-consumer。
7. 至少三类阻塞点是怎么出现的¶
一旦系统从“同步直跑”变成“异步提交 + 后台处理”,阻塞点就至少会分成下面三类。
| 阻塞者 | 它在等什么 | 谁负责唤醒 | 为什么会出现 |
|---|---|---|---|
ai_submit 调用者 |
等待空槽,或等待队列可继续入队 | worker 完成旧请求后释放槽位,或从队列取走请求后腾出空间 | 系统资源是有界的,不能无限接收请求 |
| 后台 worker | 等待队列非空 | 新的提交者把请求压入队列后唤醒 | 没有请求时,worker 不应忙等 |
ai_wait 调用者 |
等待某个具体请求变成完成或失败 | worker 写回结果并更新状态后唤醒 | 结果不是提交瞬间就能拿到的 |
如果把它和 sleep() / wakeup() 一起看,会更清楚:
- 有界资源会自然带来“等空位”;
- 后台消费者会自然带来“等非空”;
- 单个请求生命周期会自然带来“等完成”。
这正对应 Part 2 任务 B 中的三种典型方向:
- 队列空,worker 无事可做
- 槽位满,新的提交无法进入
- 请求未完成,调用者在
wait中等待
8. 为什么 AI 更适合异步¶
把 AI 推理改成“submit + 后台 worker + wait/query”,核心收益可以概括为下面五点:
- 复用 模型权重、运行时缓冲区和长驻进程可以被多次请求共享
- 解耦 提交请求和真正执行推理可以分成两个阶段,不必绑死在一次同步调用里
- 排队 多个请求到来时,系统可以明确表达“谁先做、谁后做”
- 背压 当槽位或队列满了,系统可以阻塞提交者,而不是无限膨胀内部状态
- 长驻 重的初始化只做一次,冷启动成本可以被多个请求摊薄
因此,对 AI 这类“计算重、状态重、初始化重、执行时间长”的负载而言,异步结构往往比同步直跑更稳定,也更接近真实服务系统的组织方式。
读这页时最值得抓住的直觉
llmrun --stdin 告诉你:同一个进程可以连续处理多次请求。pipe 告诉你:有界缓冲区天然会带来 producer-consumer、睡眠与唤醒。把这两点合起来,就很自然会走向“请求队列 + 后台 worker”的结构。
9. 读到这里,你应该已经能回答的四个问题¶
- 为什么
llmrun --stdin可以视为后台 worker 的雏形 - 为什么同步“用户态直跑”不适合长期 AI 服务
- 请求槽和循环队列分别承担什么职责
- AI service 至少会出现哪些阻塞点,以及它们分别在等什么
如果你已经能说清这四点,就可以进入 Part 2 题目了。