1. 先搞清楚CPU调度算法到底在解决什么问题很多人第一次接触 CPU调度算法都是在操作系统课的期末复习周抱着 FCFS、SJF、优先级调度、RR 这四个名词背公式、套表格考完就忘。我自己当年也是这样直到后来做后端服务压测、调容器资源限制、看内核 trace 的时候才发现这些算法压根不是纸上的东西——它们决定了你的接口在并发上来之后尾延迟是 50ms 还是 5s。这篇文章想干的事很明确把 FCFS先来先服务、SJF短作业优先、优先级调度、RR时间片轮转这四个算法从考试题还原成工程问题。会讲清楚它们各自的设计意图、会踩的坑、怎么手算出结果、怎么用 Python 把四种算法跑一遍做横向对比最后给一份排查时间算错的速查表。适合两类人正在学操作系统的同学以及需要对任务调度做技术选型的开发、运维、嵌入式工程师。1.1 从食堂打饭窗口理解就绪队列与调度时机先抛开所有术语。想象一个食堂只有一个打饭窗口单核 CPU一堆人排队就绪队列每个人要打的菜量不同CPU 服务时间。这时候你会发现几个现实问题谁先打能不能让只打一份汤的人插队如果队伍里有个领导高优先级来了怎么办如果一个人打得太多后面的人是不是要等死这四个问题恰好对应了四个算法的核心分歧点。FCFS 回答谁先来谁先打SJF 回答谁快谁先打优先级调度回答谁重要谁先打RR 回答每人只准打 30 秒没打完回去重新排。从技术角度说调度器要处理的就三件事调度时机、选择策略、上下文切换。调度时机是指什么时候该换人了典型触发点有四种——当前进程运行完毕、当前进程被阻塞发起 I/O、时间片用完、有更高优先级的进程到达。选择策略就是从就绪队列里挑谁这正是四个算法的差异所在。上下文切换则是换人的动作它不产生任何有效计算量纯粹是开销所以每次切换都要保存现场PCB、寄存器、程序计数器这个开销在后面的时间片计算里会反复出现。注意调度时机和选择策略容易被混为一谈。很多人算错 RR 的周转时间根本原因不是时间片选错而是搞不清楚新到达的进程和时间片用完被抢占的进程谁先入队。1.2 评价算法好坏的四个指标别只盯着快不快算法好不好不能凭感觉。行业里统一用几个量化指标来对比我把它们和实际含义对应一下。周转时间从作业提交到作业完成的总时间公式是周转时间 完成时间 - 到达时间。它衡量的是用户等了多久才拿到结果。带权周转时间带权周转时间 周转时间 / 服务时间。这个指标很有意思它衡量的是我的等待相对于我的工作量是否合理。一个服务 1 秒的作业周转 3 秒带权周转是 3用户会觉得怎么这么慢一个服务 100 秒的作业周转 120 秒带权周转只有 1.2用户反而觉得可以接受。这就是为什么短作业对延迟格外敏感。等待时间等待时间 周转时间 - 服务时间也就是作业在就绪队列里干等的时间总和不含实际执行时间。这个指标最能反映调度策略的公平性。响应时间从提交到第一次获得 CPU 的时间。对交互式系统来说这个指标比周转时间重要得多——用户敲一下键盘只要屏幕有反应就行不关心后台那个批量任务什么时候跑完。指标计算公式主要影响对象典型敏感场景周转时间完成时间 - 到达时间批处理作业离线任务、报表生成带权周转时间周转时间 / 服务时间混合负载短任务体验等待时间周转时间 - 服务时间公平性评估多租户排队响应时间首次获得 CPU 时间 - 到达时间交互式任务终端、GUI、API 请求看这张表你会发现任何单一算法都不可能四项全优。SJF 能把平均周转时间压到理论最低但它的响应时间可能很难看RR 的响应时间很漂亮但平均周转时间往往不是最优。选型本质上是在做取舍而不是找最优解。1.3 四种算法的分工批处理、交互式、实时场景各取所需为什么教材里要同时讲四个算法而不是只讲一个最好的因为它们的适用场景完全不重叠。FCFS 适合批处理系统中的长作业流实现简单、绝对公平、不会有饥饿问题缺点是一旦前面有个长作业后面全堵着。SJF 适合已知或可预测服务时间的场景比如后台的定时任务、数据批处理它能显著压低平均周转时间但必须解决服务时间怎么预估和长作业会不会饿死这两个问题。优先级调度适合有明确重要性分层的场景比如实时系统中的控制任务、中断处理重要的事必须先干。RR 则是分时系统和现代通用操作系统的基础它牺牲了周转时间换来的是每个任务的响应时间都有上界。真实系统里几乎不会只用一种。Linux 的 CFS 用虚拟运行时间vruntime配合红黑树来选下一个任务本质上是带权重的公平分享实时调度类里用 SCHED_FIFO先来先服务语义和 SCHED_RR时间片轮转语义多级反馈队列更是把优先级和 RR 缝在了一起。所以理解这四个算法其实是在理解更复杂调度器的积木块。2. FCFS先来先服务实现最省事但护航效应会咬人FCFS 是所有调度算法里最好写的一个也是唯一一个不需要额外数据结构就能实现的——它甚至不需要就绪队列这个概念因为顺序就是到达顺序。2.1 算法规则与一段最小实现规则只有一句话按作业到达的先后顺序依次执行一旦开始执行就直到完成中途不打断。这是典型的非抢占式调度。用伪代码表达def fcfs(jobs): seq sorted(jobs, keylambda j: j.arrive) # 按到达时间排队 t 0 finish {} for j in seq: t max(t, j.arrive) j.service # 处理 CPU 空闲的情况 finish[j.name] t return finish注意max(t, j.arrive)这一句。很多人在算题时忘了 CPU 可能空闲——如果第一个作业到达时间是 5那么在 0 到 5 这段时间 CPU 是闲着的不能从 0 开始算。这个细节在连续作业的题目里看不出来一旦出现空隙就会算错。FCFS 的优点很实在实现简单没有额外开销绝对不会饥饿。任何作业只要提交了就一定能被执行只是早晚问题。这在实时性要求不高、但要求绝对确定性的场合很有价值。2.2 手算推演四个作业的周转时间怎么来的我用一组贯穿全文的作业集合来演示后面所有算法的对比都基于它作业到达时间服务时间优先级数值越小越高J1073J2242J3414J4541FCFS 的执行顺序就是 J1 → J2 → J3 → J4时间轴如下J1 占用 0 到 7J2 占用 7 到 11J3 占用 11 到 12J4 占用 12 到 16。J1完成 7周转 7 - 0 7等待 7 - 7 0J2完成 11周转 11 - 2 9等待 9 - 4 5J3完成 12周转 12 - 4 8等待 8 - 1 7J4完成 16周转 16 - 5 11等待 11 - 4 7平均周转时间 (7 9 8 11) / 4 8.75平均等待时间 (0 5 7 7) / 4 4.75。这里有个容易被忽略的观察J3 只服务 1 个时间单位却等了 7 个时间单位带权周转时间高达 8。对短作业来说FCFS 的体验是灾难级的。2.3 护航效应到底有多伤一组极端数据护航效应Convoy Effect是 FCFS 最著名的毛病一个长作业在前后面所有短作业都得陪着它走完全程就像一艘慢船挡住了一整条航道。举个极端例子。假设有三个作业同时到达A 服务 100B 服务 1C 服务 1。FCFS 下A 先跑完 100B 和 C 分别等到 100 和 101。平均周转时间 (100 101 102) / 3 101平均等待时间 (0 99 100) / 3 ≈ 66.3如果换成短作业优先B、C 先跑结果完全不同执行顺序 B(0-1)、C(1-2)、A(2-102)平均周转时间 (1 2 102) / 3 35平均等待时间 (0 1 2) / 3 1平均周转时间从 101 降到 35平均等待时间从 66.3 降到 1。这个差距不是优化是降维打击。而且注意这个例子里长作业 A 的周转时间几乎没变102 对 100它只是稍微晚了一点点但短作业的体验天翻地覆。实操心得我在做批量任务调度的时候会专门给预计执行时间超过阈值的任务打标记。因为这种任务一旦排在前面整个队列的 P99 延迟都会被它拖垮。把它单独放到低优先级队列里效果立竿见影。2.4 实操心得FCFS什么时候还能用别看 FCFS 毛病这么多它在两种场合依然是正确选择。第一种是服务时间高度同质化的队列。如果所有作业的服务时间都差不多比如每个任务都是 10ms 左右的数据同步护航效应就不存在因为根本没有长短之分FCFS 反而是最公平的。第二种是需要严格确定性的场合。FCFS 的执行顺序完全可预测这对调试、日志审计、故障复现非常友好。你在排查一个偶现问题时如果调度策略本身带随机性排查难度会成倍上升。我踩过的一个坑是早期做过一个日志聚合服务用的是简单的 FIFO 队列。平时没问题直到某天上游推送了一个 200MB 的历史日志补传包整个队列堵了将近 4 分钟实时日志全部延迟告警。后来加了单任务最大处理时间 超长任务转后台队列的机制才解决。这就是典型的护航效应在生产环境里的表现。3. SJF短作业优先平均周转时间的理论最优解如果你只关心一个指标——平均周转时间——那 SJF 就是标准答案而且是可以证明的最优解。这个结论在流水车间调度理论里被称为 SPT 规则Shortest Processing Time结论是在所有非抢占式调度策略中按服务时间递增的顺序执行能让平均周转时间最小。3.1 非抢占式SJF的贪心逻辑非抢占式 SJF 的规则是每当 CPU 空闲时从已经到达的作业中挑选服务时间最短的那个执行中途不打断。这里有个关键限定词——已经到达。很多人算 SJF 时会挑整个作业集合里服务时间最短的哪怕它还没到达这就错了。SJF 是在线贪心只能看到当前时刻已经到达的作业。用代码表达def sjf_non_preemptive(jobs): pending list(jobs) t 0 finish {} while pending: ready [j for j in pending if j.arrive t] if not ready: # CPU 空闲跳到下一个到达时刻 t min(j.arrive for j in pending) continue cur min(ready, keylambda j: (j.service, j.arrive)) # 服务时间相同按到达时间 t cur.service finish[cur.name] t pending.remove(cur) return finishkey里带上j.arrive是为了处理服务时间相同的情况保证结果稳定可复现。这一点在做对比实验时很重要——如果排序不稳定同样的输入可能跑出不同的结果你就没法判断差异到底来自算法还是来自排序的随机性。用前面的作业集合手算t0 时只有 J1 到达只能选 J1J1 跑 0 到 7。t7 时 J2、J3、J4 都到了服务时间分别是 4、1、4选最短的 J3跑 7 到 8。接着服务时间为 4 的有 J2 和 J4按到达时间选 J2跑 8 到 12最后 J4 跑 12 到 16。J1完成 7周转 7等待 0J3完成 8周转 4等待 3J2完成 12周转 10等待 6J4完成 16周转 11等待 7平均周转时间 (7 4 10 11) / 4 8.0平均等待时间 (0 3 6 7) / 4 4.0。对比 FCFS 的 8.75 和 4.75SJF 确实更优。但提升幅度没有前面那个极端例子那么夸张原因是这组数据里 J1 是第一个到达的SJF 拿它没办法。3.2 抢占式SRTF每个时间单位都要重新比一次抢占式 SJF 又叫 SRTFShortest Remaining Time First规则是每来一个新作业就比较它和当前运行作业的剩余服务时间如果新来的更短立刻抢占。它的实现方式很简单粗暴——按单位时间推进每个时间片都重新做一次选择def srtf(jobs): remain {j.name: j.service for j in jobs} finish {} t 0 while len(finish) len(jobs): ready [j for j in jobs if j.arrive t and remain[j.name] 0] if not ready: t 1 continue cur min(ready, keylambda j: (remain[j.name], j.arrive)) t 1 remain[cur.name] - 1 if remain[cur.name] 0: finish[cur.name] t return finish还是同一组作业推演一遍t0 到 2J1 独跑剩余 5。t2 时 J2 到达剩余 4比 J1 的 5 短抢占。t2 到 4J2 跑 2 个单位剩余 2。t4 时 J3 到达剩余 1比 J2 的 2 短抢占。J3 在 t5 完成周转 1。t5 时 J4 到达剩余 4候选是 J2 剩余 2、J4 剩余 4、J1 剩余 5选 J2。J2 在 t7 完成周转 5。接着 J4 剩余 4 比 J1 剩余 5 短J4 跑 7 到 11 完成周转 6。最后 J1 从 11 跑到 16周转 16。平均周转时间 (16 5 1 6) / 4 7.0平均等待时间 (9 1 0 2) / 4 3.0SRTF 把平均周转时间压到了 7.0是这四种算法里最低的。但代价也很明显——J1 被连续抢占两次实际执行被打断得七零八落而且它的周转时间从 7 涨到了 16。抢占式算法的平均值更漂亮但个体方差更大这是所有抢占式策略的通病。3.3 同一组作业四种算法横向对比把目前算出来的结果整理一下后面 RR 的计算结果也一并放进来对比算法J1 周转J2 周转J3 周转J4 周转平均周转平均等待FCFS798118.754.75SJF非抢占7104118.004.00SRTF抢占165167.003.00优先级非抢占7131269.505.50RR时间片21673109.005.00这张表里最值得玩味的是 SRTF 那一行。它的平均值最优但 J1 的周转时间 16 是最差的一列。如果 J1 是一个用户直接等待的请求那这个调度策略就是把整体指标做好了把单用户体验做烂了。这也是为什么真实系统里很少用纯 SRTF而是用它的加权版本。3.4 饥饿问题与老化补偿SJF 和优先级调度都有一个致命的共同问题饥饿。如果一个长作业后面源源不断地来短作业这个长作业可能永远等不到 CPU。举个具体的J1 服务 100到达 t0。之后每隔 1 个时间单位就来一个服务时间为 1 的短作业。在 SRTF 下当前作业剩余时间是 100、99、98……而每个新来的短作业剩余时间都是 1永远比它短。结果就是 J1 一直被打断永远跑不完。解决思路叫老化Aging让等待时间长的作业优先级逐渐提升。实现上有两种常见做法。一种是等待时间折算把优先级定义为priority base_priority - wait_time / KK 是老化系数等待越久数值越小优先级越高。另一种是饥饿计数器作业每被跳过 N 次就强制提升一级优先级。老化系数 K 的取值需要根据实际负载调。K 太小老化过快调度退化成 FCFSK 太大老化太慢解决不了饥饿。经验做法是先统计系统内作业的平均服务时间让 K 大致等于这个量级这样老化速度和服务时间尺度匹配。3.5 服务时间怎么估指数平均法的工程做法SJF 的前提是知道服务时间但现实中你根本不知道下一个请求要跑多久。工程上的解法是用历史预测未来最经典的公式是指数平均预测值(n1) α × 实际值(n) (1 - α) × 预测值(n)α 取值在 0 到 1 之间。α 越接近 1越相信最近一次的观测越接近 0越依赖长期历史。通常取 α 0.5兼顾响应性和稳定性。比如一个进程前三次实际运行时间是 6、4、8初始预测值设 5第一次预测 5实际 6新预测 0.5×6 0.5×5 5.5第二次预测 5.5实际 4新预测 0.5×4 0.5×5.5 4.75第三次预测 4.75实际 8新预测 0.5×8 0.5×4.75 6.375这个方法的妙处在于实现成本极低——每个进程只需要存一个预测值一次乘加就能更新。Linux 里估算进程交互性的思路和它本质相同都是靠历史行为推断未来。注意用预测值做 SJF 时必须在预测值后面加一个最小保护值。否则一个预测为 0 的进程会反复抢占 CPU造成所谓预测偏差自激——它跑得越短预测值越低越容易被选中越容易被观测到短时间。4. 优先级调度算法把重要性翻译成数字FCFS 和 SJF 都是把时间当作唯一尺度但现实中的任务重要性并不由时间决定。中断处理程序可能只需要 10 微秒但它的重要性远超一个跑 10 秒的批处理任务。优先级调度解决的就是这个问题。4.1 静态与动态优先级的取舍优先级可以是静态的——创建时确定运行期间不变也可以是动态的——随运行状态变化。静态优先级的优点是简单、可预测、开销低适合嵌入式实时系统。缺点是它无法应对运行时的情况变化如果一开始优先级设错了就只能错到底。而且静态优先级特别容易造成低优先级任务长期饥饿。动态优先级灵活得多但引入了一个新问题优先级怎么变常见的动态规则包括等待时间越长优先级越高抗饥饿、占用 CPU 越多优先级越低防止霸占、I/O 密集型的进程优先级升高因为它们通常会很快让出 CPU。在实现上动态优先级的更新需要一个触发时机。最简单的是每次调度时重算一遍成本高但效果好更常用的做法是定时器周期更新比如每 10ms 扫一遍就绪队列做衰减。4.2 优先级反转一个真实会出事的场景这是优先级调度里最容易出事的地方也是我在实际项目里真正被坑过的。场景是这样的有三个任务高优先级 H、中优先级 M、低优先级 L。H 和 L 需要访问同一个互斥锁保护的资源。执行序列L 先拿到锁正在临界区里干活此时 H 到达抢占 CPU尝试拿锁失败被阻塞然后 M 到达因为 H 已经被阻塞、L 优先级又低M 抢到了 CPU 开始长时间运行。结果就是H 明明优先级最高却在等 M 跑完而 M 的优先级比 L 高、比 H 低。高优先级任务被中优先级任务间接拖住了。这就是优先级反转。解决方法是优先级继承当 H 因为等锁而阻塞时持有锁的 L 临时继承 H 的优先级。这样 M 就无法抢占 L 了L 能尽快跑完临界区释放锁H 也就能尽快被唤醒。另一种方案是优先级天花板给锁设定一个天花板优先级任何持有该锁的任务自动提升到天花板级别简单粗暴但更保守。实操心得优先级反转不是理论玩具。我在一个多线程数据采集程序里遇到过采集线程高优先级被日志写入线程中优先级拖住了将近 200ms原因是采集线程在等一把被配置读取线程持有的锁而配置读取线程的优先级最低。排查了两天才定位到。如果你的系统里有高优先级任务偶发性卡顿的现象优先怀疑这个。4.3 多级反馈队列把优先级和RR缝在一起真实系统里用得多的是多级反馈队列MLFQ它把优先级调度和 RR 结合起来同时用动态调整来兼顾响应和吞吐。基本结构是多个就绪队列优先级从高到低排列。规则大致是四条新任务进入最高优先级队列同一队列内按 RR 轮转任务用完整时间片还没结束就降到下一级队列在低优先级队列里等待过久的任务提升回高优先级老化。时间片设置也有讲究通常是队列优先级越低时间片越长。比如第一级 8ms、第二级 16ms、第三级 32ms。这个设计逻辑很清晰交互式任务通常很快就能结束放在高优先级、短时间片里能获得极低的响应时间而长计算任务会一路下沉到低优先级、长时间片避免频繁切换浪费 CPU。MLFQ 的精妙之处在于它不需要预先知道任何任务的类型。I/O 密集型任务因为频繁让出 CPU会一直留在高优先级队列CPU 密集型任务会自动下沉。这种用行为自动分类的思路比人工打标签靠谱得多。5. RR时间片轮转交互流畅度的守门员如果前面几个算法都在优化总时间RR 优化的是完全另一个维度让每个任务等待的时间都有上界。5.1 算法规则与那道绕不开的入队顺序题RR 的规则所有就绪任务排成一个队列轮流执行一个固定长度的时间片。时间片用完还没结束的任务回到队尾重新排队。这是抢占式算法。规则听着简单但有一个细节会直接决定计算结果——时间片用完的时刻和被抢占的任务、新到达的任务谁先入队常见约定有两种我采用教材里最通用的那种时间片到期时先把在此时刻及之前到达的新任务按到达顺序插入队尾再把当前被抢占的任务插入队尾。换句话说新来的排在被抢占的前面。另一种约定是被抢占的先入队、新来的后入队算出来的周转时间会不一样。所以做题或者写代码之前一定要先确认用的是哪种约定否则和标准答案对不上还会怀疑自己算错了。5.2 时间片取多大一个可以算出来的区间时间片的大小是 RR 最核心的参数它直接决定了两件事响应时间和切换开销。时间片太小切换次数剧增CPU 大量时间浪费在上下文切换上。假设一次上下文切换开销是 0.1ms时间片是 1ms那就有接近 9% 的 CPU 时间被浪费掉了这个比例相当吓人。时间片太大RR 就退化成 FCFS短作业又要挨护航效应的打。工程上的经验区间是这样的时间片应该大于 80% 的 CPU 突发长度同时让上下文切换开销占比控制在 5% 以内。如果一次上下文切换耗时 c时间片是 q那么开销占比约为c / (q c)。要让这个值小于 5%需要q 19c。以典型 Linux 环境为例一次上下文切换的开销在 1 到 5 微秒量级那么时间片应该在 20 到 100 微秒以上。历史上经典的分时系统时间片设置在 10ms 到 100ms 之间现代系统因为切换开销更低倾向取更小的值来提升交互性。时间片 q切换开销占比c 0.1ms典型适用场景0.5ms约 16.7%极少使用开销浪费严重1ms约 9.1%高实时性、短任务为主10ms约 1.0%通用分时系统100ms约 0.1%交互性要求低、吞吐优先5.3 手算推演RR的完整过程用前面的作业集合取时间片 q 2按新到达先入队的约定推演。t0就绪队列 [J1]。J1 执行 0 到 2剩余 5。t2 时 J2 到达入队然后 J1 入队尾。队列 [J2, J1]。J2 执行 2 到 4剩余 2。t4 时 J3 到达入队J2 入队尾。队列 [J1, J3, J2]。J1 执行 4 到 6剩余 3。t5 时 J4 到达t6 时入队然后 J1 入队尾。队列 [J3, J2, J4, J1]。J3 执行 6 到 7服务时间只有 1完成于 7周转 3等待 2。队列 [J2, J4, J1]。J2 执行 7 到 9剩余 2 减到 0完成于 9周转 7等待 3。队列 [J4, J1]。J4 执行 9 到 11剩余 2入队尾。队列 [J1, J4]。J1 执行 11 到 13剩余 1入队尾。队列 [J4, J1]。J4 执行 13 到 15剩余 0完成于 15周转 10等待 6。队列 [J1]。J1 执行 15 到 16剩余 0完成于 16周转 16等待 9。平均周转 (16 7 3 10) / 4 9.0平均等待 (9 3 2 6) / 4 5.0。注意 J3 的结果它在 RR 下的完成时间只比 SRTF 晚 2 个单位但比 FCFS 早了 5 个单位。短作业在 RR 下不会因为排在长作业后面而无限期等待这正是 RR 的价值。5.4 上下文切换开销的账要这么算我做一个真实的场景估算。假设系统有 50 个活跃进程时间片 10ms一次上下文切换开销 3 微秒。单个进程完成一轮需要 50 × (10 0.003) ≈ 500.15ms。切换开销占比 0.003 / 10.003 ≈ 0.03%可以忽略。但如果是另一个极端50 个进程时间片设成 0.1ms。单轮时间 50 × 0.103 5.15ms切换开销占比 0.003 / 0.103 ≈ 2.9%。看起来还能接受但实际的影响不止于此——过于频繁的切换会严重破坏 CPU 缓存局部性导致缓存命中率骤降实际性能损失远超理论计算值。这是纸面公式算不出来的隐性成本。注意调整时间片时除了算公式一定要看实际的缓存未命中率和上下文切换次数的监控指标。vmstat里的cs列上下文切换次数如果持续在每秒几十万次以上即使理论开销占比不高实际吞吐也会明显下降。6. 用Python把四种算法跑一遍一套可复现的仿真纸上推演容易出错也难验证。我用一套统一的代码框架把四种算法实现出来保证统计口径一致输出可以直接互相比较。6.1 数据结构和统计口径设计统一用 dataclass 定义作业把静态属性和运行时状态分清楚。静态属性是到达时间、服务时间、优先级运行时状态是剩余时间、完成时间。这样做的好处是算法之间可以共享同一份作业定义不用为每个算法重新构造数据。统计函数独立出来接收作业列表和一个完成时间字典统一算出周转、带权周转、等待三个指标。统计口径统一了结果才有可比性。6.2 完整仿真代码from dataclasses import dataclass from collections import deque dataclass class Job: name: str arrive: int service: int prio: int 0 # 数值越小优先级越高 def build_jobs(): return [ Job(J1, 0, 7, 3), Job(J2, 2, 4, 2), Job(J3, 4, 1, 4), Job(J4, 5, 4, 1), ] def report(title, jobs, finish): n len(jobs) tot_turn tot_wait 0.0 print(f--- {title} ---) print(f{作业:6}{到达:6}{服务:6}{完成:6}{周转:6}{带权周转:10}{等待:6}) for j in jobs: f finish[j.name] turn f - j.arrive wait turn - j.service tot_turn turn tot_wait wait print(f{j.name:6}{j.arrive:6}{j.service:6}{f:6}{turn:6}{turn / j.service:10.2f}{wait:6}) print(f平均周转时间 {tot_turn / n:.2f} 平均等待时间 {tot_wait / n:.2f}\n) def fcfs(jobs): seq sorted(jobs, keylambda j: j.arrive) t, finish 0, {} for j in seq: t max(t, j.arrive) j.service finish[j.name] t return finish def sjf(jobs): pending list(jobs) t, finish 0, {} while pending: ready [j for j in pending if j.arrive t] if not ready: t min(j.arrive for j in pending) continue cur min(ready, keylambda j: (j.service, j.arrive)) t cur.service finish[cur.name] t pending.remove(cur) return finish def srtf(jobs): remain {j.name: j.service for j in jobs} finish, t {}, 0 while len(finish) len(jobs): ready [j for j in jobs if j.arrive t and remain[j.name] 0] if not ready: t 1 continue cur min(ready, keylambda j: (remain[j.name], j.arrive)) t 1 remain[cur.name] - 1 if remain[cur.name] 0: finish[cur.name] t return finish def priority_np(jobs): pending list(jobs) t, finish 0, {} while pending: ready [j for j in pending if j.arrive t] if not ready: t min(j.arrive for j in pending) continue cur min(ready, keylambda j: (j.prio, j.arrive)) t cur.service finish[cur.name] t pending.remove(cur) return finish def rr(jobs, quantum2): seq sorted(jobs, keylambda j: j.arrive) remain {j.name: j.service for j in seq} finish, ready {}, deque() t, idx, n 0, 0, len(seq) if n 0: return finish t seq[0].arrive ready.append(seq[0]) idx 1 while ready: cur ready.popleft() run min(quantum, remain[cur.name]) t run remain[cur.name] - run # 新到达的作业先入队 while idx n and seq[idx].arrive t: ready.append(seq[idx]) idx 1 if remain[cur.name] 0: finish[cur.name] t else: ready.append(cur) # 被抢占的后入队 if not ready and idx n: # CPU 空闲 t seq[idx].arrive ready.append(seq[idx]) idx 1 return finish if __name__ __main__: jobs build_jobs() report(FCFS, jobs, fcfs(jobs)) report(SJF 非抢占, jobs, sjf(jobs)) report(SRTF 抢占, jobs, srtf(jobs)) report(优先级 非抢占, jobs, priority_np(jobs)) report(RR 时间片2, jobs, rr(jobs, 2))6.3 跑出来的结果与验证直接运行输出如下--- FCFS --- 平均周转时间 8.75 平均等待时间 4.75 --- SJF 非抢占 --- 平均周转时间 8.00 平均等待时间 4.00 --- SRTF 抢占 --- 平均周转时间 7.00 平均等待时间 3.00 --- 优先级 非抢占 --- 平均周转时间 9.50 平均等待时间 5.50 --- RR 时间片2 --- 平均周转时间 9.00 平均等待时间 5.00和手算结果完全一致说明实现没有偏差。这一步很关键——如果你手算和代码跑出来的结果对不上八成是三个地方出了问题入队顺序约定不一致、忘了处理 CPU 空闲、或者抢占判断的临界条件写错了比如该用写成了。优先级那一行的数据可以手工验证一下J1 到达 0独跑 0 到 7。t7 时已到达的作业有 J2优先级 2、J3优先级 4、J4优先级 1数值最小的是 J4所以 J4 跑 7 到 11。接着比 J22和 J34选 J2 跑 11 到 15最后 J3 跑 15 到 16。周转分别是 7、13、12、6平均 9.5。没错。6.4 改参数做实验把时间片从1扫到8既然代码写好了顺手做个小实验看看时间片对 RR 的影响。把quantum从 1 扫到 8同一组作业的结果如下时间片 q平均周转时间平均等待时间备注19.505.50切换最频繁29.005.00311.007.0048.504.50本组数据下的最优点59.505.50610.256.2578.754.75等价于 FCFS这组数据有个反直觉的地方q4 的结果居然比 q2 和 q1 都好甚至比 FCFS 都好。这不是 bug也不是普遍规律而是特定输入下的巧合——这组作业的到达时间和剩余时间刚好和 q4 形成了较优的错位让 J2 能一次跑完。我在实际调参时总结出的经验是时间片和负载特征强耦合没有通用最优值。真的要调就收集自己系统真实的到达时间分布和服务时间分布用这段仿真代码扫一遍参数空间看哪个值在你的负载下平均指标最好。凭感觉设一个10ms是偷懒不是工程。另外注意平均指标好不代表体验好。生产环境里我一般会同时看 P95 和 P99 的周转时间和响应时间因为平均值会被大量短任务拉低掩盖掉长尾问题。这也解释了一个常见困惑为什么监控面板上平均延迟很漂亮用户却在投诉卡顿——因为你在优化平均值用户在体验长尾。7. 常见问题与排查实录算这类题目或者写这类调度逻辑出错的地方高度集中。我把这些年踩过的坑和帮别人排查过的问题整理成一份速查表。7.1 算错时间的六种典型情况速查表症状根本原因修正方法RR 结果和标准答案差几个单位入队顺序约定不同明确新到达先入队还是被抢占先入队全流程统一第一个作业开始时间不是它的到达时间忘了处理 CPU 空闲用max(当前时间, 到达时间)作为起算点SJF 结果偏乐观选了尚未到达的最短作业筛选条件必须是arrive tSRTF 抢占次数不对比较的是服务时间而非剩余时间抢占判断用剩余时间不是原始服务时间优先级结果不稳定优先级相同时排序不确定排序键加上到达时间做第二判据带权周转时间算错分母用了周转时间分母是服务时间且注意服务时间为 0 的边界关于最后一条补充一句服务时间为 0 的作业在真实系统里是存在的比如空转的定时器回调做除法前一定要加保护否则程序直接抛除零异常。我见过一个批处理框架因为这个问题在凌晨崩溃排查了一整晚最后发现是某个定时任务在特定条件下服务时间被估算成了 0。还有一个不那么常见但很折磨人的问题时间片大于所有作业的服务时间。这时候 RR 完全退化成 FCFS如果你在对比实验里把 q 设成了 100会得到 RR 和 FCFS 完全一样的结果然后误以为代码写错了。这是预期行为不是 bug。7.2 调试这类题目的三个习惯第一个习惯是画时间轴。不要只在脑子里推演拿张纸画一条横线标出每个时刻发生了什么。我见过太多人算错是因为脑子里同时跟踪了太多状态——谁在跑、剩余多少、队列里还有谁、下一个谁到。画出来一个时刻一个时刻标错误率会大幅下降。第二个习惯是反向验证。算完之后用总量校验一遍所有作业的总执行时间加起来应该等于最后一个作业的完成时间减去第一个作业的开始时间再减去 CPU 的空闲时间。如果不相等说明一定算错了。这个检查特别适合交叉验证抢占式算法的结果因为抢占式最容易出现时间凭空多出来或少了的情况。第三个习惯是先写代码再手算。这个顺序听起来反了但对我很有效。因为写代码的过程会强迫你把所有边界条件想清楚——CPU 空闲怎么办、多个作业同时到达怎么排序、时间片正好用完且作业正好完成怎么算。代码写完了手算时脑子里已经有完整的规则模型反而不容易错。补充一个检查技巧把 SJF 的结果和 SRTF 的结果放在一起看。SRTF 的平均周转时间一定小于等于 SJF因为抢占式总是在非抢占式的可选集合里多了一个更优的选择。如果你的代码跑出 SRTF 比 SJF 还差那一定是 SRTF 的实现有问题。我把这四种算法反复实现过好几遍最大的体会是调度算法的难点从来不在算法本身而在于规则边界的精确定义。FCFS、SJF、优先级、RR 这四个的逻辑一页纸就能写完但每一次实现都会在同时到达怎么排时间片和完成时刻重合怎么算CPU 空闲要不要推进时间这些细节上卡住。这些细节没有标准答案取决于约定但必须自洽。所以如果你在做技术选型先别急着比较谁的指标更好先把边界约定写下来否则后面所有的对比都是空中楼阁。