kswapd 后台进程到底做什么:从水位线到后台回收循环

4 阅读18分钟

kswapd 后台进程到底做什么:从水位线到后台回收循环

主线第 3 篇讲过 zone 里有三条水位线:minlowhigh。但当空闲页真的低于这些水位时,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 内核线程,名字类似 kswapd0kswapd1。单 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 ...        │
└────────────────────────────────────────────────────────────────────┘

注意这里有两个边界:

  1. kswapd 是按 node 工作的后台线程,不是每个进程一个。
  2. kswapd 的目标是恢复水位,不是把内存清空。

所以你不能把 kswapd 理解成“系统没内存了,内核派它去释放一切”。更准确的是:分配路径发现某些 zone 的水位不理想时,唤醒对应 node 的 kswapdkswapd 在后台扫描、回收、睡眠,再等待下一次唤醒。

二、水位线到底控制什么

/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[&#34;分配路径发现 watermark 紧张&#34;] --> B[&#34;wakeup_kswapd(pgdat, order, highest_zoneidx)&#34;]
    B --> C[&#34;kswapd 从睡眠中醒来&#34;]
    C --> D[&#34;balance_pgdat&#34;]
    D --> E{&#34;相关 zone 是否已平衡?&#34;}
    E -- &#34;是&#34; --> F[&#34;kswapd 重新睡眠&#34;]
    E -- &#34;否&#34; --> G[&#34;shrink_node&#34;]
    G --> H[&#34;shrink_lruvec&#34;]
    H --> I[&#34;扫描 anon/file LRU&#34;]
    H --> J[&#34;必要时 shrink_slab&#34;]
    I --> K{&#34;回收到足够页?&#34;}
    J --> K
    K -- &#34;还不够&#34; --> D
    K -- &#34;够了或没有进展&#34; --> 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_kswapdkswapd 扫描过的页数。
pgsteal_kswapdkswapd 成功回收的页数。
pgscan_directdirect reclaim 扫描过的页数。
pgsteal_directdirect reclaim 成功回收的页数。
allocstall_*分配路径因为回收而停顿的次数,按 zone 分类。

如果 pgscan_kswapdpgsteal_kswapd 增长,而 pgscan_directallocstall_* 不增长,通常说明这次压力主要被后台回收消化了。

如果 pgscan_directallocstall_* 明显增长,就说明有分配者已经亲自下场回收,应用侧更容易感到延迟。

六、容器里观察时要小心

容器实验要特别注意三个边界:

  1. /proc/vmstat 通常是宿主内核的全局计数,不是某个容器独享计数。
  2. 普通 Docker PID namespace 里看不到宿主内核线程,所以默认可能看不到 kswapd0
  3. cgroup 内存限制可能触发 memcg reclaim 或 memcg OOM,它不总是表现成全局 kswapd 行为。

所以这篇实验用两个参数:

--platform linux/amd64

保证用户态容器是 x86_64。

--pid=host

只为了能在 /proc/<pid>/comm 里看到宿主 PID namespace 的 kswapd0。不加它也能读 /proc/vmstat/proc/zoneinfo,但看不到 kswapd0 线程本身。

七、完整实验代码

下面这份代码做几件事:

  1. 打印 uname、cgroup 内存限制、可见的 kswapd
  2. 读取 /proc/meminfo/proc/zoneinfo/proc/vmstat
  3. mmap 一段匿名内存并逐页写入,强制真正分配物理页。
  4. 再次读取水位线和 vmstat,计算相对实验开始时的 delta。
  5. 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/inactiveanon/file,以及现代内核里的 Multi-Gen LRU。