ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CPU调度与多核负载均衡:从概念到实践全解析

CPU调度与多核负载均衡:从概念到实践全解析

我们经常遇到一个现象:一台多核服务器,配置看着没问题,但top一看,一个核心跑到 100%,其余核心空闲一大半。业务响应变慢,但你又不知道问题出在操作系统哪一层。这类问题十有八九与 CPU 调度和负载均衡机制有关。我早年在做性能调优时也被这种问题折磨过,后来把调度器的实现思路啃了一遍,才算真正理解那些系统参数和内核线程的行为逻辑。本文就围绕“CPU负载均衡之调度概念理解”这个主题,把调度器、运行队列、多核负载均衡这些概念掰开揉碎讲清楚。

这篇文章适合刚接触内核调度的人、做性能分析和系统调优的工程师,也适合写多线程程序的开发者。你不需要预先精通内核源码,只要理解进程调度的大致流程,就能跟着我的思路把整个调度体系串起来,并且在排查问题时知道该往哪个方向看。

1. 调度器在解决什么问题:从排队到分活

1.1 调度的本质是资源分配策略

CPU 本身是稀缺资源,而进程和线程数量通常远多于 CPU 核心数。操作系统不可能让每个线程独占一个核心,它必须决定“下一个该谁运行”“运行多久”“什么时候换下来”。这个决策过程就是调度。换个生活化的说法,CPU 调度器就像一家银行网点的大堂经理,手里有多个窗口(核心),客户(线程)数量远多于窗口,经理要按照某种规则引导客户到对应窗口办理业务。规则做得好,客户等待时间短、窗口利用率高;规则做不好,某个窗口排长队,另外几个窗口却闲着。

调度器要解决的核心矛盾有三个:一是公平性,不能让某些线程饿死;二是效率,尽量压低平均响应时间和上下文切换开销;三是实时性,在满足前两者的前提下,还要为实时任务预留确定性。这三者往往互相冲突,所以不同操作系统在调度器设计上的取舍也不同,但基本框架都绕不开队列、优先级、时间片和抢占这几样东西。

1.2 调度实体、运行队列与优先级

在内核视角里,被调度的单位叫调度实体。在早期 Linux 内核里,调度实体就是进程或者线程;后来虚拟化场景多了,一组线程也可以打包成一个调度实体,比如 cgroup 内的进程组。我们平时写多线程程序时,业务上关心的是线程;在内核那里,每个线程对应一个 task_struct,里面保存自己的状态、寄存器上下文、优先级、调度策略和运行统计信息。

每个 CPU 维护一条自己的运行队列(runqueue),存放当前分配给这个 CPU 的所有可运行调度实体。队列里的实体按某种顺序排列,比如按优先级,或者按虚拟运行时间排序。当调度器决定下一个运行谁时,就从这个队列里挑选头部的实体。这里有一个重要的概念:运行队列不止放正在运行的线程,那些可运行但当前没分到 CPU 的线程也挂在里面。所以运行队列的长度,通常可以表征这一核的拥挤程度。

优先级在调度里面是很直接的调参手段。普通进程的 nice 值范围是 -20 到 19,数值越小优先级越高。除了 nice 这种静态优先级,内核还维护一种动态优先级,会根据任务的行为动态调整。比如交互型任务频繁睡眠唤醒,内核倾向于给它更高的动态优先级,这样它的响应会比较快。这里体现的是调度器对任务类型的自适应,而不只是听凭用户配置。

1.3 调度时机:主动让出与被动抢占

调度器不是无时无刻都在做决策,只在特定时机被触发。理解这些时机,对分析问题很有帮助。第一种时机是任务主动让出 CPU,比如调用sleep()、等待 I/O 或锁时,线程从运行状态进入睡眠状态,调度器马上重新选择下一个运行任务。第二种时机是抢占,当高优先级任务苏醒,或者当前任务的时间片耗尽,调度器会打断当前任务,强制切换。第三种时机是内核代码在中断返回或系统调用返回路径上,发现need_resched标志被设置,那就在返回用户态之前先执行一次调度。大多数商用操作系统采用的都是抢占式调度,也就是说内核随时可以在合适的安全点打断用户态程序,保证高优先级任务不被长任务阻塞。

有了调度时机之后,才有负载均衡的用武之地。因为负载均衡本身也是一项调度活动,它需要被某种时机触发——周期性时钟中断、空闲 CPU 等等。接下来我把负载均衡这个概念单独拉出来讲。

2. 负载均衡到底平衡什么:不只是“谁空闲谁多分”

