引言:从 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 不依赖定时中断,而是结合了两种触发:
- 周期性触发:每 2 秒执行
psi_avgs_workworkqueue,计算并更新滑动平均值。 - 状态变更触发:任务状态切换时(
__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) | full | 50ms(50000us) | 限流扩容 |
| 日志写入(吞吐优先) | some | 200ms(200000us) | 切换缓冲策略 |
| 批处理任务 | full | 500ms | 降低并发度 |
| 支付链路 | full | 10ms(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 参数——它让系统的内存管理从「被动救火」走向「主动治未病」。

发表评论 取消回复