kswapd 后台进程到底做什么:从水位线到后台回收循环
主线第 3 篇讲过 zone 里有三条水位线:min、low、high。但当空闲页真的低于这些水位时,Linux 不是只有一种反应。
有时候,你看到系统还能继续分配,只是后台有个 kswapd0 在忙;有时候,申请内存的进程自己会卡住,延迟突然变高;更糟的时候,回收和压缩都失败,才可能走到 OOM。
这一篇只回答一个问题:
内存不够时,kswapd 到底做什么?它和 direct reclaim 的边界在哪里?
先把结论压缩成一张图:
alloc_pages
│
▼
检查目标 zone 的 watermark
│
├─ 水位健康
│ │
│ └─ 从 per-cpu pageset / buddy 分配
│
├─ 低于 low 或分配路径发现需要后台整理
│ │
│ ├─ 唤醒对应 node 的 kswapd
│ └─ 当前分配尽量继续走快路径或重试
│
└─ 低于 min、重试失败、或者当前请求必须马上拿到页
│
▼
direct reclaim
│
├─ 回收到足够页 -> 重试分配
└─ 仍失败 -> compaction / 分配失败 / OOM 路径
kswapd 不是“释放所有内存”的线程,也不是“负责杀进程”的线程。它的核心职责是:
在后台扫描可回收页和部分可回收缓存,
让相关 node / zone 尽量回到合适水位,
尽量避免真正申请内存的进程在分配路径里付出回收延迟。
一、先看 kswapd 站在什么位置
Linux 按 NUMA node 管理物理内存。每个 node 通常有一个 kswapd 内核线程,名字类似 kswapd0、kswapd1。单 node 机器上常见的就是 kswapd0。
它站的位置大概是这样:
┌────────────────────────────────────────────────────────────────────┐
│ NUMA node 0 │
│ │
│ kswapd0 │
│ │ │
│ │ 后台回收,让 zone 回到目标水位 │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ zones │ │
│ │ │ │
│ │ ZONE_DMA min / low / high │ │
│ │ ZONE_DMA32 min / low / high │ │
│ │ ZONE_NORMAL min / low / high │ │
│ │ ZONE_MOVABLE min / low / high │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ LRU / page cache / anonymous pages / reclaimable slab ... │
└────────────────────────────────────────────────────────────────────┘
注意这里有两个边界:
kswapd是按 node 工作的后台线程,不是每个进程一个。kswapd的目标是恢复水位,不是把内存清空。
所以你不能把 kswapd 理解成“系统没内存了,内核派它去释放一切”。更准确的是:分配路径发现某些 zone 的水位不理想时,唤醒对应 node 的 kswapd;kswapd 在后台扫描、回收、睡眠,再等待下一次唤醒。
二、水位线到底控制什么
/proc/zoneinfo 里能直接看到每个 zone 的水位线。单位是页,不是字节:
Node 0, zone Normal
pages free 10918
min 8098
low 10122
high 12146
managed 1441287
以 4KB 页为例:
min = 8098 pages ≈ 31.6 MiB
low = 10122 pages ≈ 39.5 MiB
high = 12146 pages ≈ 47.4 MiB
三条线可以先这样理解:
| 水位 | 含义 |
|---|---|
high | 比较舒服的目标水位。kswapd 被唤醒后,常常努力把相关 zone 回收到接近这个目标。 |
low | 后台回收触发线。低于它时,分配路径通常会唤醒 kswapd。 |
min | 更紧张的保留线。低于它时,普通分配更可能失败或让当前进程进入 direct reclaim。 |
真实内核判断比 free_pages < low 复杂。它还会考虑:
- 分配 order:要 1 页,还是要
2^order个连续页。 - 目标 zone 和
highest_zoneidx。 - 每个 zone 的保留页、低端内存保护、watermark boost。
- per-cpu pageset 里是否还有可用页。
- memcg、cpuset、NUMA 策略等约束。
但作为第一层模型,可以记住:
free pages 足够高
-> 当前分配者通常直接拿页
free pages 掉到 low 附近
-> 唤醒 kswapd,后台回收
free pages 掉到 min 附近,或者重试拿不到页
-> 当前分配者可能自己进入 direct reclaim
三、分配路径如何决定唤醒谁
一次用户态写缺页、一次页表页分配、一次内核分配,只要最后需要物理页,都可能走到 alloc_pages 一类路径。
抽象一下,分配路径大概是:
alloc_pages(gfp_mask, order)
│
▼
get_page_from_freelist
│
▼
遍历允许使用的 zone
│
▼
zone_watermark_fast / zone_watermark_ok
│
├─ watermark 满足
│ │
│ ▼
│ 从 per-cpu pageset 或 buddy 拿页
│
└─ watermark 不满足
│
├─ 记录需要回收的 node / zone
├─ 唤醒 kswapd
└─ 根据 gfp、order、重试状态决定是否进入慢路径
如果当前请求还能靠别的 zone、per-cpu pageset 或重试解决,那么当前进程可能不会明显卡住;它只是顺手把 kswapd 叫醒,让后台去恢复水位。
如果当前请求就是拿不到页,或者水位已经太低,分配路径就不能只靠后台线程了。它会进入慢路径,当前进程自己参与回收:
分配失败 / 水位太低
│
▼
__alloc_pages_slowpath
│
├─ wake_all_kswapds / wakeup_kswapd
├─ direct reclaim
├─ compaction
├─ 重试分配
└─ 仍然失败时,根据请求类型返回失败或进入 OOM 判断
这就是用户体验上的差别:
kswapd 后台回收:
当前进程通常不直接为回收付出完整延迟。
direct reclaim:
当前申请内存的进程自己进入回收路径,
延迟会算到这次 malloc、page fault、read、write 或内核分配上。
四、kswapd 被唤醒后主循环做什么
kswapd 平时大部分时间在睡眠。分配路径唤醒它以后,它会围绕 node 做平衡。
可以把主线先记成这条调用链:
wakeup_kswapd
│
▼
kswapd 线程醒来
│
▼
balance_pgdat
│
▼
shrink_node
│
▼
shrink_lruvec
│
▼
扫描 inactive / active、anon / file 等 LRU 列表
│
├─ 干净文件页:可以直接丢弃
├─ 脏文件页:需要 writeback 配合
├─ 匿名页:通常需要 swap,或者暂时不能回收
└─ unevictable / mlocked 页:跳过
用流程图看会更直观:
flowchart TD
A["分配路径发现 watermark 紧张"] --> B["wakeup_kswapd(pgdat, order, highest_zoneidx)"]
B --> C["kswapd 从睡眠中醒来"]
C --> D["balance_pgdat"]
D --> E{"相关 zone 是否已平衡?"}
E -- "是" --> F["kswapd 重新睡眠"]
E -- "否" --> G["shrink_node"]
G --> H["shrink_lruvec"]
H --> I["扫描 anon/file LRU"]
H --> J["必要时 shrink_slab"]
I --> K{"回收到足够页?"}
J --> K
K -- "还不够" --> D
K -- "够了或没有进展" --> E
kswapd 回收的不是“进程对象”,而是物理页和可回收缓存。后面几篇番外会把它扫描的东西拆开:
- 番外二:LRU 为什么分 active/inactive、anon/file,以及 Multi-Gen LRU。
- 番外三:匿名页为什么需要 swap entry、swap cache 和换入。
- 番外四:文件页为什么干净页可以直接丢弃,脏页需要 writeback。
- 番外五:writeback 如何把脏页写回磁盘。
- 番外六:LRU 之外的 shrinker 如何回收 dentry、inode 等内核缓存。
这一篇先不钻进这些细节,只要抓住 kswapd 的职责:
它是后台平衡 node / zone 水位的线程。
它会扫描页和可回收缓存。
它不直接代表 OOM,也不负责“杀掉某个进程”。
五、direct reclaim 什么时候出现
如果后台回收来不及,或者当前分配必须马上满足,申请内存的进程会自己进入回收路径。这就是 direct reclaim。
流程可以画成这样:
当前进程申请页
│
▼
快路径分配失败
│
▼
唤醒 kswapd
│
▼
当前进程进入 direct reclaim
│
├─ 扫描 LRU
├─ 回收 page cache
├─ 可能换出匿名页
├─ 可能等待 writeback
└─ 可能触发 slab shrinker
│
▼
回收有进展?
│
├─ 有 -> 重试分配
└─ 没有 -> compaction / OOM 判断 / 返回失败
direct reclaim 的关键不是“它和 kswapd 扫描的数据结构完全不同”,而是:
direct reclaim 的执行者是当前申请内存的进程。
所以回收成本会直接变成当前进程的分配延迟。
这类现象可以从 /proc/vmstat 看线索:
| 计数器 | 大致含义 |
|---|---|
pgscan_kswapd | kswapd 扫描过的页数。 |
pgsteal_kswapd | kswapd 成功回收的页数。 |
pgscan_direct | direct reclaim 扫描过的页数。 |
pgsteal_direct | direct reclaim 成功回收的页数。 |
allocstall_* | 分配路径因为回收而停顿的次数,按 zone 分类。 |
如果 pgscan_kswapd、pgsteal_kswapd 增长,而 pgscan_direct、allocstall_* 不增长,通常说明这次压力主要被后台回收消化了。
如果 pgscan_direct、allocstall_* 明显增长,就说明有分配者已经亲自下场回收,应用侧更容易感到延迟。
六、容器里观察时要小心
容器实验要特别注意三个边界:
/proc/vmstat通常是宿主内核的全局计数,不是某个容器独享计数。- 普通 Docker PID namespace 里看不到宿主内核线程,所以默认可能看不到
kswapd0。 - cgroup 内存限制可能触发 memcg reclaim 或 memcg OOM,它不总是表现成全局
kswapd行为。
所以这篇实验用两个参数:
--platform linux/amd64
保证用户态容器是 x86_64。
--pid=host
只为了能在 /proc/<pid>/comm 里看到宿主 PID namespace 的 kswapd0。不加它也能读 /proc/vmstat 和 /proc/zoneinfo,但看不到 kswapd0 线程本身。
七、完整实验代码
下面这份代码做几件事:
- 打印
uname、cgroup 内存限制、可见的kswapd。 - 读取
/proc/meminfo、/proc/zoneinfo、/proc/vmstat。 mmap一段匿名内存并逐页写入,强制真正分配物理页。- 再次读取水位线和 vmstat,计算相对实验开始时的 delta。
munmap后再观察一次。
同一份代码也保存为 assets/kswapd-watermark-demo.c。
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/utsname.h>
#include <time.h>
#include <unistd.h>
struct vm_counter {
const char *name;
unsigned long long value;
int found;
};
struct zone_row {
int node;
char zone[64];
unsigned long long free_pages;
unsigned long long min;
unsigned long long low;
unsigned long long high;
unsigned long long managed;
int seen;
};
static const char *const vmstat_keys[] = {
"pgscan_kswapd",
"pgsteal_kswapd",
"pgscan_direct",
"pgsteal_direct",
"pgscan_direct_throttle",
"allocstall_dma",
"allocstall_dma32",
"allocstall_normal",
"allocstall_movable",
};
static void die(const char *msg)
{
perror(msg);
exit(1);
}
static void print_cgroup_memory(void)
{
const char *paths[] = {
"/sys/fs/cgroup/memory.current",
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory.swap.current",
"/sys/fs/cgroup/memory.swap.max",
};
size_t i;
printf("cgroup memory:\n");
for (i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) {
FILE *fp = fopen(paths[i], "r");
char buf[128];
if (!fp)
continue;
if (fgets(buf, sizeof(buf), fp)) {
buf[strcspn(buf, "\n")] = '\0';
printf(" %s = %s\n", paths[i], buf);
}
fclose(fp);
}
}
static void print_meminfo(const char *tag)
{
const char *wanted[] = {
"MemTotal:",
"MemAvailable:",
"SwapTotal:",
"SwapFree:",
};
FILE *fp = fopen("/proc/meminfo", "r");
char line[256];
size_t i;
if (!fp)
die("fopen /proc/meminfo");
printf("[%s] /proc/meminfo\n", tag);
while (fgets(line, sizeof(line), fp)) {
for (i = 0; i < sizeof(wanted) / sizeof(wanted[0]); i++) {
if (strncmp(line, wanted[i], strlen(wanted[i])) == 0) {
fputs(line, stdout);
break;
}
}
}
fclose(fp);
}
static void read_vmstat(struct vm_counter *counters, size_t nr)
{
FILE *fp = fopen("/proc/vmstat", "r");
char name[128];
unsigned long long value;
size_t i;
if (!fp)
die("fopen /proc/vmstat");
for (i = 0; i < nr; i++) {
counters[i].value = 0;
counters[i].found = 0;
}
while (fscanf(fp, "%127s %llu", name, &value) == 2) {
for (i = 0; i < nr; i++) {
if (strcmp(name, counters[i].name) == 0) {
counters[i].value = value;
counters[i].found = 1;
break;
}
}
}
fclose(fp);
}
static void print_vmstat_delta(const char *tag,
const struct vm_counter *base,
const struct vm_counter *now,
size_t nr)
{
size_t i;
printf("[%s] /proc/vmstat selected counters\n", tag);
for (i = 0; i < nr; i++) {
if (!now[i].found) {
printf("%-24s not-found\n", now[i].name);
continue;
}
printf("%-24s %llu", now[i].name, now[i].value);
if (base && base[i].found)
printf(" delta=%+lld",
(long long)(now[i].value - base[i].value));
putchar('\n');
}
}
static void print_zoneinfo(void)
{
FILE *fp = fopen("/proc/zoneinfo", "r");
char line[256];
struct zone_row row;
int have_row = 0;
if (!fp)
die("fopen /proc/zoneinfo");
printf("zone watermarks, pages:\n");
memset(&row, 0, sizeof(row));
while (fgets(line, sizeof(line), fp)) {
int node;
char zone[64];
unsigned long long value;
if (sscanf(line, "Node %d, zone %63s", &node, zone) == 2) {
if (have_row && row.seen) {
printf(" node=%d zone=%-8s free=%llu min=%llu low=%llu high=%llu managed=%llu\n",
row.node, row.zone, row.free_pages, row.min,
row.low, row.high, row.managed);
}
memset(&row, 0, sizeof(row));
row.node = node;
snprintf(row.zone, sizeof(row.zone), "%s", zone);
have_row = 1;
continue;
}
if (!have_row)
continue;
if (sscanf(line, " pages free %llu", &value) == 1) {
row.free_pages = value;
row.seen = 1;
} else if (sscanf(line, " min %llu", &value) == 1) {
row.min = value;
} else if (sscanf(line, " low %llu", &value) == 1) {
row.low = value;
} else if (sscanf(line, " high %llu", &value) == 1) {
row.high = value;
} else if (sscanf(line, " managed %llu", &value) == 1) {
row.managed = value;
}
}
if (have_row && row.seen) {
printf(" node=%d zone=%-8s free=%llu min=%llu low=%llu high=%llu managed=%llu\n",
row.node, row.zone, row.free_pages, row.min, row.low,
row.high, row.managed);
}
fclose(fp);
}
static void find_kswapd(void)
{
int found = 0;
long pid;
printf("visible kswapd threads:\n");
for (pid = 1; pid < 1000000; pid++) {
char path[64];
char comm[128];
FILE *fp;
snprintf(path, sizeof(path), "/proc/%ld/comm", pid);
fp = fopen(path, "r");
if (!fp)
continue;
if (fgets(comm, sizeof(comm), fp)) {
comm[strcspn(comm, "\n")] = '\0';
if (strncmp(comm, "kswapd", 6) == 0 &&
comm[6] >= '0' && comm[6] <= '9') {
printf(" pid=%ld comm=%s\n", pid, comm);
found = 1;
}
}
fclose(fp);
}
if (!found)
printf(" none in this PID namespace; run the container with --pid=host to see host kernel threads\n");
}
static void sleep_seconds(unsigned int seconds)
{
struct timespec req;
req.tv_sec = seconds;
req.tv_nsec = 0;
while (nanosleep(&req, &req) != 0) {
if (errno != EINTR)
die("nanosleep");
}
}
int main(int argc, char **argv)
{
unsigned long long mb = 512;
unsigned int hold_seconds = 2;
size_t len;
size_t page_size;
unsigned char *p;
struct utsname uts;
struct vm_counter before[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_touch[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_sleep[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_free[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
size_t nr_counters = sizeof(vmstat_keys) / sizeof(vmstat_keys[0]);
size_t i;
if (argc >= 2)
mb = strtoull(argv[1], NULL, 10);
if (argc >= 3)
hold_seconds = (unsigned int)strtoul(argv[2], NULL, 10);
if (mb == 0) {
fprintf(stderr, "usage: %s [megabytes_to_touch] [hold_seconds]\n",
argv[0]);
return 2;
}
for (i = 0; i < nr_counters; i++) {
before[i].name = vmstat_keys[i];
after_touch[i].name = vmstat_keys[i];
after_sleep[i].name = vmstat_keys[i];
after_free[i].name = vmstat_keys[i];
}
if (uname(&uts) != 0)
die("uname");
page_size = (size_t)sysconf(_SC_PAGESIZE);
len = (size_t)mb * 1024UL * 1024UL;
printf("machine=%s sysname=%s release=%s\n",
uts.machine, uts.sysname, uts.release);
printf("page_size=%zu bytes\n", page_size);
printf("target=%llu MiB, hold_seconds=%u\n", mb, hold_seconds);
print_cgroup_memory();
find_kswapd();
print_meminfo("before");
print_zoneinfo();
read_vmstat(before, nr_counters);
print_vmstat_delta("before", NULL, before, nr_counters);
p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED)
die("mmap");
printf("touching %zu pages...\n", len / page_size);
for (i = 0; i < len; i += page_size)
p[i] = (unsigned char)(i / page_size);
read_vmstat(after_touch, nr_counters);
print_meminfo("after touch");
print_zoneinfo();
print_vmstat_delta("after touch", before, after_touch, nr_counters);
if (hold_seconds > 0) {
printf("sleeping %u seconds...\n", hold_seconds);
sleep_seconds(hold_seconds);
}
read_vmstat(after_sleep, nr_counters);
print_meminfo("after sleep");
print_zoneinfo();
print_vmstat_delta("after sleep", before, after_sleep, nr_counters);
if (munmap(p, len) != 0)
die("munmap");
sleep_seconds(1);
read_vmstat(after_free, nr_counters);
print_meminfo("after munmap");
print_zoneinfo();
print_vmstat_delta("after munmap", before, after_free, nr_counters);
return 0;
}
编译和运行命令:
docker run --rm --platform linux/amd64 --pid=host \
-v "$PWD/os/memory/assets:/src:ro" -w /tmp \
gcc:13 \
bash -lc 'gcc -O2 -Wall -Wextra -std=c11 /src/kswapd-watermark-demo.c -o /tmp/wmark-demo && /tmp/wmark-demo 4096 3'
这里触摸 4096 MiB 匿名内存,并保持 3 秒。触摸必须逐页写入,因为单纯 mmap 只创建 VMA,不会立刻分配全部物理页。
八、一次真实 x86_64 容器输出
实验环境:
Docker --platform linux/amd64
container uname -m: x86_64
kernel: Linux 6.12.65-linuxkit
page size: 4096 bytes
关键输出如下。为了让重点更清楚,这里只保留和水位、kswapd、direct reclaim 有关的行:
machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096 bytes
target=4096 MiB, hold_seconds=3
cgroup memory:
/sys/fs/cgroup/memory.current = 4718592
/sys/fs/cgroup/memory.max = max
/sys/fs/cgroup/memory.swap.current = 0
/sys/fs/cgroup/memory.swap.max = max
visible kswapd threads:
pid=97 comm=kswapd0
[before] /proc/meminfo
MemTotal: 8024788 kB
MemAvailable: 6986648 kB
SwapTotal: 1048572 kB
SwapFree: 1048572 kB
zone watermarks, pages:
node=0 zone=DMA free=84705 min=3165 low=3956 high=4747 managed=564910
node=0 zone=DMA32 free=0 min=0 low=0 high=0 managed=0
node=0 zone=Normal free=10918 min=8098 low=10122 high=12146 managed=1441287
node=0 zone=Movable free=0 min=32 low=32 high=32 managed=0
[before] /proc/vmstat selected counters
pgscan_kswapd 253331
pgsteal_kswapd 252775
pgscan_direct 0
pgsteal_direct 0
pgscan_direct_throttle 0
allocstall_dma 0
allocstall_dma32 0
allocstall_normal 0
allocstall_movable 0
touching 1048576 pages...
[after touch] /proc/meminfo
MemTotal: 8024788 kB
MemAvailable: 2849020 kB
SwapTotal: 1048572 kB
SwapFree: 1048572 kB
zone watermarks, pages:
node=0 zone=DMA free=10116 min=3165 low=3956 high=4747 managed=564910
node=0 zone=DMA32 free=0 min=0 low=0 high=0 managed=0
node=0 zone=Normal free=8837 min=8098 low=10122 high=12146 managed=1441287
node=0 zone=Movable free=0 min=32 low=32 high=32 managed=0
[after touch] /proc/vmstat selected counters
pgscan_kswapd 1156290 delta=+902959
pgsteal_kswapd 1154246 delta=+901471
pgscan_direct 0 delta=+0
pgsteal_direct 0 delta=+0
pgscan_direct_throttle 0 delta=+0
allocstall_dma 0 delta=+0
allocstall_dma32 0 delta=+0
allocstall_normal 0 delta=+0
allocstall_movable 0 delta=+0
[after sleep] /proc/meminfo
MemTotal: 8024788 kB
MemAvailable: 2848636 kB
SwapTotal: 1048572 kB
SwapFree: 1048572 kB
zone watermarks, pages:
node=0 zone=DMA free=16585 min=3165 low=3956 high=4747 managed=564910
node=0 zone=DMA32 free=0 min=0 low=0 high=0 managed=0
node=0 zone=Normal free=12185 min=8098 low=10122 high=12146 managed=1441287
node=0 zone=Movable free=0 min=32 low=32 high=32 managed=0
[after sleep] /proc/vmstat selected counters
pgscan_kswapd 1163138 delta=+909807
pgsteal_kswapd 1161094 delta=+908319
pgscan_direct 0 delta=+0
pgsteal_direct 0 delta=+0
pgscan_direct_throttle 0 delta=+0
allocstall_dma 0 delta=+0
allocstall_dma32 0 delta=+0
allocstall_normal 0 delta=+0
allocstall_movable 0 delta=+0
[after munmap] /proc/meminfo
MemTotal: 8024788 kB
MemAvailable: 6993200 kB
SwapTotal: 1048572 kB
SwapFree: 1048572 kB
zone watermarks, pages:
node=0 zone=DMA free=141904 min=3165 low=3956 high=4747 managed=564910
node=0 zone=DMA32 free=0 min=0 low=0 high=0 managed=0
node=0 zone=Normal free=923003 min=8098 low=10122 high=12146 managed=1441287
node=0 zone=Movable free=0 min=32 low=32 high=32 managed=0
[after munmap] /proc/vmstat selected counters
pgscan_kswapd 1163138 delta=+909807
pgsteal_kswapd 1161094 delta=+908319
pgscan_direct 0 delta=+0
pgsteal_direct 0 delta=+0
pgscan_direct_throttle 0 delta=+0
allocstall_dma 0 delta=+0
allocstall_dma32 0 delta=+0
allocstall_normal 0 delta=+0
allocstall_movable 0 delta=+0
这次输出可以读出几个事实。
第一,容器确实是 x86_64 用户态:
machine=x86_64
第二,kswapd0 是宿主 PID namespace 里的内核线程:
visible kswapd threads:
pid=97 comm=kswapd0
第三,逐页触摸 4GB 匿名内存后,Normal zone 的空闲页从 10918 掉到 8837,已经低于 low=10122:
before:
Normal free=10918 min=8098 low=10122 high=12146
after touch:
Normal free=8837 min=8098 low=10122 high=12146
第四,kswapd 的扫描和回收计数明显增加:
pgscan_kswapd delta=+902959
pgsteal_kswapd delta=+901471
这说明后台回收确实发生了。这里的单位是页,4KB 页下约等于:
902959 pages * 4096 bytes ≈ 3527 MiB scanned
901471 pages * 4096 bytes ≈ 3521 MiB reclaimed
第五,保持 3 秒后,Normal zone 的空闲页恢复到 12185,刚好回到 high=12146 附近:
after sleep:
Normal free=12185 min=8098 low=10122 high=12146
这就是 kswapd 的典型目标:不是把内存越清越多,而是把水位恢复到合适位置,然后继续睡眠。
第六,这次实验里 direct reclaim 没发生:
pgscan_direct delta=+0
pgsteal_direct delta=+0
allocstall_* delta=+0
这说明当前压力主要由后台回收吸收,分配者没有进入明显的 direct reclaim 停顿。
九、把实验和内核路径对上
把上面的输出放回分配路径里:
程序 mmap 4096 MiB
│
└─ 只创建 VMA,不立刻分配 4096 MiB 物理页
程序逐页写入
│
▼
每页第一次写触发匿名页缺页
│
▼
缺页路径需要 alloc_pages
│
▼
zone free pages 接近或低于 low
│
├─ 唤醒 kswapd0
└─ 当前分配继续成功,没有进入 direct reclaim
kswapd0 后台扫描
│
├─ pgscan_kswapd 增长
├─ pgsteal_kswapd 增长
└─ Normal zone free 回到 high 附近
如果这次机器更紧张,或者把内存压力打得更狠,你可能看到另一种结果:
pgscan_direct 增长
pgsteal_direct 增长
allocstall_normal 增长
那就说明当前申请内存的进程已经不只是“叫醒后台线程”,而是自己进入回收路径了。
十、常见误解
误解一:kswapd 一忙就是系统要 OOM 了。
不是。kswapd 活跃只说明后台回收在工作。只有回收、重试、可能的压缩等路径都无法满足分配,并且内核判断没有可行进展时,才可能进入 OOM 路径。
误解二:kswapd 会直接释放进程占用的所有匿名内存。
不会。匿名页没有文件后盾,通常需要 swap 才能回收;如果没有 swap、页很热、页被锁住、或者受 memcg 等条件限制,就不一定能回收。
误解三:文件页一定先于匿名页回收。
不绝对。干净文件页成本低,确实常常更容易回收;但真实选择会受 LRU 冷热、swappiness、refault、脏页、memcg、回收优先级等影响。
误解四:容器里看不到 kswapd0 就说明没有 kswapd。
不是。普通容器有自己的 PID namespace,看不到宿主内核线程很正常。用 --pid=host 或在宿主上看,才能直接看到 kswapd0。
十一、收束
这一篇只需要记住一条主线:
watermark 是触发条件。
kswapd 是后台回收者。
direct reclaim 是当前分配者亲自回收。
OOM 是回收和重试都无法解决后的更后面路径。
再压缩成动态链路:
alloc_pages
│
▼
检查 zone watermark
│
├─ 高于 low
│ └─ 正常分配
│
├─ 低于 low
│ ├─ wakeup_kswapd
│ └─ 后台回收到 high 附近
│
└─ 低于 min / 分配失败
├─ direct reclaim
├─ compaction
└─ 仍失败才可能 OOM
下一篇再进入 kswapd 真正扫描的数据结构:LRU。那里会看到 Linux 为什么不维护一个教科书式的“全局最近最少使用队列”,而是把页拆成 active/inactive、anon/file,以及现代内核里的 Multi-Gen LRU。