调度系列写到第三篇了。前两篇我们花了不少篇幅在准备“调度”本身这件事的素材:任务怎么排队,资源怎么描述,队列和资源池应当怎么匹配。今天这篇要回答一个最常被问、也最容易答错的问题——调度到底是怎么“跑起来”的?
我见过不少刚接触调度系统的人,第一反应常常是:“那不就是有个 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(); } 式的轮询线程。这个做法不是完全不行,但它有一个问题:无论有没有事件,它