2.1 单核时代的调度思路

在单核 CPU 时代,负载均衡基本不成问题,因为没得选,所有可运行任务都在同一个运行队列里排队,不存在多个 CPU 之间的任务分配问题。那时调度器的主要精力放在算法本身上:怎么从众多待运行任务中挑一个出来,怎么计算时间片,怎么处理优先级反转。

后来多核 CPU 普及,如果每个核仍然共享同一个全局运行队列,就会产生严重的锁竞争。所有核同时访问同一个队列,内核必须加锁,锁的争抢越激烈,性能损耗越大。所以现代内核普遍采用每个 CPU 独立运行队列的结构,彻底避免全局锁。但是这种结构又引入一个新问题:各核的运行队列负载不均。有些核忙得要命,有些核闲得发慌,系统的整体吞吐量上不去,这就需要负载均衡机制来动态调整。

2.2 多核负载不均的根源

负载不均衡的来源比大多数人想象的复杂。第一类是程序本身的问题,比如主从架构下,主线程处理大部分请求,从线程只做辅助工作;比如线程绑核绑错了位置,把密集计算线程全放在同一个核上。第二类是内核初始分配的问题,新创建的线程默认放到当前正在运行的 CPU 上,创建线程的进程本身所在核负载就高,如果程序短时间创建大量线程,这批线程就会扎堆出现在同一个核上。第三类是中断与软中断的分布,网卡中断如果不做多队列配置,很可能集中到一个核,哪怕调度器把线程均匀分布了,这个核仍然因为处理中断而显得特别忙。

我在做性能分析时,经常用mpstat -P ALL看每个核的使用率。如果只看系统的整体负载,感觉没过载,但业务延迟很高,这种时候十有八九就是小核不均的问题。有一个很典型的场景:数据库实例的数据落盘线程绑定在 CPU 15,某次压测时 15 号核使用率 100%,其他核只有 20% 到 30%,整体 CPU 才用了不到一半。这种问题靠改代码可能没有意义,因为单线程就是计算密集,更好的方式是拆分任务,让其他核也分担一部分。

2.3 负载均衡的度量指标

负载“不均衡”怎么量化?最简单的指标是每个 CPU 运行队列中的可运行任务数量,Linux 的load average其实就和可运行任务数有关。但只看任务数量不够,因为任务本身有轻重:有的任务占用 CPU 很久,有的任务只是短暂运行就睡眠。更准确的度量方式是 CPU 占用时间,或者更细粒度地看每个调度实体的虚拟运行时间。

在不同架构下,负载均衡的粒度也不同。在 NUMA 架构里,CPU 和内存的访问关系不再对等:CPU 访问本地内存快,访问远端内存慢。如果调度器把一个线程迁移到另一个 NUMA 节点,线程访问自己数据时就要绕远路,可能得不偿失。所以负载均衡不能只盯着 CPU 利用率,还要考虑内存访问成本。现代内核在负载均衡时会尽量保证线程不跨 NUMA 节点迁移,宁可允许节点内轻微不均衡,也不频繁跨节点搬移任务。这是一个非常重要的取舍。

3. 调度算法与负载均衡机制:常用方案和它们背后的逻辑

3.1 时间片轮转与优先级调度

先说说历史悠久的两种基础算法。时间片轮转非常简单:每个可运行任务轮流运行一个固定长度的时间片,时间片用完就排到队尾。公平性很好,响应时间有上下界,但问题也很明显:没有区分任务的轻重缓急。一个后台批处理任务和一个前台交互任务拿同样的时间片,交互任务可能因为等待时间过长而显得很卡。

优先级调度解决了区分度问题:可运行队列按优先级分成多个子队列,高优先级队列里的任务先被调度,高优先级任务可以抢占低优先级任务。但是纯优先级调度会导致低优先级任务长期得不到 CPU,即“饥饿”。为了解决饥饿问题,许多系统做了动态优先级调整:任务每等待一个周期,优先级就临时提升一点,最终保证它能被调度到。这种策略在实时操作系统中很常见,比如早期 Windows 和部分嵌入式系统。

3.2 CFS 调度器:用虚拟运行时间换公平

现代 Linux 使用的 CFS(完全公平调度器)思路很有意思。它不再直接给任务分时间片,而是维护一个虚拟运行时间vruntime。任务每运行一个周期,虚拟运行时间就累加,但累加速度和任务权重成反比:权重高的任务,虚拟时间增长慢;权重低的任务,虚拟时间增长快。调度器每次选择虚拟运行时间最小的任务来运行,这样从虚拟时间角度看,所有任务保持“相对公平”。

