引言:从 OOM Killer 到主动防御

在生产环境中,内存压力是最常见的系统性能瓶颈之一。传统的 OOM Killer 总是在内存耗尽后才仓促介入——它像一个消防员,只在房屋已经起火时才赶到。当 OOM Killer 被触发时,系统往往已经经历了严重的性能抖动,关键进程被随机终止,业务中断已不可避免。

我们真正需要的是一套早期预警系统:在内存压力开始累积但尚未爆表时,就能感知并采取措施。Linux 4.20 引入的 PSI(Pressure Stall Information) 正是为此而生。它用「任务因资源等待而停滞的时间占比」来量化延迟压力,将一个抽象的"内存不够了"转化为可读的数字——80% 的任务在因为内存不足而排队?该扩容了。

本文将从 PSI 的内核源码出发,逐层拆解其数据结构、计算模型、事件通知机制、cgroup v2 集成方案,最终落地到生产级调优实践。

一、核心设计理念:用停滞时间量化压力

PSI 的核心思路极其优雅:不直接测量"内存还剩多少",而是测量有多少比例的任务因为等待 CPU、内存或 I/O 而无法推进。这个「停滞时间占比」直接对应用户体验——用户感知到的就是卡顿。

PSI 为每类资源(cpu、memory、io)生成两种指标:

  • some:至少有一个任务停滞的时间占比。对内存而言,表示至少有一个任务在等内存。
  • full:所有非空闲任务都在停滞的时间占比。对内存而言,表示所有干活的任务都在等内存——这是真正的瓶颈。

两种指标都提供多窗口滑动平均:avg10(10秒)、avg60(60秒)、avg300(300秒),以及 total 累计值。窗口越长,趋势判断越稳;窗口越短,响应越灵敏。

二、内核源码分析:数据结构与计算模型

PSI 的核心数据结构在 kernel/sched/psi.c 中定义。我们先看关键结构体:

// include/linux/psi_types.h
struct psi_group_cpu {
    /* 序列锁,用于无锁读(seqcount) */
    seqcount_t seq;
    
    /* 上一次更新的时间戳 */
    u64 last_update;
    
    /* 累计停滞时间(分资源) */
    u64 some_delays[PSI_NONIDLE];      /* some 停滞时间 */
    u64 full_delays[PSI_NONIDLE];       /* full 停滞时间 */
    
    /* 上次到本次更新期间的任务状态跟踪 */
    unsigned int tasks[NR_PSI_TASKS];   /* TASK_RUNNING/STOPPED 等计数 */
};

struct psi_group {
    /* 每 CPU 数据 */
    struct psi_group_cpu __percpu *pcpu;
    
    /* 多窗口平均(seqcount 保护) */
    u64 avg_total[PSI_NONIDLE][3];      /* 总停滞时间,[3] = avg10/60/300 */
    u64 avg_next_update;                /* 下次计算窗口的时间 */
    u64 avg[PSI_NONIDLE][3][2];         /* [资源][窗口][上次值/当前值] */
    
    /* 锁:更新时互斥 */
    struct mutex avgs_lock;
    
    /* 等待队列:因压力而上等待的进程 */
    wait_queue_head_t wait;
    
    /* 跟踪触发器(trace events) */
    struct list_head list;
};

PSI 的计算模型本质是一个指数移动平均(EMA)的变体。每 2 秒(PSI_FREQ)进行一次采样,累积各任务的停滞时间,然后在更新 avg10/60/300 时用如下公式:


// 伪代码:avg_window 更新
decay = (now - last_update) / window_size;
new_avg = old_avg * exp(-decay) + current_stall * (1 - exp(-decay));

其中 window_size 是对应窗口(10s/60s/300s),decay 是衰减系数。这个公式确保新数据权重高,旧数据缓慢淡出——十秒前的冲击不再影响今天的平均值,但趋势被完整保留。

