调度系列写到第三篇了。前两篇我们花了不少篇幅在准备“调度”本身这件事的素材:任务怎么排队,资源怎么描述,队列和资源池应当怎么匹配。今天这篇要回答一个最常被问、也最容易答错的问题——调度到底是怎么“跑起来”的?
我见过不少刚接触调度系统的人,第一反应常常是:“那不就是有个 scheduler 进程一直在后台循环扫描吗?” 等真把源码翻开,会发现完全不是这么回事。调度器在很多场景下压根不是一条持续转动的线程,它更像一个被闹钟叫醒、被电话打断、被门铃催着跑的管理员。没有事件发生时,它可以悠闲地睡大觉,一点 CPU 都不占;一旦有事情触发,它必须在几毫秒甚至几微秒内完成一次资源的重新分配。
如果你刚打开这篇,也不影响单独阅读。我会把“调度如何跑起来”这条链路拆成四块来看:靠什么被唤醒、拿什么做决策、用什么保证切换不丢状态、以及放在分布式环境下为什么会出现“同一个任务被两台机器同时执行”这种见鬼的事。最后我们再落地到自己动手写一个调度器时,那个最简事件循环到底该怎么设计。
1. 它怎么醒来:时钟中断、事件与“被踢一脚”
1.1 调度器并不是一直在盯着你的“监工”
最早我其实也被调度器的名字骗过。以为操作系统的调度器应该是那种每秒扫描几百次运行队列的管家,后来认真读代码才发现,调度器本身没有一个独立线程在持续轮询。它在绝大多数时间里就是一段躺在内核里的函数,只有满足特定条件时才会被调用一次。
这个认知对后续理解所有调度系统都很关键:调度器的工作模式是“被动触发 + 主动决策”,而不是“主动巡视 + 随机干预”。拿最基础的 CPU 调度来说,如果你的机器上只有一个任务在跑,调度器完全没必要频繁打断它,让它一直执行反而吞吐最高、功耗最低。只有当时间片用完、有更高优先级任务被唤醒、当前任务主动让出 CPU、或者有 I/O 完成导致某个阻塞任务恢复就绪时,调度器才需要一个“决策点”去重新算一次:接下来到底该让谁上 CPU。
这也是为什么很多实时系统和嵌入式领域会给调度器单独分配一个任务的思路是错的。你单独起一个高优先级线程来做“调度”,反而会引入线程本身与业务任务之间的竞争。正确做法是:让调度逻辑挂在中断处理、系统调用返回路径、或者任务状态迁移的回调里。当关键时刻来临,它会自动“跳”出来。
1.2 唤醒调度器的三种典型信号
如果要把“谁来踢这一脚”归纳一下,我习惯把它分成三类:
-
时间驱动:最常见的就是定时器中断。内核每过一个 tick,或者高精度定时器到点后,会检查当前任务是否已经运行了足够长的时间。Linux 这类通用操作系统以前是用周期 tick 来做时间片轮转判断的,现在很多内核已经变成无 tick 模式,只有在确有必要时才会唤醒调度逻辑。不管哪种方式,本质都是“时间到了,该重新做决定了”。
-
事件驱动:新任务进入就绪队列、任务执行完毕、锁被释放、I/O 完成、某个 worker 注册上线、有请求从消息队列里呼啦涌进来……这些都是事件。它们的共同点是改变了“当前资源供需关系”的某一项,让之前算出来的分配方案失效了,所以必须重新排队、重新选人。
-
显示让出:任务主动调用 yield、sleep、或者陷入 IO 等待,等于主动告诉调度器“我现在不占资源了”。这种情况调度器几乎不需要犹豫,直接把下一个就绪任务拉起来就行。
如果拿餐厅打比方,调度器就是老板娘:新客人进门(事件驱动)她要安排座位;每桌吃满两小时(时间驱动)她要去催菜或者翻台;有客人主动结账走人(主动让出)她才知道空出来一张桌子。没有这些信号时,老板娘不会满店转圈瞎指挥。
1.3 调度器空闲时真的在“睡觉”吗
当系统里没有足够的事件、没有到点的定时器时,调度器所在的路径就彻底闲下来了。在操作系统里,CPU 会进入 idle 循环,甚至用硬件层面的睡眠指令把功耗拉低。在分布式调度中心里,调度主循环通常阻塞在消息队列或 epoll 上,一个请求都不来,CPU 占用率就是零。
很多人做自研调度系统时有个坏毛病:担心漏掉任务,于是写一个 while(true) { sleep(1); scan(); } 式的轮询线程。这个做法不是完全不行,但它有一个问题:无论有没有事件,它都在空转。更合理的形态是事件驱动加定时器管理的混合模型。系统没有事件时应该能稳稳地睡在 select/epoll/条件变量上,只有新任务到达或定时器到点时才醒过来干活。资源是稀缺的,调度器本身也不该滥用它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 被唤醒之后怎么选人:调度决策的输入、模型与输出
2.1 决策不是拍脑袋,它要读取这些“账本”
调度器被踢醒后,第一件事不是立刻选择任务,而是收集当前系统的真实状态。不同领域的调度器读的数据不同,但抽象完都是这么几类:
- 任务侧:当前有哪些任务处于就绪状态,每个任务自身的优先级是多少,已经等待了多长时间,已经运行了多少时间,任务声明的资源需求量有多大。
- 资源侧:CPU 总核数、空闲核数、内存余量、GPU 显存占用、磁盘队列深度、网络带宽,或者说在业务调度系统里到底有多少个可用的执行器节点。
- 约束侧:某些任务只能跑在特定机器上,某些任务不能和另一类任务共享同一块资源,某些作业有严格的 deadline,过了这个时间再跑就没有意义。
这些数据就是调度器的“账本”。它做决策时本质上是把账本里的需求和供给做一次匹配,然后根据策略选出一个最合适的匹配结果。如果你自己写调度器却忽略了状态收集,策略写得再漂亮也是空中楼阁,因为输入数据都不准。
2.2 策略选择本质上是在权衡什么
调度策略本身有很多种,名字听起来花里胡哨,拆开看核心都是在权衡几个互相矛盾的目标:
| 策略 | 核心思想 | 优点 | 典型代价 |
|---|---|---|---|
| 先来先服务 | 谁先到谁先执行 | 公平直观,实现简单 | 一个长任务堵在后面所有短任务 |
| 短作业优先 | 预计运行时间短的先跑 | 平均等待时间低 | 长任务容易被饿死 |
| 时间片轮转 | 每人固定跑一段时间 | 响应快,交互好 | 切换开销大,吞吐一般 |
| 多级反馈队列 | 按动态优先级分队列 | 兼顾响应与吞吐 | 参数调起来麻烦 |
| 公平调度 | 按权重分配 CPU 份额 | 隔离性好,大家都有的跑 | 延迟指标不是最优 |
拿最常见的 CFS/EEVDF 类公平调度器来说,它的目标并不是让每个任务“轮流执行一次”,而是让每个任务在过去一段窗口内获得的 CPU 时间尽量接近它的权重。比如 A 的权重是 B 的两倍,理想状态下 A 每跑两个时间片,B 才跑一个时间片。实现办法是把每个任务的虚拟运行时间维护好,每次选择虚拟运行时间最小(即“受亏待”最严重)的任务上 CPU。这个思路非常通用,很多并发控制、流量调度其实也在用类似的“最小亏欠优先”思想。
2.3 多级反馈队列:一个特别值得吃透的通用模型
如果让我只推荐一种调度模型来学习,我一定会推荐多级反馈队列(MLFQ)。它不算新,上世纪六十年代就有了,但直到今天,从操作系统线程调度到业务层请求调度,到处都能看到它的变体。
它的运行逻辑可以概括成三条规则:
- 新任务先进入最高优先级队列 Q1。
- Q1 里的任务每次只能运行一个很短的时间片,如果时间片用完还没执行完,就降级到 Q2。
- Q2 的任务运行时间片更长一些,执行完仍然没结束就继续降级到 Qn,以此类推。优先级越低的队列,时间片越长。
这个模型妙在不用提前知道任务到底要跑多久,就能自动做到“短任务快速完成,长任务不饿死”。一个几次交互就结束的短任务,在 Q1 就被处理掉了,根本不会降级;一个一直抢 CPU 的长任务,会一路降级到低优先级队列,拿到很长的时间片慢慢跑。IO 密集型任务因为经常主动让出 CPU,反而会留在高优先级队列里,响应速度非常快。
我自己做任务分发系统时,模仿过这个结构:普通任务走默认队列,如果一次跑超过 N 秒就降低它在系统里的竞速权重,腾出资源给短小紧急的任务。效果比单一优先级队列好了不少。如果你要自研一套自动化调度系统,MLFQ 是一个特别好的起点。
3. “选好了”到“换上去”:状态机与上下文切换才是闭环的关键
3.1 任务状态机欠的债,后面一定会还
调度器选定下一个执行者后,真正让它落地的其实是另一套机制:保存当前任务在哪儿、把新任务恢复到哪个位置、执行完成后任务状态又如何流转。
在学习阶段,大家可能都背过操作系统的五态模型:创建、就绪、运行、阻塞、终止。但等真的去设计一个业务调度系统,你会发现状态机需要定义得更细。我自己画任务状态迁移表时至少会包含这些状态:
| 当前状态 | 事件 | 是否允许 | 新状态 | 备注 |
|---|---|---|---|---|
| PENDING | 调度器选中 | 是 | RUNNING | 生成执行实例,记录调度时间 |
| RUNNING | 执行成功回调 | 是 | SUCCESS | 记录完成时间与结果摘要 |
| RUNNING | 执行失败回调 | 是 | FAILED | 按策略决定是否重试 |
| RUNNING | 心跳超时 | 是 | LOST | 等待裁决,不立刻重复派发 |
| LOST | 超时未恢复 | 是 | FAILED | 触发补偿或人工介入 |
| FAILED | 达到重试上限 | 否 | 仍为 FAILED | 避免死循环 |
这张表的价值在出问题时特别明显。很多人排查分布式任务问题,第一步就是查任务现在处于哪个状态、从哪个状态变过来的、触发变更的事件是什么。如果状态迁移没有设计好,日志里会看到任务在 RUNNING 和 FAILED 之间反复横跳,调度器彻底失去判断依据。
3.2 上下文切换:单机保存寄存器,分布式保存状态快照
为什么“切换”这个动作如此重要?因为要做一次调度,不只是决定“下一个轮到谁”,还必须保证“当前这个先挂起,之后还能接得上”。
单机操作系统里的上下文切换,做的核心事情是把当前任务的寄存器现场、程序计数器、栈指针保存到它的进程控制块里,再把下一个任务的这些现场数据加载回 CPU。切换一次大概只花几微秒。这部分靠硬件和内核共同完成,业务开发其实很难直接感知。
到了分布式任务调度,上下文切换就变成了另一件事。任务在这台机器上跑到一半,因为节点故障或者负载不均,需要挪到另一台机器继续跑。这时要保存的不再是寄存器,而是任务的输入参数、中间状态、依赖的外部资源句柄、以及已经处理到的数据位置。这就是所谓的检查点技术,也是很多长任务调度系统最头疼的地方。
一个值得记住的结论是:调度器能安全地做“切换”,前提是存在可靠的“状态保存”机制。 如果没有状态保存机制,调度器哪怕决策再准确,也不敢贸然中断正在执行的任务,一中断就丢进度,一丢进度就重跑,重跑又引发重复副作用。这篇文章后面要讲的“重复执行”问题,根源也在这里。
3.3 一个来自实操的教训:先画状态迁移表,再写调度主流程
我早期写一个批量处理调度器时偷懒,没有把状态机定义清楚,只靠一个字符串字段表示任务状态,到处 status = "running" 直接赋值。结果上线后出现过一个非常隐蔽的 bug:一个任务回调失败后进入重试流程,重试时却不小心从旧缓存里 load 到上一轮的状态,直接把它再次标记成 running,导致同一个任务的两个执行实例在同时跑。
后来修复时我把所有状态流转集中到一个函数里,只允许通过这个函数修改状态,并且每个事件都校验当前状态是否允许迁移。比如从 SUCCESS 就不能再迁回 RUNNING,除非显式创建一个新版本的任务。这样一个简单的约束,挡掉了后续绝大多数状态错乱问题。做任何调度系统,前期的状态机设计都值得花最多时间。
4. 集群调度里“同一个任务被多台机器同时执行”是怎么发生的
4.1 典型现象:凌晨定时任务重复执行了
聊完单机层面的调度机制,我们把视角切到分布式集群调度。这是实际业务里最容易出问题的地方,也是社区里被问烂的一个场景:我配置了一个 xxl-job 定时任务,每天凌晨跑一次数据同步,结果某天告警群里突然出现两个一模一样的执行实例,两边都在往生产库写数据。
很多人遇到这种问题的第一反应是“是不是我 Cron 表达式写错了,一分钟内触发两次”。但排查到最后往往发现调度配置没问题,问题出在调度链路的某个环节给了同一个任务两次“通行证”。
根据我的排查经验,比较常见的根因大概是这么几类:
- 调度中心脑裂:集群里有多个调度节点,它们之间的 Leader 选举由于网络抖动出现短暂脑裂,两个节点都认为自己是当前任务的负责任务者,于是同一时刻都向执行器发送了触发指令。
- 超时重发:任务本身跑得比较久,超过了调度中心配置的触发超时时间。调度中心认为上次触发失败了,于是按重试策略再次下发指令,但上一个实例其实还在正常运行。
- 执行器假死恢复:某个执行器因为长时间 Full GC 或者网络隔离,导致调度中心误判它离线,把任务重新派发给了另一个执行器。此后原执行器恢复,两个实例同时存活,任务就重复了。
- 手动触发与自动触发叠加:有人在控制台手动执行了一次任务,同时定时触发也没取消,两条触发记录几乎同时生效。
4.2 排查链路:从日志到状态的层层倒推
这类问题最适合用日志倒推,不建议直接去改配置碰运气。我一般按下面几步走:
- 先把任务的调度日志拉出来,确认同一时间窗口内到底生成了几条触发记录。
- 看每条触发记录的发起节点是不是同一个 IP。如果不是同一个 IP,高度怀疑调度集群脑裂。
- 看任务的执行耗时与调度重试阈值。如果执行耗时刚好接近超时时间,重点怀疑“超时后重发”。
- 去看两个执行实例的进程启动时间与执行器注册记录,确认是不是“假死恢复”场景。
- 最后看执行器有没有做幂等。即使前面排查不出原因,只要接口幂等,重复执行也只会有一份数据生效。
日志筛选时我习惯按任务 ID 加执行时间段一起查,比如搜 jobId=123 trigger 和 jobId=123 receive,把调度端日志和执行端日志对齐到同一个业务流水号上。如果你的调度系统没打印全局 TraceID,排查这种问题会痛苦很多,这也是我建议所有调度平台一定要为每次触发生成全局唯一执行编号的原因。
4.3 根子还是出在“状态可见性”和“原子性”上
把表层的技术原因剥开,这类故障的根子在于分布式系统里两个经典问题:状态可见性和原子性。
调度中心下发指令是一个动作,执行器开始执行是另一个动作,两者之间没有原子性保证。A 节点下发后,B 节点不知道 A 已经下发过了,因为“这个任务已经被触发”这个状态没有在共享存储里被原子地记录。或者记录了,但记录方式和检查方式不够原子,两个节点同时检查、同时通过。
要打破这个局面,需要引入一个所有调度节点都认的仲裁者。最常见做法是基于数据库唯一索引或 Redis 分布式锁,在每次触发前先抢占一个“触发令牌”。比如在数据库建一张执行记录表,并给 job_id、fire_time、trigger_server_ip 建唯一索引,谁先插入成功,谁才拥有这次触发权。其它节点插入失败,就说明已经有人抢先了。
4.4 光靠调度器不够,任务本身也要做幂等防护
即使调度系统把上面这些手段都用上,分布式环境下仍然可能出现“下游收到两条相同请求”的极端情况,比如网络重传、消息队列至少一次投递语义。所以做任务的人一定要在业务侧再上最后一道保险。
我常用的做法是给每个任务生成幂等键。任务内部拿着这个幂等键去 Redis 或数据库里做 SETNX,只有第一次拿到锁的执行实例可以继续往下走,后面的请求直接返回“已执行过”。这样做还有一个额外的好处:即使调度端因为 bug 重复触发,用户看到的结果也只有一个成功实例,不会造成脏数据。
另一个思路是引入“任务世代”的概念。每次修改任务配置、手动触发、定时触发,都生成一个新的版本号或批次号。执行器在处理任务前先检查自己拿到的版本号是不是最新。如果发现已经有更新版本的任务在运行,当前实例应该主动退出而不是闷头执行。这个机制对处理“手动触发和定时触发叠加”的场景特别有效。
5. 不自研一套调度器时,该怎么理解它的“心跳”
5.1 一个最简调度框架的核心循环长什么样
前面讲了调度器是被动唤醒的,那当我们自己动手写一个自动化调度系统,或者想在一个常驻进程里塞一套任务调度能力时,代码到底应该怎么组织?我觉得最简单也最不容易跑偏的模型,是“一主循环,三输入”:
go复制type Scheduler struct {
queue PriorityQueue
timers *TimerWheel
readyCh chan *Task // 事件输入:新任务到达
resultCh chan *TaskResult // 事件输入:执行器回调
quit chan struct{}
}
func (s *Scheduler) Loop() {
for {
select {
case task := <-s.readyCh:
s.enqueue(task)
s.tryDispatch()
case res := <-s.resultCh:
s.applyResult(res)
s.tryDispatch()
case now := <-s.timerC:
s.dispatchDueTasks(now)
s.tryDispatch()
case <-s.quit:
log.Info("scheduler exited")
return
}
}
}
主循环在 select 上阻塞,没有事件时协程就是挂起的。新任务来,回调事件来,定时器到点,分别走不同入口,最后都汇聚到一个 tryDispatch()。这个函数才是真正检查“当前有没有资源、有没有可派发任务、按什么策略选择”的地方。由于所有输入都进同一个队列,天然避免了并发下同时修改状态的问题。
5.2 自己写调度器最常踩的三个坑
-
条件变量或通道用不好导致忙轮询:有人图省事,直接用
for { time.Sleep(100ms); dispatch() }来扫描队列。这个实现简单,但任务量上来后空转的 CPU 浪费很难看,延迟也被拉高到 100ms 级别。正确做法是用事件触发加定时器管理,让调度器在无可做时真正休眠。 -
调度状态和数据存储的状态没有对齐:调度器内存里维护了一套“当前有哪些 worker 可用”的视图,但 worker 实际已经挂掉,视图没有及时更新。等到派发任务过去才发现连不上,只能重试。要避免这个问题,需要靠心跳机制持续校准视图,并且对长时间不发心跳的 worker 做主动剔除。
-
时间轮参数设置不合适:很多定时任务调度会用时间轮来管理延迟任务。时间轮单个 tick 的粒度如果设得太细,CPU 开销会明显上升;设得太粗,任务延迟又没法保证。我一般会先估算业务里最密集的调度周期,把 tick 粒度设为它的四分之一到二分之一比较稳妥。
5.3 从裸机时间片到 GPU 推理服务,底层思想是同一套
裸机环境下没有操作系统的支持,要自己做时间片调度,通常就是靠一个硬件定时器中断。每个 tick 到来,中断服务程序保存当前任务的现场,切换栈指针,加载下一个任务的现场。这就是整个系统的心跳,一跳又一跳,把看似并发的多任务跑出来。
到了 GPU 推理服务里调度又是另一种形态。以 vLLM 这类推理引擎为例,它的调度器并不是周期性扫描请求队列,而是在有新请求进入、有请求执行完成释放了 KV cache、或者有请求因显存不足需要被抢占时,才触发一次调度决策。调度器会计算当前请求批次的显存占用,把能塞进显存的请求组成一个连续批次执行,塞不下的就去排队。这本质上还是在做“资源水位监测 + 队列选择 + 状态管理”,只不过资源从 CPU 时间片换成了显存和 KV cache,任务从线程换成了请求。搞清楚这套底层的共同逻辑,再去看 GPU 调度、AGV 调度、列车调度、智能制造里的作业车间调度,你会发现它们说的其实是同一件事:在有限资源上,决定谁先谁后、谁能拿到资源、谁该被暂时挂起。区别只是资源种类、任务形态和决策的目标函数不同。
最后说一点我自己的体会。调度系统从来不是“一个算法”就能解决所有问题的领域,更多时候是在不同目标之间做取舍。想明白它是被什么机制叫醒、拿什么数据做决策、靠什么保证切换不中断、以及分布式环境里如何避免重复决策,这四件事,就足够支撑你理解大多数调度系统的运转方式了。真到需要自己动手写的时候,建议从最小的单机事件循环开始,先把状态机画明白,再一步步加分布式能力,路径会顺很多。
