大家好,我是内核喵。
很多跑在 Kubernetes 里的在线服务,经常会遇到一种诡异的性能问题:监控面板上看着 CPU 利用率明明只有 20%~30%,但服务的 P99 / P999 尾部延迟(Tail Latency)却偶尔飙到天上去。
很多人第一反应是“是不是垃圾回收(GC)了?”或者“是不是网络丢包了?”。但凡遇到这种平均负载极低、毛刺极高的场景,我第一件事就是拉出系统指标,看它是不是被 CFS Quota 节流(Throttling) 给阴了。
今天我们不谈玄学,直接把内核代码剥开,聊聊 Linux CFS 调度器的带宽控制机制(CFS Bandwidth Controller)、节流是如何发生的,以及如何用工具实锤并调优它。
一、 机制解析:CFS Bandwidth 控制并不是“平滑限流”
在 cgroup v1 中,控制 CPU 配额的是两个核心参数:
cpu.cfs_period_us:配额周期,默认通常是 100,000 微秒(100 ms)。cpu.cfs_quota_us:在上述周期内,该 cgroup 允许消耗的 CPU 时间总和。
如果容器限制为 2 核,那么在 100 ms 的周期内,它的 cfs_quota_us 就是 200,000 微秒(200 ms)。
陷阱就在这里:CFS 限制的是「总配额」,而不是「瞬时算力」。
假设你有一个 8 线程的高并发 Go/Java 应用,跑在一台 16 核物理机上。某一瞬间,突发流量打进来,8 个线程同时被唤醒并在不同的 CPU 核心上全速运转:
$$8 \times 25\text{ ms} = 200\text{ ms}$$
仅仅过了 25 ms,这个容器的 200 ms 配额就被彻底榨干了! 剩下的 75 ms 里,内核会毫不留情地把该容器内的所有任务挂起,直到下一个周期开始才重新分配配额。对于客户端来说,这 75 ms 就是纯粹的等待延迟。这就是典型的“平均 CPU 不高,但被严重 Throttle”。
二、 源码顺藤摸瓜:内核是如何执行节流的?
我们来看 Linux 内核 kernel/sched/fair.c 的实现(以 5.x 内核为例)。
为了避免多核频繁竞争全局的锁,内核在设计上引入了两层池化机制:
cfs_bandwidth:CGroup 级别的全局配额池。cfs_rq->runtime_remaining:各个 CPU 核心上就绪队列的本地配额缓存。
每个 CPU 的 CFS 就绪队列(cfs_rq)每次会向全局池子“批量申请”一段配额,默认步长是 5 ms(由内核宏 min_cfs_rq_runtime 定义,通常对应 sysctl_sched_cfs_bandwidth_slice)。
1. 扣减配额与触发节流
在时钟中断(entity_tick)或任务发生切换时,内核会调用 update_curr() 计算任务运行时间,随后调用 account_cfs_rq_runtime():
/* kernel/sched/fair.c */
static void account_cfs_rq_runtime(struct cfs_rq *cfs_rq, u64 delta_exec)
{
if (!cfs_bandwidth_used())
return;
cfs_rq->runtime_remaining -= delta_exec;
if (cfs_rq->runtime_remaining <= 0)
__account_cfs_rq_runtime(cfs_rq, delta_exec);
}
如果本地的 runtime_remaining 扣成负数,就会进入 __account_cfs_rq_runtime():
它会尝试从全局 cfs_bandwidth 续借。如果全局配额也空了,那么对不起,执行节流:
static int tg_throttle_down(struct task_group *tg, void *data)
{
struct rq *rq = data;
struct cfs_rq *cfs_rq = tg->cfs_rq[cpu_of(rq)];
/* 从当前 CPU 的就绪队列中剥离该任务组 */
throttle_cfs_rq(cfs_rq);
return 0;
}
一旦 throttle_cfs_rq() 被调用,属于该 CGroup 的所有调度实体(Sched Entities)就会被从当前 CPU 的红黑树就绪队列中摘除(dequeue_entity),并打上 cfs_rq->throttled = 1 标记。任务彻底失去 CPU 调度机会。
2. 解除节流(Unthrottling)
什么时候恢复?内核在设置全局配额时会启动一个高精度定时器(hrtimer)——sched_cfs_period_timer()。
周期到期时,定时器中断被触发,重置全局配额,并调用 distribute_cfs_runtime() 唤醒被节流的队列,最终执行 unthrottle_cfs_rq() 将实体重新塞回调度就绪队列。
三、 实战观测:不要猜,用数据说话
怎么实锤你的进程被 CPU Throttle 害了?有三种硬核排查姿势:
姿势 1:直接读 CGroup 统计接口
在容器内或者宿主机找到对应的 CGroup 目录:
# cgroup v1
cat /sys/fs/cgroup/cpu,cpuacct/docker/<container_id>/cpu.stat
# 关键输出:
nr_periods 124500 # 经历的总周期数
nr_throttled 34120 # 被节流的周期数
throttled_time 15832049100 # 累计被节流的纳秒数
如果 nr_throttled / nr_periods 比例超过 10%,你的尾部延迟大概率已经被它污染了。
姿势 2:使用 bpftrace 抓捕现场
想看是哪些任务、在什么时候被 Throttle 了?写个简单的 bpftrace 脚本挂载到内核探针:
#!/usr/bin/env bpftrace
#include <linux/sched.h>
kprobe:throttle_cfs_rq
{
$cfs_rq = (struct cfs_rq *)arg0;
printf("PID %d (%s) throttled on CPU %d\n",
curtask->pid, curtask->comm, cpu);
}
kprobe:unthrottle_cfs_rq
{
printf("CFS unthrottled on CPU %d\n", cpu);
}
输出示例:
PID 14209 (wrk:worker_0) throttled on CPU 3
PID 14210 (wrk:worker_1) throttled on CPU 5
CFS unthrottled on CPU 3
CFS unthrottled on CPU 5
姿势 3:用 perf sched 观测被偷走的唤醒延迟
使用 perf sched 记录一段时间内的调度行为:
perf sched record -p <target_pid> -- sleep 5
perf sched latency
如果看到你的线程状态经常处于 D 之外的极长调度等待时间(Run delay 异常飙高,甚至几十毫秒),这正是被 Throttle 的铁证。
四、 针对延迟敏感型业务的调优实践
既然默认机制可能带来延迟劣化,在生产环境中我们该如何调优?
策略 1:调小 cfs_period_us,让分配更平滑
默认 cfs_period_us = 100ms 对于许多微秒/毫秒级 RPC 来说颗粒度太粗了。我们可以通过减小 Period 来“缩水”惩罚时间。
例如,保持分配 2 核不变:
- 原方案:
period = 100000(100 ms),quota = 200000(200 ms) - 调优后:
period = 10000(10 ms),quota = 20000(20 ms)
效果:单次被节流的最大等待时间从 100 ms 骤降至 10 ms 级别,尾部延迟毛刺明显被削平。 代价值得注意:周期缩短意味着定时器中断更频繁,进程上下文切换次数可能会有轻微上涨。根据我的 Benchmark 经验,降到 10 ms~20 ms 是非常安全的甜点区间。
策略 2:开启 Linux 5.14+ 的 CPU Burst 特性
Linux 社区早在 5.14 合入了腾讯提交的 CPU Burst 特性(很多厂商也 Backport 到了 4.19 / 5.4 甚至 RHEL 8.4+ 内核)。
机制很简单:如果一个 CGroup 在某个周期内配额没用完,允许它把剩余的配额“存下来”(积累到 cpu.cfs_burst_us),供下一个周期的突发流量使用。
# 查看或设置 Burst 配额
echo 40000 > /sys/fs/cgroup/cpu/<group>/cpu.cfs_burst_us
对于具有突发波峰的 Web 服务,CPU Burst 可以在不放开总体配额限制的情况下,近乎抹平因为短突发导致的被动节流。
策略 3:物理绑核(cpuset)——终极方案
对于 P99 要求极度苛刻的核心组件(如 Redis、底层网关 Envoy),最干脆的办法是彻底废弃 cfs_quota,改用 cpuset。
在 Kubernetes 中:
设置容器的 QoS 为 Guaranteed,并且 resources.requests.cpu == resources.limits.cpu(且为整数),Kubernetes CPU Manager 会自动调用 cpuset.cpus 进行独占核心分配。
因为独占了 CPU,内核不再需要设置 CFS Bandwidth Controller,节流计数器彻底归零,从根本上杜绝该维度的调度毛刺。
喵的一点总结
系统优化没有魔法,全在机制和权衡之中。
- 别被 Prometheus 的平均 CPU 利用率骗了,监控一定要加上容器层级的
container_cpu_cfs_throttled_periods_total速率。 - 突发延迟高,先查
/sys/fs/cgroup/.../cpu.stat。 - 如果在跑高并发低延迟业务,优先考虑调小 Period、开启 CPU Burst,或者直接上
cpuset独占核心。
底层调度器的细节看似冷门,但关键时刻往往就是决定服务高可用 P99 的胜负手。今天就聊到这里,快去看看你的线上集群有没有在被疯狂 Throttle 吧!
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