关键在于采样逻辑。PSI 不依赖定时中断,而是结合了两种触发:

  1. 周期性触发:每 2 秒执行 psi_avgs_work workqueue,计算并更新滑动平均值。
  2. 状态变更触发:任务状态切换时(__schedule() → psi_task_change()),即时更新 per-cpu 的 task[] 计数器。

// kernel/sched/core.c 中调用
void psi_task_change(struct task_struct *task, int delta) {
    int cpu = task_cpu(task);
    struct psi_group *group = &psi_groups[PSI_MEM]; // 以内存为例
    struct psi_group_cpu *groupc;
    
    groupc = per_cpu_ptr(group->pcpu, cpu);
    write_seqcount_begin(&groupc->seq);
    groupc->tasks[PSI_MEM_SOME] += delta;
    if (task->memstall)  /* 该任务因内存停滞 */
        groupc->tasks[PSI_MEM_FULL] += delta;
    write_seqcount_end(&groupc->seq);
}

三、事件通知:epoll 驱动的零轮询监控

PSI 最强大的设计之一是事件通知机制。应用无需周期性读取 /proc/pressure/memory,只需 poll/epoll 在 cgroup 的 memory.pressure 文件上,当压力超过阈值时内核主动唤醒。

完整流程:

// 1. 用户空间:设置阈值并 poll
int fd = open("/sys/fs/cgroup/myapp/memory.pressure", O_RDWR | O_NONBLOCK);
// 向内核注册:当 full avg10 > 50ms 时唤醒
write(fd, "full avg10 50000", 15);
// 事件监听
struct pollfd pfd = { .fd = fd, .events = POLLPRI | POLLERR };
poll(&pfd, 1, -1);  // 阻塞等待压力事件

// 2. 内核空间:psi_trigger 机制
struct psi_trigger {
    struct psi_group *group;
    unsigned long ctx;            /* 触发器 ID */
    enum psi_states state;        /* some/full */
    u32 win;                      /* 窗口大小(us) */
    u32 threshold;                /* 阈值(us) */
    wait_queue_head_t wait;
    struct list_head node;
};

当 psi_avgs_work 计算出新的 avg 值后,会遍历所有注册的 trigger,比较是否越界。如果触发,就唤醒对应 waitqueue 上所有等待的进程:


static void psi_trigger_poll(struct psi_group *group) {
    struct psi_trigger *t;
    
    list_for_each_entry(t, &group->triggers, node) {
        /* 比较当前 avg 是否超过阈值 */
        u64 avg = group->avg[t->state][t->win_idx][0];
        if (avg >= t->threshold) {
            /* 该触发器被满足,唤醒等待队列 */
            wake_up_interruptible(&t->wait);
            /* 对 edge-triggered 模式,清除条件 */
            if (t->edge)
                group->avg[t->state][t->win_idx][0] = 0;
        }
    }
}

这种 epoll-edge-triggered 模式特别适合融入事件驱动框架(如 Go 的 netpoll、Rust 的 tokio)。你可以把 memory.pressure 文件 fd 直接挂到 epoll 实例上,完全零 CPU 开销,只在压力真正超标时回调。

四、cgroup v2 集成:容器级压力隔离

cgroup v2 的 PSI 通过各资源控制器下的 *.pressure 文件暴露。以 memory 为例:

# 查看全系统内存压力
$ cat /proc/pressure/memory
some avg10=2.31 avg60=1.05 avg300=0.21 total=9283746
full avg10=1.02 avg60=0.48 avg300=0.09 total=4928371

# 查看某 cgroup 的内存压力(因 cgroup v2 的 current/total 分离)
$ cat /sys/fs/cgroup/myapp/memory.pressure
some avg10=15.2 avg60=8.3 avg300=2.1 total=28371
full avg10=7.8 avg60=3.9 avg300=0.9 total=12847

cgroup v2 的 PSI 与 cgroup v1 有本质区别。v2 的 memory.pressure 报告的是该 cgroup 及所有祖先 cgroup 造成的停滞时间,体现了层级责任归属。如果一个子 cgroup 内存吃紧导致高层任务等待,压力会向上累积反映。

