PELT 与 cgroup v2 压力反馈:Linux 内核资源竞争监测的工程实践

引言

在大型生产环境中,"系统变慢"往往是最令人头疼的故障表现。CPU 被争抢?内存回收跟不上了?还是磁盘 IO 成为了瓶颈?Linux 内核提供了多种底层机制来量化资源竞争,其中 PELT(Per-Entity Load Tracking) 和 cgroup v2 压力反馈(Pressure Stall Information, PSI) 是最核心的两个子系统。它们不仅能回答"到底慢在哪",还能让应用层主动感知资源压力,做出动态降级、弹性伸缩等智能决策。

本文将深入 PELT 和 PSI 的内核实现原理,分析其设计取舍与数值含义,并展示如何在实际工程中利用它们构建可观测的弹性系统。

一、PELT:从全局负载到单实体负载追踪

1.1 为什么需要 Per-Entity 负载追踪

传统的 CPU 负载指标(loadavg)只有一个全局数值,无法区分是哪个进程或 cgroup 导致的负载。在容器化和多租户场景下,这种粗粒度指标几乎无用。PELT 最早由 Linux 3.8 引入,旨在为每个调度实体(sched entity)—— 即单个进程、进程组(task_group)或 cgroup —— 独立计算其负载贡献。

1.2 指数移动加权平均:衰减与累积的平衡

PELT 的核心是一个Exponential Moving Average(EMA)滤波器,用Y值近似代表1分钟、5分钟、15分钟负载。每个实体的负载贡献按如下递归公式更新:

load_avg = load_avg × decay + runnable × (1 - decay)
util_avg  = util_avg  × decay + running  × (1 - decay)

其中 decay 因子由被称为"__accumulate_pelt_deltas()"的例程通过位移操作实现。内核预存了32个衰减周期(每1ms为一个周期)对应的乘数表 decay/load_avg,避免每次除法运算。每个实体的负载跟踪结构体(sched_avg)包含以下关键字段:

字段含义
last_update_time上次更新的时钟周期(clock_pelt)
load_sum可运行状态的累积时间 × NICE_0_LOAD
runnable_sum就绪或运行状态的总累积时间
util_sum实际运行时的累积 CPU 时间 × Capacity Scale (SCHED_CAPACITY_SCALE)
load_avg归一化后的负载平均值
util_avg归一化后的利用率平均值(0..1024)

1.3 层级传播与频率缩放

PELT 的负载值在 cgroup 层级中自下而上传播。一个 cgroup 的 load_avg 等于其子实体(子进程或子 cgroup)的 load_avg 之和乘以一个衰减因子。这一设计让上层 cgroup 能看到整体负载,但随之带来了"层级衰减问题"——深层嵌套的 cgroup 负载会被反复衰减,导致上层看到的值偏低。

此外,PELT 的 util_avg 会乘以 cpu_capacity_orig / arch_scale_cpu_capacity() 来适配动态调频(DVFS)场景,确保一个在 1.6GHz 小核上跑满的线程不会比在 3.2GHz 大核上跑一半的线程看起来更忙。

二、PSI:压力停滞信息的内核实现

2.1 从"统计"到"停滞"

PELT 告诉你"实体有多忙",PSI 则更直接地告诉你"实体被资源阻塞了多久"。PSI 建立在 PELT 基础之上,但增加了一个关键视角:每个实体在资源争抢上的停滞时间(stall time)。

2.2 三种压力类型

PSI 监控三种资源维度:

  • some:至少有一个任务在某个资源上停滞
  • full:所有非空闲任务都在该资源上停滞
  • avg:平均停滞程度,给出 10s/60s/300s 三个时间窗口

"some" 适合发现毛刺,"full" 适合发现严重瓶颈。例如 full avg10=25.6 表示过去 10 秒内,CPU 上所有任务平均有 25.6% 的时间在排队等待。

2.3 内核实现:中断窗口与触发器

PSI 的数据流动可以概括为:

psi_group 对象
  ├── tasks[NR_PSI_TASKS]       // 跟踪的任务计数
  ├── avg[NR_PSI_AGGREGATORS]   // 累积量,每 WINDOW 周期更新
  ├── total[PSI_POLL][...]      // 单调累计,供用户态读取
  └── polling                   // 是否由用户态轮询驱动

