mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1155 字
3 分钟
协作调度(Cooperative Scheduling)
2026-06-13

一、为什么需要协作调度#

你用 React 写了一个列表渲染 10000 条数据,页面直接卡死了半秒——动画停顿、输入无响应、用户骂娘。问题不是渲染慢,而是渲染占据了主线程,其他事情都得等着。浏览器的渲染、用户输入、JS 执行都跑在同一个线程上,一个长任务就能把整条流水线堵死。

操作系统的抢占式调度可以强行中断线程,但浏览器主线程没法这么做——JS 代码执行到一半强行中断,状态就乱了。唯一的办法是让任务自己「知趣」地定期让出控制权,让浏览器有机会处理渲染和输入。这就是协作调度:任务主动在安全点暂停,把执行权交还给调度器

协作调度的应用远不止浏览器。Go 运行时的 goroutine 调度、Erlang BEAM 虚拟机的 reduction counting、Python 的 asyncio——它们都依赖任务在某个点主动让出,才能保证系统整体的响应性。

二、现实类比#

会议主持人请发言者每 5 分钟暂停一下,让其他人也能说话。没有人被强行打断——每个发言者自愿让出时间。主持人确保没人独占整个会议。如果某个发言者拒绝停嘴,其他所有人都只能干等。

三、核心思想#

协作调度的运行模式是:循环执行工作单元,每个单元执行后检查是否超时,超时则让出,调度器安排下次继续。

sequenceDiagram participant W as 工作循环 participant H as 宿主(浏览器/运行时) W->>W: 处理工作单元 1 W->>H: 让出控制权(yield) H->>H: 处理用户输入 & 重绘 H->>W: 恢复执行 W->>W: 处理工作单元 2 W->>H: 让出控制权(yield) H->>H: 处理动画 & 其他任务 H->>W: 恢复执行 W->>W: 处理工作单元 3(完成)
属性
调度模型非抢占式——任务必须主动让出
让出检查O(1)——比较当前时间与截止时间
饥饿风险一个不让出的任务会阻塞所有任务
典型时间片预算5-16ms(60fps 下的一帧)

四、变体与对比#

模式关系区别
抢占式调度协作调度的对立面抢占式由操作系统强制中断;协作式依赖任务主动让出
事件循环事件循环依赖协作调度事件循环是架构模式;协作调度是其中的调度策略
工作窃取互补关系协作调度解决线程内的让出问题;工作窃取解决线程间的负载均衡
协程/生成器协作调度的语言级实现协程是语言提供的让出原语;协作调度是调度策略
Warning

一个不让出的任务会独占整个时间片,饿死所有其他排队任务。与抢占式调度不同,协作调度器无法强制移除行为异常的任务——这是它的根本弱点。

五、多语言实现#

Go:基于时间片的工作循环#

type Task func() bool // 返回 true 表示任务完成
type Scheduler struct {
YieldInterval time.Duration
queue []Task
}
func New(yieldInterval time.Duration) *Scheduler {
return &Scheduler{YieldInterval: yieldInterval}
}
func (s *Scheduler) Schedule(task Task) {
s.queue = append(s.queue, task)
}
// WorkLoop 处理任务,时间片到期时让出
// 返回 true 表示全部完成,false 表示已让出
func (s *Scheduler) WorkLoop() bool {
start := time.Now()
for len(s.queue) > 0 {
if time.Since(start) >= s.YieldInterval {
return false // 让出,下次继续
}
done := s.queue[0]()
if done {
s.queue = s.queue[1:]
}
}
return true
}

TypeScript:React 风格的协作调度器#

type Task = () => boolean; // 返回 true 表示还有剩余工作
function createScheduler(yieldInterval = 5) {
const queue: Task[] = [];
let isRunning = false;
function shouldYield(startTime: number): boolean {
return performance.now() - startTime >= yieldInterval;
}
function workLoop(): void {
const startTime = performance.now();
while (queue.length > 0) {
if (shouldYield(startTime)) {
// 让出控制权,使用 MessageChannel 而非 setTimeout
// 因为 setTimeout 在嵌套调用后会有 4ms 最小延迟
scheduleCallback(workLoop);
return;
}
const task = queue[0]!;
const hasMoreWork = task();
if (!hasMoreWork) {
queue.shift();
}
}
isRunning = false;
}
return {
scheduleTask(task: Task) {
queue.push(task);
if (!isRunning) {
isRunning = true;
scheduleCallback(workLoop);
}
},
};
}
// 使用 MessageChannel 实现零延迟调度
const channel = new MessageChannel();
channel.port1.onmessage = null; // 由调用方设置
function scheduleCallback(fn: () => void) {
channel.port1.onmessage = () => fn();
channel.port2.postMessage(null);
}

六、生产验证#

  • ReactScheduler.js#L188-L258workLoop 从最小堆中处理任务,每轮调用 shouldYieldToHost() 检查 5ms 时间片是否耗尽,是则让出给浏览器
  • Go Runtimeproc.go#L4143-L4200schedule() 是调度器主循环,Gosched() 是主动让出点,goschedImpl 处理协作式上下文切换
  • Erlang BEAM VM — reduction counting 机制:每个进程分配约 4000 次 reduction,用完就让出,保证调度公平性

七、小结#

何时使用:

  • UI 线程工作——处理大数据集时保持动画和输入响应
  • 批处理——分块处理元素,中间暂停让其他系统工作
  • 长计算——将递归树遍历或列表操作拆为可恢复的块
  • 并发运行时——实现绿色线程或协程调度

何时不用:

  • 短任务——工作在 1ms 内完成时,让出的调度开销不值得
  • 实时保证——协作调度无法保证截止时间;需要抢占式调度
  • CPU 密集且无交互——没有其他需要线程时,让出纯属浪费时间
  • 简单场景——浏览器的 requestIdleCallback 对非紧急工作可能就够了

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

协作调度(Cooperative Scheduling)
https://blog.souloss.com/posts/programming/concurrency/concurrency-cooperative-scheduling/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时