CFS 维护可运行任务时用红黑树,虚拟运行时间最小的任务位于树的最左侧,查找下一个要运行的任务时间复杂度是 O(log n)。这个设计也兼顾了效率。你可以用cat /proc/sched_debug看当前每个 CPU 的红黑树里有哪些任务。我遇到过一种问题:CFS 的树里有一个任务的vruntime异常偏小,它总是被选中,导致其他任务轮流被饿着。排查后发现是因为该任务频繁睡眠唤醒,每次唤醒都被重置了部分的虚拟时间。这个现象说明,CFS 虽然号称完全公平,但睡眠任务在唤醒时机的处理上仍然有一些偏向性,这是内核为了交互响应做出的有意权衡。

3.3 多核负载均衡的 pull 和 push 机制

多核负载均衡具体怎么执行?Linux 内核有两套互补的机制,形象地称为 pull 和 push。pull 机制发生在 CPU 空闲的时候:当某个核发现自己的运行队列空了,它不会傻傻等着,而是主动去其他核的运行队列里“抢”任务过来运行。这个过程在源码里对应idle_balance路径。push 机制发生在任务唤醒的时候,调度器会检查当前 CPU 是否过载,如果是,就把新唤醒的任务推到其他负载较低的 CPU 上。这样做的目的是避免在唤醒时就把所有任务堆在一个核上。

一种非常值得注意的情况是:如果系统的任务数本来就很少,比如只有 2 个线程在 8 核机器上跑,pull 机制实际上没什么用,因为任务不迁移也能跑。此时真正决定性能的是任务是否落在了同一个核上,以及这两个线程之间有没有数据交互。如果两个线程频繁共享数据,落在同一个核上反而更好,因为缓存共享、不需要跨核同步;反之,如果两个线程各自独立计算,落在不同核上更好。负载均衡机制并不理解任务的业务关系,它只做统计层面的搬运,所以有时候手动绑核比内核自动均衡的效果更好。

3.4 调度域与 NUMA 感知

现代内核的负载均衡不是盲目全局搬移,而是分层的。调度域(scheduling domain)把 CPU 集合组织成层级结构,每个层级内先做均衡,再考虑跨层级迁移。比如在一个双路服务器上,内核会先看同一个 NUMA 节点内各核是否均衡,再去调整节点之间的负载。这种分层设计降低了迁移成本,也减少了无谓的任务搬移。

我在实际调优中有过一次深刻教训:某台服务器上跑一个内存数据库的前端线程和一后端落盘线程,前端线程对响应时间极敏感。我把 load balance 的sched_mc_power_savings打开,期望省电。结果发现前端线程经常被迁移到另一个 NUMA 节点,每次迁移后访问共享内存都要跨节点,延迟直接翻倍。后来我关掉了这个选项,又手动把关键线程绑定到固定核,性能才恢复。这说明调度器的负载均衡策略是通用性的,它的优先级是全局吞吐,不是某个线程的延迟。对延迟敏感的场景,用户态绑核反而更可控。

4. 调度相关参数、观察工具与调优思路

4.1 关键内核参数与 sysctl 控制项

在 Linux 里,有一些调度相关的参数可以直接调整。比较常用的是sched_autogroup_enabled,它决定了调度器是否将同一会话的进程自动分组,对桌面场景和终端编译任务影响明显。还有sched_child_runs_first,控制子进程创建后是否先于父进程运行。这个参数在某些 fork 密集的场景会影响缓存命中率。

sched_wakeup_granularity_ns和sched_min_granularity_ns则控制 CFS 调度的粒度。前者表示新唤醒的任务能抢占当前任务的最小时间差;后者表示每个任务单次运行的最短时间。如果wakeup_granularity调得太小,频繁唤醒的任务会频繁抢占,上下文切换开销大增;调得太大,交互任务响应又会变慢。我一般保持默认值,只有在经过 profile 明确看到切换开销过高时才去调整。

/proc/sys/kernel/sched_rt_period_us和sched_rt_runtime_us控制实时任务的 CPU 预算。默认情况下实时任务最多占用 95% 的 CPU 时间,剩下 5% 留给普通任务。这是一个很有用的保护机制,防止某个实时调度策略的任务把系统锁死。很多新手做实时程序时会把这个值调成 -1(无限),我建议不到万不得已不要这么做。

4.2 观察工具:top、mpstat、perf 和 /proc 接口