PSI 的 per-cgroup 实现依赖一个关键优化:psi_group 通过 cgroup_add_ok() 挂载时,仅在被监控的 cgroup 中分配 per-cpu 结构,未监控 cgroup 的开销为零(编译选项 CONFIG_PSI_DEFAULT_DISABLED)。全系统级监控默认开启,可通过 psi=0` 在内核启动时关闭全系统级别。

五、生产级调优实践

5.1 阈值设定策略

PSI 的阈值设定直接取决于业务对延迟的容忍度。以下是经过验证的参考值:

业务类型指标avg10 阈值动作
在线查询(SLA <100ms)full50ms(50000us)限流扩容
日志写入(吞吐优先)some200ms(200000us)切换缓冲策略
批处理任务full500ms降低并发度
支付链路full10ms(10000us)立即扩容+熔断

5.2 Go 语言集成示例

以下是一个完整的 Go 内存压力监控程序,利用 epoll-edge-triggered 模式:

package psi

import (
    "fmt"
    "golang.org/x/sys/unix"
    "os"
    "sync"
    "time"
)

type MemoryPressureMonitor struct {
    threshold uint64 // 阈值,微秒
    window     time.Duration
    callback   func(avg float64)
    
    mu        sync.Mutex
    fd        int
}

func NewMonitor(cgroupPath string, thresholdUs uint64, window time.Duration, cb func(float64)) (*MemoryPressureMonitor, error) {
    path := cgroupPath + "/memory.pressure"
    fd, err := unix.Open(path, unix.O_RDWR|unix.O_NONBLOCK, 0)
    if err != nil {
        return nil, fmt.Errorf("open pressure file: %w", err)
    }
    
    m := &MemoryPressureMonitor{
        threshold: thresholdUs,
        window:    window,
        callback:  cb,
        fd:        fd,
    }
    
    // 向内核注册阈值触发器
    // "full avg10 50000" -- 10秒窗口内full停滞超50ms触发
    var buf [64]byte
    n := copy(buf[:], []byte(fmt.Sprintf("full avg%d %d", 
        int(window.Seconds()), thresholdUs)))
    if _, err := unix.Write(fd, buf[:n]); err != nil {
        unix.Close(fd)
        return nil, fmt.Errorf("write trigger: %w", err)
    }
    
    return m, nil
}

func (m *MemoryPressureMonitor) Start(epollFd int) error {
    event := unix.EpollEvent{
        Events: unix.EPOLLIN | unix.EPOLLPRI | unix.EPOLLET,
        Fd:     int32(m.fd),
    }
    if err := unix.EpollCtl(epollFd, unix.EPOLL_CTL_ADD, m.fd, &event); err != nil {
        return fmt.Errorf("epoll ctl: %w", err)
    }
    
    var events [16]unix.EpollEvent
    for {
        n, err := unix.EpollWait(epollFd, events[:], -1)
        if err != nil {
            if err == unix.EINTR {
                continue
            }
            return err
        }
        
        for i := 0; i < n; i++ {
            if events[i].Fd == int32(m.fd) {
                // 压力事件触发!读取当前值
                m.handleEvent()
            }
        }
    }
}

func (m *MemoryPressureMonitor) handleEvent() {
    // 读取并解析
    buf := make([]byte, 256)
    unix.Seek(m.fd, 0, 0)
    n, _ := unix.Read(m.fd, buf)
    // 解析 "full avg10=xx.xx ..."
    var fullAvg10 float64
    fmt.Sscanf(string(buf[:n]), "full avg10=%f", &fullAvg10)
    
    m.callback(fullAvg10)
}

func (m *MemoryPressureMonitor) Close() {
    unix.Close(m.fd)
}

5.3 与 OOM Killer 的协同

PSI 不应该替代 OOM Killer,而是与之协同。推荐的架构是第一层用 PSI 早期预警,在压力升至危险阈值前主动驱逐/扩容;第二层保留 OOM Killer 作为最后手段。这种双层防御避免了 OOM 在深夜击穿单点服务。

具体实现上,Kubernetes 社区已经将 PSI 集成进 kubelet,用于做出更智能的驱逐决策——kubelet 根据 memory.pressure (some avg10) 判断节点是否进入内存压力状态,并触发 MemoryPressure condition,进而驱逐低优先级 Pod,而不是苦等 OOM。

六、与 eBPF 结合:自定义压力追踪

虽然 PSI 提供了标准化的压力指标,但有时你需要更细粒度的追踪——比如"是哪个特定分配路径导致了停滞"。这时可以把 PSI 与 eBPF 结合:

// BPF 程序:在 kswapd 唤醒时检测压力等级
SEC("tracepoint/power/cpu_idle")
int trace_pressure(struct trace_event_raw_cpu_idle *ctx) {
    /* 读取当前 PSI 指标,通过 bpf_perf_event_output 推送 */
    u32 cpu = bpf_get_smp_processor_id();
    struct psi_group *group = bpf_this_cpu_ptr(&psi_groups);
    
    /* eBPF 不能直接调用 psi_avgs_work,但可以通过已有的 avg 数组映射 */
    /* 完整实现需结合 BTF + bpf_core_read */
    
    return 0;
}

/* 更实用的方案:直接读取 /sys/fs/cgroup/.../memory.pressure */
SEC("kprobe/mem_cgroup_charge_statistics")
int trace_charge(struct pt_regs *ctx) {
    struct mem_cgroup *memcg = (struct mem_cgroup *)PT_REGS_PARM1(ctx);
    
    /* 判定该 cgroup 是否处于高压 */
    /* 若 avg60 > 100ms,则记录罪魁祸首的 cgroup 路径 */
    char path[256];
    bpf_probe_read_kernel_str(path, sizeof(path), memcg->css.cgroup->kn->name);
    bpf_printk("High pressure cgroup: %s\n", path);
    
    return 0;
}

通过 eBPF 的 kprobe 挂载到内存分配路径(如 mem_cgroup_charge),当 PSI 检测到高压时,可以精准回溯是哪一个 cgroup、哪一个系统调用造成了瓶颈——这是纯 PSI 看不到的因果链。

七、PSI 的局限与应对

PSI 并非万能药,以下几点需要在生产部署中注意:

  • 短时尖峰被平滑:avg60/300 的窗口会使毫秒级毛刺消失,纯靠 PSI 无法捕捉到「突然出现 200ms 停滞」。对策:在关键路径配合 ftrace/function_graph 实时拐点追踪,与 PSI 长窗口互补。
  • NUMA 局部压力被平均:跨 NUMA 节点访问对某个节点是致命压力,但全局 PSI 看了两个节点的均值可能并不高。对策:结合 vmstat -m 的 NUMA 数据和 per-NUMA-node cgroup 划分。
  • 实时性限制:PSI 采样间隔 2 秒 + 窗口最小 10 秒,不适合对延迟极度敏感的硬实时场景。对策:hard irq 级别的实时监控依然不可替代,PSI 补充的是宏观趋势层。
  • cgroup 层级开销:monitored cgroup 层级过多时,每次任务切换都要遍历祖先 cgroup 的 psi_group。对策:限制监控深度,非关键 cgroup 不注册。

总结

PSI 将"资源压力"从模糊的体感转化为可度量、可编程的数字,是 Linux 内核在可观测性领域的一次重要思想革新。它不是要替代 OOM Killer、cgroup 限流或 eBPF 追踪,而是在防御体系的第一层添加了一个持续感知的哨兵。

对于工程师而言,写好 PSI 的利用之道,意味着:知道什么时候该看 avg10(趋势初现),什么时候该看 avg300(长期水位),什么时候该用 epoll 事件驱动而非轮询,以及如何与 eBPF 互补构建完整的压力溯源链路。

代码能上 PSI 的生产价值远大于调几个 sysctl 参数——它让系统的内存管理从「被动救火」走向「主动治未病」。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.420390s