
Scheduler unloadedCh is an untyped (chan any) wakeup signal
快速结论:该报错由 bug-hunt-council 在 Ollama 调度器的代码审计中发现,涉及 server/sched.go 中 unloadedCh 通道为无类型 chan any 的设计问题。优先排查是否因并发卸载模型时信号无身份标识导致 runner 泄漏或状态不一致,但最终结论认为这是一个良性唤醒信号,删除操作已按 modelKey 正确索引。
问题场景
问题出现在 Ollama 调度器(Scheduler)中,当多个请求并发加载/卸载模型时触发。具体场景是:
– 请求 A 加载模型 M(runner R1,refCount=1)
– 请求 B 加载模型 N,需要驱逐 runner R1,标记 R1 过期并等待 unloadedCh
– 同时,请求 C 加载模型 P,驱逐另一个 runner R2
– R2 的卸载先完成,发送信号到 unloadedCh
– 请求 B 错误地收到了 R2 的信号,以为自己等待的 R1 已卸载,但 R1 仍在运行
– 导致 loaded map 状态与实际 runner 状态不一致,可能引发 runner 泄漏或损坏。
报错原文
// server/sched.go:65
unloadedCh chan any
// server/sched.go:472 — sender (inside expiredCh case, after runner.unload())
s.unloadedCh <- struct{}{} // NO runner identity
// server/sched.go:361,366,1644 — receivers
case <-s.unloadedCh: // receives ANY unload event, can't tell which runner
原因分析
可能原因:unloadedCh 是 chan any 类型,发送端仅发送裸 struct{}{} 而不携带 runner 身份标识。当多个 runner 并发过期时,等待特定 runner 的接收方可能错误地消费另一个 runner 的信号,导致 loaded map 中删除错误的 runner 或未能删除正确的 runner,违反“每个 unloadedCh 信号必须对应特定被过期 runner”的不变量。
但 Issue 评论中,提交者经复查认为:删除操作在 PR #10819 中已修复为按 modelKey 索引,因此该无类型通道仅是一个良性唤醒信号,不会导致错误的 runner 删除。问题严重性被高估。
环境排查
- 确认 Ollama 版本:需包含 PR #10819 修复(检查
server/sched.go中delete操作是否按 modelKey 索引) - 确认是否使用并发加载/卸载模型的操作(例如同时加载多个不同模型)
- 压力测试时可使用
go test -race ./server/ -run TestScheduler -count 100触发竞态
解决步骤
- (可优先尝试) 升级到包含 PR #10819 修复的 Ollama 版本。该 PR 已修正
loadedmap 中的删除操作为按 modelKey 索引,确保即使信号无身份标识,删除逻辑也不出错。 - 若问题仍复现,考虑实施 Issue 中建议的修复方案之一:
– 将unloadedCh类型从chan any改为chan string(modelKey),在发送时传入runner.modelKey,接收方只处理匹配的 key。
– 或改为每个 runner 使用独立的chan struct{},等待方阻塞在自己 runner 的通道上,彻底消除信号混淆。 - 添加并发身份验证测试,例如:
func TestSchedulerConcurrentEvictionNoCrossTalk(t *testing.T) { // 加载3个模型,触发并发驱逐其中2个 // 断言:每个 unloadedCh 信号对应正确的 runner // 断言:loaded map 状态与驱逐后实际 runner 状态一致 }
验证方法
运行压力测试 go test -race ./server/ -run TestScheduler -count 100,确认无竞态错误且 loaded map 与 runner 实际状态一致。在业务代码中观察是否仍有 runner 泄漏(例如加载多个模型后卸载,看进程是否持续存在残余 runner 线程)。



