Android PSI 详解:libpsi 源码解析------116 行代码架起 lmkd 与内核之间的桥
系列上一篇《Android 高版本 LMKD 源码解析》里,
init_mp_psi()里两句init_psi_monitor()/register_psi_monitor()一笔带过。本篇把它们挖到底:libpsi 是什么、每行代码在做什么、内核 PSI 接口的协议细节,以及那些不读源码绝对不会注意到的"坑"(比如 EPOLLPRI 而不是 EPOLLIN)。本文基于
system/memory/libpsi/psi.c(本地 psi.cpp,共 116 行),行号均已标注。
0. libpsi 在整个体系中的位置
bash
┌───────────────────── lmkd 进程 ─────────────────────┐
│ lmkd.cpp (3847 行) │
│ │ 调用 │
│ ▼ │
│ libpsi (psi.cpp, 116 行) ←── 本篇主角 │
│ │ open/write /proc/pressure/memory │
│ │ epoll_ctl(EPOLLPRI) │
└─────┼───────────────────────────────────────────────┘
▼
┌───────────────────── 内核 ──────────────────────────┐
│ PSI 子系统 (kernel/sched/psi.c, CONFIG_PSI=y) │
│ /proc/pressure/{memory,io,cpu} │
│ - 读:输出 avg10/avg60/avg300/total 统计 │
│ - 写:注册 trigger,返回可 poll 的事件 fd │
└─────────────────────────────────────────────────────┘
libpsi 的定位:对内核 PSI 接口的极薄封装------5 个函数、116 行,不含任何策略。它把"内核协议细节"(写入格式、事件语义、输出解析)集中在一处,让 lmkd 专注于查杀策略。
1. 公共头文件:psi.h 的数据结构
psi.cpp 用到的 PSI_SOME/PSI_FULL、PSI_PATH_MEMORY 来自 <psi/psi.h>(libpsi 的公共头,路径宏经由内核 uapi 头 <linux/psi.h> 提供):
arduino
enum psi_stall_type {
PSI_SOME, /* 部分停顿:至少一个任务在等内存 */
PSI_FULL, /* 完全停顿:所有非 idle 任务都在等内存 */
};
struct psi_stats {
float avg10; /* 过去 10s 停顿时间占比(百分比) */
float avg60; /* 过去 60s */
float avg300; /* 过去 300s */
unsigned long total;/* 系统启动以来累计停顿微秒数 */
};
int init_psi_monitor(enum psi_stall_type stall_type, int threshold_us, int window_us);
int register_psi_monitor(int epollfd, int fd, void* data);
int unregister_psi_monitor(int epollfd, int fd);
void destroy_psi_monitor(int fd);
int parse_psi_line(char *line, enum psi_stall_type stall_type, struct psi_stats stats[]);
enum psi_stall_type 的顺序(SOME=0, FULL=1)不是随意的------psi.cpp 里用 stall_type_name[stall_type] 直接做数组下标,lmkd 里也用 mem_stats[PSI_FULL] 下标访问。
2. init_psi_monitor():向内核注册一个触发器
libpsi 的第一个、也是最重要的函数:
c
/* psi.cpp:31-34 */
static const char* stall_type_name[] = {
"some",
"full",
};
/* psi.cpp:36-79 */
int init_psi_monitor(enum psi_stall_type stall_type,
int threshold_us, int window_us) {
int fd;
int res;
char buf[256];
fd = TEMP_FAILURE_RETRY(open(PSI_PATH_MEMORY, O_WRONLY | O_CLOEXEC));
if (fd < 0) {
ALOGE("No kernel psi monitor support (errno=%d)", errno);
return -1;
}
switch (stall_type) {
case (PSI_SOME):
case (PSI_FULL):
res = snprintf(buf, sizeof(buf), "%s %d %d",
stall_type_name[stall_type], threshold_us, window_us);
break;
default:
ALOGE("Invalid psi stall type: %d", stall_type);
errno = EINVAL;
goto err;
}
if (res >= (ssize_t)sizeof(buf)) {
ALOGE("%s line overflow for psi stall type '%s'",
PSI_PATH_MEMORY, stall_type_name[stall_type]);
errno = EINVAL;
goto err;
}
res = TEMP_FAILURE_RETRY(write(fd, buf, strlen(buf) + 1));
if (res < 0) {
ALOGE("%s write failed for psi stall type '%s'; errno=%d",
PSI_PATH_MEMORY, stall_type_name[stall_type], errno);
goto err;
}
return fd;
err:
close(fd);
return -1;
}
逐个拆解:
2.1 open(O_WRONLY | O_CLOEXEC)
三个细节:
- O_WRONLY :注册 trigger 是一次"写"操作,不需要读权限。lmkd 读统计数据用的是另一个 自己
reread_file()打开的只读 fd(见第 5 节),同一个文件两条通道; - O_CLOEXEC:lmkd 将来 fork/exec 其他东西时不会泄露这个 fd;
- open 失败的兜底 :如果内核没编译 PSI(
CONFIG_PSI=n)或 cmdline 用psi=0关闭了,这里 open 会失败,打出日志"No kernel psi monitor support",返回 -1------这正是上篇博客里 lmkd 三级 fallback(PSI → vmpressure → 启动失败)的第一级出口:init_psi_monitors()返回 false,init_monitors()(lmkd.cpp:3354)转入init_mp_common()老 vmpressure 路径。
2.2 写入格式:"some 70000 1000000"
lmkd 调用它时(lmkd.cpp:3161-3163):
css
fd = init_psi_monitor(psi_thresholds[level].stall_type,
psi_thresholds[level].threshold_ms * US_PER_MS, /* 70ms → 70000us */
PSI_WINDOW_SIZE_MS * US_PER_MS); /* 1000ms→ 1000000us */
拼出来的字符串就是:
sql
some 70000 1000000 ← medium 级:1 秒窗口内 some 停顿 ≥ 70ms 触发
full 700000 1000000 ← critical 级:1 秒窗口内 full 停顿 ≥ 700ms 触发
含义:"请内核帮我盯着:在 1 秒的滑窗内,一旦 some(或 full)停顿累计达到阈值,就叫醒我"。
内核侧对参数有硬约束(kernel/sched/psi.c):
| 约束 | 值 |
|---|---|
| 窗口下限 | 500ms(PSI_WINDOW_MIN_SIZE_US) |
| 窗口上限 | 10s(PSI_WINDOW_MAX_SIZE_US) |
| 阈值 | 必须大于 0 且不超过窗口 |
| 每个打开的 fd | 只能注册一个 trigger |
满足 500ms ≤ window ≤ 10s,lmkd 选的 1s 窗口正中间。另外注意 write(fd, buf, strlen(buf) + 1) 连结尾 \0 一起写------内核解析时按 NUL 结尾字符串处理,多写一个字节无伤大雅。
2.3 返回值的巧妙之处
成功时直接把 open 出来的 fd 返回 ------内核在 write() 成功的那一刻,已经把这个打开的文件与新建的 trigger 绑定,fd 瞬间变成了"事件 fd"。libpsi 不需要额外的 ioctl 或二次 open。这也是为什么函数名叫 init_*_monitor 而返回的是个 fd。
错误路径统一 goto err; close(fd); return -1;,并且每个分支都先设好 errno(EINVAL 或 write 的 errno),调用方可以直接 strerror(errno)。
3. register_psi_monitor():EPOLLPRI 的坑
ini
/* psi.cpp:81-92 */
int register_psi_monitor(int epollfd, int fd, void* data) {
int res;
struct epoll_event epev;
epev.events = EPOLLPRI;
epev.data.ptr = data;
res = epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &epev);
if (res < 0) {
ALOGE("epoll_ctl for psi monitor failed; errno=%d", errno);
}
return res;
}
短短 12 行,藏着两个值得写进博客的点:
3.1 为什么是 EPOLLPRI 而不是 EPOLLIN?
PSI trigger fd 上没有任何可读数据 。内核通过 poll 队列的"优先级事件"(POLLPRI,select 语义里的 exceptional condition)通知用户态------和 inotify 的事件 fd 同一套语义。如果你按肌肉记忆写 EPOLLIN,epoll 会永远不唤醒,这是接入 PSI 时最经典的翻车现场。
3.2 事件是"一次性派发"的
内核 psi 的 poll 回调里,事件标志是用一次消费一次 的(内核在 .poll 里对 trigger 的 event 标志做 cmpxchg 清零,返回 EPOLLPRI)。效果是:每次 trigger 命中,恰好产生一次 epoll 唤醒。所以:
- lmkd 从不 read 这个 fd,也无需 read;
- 事件处理函数靠参数
events区分"真事件"和"轮询"------上篇博客里mp_event_psi()的关键判断:
arduino
/* lmkd.cpp:2770 */
} else if (level == VMPRESS_LEVEL_CRITICAL && events != 0) {
/* 只有真·critical 事件才按 NOT_RESPONDING 杀,轮询进来的不算 */
(call_handler 轮询路径调用 handler 时传的 events=0,lmkd.cpp:3639。)
3.3 epev.data.ptr = data
把 lmkd 的 struct event_handler_info*(内含函数指针 mp_event_psi 和级别 data)原样挂到 epoll 事件上,mainloop() 第二遍循环里 evt->data.ptr 取回直接调用(lmkd.cpp:3695-3698)。libpsi 对 void* data 语义不做任何假设,纯粹透传------库和调用方解耦的干净写法。
4. 反注册与销毁
arduino
/* psi.cpp:94-102 */
int unregister_psi_monitor(int epollfd, int fd) {
return epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL);
}
void destroy_psi_monitor(int fd) {
if (fd >= 0) {
close(fd);
}
}
close(fd) 的同时,内核在文件 release 回调里销毁绑定的 trigger,不需要额外的"注销"写入。
lmkd 在两条路径上用到它们:
属性热更新 (LMK_UPDATE_PROPS 命令,lmkd.cpp:1524-1551)------阈值可能变了,必须拆掉旧 monitor 换新的:
scss
/* lmkd.cpp:1528-1534 */
if (update_props()) {
if (!use_inkernel_interface) {
/* Reinitialize monitors to apply new settings */
destroy_monitors(); /* → unregister_psi_monitor + destroy_psi_monitor */
if (init_monitors()) { ... } /* → init_psi_monitor + register_psi_monitor */
}
...
}
init_psi_monitors() 的逐级回滚 (lmkd.cpp:3243-3254):MEDIUM 注册成功、CRITICAL 失败时,把已注册的 LOW/MEDIUM 逐一 destroy_mp_psi(),不留半成品。
5. parse_psi_line():解析统计输出
arduino
/* psi.cpp:104-116 */
int parse_psi_line(char *line, enum psi_stall_type stall_type, struct psi_stats stats[]) {
char type_name[5];
struct psi_stats *stat = &stats[stall_type];
if (!line || sscanf(line, "%4s avg10=%f avg60=%f avg300=%f total=%lu",
type_name, &stat->avg10, &stat->avg60, &stat->avg300, &stat->total) != 5) {
return -1;
}
if (strcmp(type_name, stall_type_name[stall_type])) {
return -1;
}
return 0;
}
它解析的对象长这样(cat /proc/pressure/memory):
ini
some avg10=0.25 avg60=0.10 avg300=0.02 total=123456789
full avg10=0.00 avg60=0.00 avg300=0.00 total=9876543
四层防御:
%4s:type_name 缓冲区只有 5 字节,%4s最多读 4 个字符 +\0("some"/"full" 恰好 4 个字母),即使遇到畸形输入也不会溢出;!= 5:sscanf 必须恰好解析出 5 个字段,缺一不可(%f收到非数字会少匹配);strcmp(type_name, ...):确认这行真的是请求的类型------防止把 "full" 行的数字填进stats[PSI_SOME];- NULL 检查 :
!line,因为 lmkd 传进来的是strtok_r的返回值,文件读空时可能是 NULL。
5.1 lmkd 侧的三个封装:读统计用的是另一个 fd
arduino
/* lmkd.cpp:1946-1991 */
static int psi_parse(struct reread_data *file_data, struct psi_stats stats[], bool full) {
char *buf;
char *save_ptr;
char *line;
if ((buf = reread_file(file_data)) == NULL) {
return -1;
}
line = strtok_r(buf, "\n", &save_ptr);
if (parse_psi_line(line, PSI_SOME, stats)) {
return -1;
}
if (full) {
line = strtok_r(NULL, "\n", &save_ptr);
if (parse_psi_line(line, PSI_FULL, stats)) {
return -1;
}
}
return 0;
}
static int psi_parse_mem(struct psi_data *psi_data) {
static struct reread_data file_data = {
.filename = PSI_PATH_MEMORY,
.fd = -1,
};
return psi_parse(&file_data, psi_data->mem_stats, true);
}
static int psi_parse_io(struct psi_data *psi_data) { ... /* PSI_PATH_IO, true */ }
static int psi_parse_cpu(struct psi_data *psi_data) { ... /* PSI_PATH_CPU, false */ }
两个耐人寻味的设计:
双 fd 架构 。此刻 lmkd 进程里对 /proc/pressure/memory 有两个打开的文件:
css
fd A(O_WRONLY,libpsi 注册的 trigger fd)→ epoll 监听 EPOLLPRI → 事件驱动
fd B(O_RDONLY,lmkd 的 reread_file 缓存) → 每次事件后 pread → 读 avg10/total
trigger fd 负责"何时看",stats fd 负责"看什么"。fd B 走 reread_file()(lmkd.cpp:623)------首次 open 后永久缓存 fd 和缓冲区,因为内存高压时连 open() 都可能卡住。
full=false 的 CPU 。psi_parse_cpu 只解析第一行(some)------CPU 压力文件历史格式上就没有 full 统计行(CPU 上永远有任务在跑,不存在"全员停顿"),libpsi 用一个 bool 参数天然兼容了三种资源的格式差异。
5.2 统计数据在 lmkd 里的实际用途
scss
/* lmkd.cpp:2755-2757 */
if (!psi_parse_mem(&psi_data)) {
critical_stall = psi_data.mem_stats[PSI_FULL].avg10 > (float)stall_limit_critical;
}
决策树执行前读一次 /proc/pressure/memory,若 full avg10 (过去 10 秒完全停顿占比)超过 ro.lmk.stall_limit_critical(默认 100%),则 critical_stall=true → min_score_adj 直接降为 0,允许杀前台可感知应用 (lmkd.cpp:2857-2859)。此外 psi_parse_io/psi_parse_cpu 的结果会随 killinfo_log() 写进 event log(tag 10195355),供事后分析停顿时 IO/CPU 是否同病相怜。
6. 内核侧:一页纸讲清 PSI 原理
配合 libpsi 读,内核(kernel/sched/psi.c)的行为可以浓缩成四条:
- 记账 :调度器和内存回收路径上,任务因缺内存而停顿时,内核按 CPU 维度累计三种状态的时间:
SOME(至少一个任务停)、FULL(所有非 idle 任务停)、NONIDLE。聚合出avg10/60/300(各时间窗停顿百分比)和total(累计微秒)------就是/proc/pressure/*的输出,也是parse_psi_line解析的内容; - 触发判定 :对每个注册的 trigger,在窗口内累计对应类型的停顿时间,达到阈值即触发;
- 限速 :两次事件之间至少间隔一个窗口。这是内核文档明确的语义,也是 lmkd 那套"事件后轮询 1 秒"设计的根本原因------事件每秒最多来一次,窗口内的后续恶化只能靠 lmkd 自己以 10/100ms 轮询补盲(上篇博客 6.8 节);
- 通知 :触发时唤醒该 fd 的 poll 等待队列,poll/epoll 返回
POLLPRI;事件标志被消费掉,每个触发点恰好一次唤醒。
一句话:PSI 把"内存压力"从"还剩多少"(meminfo)变成"卡了多久"(stall time),libpsi 把这套语义翻译成三个 fd 操作:write 注册、poll 等待、read 统计。
7. libpsi × lmkd 完整协作时序
把两篇博客串起来(行号均为 lmkd.cpp):
scss
[启动] init_mp_psi() (3153)
│ init_psi_monitor(PSI_SOME, 70000, 1000000) ← psi.cpp:36 open+write
│ init_psi_monitor(PSI_FULL, 700000, 1000000) ← psi.cpp:36 open+write
│ register_psi_monitor(epollfd, fd, &hinfo) ← psi.cpp:81 epoll_ctl(EPOLLPRI)
▼
[待机] mainloop(): epoll_wait(-1) ← 零开销
▼
[事件] 内核窗口内停顿 ≥ 阈值 → POLLPRI 唤醒
│ mp_event_psi(data=level, events=EPOLLPRI) ← 2583
│ ├─ reread_file + parse_psi_line (fd B) ← 1946 读 mem/vmstat
│ ├─ psi_parse_mem → mem_stats[PSI_FULL].avg10 ← 1969/2755 判 critical_stall
│ ├─ 决策树 → find_and_kill_process() ← 2421
│ └─ 10/100ms 轮询持续 1s(PSI_WINDOW_SIZE_MS) ← 135/2890
▼
[销毁] LMK_UPDATE_PROPS 热更新或退出
destroy_mp_psi() (3181)
├─ unregister_psi_monitor(epollfd, fd) ← psi.cpp:94
└─ destroy_psi_monitor(fd) ← psi.cpp:98 close → 内核销毁 trigger
8. 动手实验
8.1 观察统计
ini
$ adb shell cat /proc/pressure/memory
some avg10=0.15 avg60=0.31 avg300=0.12 total=1819288761
full avg10=0.00 avg60=0.01 avg300=0.00 total=57236773
开几个大应用再 cat,看 some/total 飙升;在内存压力下 full 非 0 就说明出现了"全员停顿"。
8.2 最小 trigger demo(需 root)
把下面的小程序推到设备上跑,等 150ms/1s 的部分停顿出现:
c
#include <fcntl.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void) {
int fd = open("/proc/pressure/memory", O_WRONLY | O_CLOEXEC);
if (fd < 0) { perror("open"); return 1; }
char buf[64];
int n = snprintf(buf, sizeof(buf), "some 150000 1000000");
if (write(fd, buf, n + 1) < 0) { perror("write"); return 1; }
struct pollfd pfd = { .fd = fd, .events = POLLPRI };
while (poll(&pfd, 1, -1) > 0) {
printf("memory pressure event! (revents=%d)\n", pfd.revents);
sleep(1); /* 不 sleep 的话,可以观察到事件最多 1s 一次(限速) */
}
return 0;
}
把 .events 改成 POLLIN 再跑一遍------永远不会醒,亲测 3.1 节的坑。
8.3 调节 lmkd 的 PSI 阈值
ruby
$ adb root
$ adb shell setprop persist.device_config.lmkd_native.psi_partial_stall_ms 50
$ adb shell setprop persist.device_config.lmkd_native.psi_complete_stall_ms 500
$ adb shell stop lmkd && adb shell start lmkd # 或发 LMK_UPDATE_PROPS
属性生效路径就是第 4 节的 LMK_UPDATE_PROPS → destroy_monitors() → init_monitors(),libpsi 的 register/destroy 就是为这种热更新准备的。
9. 总结
| libpsi 函数 | 一句话职责 | 踩坑点 |
|---|---|---|
init_psi_monitor |
open+write 注册内核 trigger,fd 即事件 fd | 窗口 500ms~10s;一个 fd 一个 trigger;参数是微秒 |
register_psi_monitor |
挂进 epoll | EPOLLPRI,写 EPOLLIN 永远不醒 |
unregister/destroy_psi_monitor |
摘除并 close | close 即注销 trigger |
parse_psi_line |
解析 some/full avg10=... total=... |
%4s 防溢出;CPU 无 full 行 |
libpsi 的价值恰恰在于它把所有这些协议细节封进了 116 行代码,让 lmkd.cpp 可以用纯粹的策略语言写代码:init_psi_monitor(PSI_SOME, 70ms, 1s) 读起来就像一句需求。薄而正确的基础库,是上层系统软件能写简单的前提。
系列下一篇可以顺着往两头走:往下是内核 kernel/sched/psi.c 的记账与触发实现;往上是 AMS 侧 ProcessList.java 的 updateOomAdjLocked()------看 oomadj 是怎么被算出来、又怎么通过 LMK_PROCPRIO 灌进 lmkd 的进程表。