1155 字
3 分钟
协作调度(Cooperative Scheduling)
一、为什么需要协作调度
你用 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);}六、生产验证
- React — Scheduler.js#L188-L258:
workLoop从最小堆中处理任务,每轮调用shouldYieldToHost()检查 5ms 时间片是否耗尽,是则让出给浏览器 - Go Runtime — proc.go#L4143-L4200:
schedule()是调度器主循环,Gosched()是主动让出点,goschedImpl处理协作式上下文切换 - Erlang BEAM VM — reduction counting 机制:每个进程分配约 4000 次 reduction,用完就让出,保证调度公平性
七、小结
何时使用:
- UI 线程工作——处理大数据集时保持动画和输入响应
- 批处理——分块处理元素,中间暂停让其他系统工作
- 长计算——将递归树遍历或列表操作拆为可恢复的块
- 并发运行时——实现绿色线程或协程调度
何时不用:
- 短任务——工作在 1ms 内完成时,让出的调度开销不值得
- 实时保证——协作调度无法保证截止时间;需要抢占式调度
- CPU 密集且无交互——没有其他需要线程时,让出纯属浪费时间
- 简单场景——浏览器的
requestIdleCallback对非紧急工作可能就够了
八、参考资料
- React Scheduler 源码 - React 协作调度实现
- Go 调度器设计文档 - Go 运行时调度器设计原理
- Erlang BEAM 调度 - BEAM 虚拟机 reduction counting 机制
- W3C requestIdleCallback - 浏览器空闲回调 API 规范
- Fiber 架构 - React Fiber 的协作调度设计文档
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
协作调度(Cooperative Scheduling)
https://blog.souloss.com/posts/programming/concurrency/concurrency-cooperative-scheduling/ 部分信息可能已经过时
相关文章 智能推荐