更新路径有两种触发方式:

  • 时钟驱动:每 tick,内核通过 psi_task_tick() 调用 psi_avgs_work() 周期性计算平均值。
  • 事件驱动:任务进入/离开等待状态时调用 psi_task_change() 实时更新 tasks 计数。

每个 CPU 上独立维护一个 cpu=.../avgX=.../total=... 快照,最终按 CPU 维度聚合,确保多核并发下的准确性。

2.4 等待队列与阻塞读取

PSI 的另一强大功能是为压力事件提供等待接口。内核的 psi_trigger_poll() 允许进程在压力超过阈值的资源上阻塞,直到压力回落。cgroup v2 memory /io pressure 文件支持 echo "some 80 2s" > memory.pressure 让内核在"some"压力超过 80%(2秒窗口内)时触发通知。

三、cgroup v2 压力反馈的实时链路

3.1 cgroup 与 PSI 的协作

cgroup v2 的 cpu.pressure、memory.pressure、io.pressure 三个接口直接映射到 PSI 的 psi_group 追踪。当一个 cgroup 的内存被限制到 memory.max 时,分配路径进入直接回收,任务因此被阻塞,PSI 立即累积 MEM_FULL 停滞时间。

3.2 活动通知与 epoll

cgroup v2 通过 cgroup.pressure 文件与 eventfd 结合实现事件驱动的压力通知:

// 监控 cgroup 的内存压力
fd = open("memory.pressure", O_WRONLY);
write(fd, "some 50 1s\0", 11);  // some 50% 1秒窗口

efd = eventfd(0, EFD_CLOEXEC);
ctrl_fd = open("cgroup.pressure", O_WRONLY);
write(ctrl_fd, to_string(efd));

// 现在 epoll_wait(efd) 会在压力超过阈值时唤醒

这条路径完全在内核态完成,无需用户态轮询,响应时间可达微秒级。

四、实际工程:构建弹性伸缩的容器平台

4.1 基于 PSI 的自适应降级

以 Nginx 为例,当系统 CPU 达到 FULL avg10 > 60 时,后端服务应自动降低工作线程数以减少上下文开销。实现方式:

// 伪代码:基于 CPU full 压力的 worker 调控
const FULL_THRESHOLD = 60.0;
const READ_INTERVAL_MS = 5000;

setInterval(async () => {
    const psi = await readCPUPressure();  // 读 /proc/pressure/cpu
    const full10 = psi.cpu.some.avg10;   // some avg10 对应 CPU 排队程度
    if (full10 > FULL_THRESHOLD) {
        workers.downscale();
    } else if (full10 < FULL_THRESHOLD * 0.5) {
        workers.upscale();
    }
}, READ_INTERVAL_MS);

4.2 基于内存压力的 OOM 预防

传统 OOM Killer 是"事后补救",PSI 提供了"事前预警"。Kubernetes 的 Vertical Pod Autoscaler (VPA) 可以结合 PSI 的 memory full avg10 在 OOM 发生前主动重启容器或扩容。

// 容器级别的 OOM 预防
func monitorMemoryPressure(cgroupPath string, threshold float64) {
    ticker := time.NewTicker(5 * time.Second)
    for range ticker.C {
        f, _ := os.Open(filepath.Join(cgroupPath, "memory.pressure"))
        scanner := bufio.NewScanner(f)
        for scanner.Scan() {
            line := scanner.Text()
            if strings.HasPrefix(line, "some") {
                parts := strings.Fields(line)
                avg10, _ := strconv.ParseFloat(parts[2][4:], 64)
                if avg10 > threshold {
                    triggerEviction(cgroupPath)  // 清理缓存、回收内存
                }
            }
        }
        f.Close()
    }
}

4.3 IO 压力驱动的 QoS

在混合部署(OLTP + OLAP)中,OLAP 扫描操作可能导致 IO 争抢,拖慢 OLTP。通过监控 /sys/fs/cgroup/.../io.pressure 的 full avg60,OLAP 控制器可以在检测到存储争抢时主动降低 IO 并发。