要理解系统的调度行为,光看top是不够的。top把 CPU 使用率汇总成整体百分比,看不出核间不均。我常用的组合是mpstat -P ALL 1,每秒刷一次每个核的使用率;再配合pidstat -w 1看每个进程的切换次数和等待时间。切换次数过高通常意味着调度过于频繁。

如果你想看具体线程的调度统计,可以查/proc/<pid>/sched。里面有几个关键字段:se.avg.load_avg代表该调度实体的平均负载,nr_switches代表切换次数,这两个字段对定位线程是否频繁被迁移和抢占很有参考价值。/proc/schedstat则记录了每个 CPU 的运行队列信息、负载均衡次数以及迁移次数,这些数据是判断是否发生过多任务迁移的重要依据。

perf sched子命令也值得一用。perf sched record录制一段调度事件,再用perf sched latency看每个 task 的调度延迟分布。我在分析一个分布式存储的响应抖动问题时,就是依靠perf sched latency发现某个线程的平均调度延迟达到几十毫秒,严重拖垮了请求延迟。后来定位到原因是该线程所在核心与中断 CPU 冲突,改造绑核之后调度延迟恢复到了微秒级。

4.3 绑核、irqbalance 与 cgroup 的配合

如果你确认某个业务线程对延迟敏感,直接绑核是最可靠的手段。可以使用taskset工具绑定 CPU 列表。比如taskset -pc 4-7 <pid>将线程限定到 4 到 7 号核上。不过绑定之后要留意一个副作用:如果这个核还需要处理中断或软中断,那么中断带来的开销会直接挤占业务线程,延迟依然不稳。最好的做法是把业务线程绑在专用核上,同时把相关中断迁移到其他核。

中断绑核可以借助irqbalance服务。这个服务会自动把中断分散到多个 CPU,但它的决策并不总是适合延迟敏感业务。我有一次遇到网卡单队列中断全部落在 CPU2 上,而这个 CPU 正好是业务主线程的所在核。实测把该中断手动绑定到 CPU6 之后,业务的 P99 延迟降低了将近一半。这说明中断和业务线程的 CPU 隔离在性能优化中至关重要。

cgroup 的 cpu 子系统则适合在资源隔离和配额控制场景下使用。比如用 cgroup 限制某个进程组最多使用 200% CPU,内核会自动在多个核上分配额度。这里有一个容易踩的坑:如果你既用 cgroup 限额,又把线程绑核到一个固定的 CPU 上,当限额计算出需要多个核时,绑核范围却只有一个核,内核会频繁尝试调度但无法突破硬绑限制,导致调度延迟上升。这种组合配置需要谨慎设计。

4.4 从一次线上抖动看调度与负载均衡的交互

讲一个我印象很深的排查经历。某服务每秒钟处理数万个小请求,业务监控显示响应时间偶尔飙升到数百毫秒,但大多数时间都在 10 毫秒以内。我最初怀疑是锁竞争,后来看mpstat发现抖动发生时 CPU0 使用率接近 100%,其他核都很空闲。进一步看中断分布,发现网卡的多队列没有开启,所有网络中断都落在 CPU0。而 CPU0 上还跑着业务主线程,网络中断一多,业务线程就无法及时得到调度。

我当时的处理分两步:第一步开网卡多队列,让中断分散到多个核;第二步用taskset把核心业务线程绑到 CPU2,并确认 CPU2 上没有高优先级的中断处理。调整之后抖动消失。这个案例本质上就是负载不均衡的问题:中断和业务线程抢同一个核,系统的全局负载并不高,但单一核已经过载。可见理解 CPU 调度概念,不只是为了通过考试或者看懂文档,而是为了解决实际生产环境中“表面负载正常、实际局部过载”的问题。

5. 常见问题速查与排障思路

5.1 典型故障现象与对应排查方向

我在相处各类调度问题时,总结了几类高频场景。用表格列出来会比较清晰:

现象可能原因排查起点
单核 100%,整体 CPU 低中断集中、线程绑核、单线程密集计算mpstat 看各核使用率,cat /proc/interrupts看中断分布
负载高但 CPU 使用率不高大量 D 状态任务阻塞在 IO/锁上ps -eo state,pid,cmd找 D 状态进程,看 IO 等待
频繁切换导致性能下降时间片过短、唤醒抢占频繁pidstat -w 1看 cswch 数值,结合 perf sched 分析
延迟抖动,时好时坏任务被迁移到远端 NUMA 或与高负载核共存perf sched latency与/proc/<pid>/sched的迁移计数
多进程互相争抢,响应变慢没有做分组隔离,全局公平导致互相拖累考虑 cgroup 分组、设置 autogroup、或业务进程绑核

