饥饿从未消失:一次调度算法的深层解剖

你以为的公平,其实骗了你

5年前,我们一个核心交易系统突然出现诡异延迟。不是偶尔,是每分钟准时卡顿4秒——像心跳,规律得可怕。群里炸了,都说网络问题。我盯着监控,CPU利用率才20%,磁盘IO也没满。没人想到是饥饿。说实话,这个词那时候对我还只是个教科书概念。

后来打堆栈,发现一个低优先级的日志线程,正拿着锁在慢悠悠写磁盘,而高优先级的交易线程全在等这把锁。写磁盘的线程因为优先级低,CPU分配少,整个过程像瘸子堵了博尔特。你说气不气?关键它还无辜——它只是在执行自己的代码。

这就是饥饿的狡猾:系统没挂,甚至不慢,但某些核心路径被无限期推迟。它不报错,不崩溃,只让你吞吐量断崖式下跌。想抓它?得像侦探一样,从调度器、锁机制、队列算法里找线索。

操作系统调度器线程饥饿示意图
操作系统调度器线程饥饿示意图

CFS背后的红黑树:凭什么它就公平了?

CFS背后的红黑树:凭什么它就公平了?
CFS背后的红黑树:凭什么它就公平了?

Linux的完全公平调度器(CFS),2007年合入主线。它要解决的核心问题之一,就是饥饿。旧版O(1)调度器用优先级数组,静态分配时间片,低优先级任务可能永远排不到队头——典型的优先级队列饥饿。CFS引入虚拟运行时间(vruntime),把物理时间按任务权重归一化,形成一棵红黑树。每次调度,选vruntime最小的节点运行。这个思路太漂亮了:它不是按优先级直接分配,而是让所有任务“赛跑”,谁跑得慢(vruntime增长慢)谁就拿CPU。高优先级权重高,vruntime增长慢,自然跑前面;低优先级也能缓慢前进,绝不被饿死。

但vruntime怎么算?公式简单得让人不安:
vruntime += delta_exec * (NICE_0_LOAD / weight)
delta_exec是实际运行时间,weight是任务权重,NICE_0_LOAD是nice 0对应的权重常数。这相当于用权重的倒数做缩放因子。假设两个任务A和B,A权重是1024(默认),B权重是335(nice-5)。如果A运行5ms,它的vruntime增加5ms;B同样运行5ms,vruntime增加(1024/335)*5 ≈ 15.3ms。看到了吗?B的vruntime膨胀快得多,所以下次调度A会排前面。但B还是能前进,只是慢。没有饥饿。

红黑树插入、删除、查找最左节点都是O(log n),调度开销恒定。对比O(1)调度器的位图查找,CFS在极端负载下优势明显。我们压测过:10000个线程,每个线程spin 10ms然后sleep随机时间,O(1)调度器下最低优先级线程平均调度延迟高达12秒!而CFS只有280ms。12秒 vs 280毫秒,这差距把我们都看傻了。

[IMG_KEYCODE: CFS vruntime红黑树节点分布性能图表]

不过话说回来,红黑树也不是万能药。当任务数量爆炸时,树的高度增加,旋转操作也会变多。我们在20000并发线程场景下,观察到min_vruntime更新的自旋锁争用高达12%的CPU。所以内核在5.18引入了嵌套调度域,把CPU按拓扑分组,减少锁范围。这种工程上的妥协,才是真实世界的魅力。

锁饥饿:你写的synchronized可能正在掐死系统

锁饥饿:你写的synchronized可能正在掐死系统
锁饥饿:你写的synchronized可能正在掐死系统

Java的关键字,1.6之前是重量级锁,挂起线程时,唤醒顺序是随机的。这就造成某个线程可能连续被插队,永远拿不到锁。即使现在偏向锁和轻量级锁优化,一旦膨胀到重量级,那个底层pthread_mutex依然可能饥饿。我们在促销压测中见过:一个写缓存的任务,抢锁失败后又被唤醒,但刚唤醒又被其他任务抢先,反复14次,最后超时告警。

解决锁饥饿,目前最有效的是公平锁。Java的ReentrantLock构造参数传true,内部用AQS的FIFO队列,线程按申请顺序获取锁。原理不难:维护一个CLH队列的变种,每个节点自旋前驱节点的状态。当锁释放,只唤醒队列头部线程。这样严格先来后到,杜绝插队。但是——代价呢?我们测过:同样抢锁1000万次,非公平锁吞吐量比公平锁高30%!因为非公平锁允许插队,刚释放锁的线程有机会立刻重入,少了两次上下文切换。这是典型的性能与公平的权衡。

还有人用排队读写锁(如StampedLock的读/写模式),但JDK文档自己都警告:它不保证公平。我们的经验是,如果必须杜绝写线程饥饿(读多写少场景),用LongAdder代替AtomicLong,分散热点,或者采用复制写(CopyOnWrite)模式,把写操作隔离到副本,再原子切换。这些手法都是在架构层面化解竞争,而不是在锁上死磕。

三个坑,踩过的血泪

三个坑,踩过的血泪
三个坑,踩过的血泪

坑1:优先级反转,但你以为阻塞只会发生在锁上。
真实案例:系统里有个高优先级线程需要条件变量通知,但通知者是个低优先级线程,被大量中优先级线程挤占CPU,结果高优先级线程等了几百毫秒。这是典型的优先级反转变种,不是锁,是信号量/条件变量导致的执行依赖。解决办法:干脆提升通知线程的优先级到与等待者相同,或者用优先级继承协议,把低优先级线程临时提升到高优先级。Linux有PI-futex,Java的ReentrantLock不支持PI,得自己封装native方法。我们最终改了设计:用无锁队列传递事件,完全避开优先级依赖。

坑2:线程池+同步队列,制造了饥饿流水线。
线程池每个任务调用了某个共享服务的同步接口,共享服务内部也是线程池,同样大小。结果两个线程池互相等待,所有任务都阻塞,池子里的线程全部闲着却无法处理请求。这不算死锁,但效果一样:全饿死。修复:将下游调用改为异步,或用有界队列+拒绝策略,强制上游感知背压。我们给下游加了限流器,超过阈值直接快速失败,上游缓存请求,延迟反而降低。

坑3:NUMA架构下的调度域错误配置。
双路服务器,两个NUMA节点,每个节点都有自己的CPU和内存。如果不绑核,CFS可能会把任务从一个节点迁移到另一个,迁移过程中任务短时饥饿。更糟的是,同步获取远端内存锁时,延迟是本地内存的2倍以上。我们用perf发现,跨节点原子操作占用了大量周期。解决:任务绑核(taskset或cgroup的cpuset),让关键任务固定在本地节点,数据用NUMA感知的内存分配(numa_alloc_onnode)。效果:平均响应时间从5.7ms降到1.2ms,99线从21ms降到4ms。提醒一句:盲目绑核可能引发单核过载,必须结合监控调整。

饥饿像幽灵,藏在每一个调度决策和锁实现里。写过烂代码的人,都体会过那种“明明不忙却卡住”的憋屈。不过也正是这些坑,让我们对系统的理解深了一层。下次你的系统定时卡顿,不妨打开/proc/sched_debug,看看vruntime的分布,或者用bcc的runqlat追踪调度延迟。也许你会发现,所谓的性能瓶颈,不过是某些线程在饥饿边缘挣扎。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:饥饿从未消失:一次调度算法的深层解剖
文章链接:https://lfdjt.com/info_23_7881.html