// Doris 扫描节点 IO 自适应限流
func adaptiveIOScan(scanner *Scanner, ctrlGroup string) {
    for {
        psi := readPSI(ctrlGroup + "/io.pressure")
        if psi.full.avg60 > 40.0 {
            scanner.SetConcurrency(scanner.Concurrency() / 2)
        } else if psi.full.avg60 < 10.0 {
            scanner.SetConcurrency(min(scanner.Concurrency()*2, MaxConcurrency))
        }
        time.Sleep(5 * time.Second)
    }
}

五、性能考量与调优

5.1 PELT 层级衰减的应对

如前所述,PELT 的负载值在 cgroup 层级传播中会自然衰减。对于深度嵌套的 cgroup 容器平台,建议以下措施:

  • 限制 cgroup 层级深度不超过 5 层
  • 利用 cgroup.type 的 threaded 模式让子 cgroup 共享父级 PELT 时钟
  • 结合 sched.stat 的实体级负载作为补充

5.2 PSI 采样的开销

启用 PSI 后,每次调度器 tick 都需要检查任务状态变化。基准测试表明,PSI 带来的额外开销通常小于 0.3% CPU,对于大多数生产负载可以忽略不计。但需要注意:

  • 开启 psi=1 内核参数(开启后不可关闭,需重启)
  • 对于存在数万个 cgroup 的场景,PSI 的内存占用为每 cgroup ~64 字节

5.3 窗口选择的工程经验

场景推荐窗口用途
网络延迟敏感服务(L7 网关)avg10秒级毛刺检测
OLTP 数据库avg60渐进式磁盘 IO 降级
批处理任务队列avg300稳定负载趋势观察
OOM 应急响应avg10 (full)快速识别内存压力

六、与其他监控体系的集成

6.1 Prometheus + node_exporter

Linux 4.20+ 的 node_exporter 已内置 PSI 采集器。关键指标包括:

  • node_pressure_cpu_waiting_seconds_total
  • node_pressure_memory_stalled_seconds_total
  • node_pressure_io_stalled_seconds_total

6.2 BPF 增强观测

通过 eBPF 可以直接读取 psi_group->avg 字段,实现内核级的 PSI 监控,无需遍历 /sys 文件系统:

// BPF 程序:跟踪特定 cgroup 的压力变化
SEC("tp_btf/psi_task_change")
int handle_psi_change(struct bpf_raw_trap_args *ctx) {
    struct task_struct *task = (struct task_struct *)ctx->args[0];
    u32 cpu = bpf_get_smp_processor_id();

    u64 *val = bpf_map_lookup_elem(&pressure_map, &cpu);
    if (val) {
        __sync_fetch_and_add(val, 1);
    }
    return 0;
}

6.3 内核源码中的 PELT/PSI 路径

关键文件一览:

  • kernel/sched/pelt.c:PELT 核心算法,包含衰减表计算与负载更新
  • kernel/sched/pelt.h:内联函数与快速路径定义
  • kernel/sched/stats.h:调度器向 PELT 数据的填充逻辑
  • kernel/psi/psi.c:PSI 核心实现,包括 trigger 机制
  • include/linux/psi_types.h:PSI 数据结构定义

七、前沿发展

7.1 Core Scheduling 与 PSI

Linux 5.14 引入的 Core Scheduling 机制让同一物理核上的 SMT 线程共享安全上下文。PSI 正在与 Core Scheduling 深度协同,未来可能出现"安全停滞"(因安全策略而非资源争抢导致的停滞)的独立统计维度。

7.2 MQ-Deadline 与 PSI 联动

blk-mq 调度器 MQ-Deadline 已在实验性支持中利用 PSI 的 io full 压力来动态调整队列深度,在"所有任务都阻塞"的场景下主动降低写入倍率。

7.3 容器编排的标准化

Kubernetes 社区正在推动 PSI 作为标准容器资源指标暴露,未来的 metrics.k8s.io API 可能包含 pressure 维度。

总结

PELT 和 PSI 代表了 Linux 内核在可观测性领域的两个重要演进方向:前者提供实体级的精确负载追踪,后者提供压力时间维度的阻塞量化。在云原生时代,这两个子系统已经从"调试工具"升级为"弹性基础设施"的基石。

对于系统工程师来说,深入理解 PELT 的衰减模型能让调度器决策更精准;理解 PSI 的事件驱动机制能让容器平台更敏感地响应资源争抢。将两者结合使用,构建自动降级、智能弹性、可靠自愈的现代 Linux 系统。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部