## 中断子系统拆分(用户按 OS 中断语义三次裁定的最终边界) 中断类比 OS:只提供中断优先级、中断队列、现场保护、中断屏蔽。 调度器建立在其之上——它问"中断侧该出谁",然后执行那个任务。 新增 internal/agent/core/interrupt.go(303 行),承载: - 中断队列(interruptQueues[5] + immediate 单槽) - 中断栈(suspendStack)—— **唯一操作者** - 中断屏蔽(critical,对应关中断) - 让位信号原语(preemptArmed/preemptLevel) 留在 scheduler.go 的是**策略**: - 任务优先级语义(Level / effectiveLevel / canPreempt / clampPluginLevel) - 恢复决策(nextInterruptSelection:栈顶 vs 中断队列比较) - 就绪队列 / 停止配额 / 背压 ### 关键设计:中断栈只有中断管理器能操作 调度器通过 stackTopLocked() **只读查询**栈顶做比较, 真正的弹栈由 restoreTopLocked() 在中断管理器内完成。 这条不变量在类型层面无法绕过。 ### 解耦机制:stackNotify 钩子 中断栈变化时通告调度器,调度器据此唤醒调度循环重新求值。 中断管理器不知道调度器会怎么选,调度器也不碰栈内部 ⇒ 将来给中断子系统加向量表或换多栈策略,不必改调度器。 ### 测试 新增 interrupt_invariant_test.go:I1–I7 共 11 条**不变量**判据 (区别于既有的行为测试)。两条经变异验证确认会变红: - canPreempt 改成恒 true → L4 不抢 L4 判据红 - maxInterruptFrames 改成 99 → 上界判据红 - 去掉 notifyStackLocked → 钩子判据红(且判据本身带超时,缺判据是"红"不是"挂") ## 修两处真实数据竞争(阻塞 CI,非本次引入) -race 实测:internal/agent/core 原有 30 个测试失败,整包被判红。 根因(ab27d1a 上已存在,与拆分无关): - core/stages.go:withExecuteCtx 往调用方共享的 tc.Arguments 原地写 ctx - io/channel.go:bindExecCtx 同样原地写设备 args 原注释称"args 每次调用独立、天然一 goroutine 一份",但代码改的是调用方那份。 工具在自己的 goroutine 里跑,超时/取消路径下主 goroutine 仍在读同一份 args。 修法:复制 map 而非原地写。变异验证:退回原地写后 TestPreempt_DeferredDuringToolExec 立刻报 DATA RACE。 ## 验证 - go build ./... 通过 - go test -p 1 ./... 全仓无失败 - -race 三关键包(core/io/proc)全绿,core 从 30 红归零 - CI 门禁自检 / 启动图判据 / 版本一致性 本地通过 ## 遗留(不在本次) - 中断向量表(计划 §7 已设计,按 §7.6 应在拆分后单独做) - maxQueue 仍约束中断队列:OS 语义上说不通,但改它是行为变更,单独立项
26 KiB
中断子系统独立化 —— 探测结论与实施计划
状态:计划(未实施) 起因:中断语义散落在
scheduler.go一个 1476 行的文件里,且入口横跨 5 个文件。 修改任何一条中断规则(级别比较、抢占条件、栈深度)都要在多处同步改,漏一处的后果是静默的。
一、探测结论(实测,非推演)
1.1 现状:中断没有独立子系统
type scheduler struct(scheduler.go:330)一个结构体同时承担四件事:
| 职责 | 字段 | 归属应为 |
|---|---|---|
| 就绪队列调度 | queue / running / maxQueue / backpressured |
调度器 |
| 中断分级排队 | interruptQueues[5] |
中断子系统 |
| 抢占与让位 | immediate / preemptArmed / preemptLevel / critical |
中断子系统 |
| 中断栈(嵌套挂起) | suspendStack / maxInterruptFrames |
中断子系统 |
| 用户停止配额 | cancelBudget / stopArmed |
中断子系统(是"打断"的一种) |
1.2 中断逻辑的实际分布
| 文件 | 中断相关职责 | 行数量级 |
|---|---|---|
scheduler.go |
队列/抢占/栈/让位/级别语义(主体) | 1476 行中约 205 处引用 |
eventloop.go |
中断注入路径 | 少量 |
overflow.go |
raiseKernelInterrupt 的一种来源 |
1 处 |
resident.go |
子 contextfull → 父 L4 | 1 处 |
task.go |
安全点让位判定 | 1 处 |
1.3 最能说明问题的证据
scheduler.go:374 那段注释:
链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中),被挂起 4 帧
这是中断子系统的核心不变量(maxInterruptFrames = int(LevelCritical),结构推论、非配置项)。
它现在藏在 1476 行文件的中间,没有任何类型或文件专门承载它。改中断语义的人必须
先读完整份 scheduler.go 才能确认自己没漏。
1.4 关键有利条件(决定拆分方案)
| 探测项 | 结果 | 含义 |
|---|---|---|
| 中断状态字段是否被跨文件直接访问 | 零(interruptQueues/immediate/suspendStack/preemptArmed/cancelBudget/stopArmed 全部只在 scheduler.go 内读写) |
拆分可用同包拆文件方式,无需改包边界、无循环依赖风险 |
Task / Level / TaskClass / SchedulerStats 定义位置 |
scheduler.go(同包) |
可自由移动 |
TaskFrame 定义位置 |
task.go(同包) |
不需移动,同包可见 |
| 外部调用面(非 scheduler.go) | raiseKernelInterrupt 3 处、其余各 1~2 处,共约 15 处 |
面很小,改签名代价可控 |
| 涉及调度器/中断的测试文件 | 16 个 | 安全网足够 |
结论:采用同包拆文件(package core 内新增 interrupt.go 等),不新建子包。
理由:状态字段零跨文件访问 ⇒ 不需要为了封装而改包边界;而同包拆分零循环依赖风险、
零 API 破坏,是纯结构搬迁。
一点五、按 OS 中断语义重新界定边界(2026-10-25 用户裁定)
中断类似操作系统中的中断语义:只提供中断优先级、中断向量、现场保护能力。 调度器建立在中断和其他子系统提供的基础之上。
这把尺子把「中断」与「调度」切得很干净,也让计划里两处归属必须修正:
| 能力 | 归属 | 判据 |
|---|---|---|
| 优先级(Level 定义/比较/clamp) | 中断 | 谁打断谁的唯一依据 |
| 中断向量(哪些待处理、按级别排队、immediate 槽) | 中断 | 待处理集合本身 |
| 现场保护(挂起现场、栈、LIFO、帧数上界) | 中断 | 被中断者的上下文保全 |
| 中断屏蔽(critical 临界区) | 中断 | 对应 CPU 的关中断 |
| 「下一个跑谁」的决策 | 调度器 | 建立在向量之上的选择 |
| 就绪队列、队列配额、转投、背压 | 调度器 | 与"打断"无关 |
| 任务 ID 分配、统计计数 | 调度器/观测 | 不是中断能力 |
🔴 两处必须修正的归属(原计划写错了)
① 停止配额(armStop/takeStop/consumeCancel)→ 归调度器,不归中断
原计划默认归中断,理由是"它也是打断"。按裁定这是错的:
queued := len(s.queue) + pending // ← 操作对象是**就绪队列**
if queued > s.cancelBudget { s.cancelBudget = queued }
它计算的是就绪队列的长度,是队列控制("这批已排队的消息别再跑了"), 既不参与级别比较、也不进中断向量、更不压中断栈。停止不是一次中断, 它是调度器对已排队任务的一次批量取消。
② critical 的定位要拆开看
critical的语义("运行任务处于不可打断状态")属于中断 → 对应关中断setCritical的调用点在任务生命周期里(task.go:316/408)→ 由调度器调用
所以它落在中断子系统的状态里、由调度器设置,与 OS 的 cli/sti 完全同构。
🔴 中断函数目前反向依赖了调度器状态(拆分时要一并处理)
实测三个中断核心函数读了这些非中断字段:
registerInterrupt → s.running s.stats s.signalWake
enqueueInterruptLocked → s.maxQueue s.allocateIDLocked s.stats
setImmediateLocked → s.allocateIDLocked s.stats
按裁定这些都不该由中断子系统拥有:
| 反向依赖 | 正确做法 |
|---|---|
s.running(读当前运行任务做级别比较) |
中断只接受调用方传入的 running;由调度器注入 |
s.stats(统计) |
统计归观测,中断只发事件/回调 |
s.maxQueue(就绪队列上限) |
中断队列不该受就绪队列上限约束;若确有上限应自有常量 |
s.allocateIDLocked(任务 ID) |
ID 归任务创建方(调度器),中断只接收入队 |
s.signalWake |
唤醒调度器是调度器的事;中断登记完由调度器决定是否唤醒 |
这说明当前"中断"与"调度"是双向耦合的,不是单向的"调度建立在中断之上"。 拆分阶段 1 若只搬代码不动这些依赖,只是把耦合搬了个位置。因此计划新增 阶段 1.5:切断反向依赖。
一点六、恢复归属的精确切分(2026-10-25 用户补充裁定)
中断子系统就是只负责中断部分:按优先级打断、被打断的任务压栈,这些由中断子系统负责。 任务恢复也是中断子系统负责。调度器是在中断子系统的基础上构筑的。
这条补充纠正了我计划的一处遗漏:原计划把恢复整体留在了调度器侧。实际上 "恢复"有两个层面,必须分开:
| 层面 | 内容 | 归属 | 判据 |
|---|---|---|---|
| 恢复决策 | 该恢复谁:比较「栈顶 vs 最高级待处理中断」,取高者;弹栈;产出 (task, frame, nextSuspended) |
中断子系统 | 这是"现场保护的逆操作"——压进去的谁、什么时候取出来,属于同一个语义整体 |
| 恢复执行 | resumeTask:rebaseFramePrefix 重建前缀 → runTaskSteps 跑 → finishInputTask → done |
调度器 | 这是在执行一个任务,与"打断/保全"无关 |
决定性的论据:栈的压入(suspend)在中断侧,若弹出与选择留在调度器侧,
那么「LIFO 语义 + 栈顶与中断队列的优先级比较」这条规则就被劈成两半——
改中断语义时又要跨两个子系统改,正是本次重构要消灭的形态。
因此 nextRef 也要按此切分
nextRef 目前是单个函数,它做四件事:
if s.immediate != nil { ... } // ① 中断侧:immediate 优先
qTask, qLevel := s.highestInterruptLocked() // ② 中断侧:最高级待处理
if n := len(s.suspendStack); n > 0 { ... } // ③ 中断侧:栈顶 vs ②取高者
if len(s.queue) > 0 { ... } // ④ 调度侧:就绪队列
①②③ 全是中断语义,④ 才是调度。切法:
- 中断子系统暴露
nextInterruptSelection() (task, frame, sel, ok)—— 内部按 ①②③ 的顺序决策,返回"该中断侧出谁"或"中断侧无货" nextRef变成:先问中断侧;中断侧无货再取就绪队列
这样"谁先跑"的优先级规则只有一处(中断子系统内部),调度器只负责 "中断侧没货时取就绪队列"。
一点七、中断向量系统(本次拆分的真正目的)
独立成中断子模块就是为了方便添加中断向量系统。
7.1 先纠正一处术语误用
我此前把 interruptQueues 称作"中断向量",错了。两者是不同层面的东西:
| 概念 | OS 语义 | 本仓现状 |
|---|---|---|
| 中断向量 | 中断号 → 处理程序入口的映射(IDT) | 不存在 |
| 中断队列 | 待处理中断的排队 | interruptQueues[5][]*Task |
本仓之所以一直不需要向量表:中断任务本身就是 *Task,入队时已带执行体
(Task.Kind 决定走哪条执行路径),不需要查表找入口。
但代价是**"有哪些中断、各自什么语义"散落在调用点**,见下。
7.2 现状:中断源是三个裸调用点,没有统一登记
实测全部 L4 来源都走同一个函数、同一套参数形状:
raiseKernelInterrupt(source, channel, text) // 级别固定 L4,不夹取
├─ reportTaskPanic → ("kernel", "kernel", "任务 #N panic…")
├─ raiseContextOverflow → ("kernel/overflow", "kernel", "上下文超页…")
└─ handleChildContextFull→("child/"+rc.id, "kernel", "驻留子…已满…")
问题是:这三个来源的差异只体现在 source 字符串上,它们的语义契约
(谁会发、级别几、是否需要模型响应、递归保护规则)全靠注释和调用方自律。
举例:reportTaskPanic 里那段递归保护是手写的:
if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
// 否则同一个 panic 会自我放大成中断风暴
这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
新增一种中断源时,没人会记得"要不要也加递归保护"——这就是要向量化的理由。
7.3 中断向量表的设计
// 向量编号:稳定的、可被日志与诊断引用的中断源标识。
type VectorID string
const (
VectorTaskPanic VectorID = "task_panic" // 任务 panic
VectorContextOverflow VectorID = "ctx_overflow" // 上下文超页
VectorChildContextFull VectorID = "child_ctxfull" // 驻留子上下文已满
// 预留:未来的内核事件源在此登记
)
// interruptVector 是一个中断源的完整契约。
type interruptVector struct {
ID VectorID
Level Level // 该源的中断级别(L4 源固定 LevelCritical)
// Build 由源自己构造中断载荷;向量表统一负责登记与让位。
Build func(a *Agent, arg any) (text string, ok bool)
// Guard 在登记前做前置判定(如 panic 的递归保护)。
// 返回 false 表示本次不产生中断。
Guard func(a *Agent, t *Task) bool
// ExpectsReply 声明本轮是否期望模型产生回复。
// false 表示"纯认知输入"——见 §7.5。
ExpectsReply bool
}
向量表本身:
var interruptVectors = map[VectorID]interruptVector{
VectorTaskPanic: {...},
VectorContextOverflow: {...},
VectorChildContextFull:{...},
}
统一入口:
func (a *Agent) raiseVector(id VectorID, arg any) bool
7.4 收益(可验证的,不是口号)
| 收益 | 现状 | 向量化后 |
|---|---|---|
| 新增一种内核中断 | 找 raiseKernelInterrupt 调用点照抄,容易漏规则 |
在表里加一条,规则随条目声明 |
| 递归保护 | 硬编码在 reportTaskPanic |
是 Guard,只对 panic 条目生效 |
| 级别来源 | 分散在 interruptLevel/parseInterruptLevel/isKernelLevelSource 三段 |
内核源由向量条目声明;插件源仍走原三段(那是外部输入,必须夹取) |
| 诊断 | source 字符串,无稳定标识 | VectorID 可被日志/状态面引用 |
| "哪些中断存在" | 全文搜索 raiseKernelInterrupt |
读向量表一处 |
7.5 附带解决:ExpectsReply 与"内核提示引起模型回复"
用户报过一个现象:内核的上下文超页提示会引发模型一整轮回复 ("主人,这条只是内核提示……")。
根因链:超页 → raiseContextOverflow → L4 → 抢占 → 新任务跑完整推理 →
finishInputTask 无条件 emitResponse。
向量表里的 ExpectsReply 字段让"这条中断要不要产生回复"成为声明,
而不是靠提示词约定(现状 tooldefs.go:218 要求"必须认真处理"、
offload.go:94 又写"无需回复",两套约定并存)。
⚠️ 注意:这一步会触及
finishInputTask的emitResponse分支, 属于行为变更,必须与拆分分开做、单独验证。本次拆分阶段不实施, 只是把字段预留在类型里(默认ExpectsReply: true,行为不变)。
7.6 实施顺序(在拆分之后)
中断向量系统是拆分的目的,但不是拆分的第一步。顺序必须是:
- 先拆(阶段 0~3)—— 纯结构,行为零变化
- 再引入向量表 —— 把三个调用点改成查表,行为仍零变化
- 最后才做 §7.5 的
ExpectsReply行为变更 —— 单独提交、单独验证
理由:如果拆文件和改语义同时做,一旦行为出问题,无法判断是搬错了还是改错了。
二、目标结构
internal/agent/core/
├── interrupt.go ← 新增:中断子系统(语义 + 状态 + 全部操作)
├── scheduler.go ← 瘦身:只留就绪队列、任务生命周期、调度循环
├── interrupt_level.go ← 新增:Level 定义、比较、解析(可合进 interrupt.go)
└── interrupt_test.go ← 新增:中断子系统自身的判据(现有测试保持不动)
2.1 中断子系统(interrupt.go)承载什么
类型与不变量
Level(L1..L4)、TaskClass(就绪/中断/self)effectiveLevel/canPreempt/clampPluginLevel(级别语义唯一实现)interruptLevel/parseInterruptLevel/isKernelLevelSource(外部信号 → 级别)maxInterruptFrames的推导与注释(核心不变量,必须有专门的落点)
状态(按 OS 语义,只留与"打断/保全"直接相关的)
interruptQueues [5][]*Task—— 中断向量(按优先级分组)immediate *Task—— 已判定的最高优先待运行者(而非"抢来的槽")suspendStack []*suspendedTask+suspendedTask—— 现场保护preemptArmed/preemptLevel—— 让位信号("有一个更高优先级已判定,请在安全点让位")critical—— 中断屏蔽
明确排除(归调度器):cancelBudget / stopArmed / stats / maxQueue / 任务 ID 分配
操作
- 入队与抢占:
enqueueInterruptLocked/setImmediateLocked/registerInterrupt/requestPreempt/requestKernelPreempt - 选择与弹出:
highestInterruptLocked/popInterruptLocked/interruptCountLocked - 让位协议:
preemptGrantedFor/clearPreempt/rearmPending/canSuspend - 中断栈:
suspend/suspendDepth(压入) - 恢复决策:
nextInterruptSelection()(含栈顶弹出、栈顶 vs 中断队列比较) - 唯一入口:
raiseKernelInterrupt(内核侧 L4)
不包含:停止配额(armStop/takeStop/consumeCancel)→ 归调度器
2.2 调度器(scheduler.go)保留什么
- 就绪队列:
queue/maxQueue/enqueue/next/queueLen/queueSnapshot - 任务生命周期:
done/removeTask/allocateIDLocked/runningTask/runningFor - 转投:
takeQueuedInputs/requeueFront - 后台压力:
hasRoom/noteBackpressure/clearBackpressure - 停止配额:
armStop/takeStop/consumeCancel(操作就绪队列,不是中断) - 任务 ID 分配:
allocateIDLocked - 统计:
stats(bumpInterruptLevel由调度器在登记中断后调用) - 调度循环:
schedulerLoop/nextRef/pumpInbox/waitSchedulerStopped - 恢复执行:
resumeTask(重建前缀 + 跑任务 + 收尾;它不问"该恢复谁", 只按中断子系统给出的选择执行) - 快照:
DumpScheduler/schedulerStatus
nextRef是两者的交界:它按优先级从 immediate → 中断队列 → 中断栈顶 → 就绪队列 取任务。放在scheduler.go(因为它是"下一个执行谁"的决策),但它调用的 中断侧查询函数归interrupt.go。
二点五、实施进展(2026-10-25)
| 阶段 | 状态 | 说明 |
|---|---|---|
| 0 安全网 | ✅ 完成 | interrupt_invariant_test.go:I1–I7 共 11 条判据,两条经变异验证确实变红 |
| 1 纯搬迁 | ✅ 完成 | 中断机制迁入 interrupt.go(303 行),scheduler.go 从 1476 → 1369 行 |
| 1.5 切断反向依赖 | ⏳ 部分 | 已建立中断栈状态钩子(stackNotify)实现单向通告;maxQueue 仍约束中断队列(见下) |
| 2 结构收敛 | ✅ 完成 | 划界落实:机制在 interrupt.go,策略在 scheduler.go |
| 3 验证 | ✅ 完成 | 全仓 go test -p 1 ./... 通过;-race 三关键包绿;变异验证通过 |
| 中断向量表 | ⏳ 未做 | §7 已设计,按 §7.6 顺序应在拆分完成后单独做 |
实际划定的边界(比原计划更严格)
用户三次裁定后确定的最终边界:
| 归属 | 内容 |
|---|---|
中断子系统(interrupt.go) |
中断队列、immediate 槽、中断栈(唯一操作者)、中断屏蔽(critical)、让位信号原语、栈状态钩子 |
调度器(scheduler.go) |
任务优先级语义(Level/effectiveLevel/canPreempt/clampPluginLevel)、恢复决策(nextInterruptSelection)、就绪队列、停止配额、背压 |
关键设计:中断栈只有中断管理器能操作。调度器通过 stackTopLocked()
只读查询栈顶做优先级比较,真正的弹栈由 restoreTopLocked() 在中断管理器内完成——
这条不变量在类型层面就无法被绕过。
解耦机制:stackNotify 钩子。中断栈变化时通告调度器,调度器据此唤醒
调度循环重新求值。中断管理器不知道调度器会怎么选,调度器也不碰栈内部——
将来给中断子系统加向量表、换多栈策略,都不必改调度器。
顺带修掉的真实竞态(阻塞 CI)
-race 实测发现两处真实数据竞争(非本次拆分引入,ab27d1a 上 CI 已红):
| 位置 | 问题 |
|---|---|
core/stages.go:withExecuteCtx |
往调用方共享的 tc.Arguments 原地写 ctx |
io/channel.go:bindExecCtx |
同样原地写设备 args |
工具执行在自己的 goroutine 里,超时/取消路径下主 goroutine 仍在读同一份 args。
修法:复制 map 而非原地写。变异验证:退回原地写后 TestPreempt_DeferredDuringToolExec
立刻报 DATA RACE。
修前 internal/agent/core 在 -race 下 30 个测试失败(整包被判红,掩盖其它真竞争);
修后归零。
阶段 1.5 遗留:maxQueue 确实约束中断队列
查证结果(回答原计划里的待定项):enqueueInterruptLocked 读的是
s.maxQueue(就绪队列上限),并在超限时丢最老的中断并计 Rejected。
这在 OS 语义上说不通——中断队列容量不该由运行队列长度决定。
但去掉它是行为变更,按"结构重构不改语义"的原则本次保持不变。
建议后续单独立项:给中断队列自有常量(如 maxInterruptQueue),
并单独验证背压行为。
三、实施步骤
阶段 0:安全网(先做,不可跳过)
- 跑一次基线:
go test ./internal/agent/core/ -count=1记录结果。 - 记录中断相关测试的全量清单(16 个文件里与 preempt/suspend/interrupt 相关的用例)。
- 补一组行为快照判据,专测中断不变量(现有测试偏功能,不变量覆盖不足):
- 中断栈帧数上界 =
int(LevelCritical),且绝不丢帧 - L4 遇 L4 不抢占(严格大于)
- 栈内严格 LIFO,不按级别重排
- 临界区内的中断只入队、不抢占
effectiveLevel对 self/就绪任务的封顶行为
- 中断栈帧数上界 =
阶段 1:纯搬迁(零逻辑修改)
- 新建
interrupt.go,把 §2.1 列出的类型、字段、方法原样搬入。scheduler的字段搬走后,interrupt.go里需要一个持有者。两个选项见 §四风险 A。
scheduler.go只留 §2.2 的内容。- 不改任何一行逻辑。搬完立即跑全量测试,必须与阶段 0 基线逐条一致。
阶段 1.5:切断反向依赖(关键,不是可选)
搬迁后立即处理 §1.5 列出的五处反向依赖,让依赖方向变成单向 「调度器 → 中断」:
registerInterrupt不再读s.running:改由调度器传入 running 快照。s.stats不再被中断函数直接改:中断返回"发生了什么",由调度器记账。s.maxQueue不再约束中断队列:中断队列上限若需要,用自有常量 (maxInterruptFrames同理,它是结构推论不是配置)。allocateIDLocked移出中断:中断只接收"已带 ID 的任务"。signalWake由调度器在登记后调用,不在中断内部。
若判断某处改动会变更行为(例如中断队列原本真的受
maxQueue限制), 停下来先确认,不要为了结构干净而悄悄改语义。
阶段 2:结构收敛
maxInterruptFrames的推导常量与注释搬到interrupt.go顶部,作为子系统不变量。raiseKernelInterrupt的三个来源(panic / overflow / child-contextfull)在interrupt.go里集中登记为注释表格,说明"L4 是统一的抢占/让位机制, 不同功能只是它的不同用途"。- 检查是否有函数可降为 unexported(搬迁后可私有化的先私有化)。
阶段 3:验证与文档
- 全量测试 +
-race。 - 在
docs/zh/补一节,说明中断子系统的边界与不变量。 - 更新
scheduler.go顶部注释,指向interrupt.go。
四、风险与对策
A. 字段搬迁的持有方式(主要决策点)
中断状态必须与调度器共享同一把锁(s.mu)——它们在同一临界区里被一起读写。
两种形态:
A1(推荐):scheduler 内嵌中断状态结构体
type scheduler struct {
mu sync.Mutex
interruptState // 内嵌:字段提升,访问方式 s.interruptQueues 不变
queue []*Task
...
}
- 优点:零调用点改动(字段提升后
s.immediate等写法照旧),搬迁风险最低。 - 缺点:锁定关系是隐式的(仍是同一把
s.mu)。
A2:中断子系统独立持锁
- 优点:边界更清晰。
- 缺点:
nextRef要同时读两个子系统,双锁必然引入顺序问题;且现有代码在 同一临界区里同时改两者,拆锁是行为变更,不是重构。
选择 A1。理由:本次目标是把语义集中,不是改并发模型。A2 属于另一次 独立的设计变更,混在一起会让"行为未变"这个前提失效。
B. 测试大量直接访问 s.mu 与内部字段
现有 scheduler_preempt_test.go 等直接构造 scheduler 并断言字段。
A1 方案下这些测试不需修改(同包 + 字段提升)。若选用 A2 则要改写测试,
进一步说明 A1 是对的。
C. 搬迁时手滑改坏逻辑
对策:阶段 1 严格"只搬不改",每次搬迁后立即跑测试;用 git diff --color-moved
复核搬移是否真的只是移动。
D. 停止配额的归属 —— 已裁定,不再是风险
见 §1.5:它操作就绪队列,归调度器。此条已从"待确认"降为"已定"。
E. nextRef 的归属 —— 已切分
见 §1.6:①②③(immediate / 中断队列 / 栈顶比较与弹栈)归中断子系统
(nextInterruptSelection),④(就绪队列)归调度器。nextRef 本身留在
scheduler.go,但只剩"先问中断侧、无货再取就绪队列"这一层编排。
注意:这会让
nextRef的代码量明显变小——那是正确的,因为它原本混装了两件事。
五、验收判据
interrupt.go承载 §2.1 全部条目;scheduler.go不再含中断队列/栈/让位字段- 中断不变量有专门判据(栈上界不丢帧、L4 不抢 L4、严格 LIFO、临界区只入队)
- 全量
go test ./internal/agent/core/与基线逐条一致 go test -race ./internal/agent/core/通过git diff --color-moved显示阶段 1 为纯搬移- 修改一条中断规则时,只需改
interrupt.go一处(这是本次重构的目的) - 中断向量表落地:三个 L4 来源改为查表,
VectorID可被日志引用 - panic 的递归保护从硬编码改为
Guard,只对 panic 条目生效 - 向量表引入阶段行为零变化(
ExpectsReply仅预留字段,默认 true) - 依赖方向单向:中断子系统不读就绪队列/统计/任务 ID 等调度器状态
- 恢复决策(栈顶弹出、与中断队列比较)在中断子系统内;恢复执行在调度器
- 「谁先跑」的优先级规则只有一处实现(
nextInterruptSelection) - 停止配额归调度器(不因"也是打断"而误归中断)
六、不做的事
- 不改中断级别数值(L1..L4 与 clamp 规则保持原样)
- 不改抢占条件、不改栈深度上界
- 不拆包(
package core保持不变) - 不改并发模型(仍共用
s.mu) - 不动
overflow.go的裁剪语义(那是上一轮已完成的工作) - 不在本次做
ExpectsReply=false的行为变更(§7.5 第 3 步,单独提交)