这里的每一类都要结合具体业务做分析。比如 D 状态任务多时,如果只用top看负载,你会发现 load average 非常高,但 CPU 并不忙。因为 D 状态线程不可被调度,它们不在运行队列里,但仍计入系统负载。这时候盲加 CPU 根本没有意义,问题在磁盘 IO 或锁等待。

5.2 定位任务频繁迁移的实操路径

如果你怀疑自己的线程被频繁迁移,最直接的方法是查看/proc/<pid>/sched里面的nr_voluntary_switches和nr_involuntary_switches,不过这两个字段只统计切换次数,不是迁移次数。内核没有直接暴露“迁移次数”给用户态,但你可以观察线程曾经运行过的 CPU 列表来确认。做法很简单:写一个小脚本,以足够高的频率去读线程的status文件,这个文件有一个字段标记当前运行 CPU。如果短时间内频繁变化,就说明迁移很频繁。

另外perf sched map可以直观看到每个时间点各 task 分布在哪个 CPU 上。命令输出里每一行表示某个时间片内各 CPU 运行的任务名,连续观察几秒就能发现任务是否在不同 CPU 间跳来跳去。我遇到过一个很隐蔽的问题:某容器内部的服务被调度器频繁迁移,但容器外的物理机负载不高,最后发现是容器所在的 cgroup 设置了宽松的 CPU 限额,内核周期性做均衡时把线程当成了普通任务搬来搬去。用perf sched map看到线程每几十毫秒换一个核,才知道是负载均衡在“捣乱”。

5.3 什么时候应该关闭或干预负载均衡

负载均衡不是任何时候都有益。对于延迟极敏感的线程池模型,频繁均衡带来的缓存失效可能超过收益。比如线程 A 和线程 B 需要频繁交换数据,它们如果长期被调度到同一个核上,共享 L1/L2 缓存,性能非常好。但因为全局负载不均,内核可能把其中一个搬走,结果两个线程的通信需要跨核同步,延迟翻几倍。

我碰到过一种现象:数据库的日志写入线程组之间共享一个内存队列,线程组绑在一起时,写入延迟非常稳定;但某次运维调整了 CPU 配额后,线程组被拆到了两个 NUMA 节点,日志写入延迟立刻波动剧烈。后来把这些线程通过cgroup cpuset固定在一个节点的核上,才算稳定。这说明负载均衡是有代价的,干预均衡的方式就是使用 cpuset 绑定,让内核知道哪些线程是一个不可拆分的整体。

反过来说,对于大量无状态的计算任务,比如分布式数据清洗、批量渲染,这种任务彼此不通信,负载均衡带来的收益很明显。这时不要绑定,让内核自由调配更好。判断的依据很简单:任务之间是否共享数据,共享数据的频率高不高。高共享低通信的任务适合绑定,低共享或无状态的计算任务适合均衡。

6. 个人经验:理解调度概念后最容易踩的思维陷阱

接触调度概念之后,我发现自己曾经有几个思维误区。第一个误区是以为“CPU 使用率高就是性能好的表现”。实际上如果一个系统的 CPU 使用率很高但吞吐量很低,很可能说明任务在自旋等待,或者调度切换过多,资源都浪费在切换上而不是业务上。真正健康的系统,CPU 使用率应该和有效吞吐量同步增长。

第二个误区是把负载均衡理解成“平均主义”。内核的负载均衡从来不是让每个核跑得一样满,而是让响应时间尽量不因为局部拥塞而恶化。比如一个核上运行了三个负载很轻的线程,另一个核上运行一个负载很重的线程,从任务数量看不均衡,但实际对系统吞吐的影响可能没什么差别。如果你只按任务数量或者使用率去设计自己的调度策略,很容易做出吃力不讨好的操作。

第三个误区是把调度参数当成万能调优开关。我见过有人把sched_wakeup_granularity_ns调到极小来追求响应速度,结果业务线程频繁抢占,真正的高优先级任务反而因为切换开销而变慢。调度系统是一个整体,调一个参数必然影响其他维度。我现在做调优时,心里的原则是:先量化,再调整,最后对照数据验证。量化手段包括调度延迟、切换次数、任务迁移次数、核间使用率偏差等,缺一不可。

负载均衡与 CPU 调度概念看着是内核的基础题,但真正理解它,需要你把队列、优先级、虚拟时钟、中断、NUMA 和业务特性串成一个完整的系统图景。只有把这张图刻进脑子里,你在排查线上疑难问题时才会比别人快一步,也才知道那些看起来“玄学”的抖动到底从何而来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进