本系列前几篇文章分别介绍了虚拟内存布局、物理内存分配器、页表体系与缺页异常处理机制。本篇继续深入,聚焦于三个紧密相关的主题:mmap 文件映射(把文件直接映射到进程地址空间)、共享内存(多进程通过同一块物理页通信)以及写时复制(COW)fork() 后父子进程高效共享内存的核心机制)。

理解这些机制对于系统编程、性能调优和内核开发都至关重要。mmap 是高性能 I/O 和数据库(如 SQLite WAL 模式、RocksDB mmap 读)的底层利器;COW 让 fork() 的成本从”复制整个进程内存”降至”几乎可以忽略不计”;共享内存则是进程间通信(IPC)延迟最低的手段,Redis 的 RDB 持久化、Nginx 的 worker 与 master 进程通信都依赖于此。

所有代码片段均基于 Linux 6.4-rc1(commit ac9a78681b92)。


一、mmap 文件映射:从系统调用到缺页处理

1.1 do_mmap:建立映射的入口

用户态调用 mmap(2) 后,经过 ksys_mmap_pgoff 进入内核核心逻辑 do_mmapmm/mmap.c)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// mm/mmap.c:1222
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, unsigned long pgoff,
unsigned long *populate, struct list_head *uf)
{
struct mm_struct *mm = current->mm;
vm_flags_t vm_flags;
int pkey = 0;

validate_mm(mm);
*populate = 0;

if (!len)
return -EINVAL;

/* PROT_READ 是否隐含 PROT_EXEC(与 personality 相关) */
if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
if (!(file && path_noexec(&file->f_path)))
prot |= PROT_EXEC;
...
len = PAGE_ALIGN(len);

/* 超过进程最大映射数量限制 */
if (mm->map_count > sysctl_max_map_count)
return -ENOMEM;

/* 找一个合适的虚拟地址区间 */
addr = get_unmapped_area(file, addr, len, pgoff, flags);
...
vm_flags = calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags)
| mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
...
addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
...
}

几个关键点:

  • get_unmapped_area:在进程地址空间中找一段满足对齐要求的空闲虚拟地址区间,若设置了 MAP_FIXED 则直接使用指定地址。对于文件映射,该函数会考虑文件系统的对齐要求(如 HugePage 映射需要 2MB 对齐);对于匿名映射,则从 mmap_base 向下增长(地址空间随机化开启时会有随机偏移)。
  • vm_flags:由保护位(PROT_READ/WRITE/EXEC)和映射标志(MAP_SHARED/PRIVATE)共同决定。MAP_SHARED 映射会设置 VM_SHARED | VM_MAYSHARE,意味着对该区域的修改会直接反映到底层文件(或共享页面);MAP_PRIVATE 则不设置 VM_SHARED,写入触发 COW,不影响原始文件。
  • mmap_region:真正负责分配 VMA(struct vm_area_struct)并将其插入进程地址空间(Linux 6.1+ 使用 maple tree 替代红黑树,查找性能更优)。它还会尝试将新 VMA 与相邻的 VMA 合并(can_vma_merge_before/after),减少 VMA 数量,降低内存占用和管理开销。
  • **vm_pgoff**:记录文件映射的起始偏移(页为单位),mmap(fd, offset=4096)vm_pgoff = 1。缺页时通过 vmf->pgoff = vma->vm_pgoff + ((addr - vma->vm_start) >> PAGE_SHIFT) 计算目标页在文件中的位置。

mmap_region 最终调用文件的 f_op->mmap 回调,以 ext4/xfs 为代表的普通文件系统最终都会走到 generic_file_mmap

1.2 generic_file_mmap:设置 VMA 操作集

1
2
3
4
5
6
7
8
9
10
11
// mm/filemap.c:3594
int generic_file_mmap(struct file *file, struct vm_area_struct *vma)
{
struct address_space *mapping = file->f_mapping;

if (!mapping->a_ops->read_folio)
return -ENOEXEC;
file_accessed(file);
vma->vm_ops = &generic_file_vm_ops; /* 关键:设置 vm_ops */
return 0;
}

generic_file_vm_ops 定义了该 VMA 的缺页处理函数集,其中最重要的两个成员是:

1
2
3
4
5
6
// mm/filemap.c:3585-3592
const struct vm_operations_struct generic_file_vm_ops = {
.fault = filemap_fault, /* 单页缺页处理 */
.map_pages = filemap_map_pages, /* 批量预映射优化 */
.page_mkwrite = filemap_page_mkwrite,
};

vm_ops 在此设置完毕,后续进程访问该地址段时触发缺页异常,内核便会调用 filemap_fault 来完成实际的物理页映射。

1.3 filemap_fault:文件 mmap 缺页处理

当进程首次访问 mmap 映射区域时,由于 PTE 为空,硬件触发 #PF,内核调用链最终到达 filemap_faultmm/filemap.c:3243):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
struct file *file = vmf->vma->vm_file;
struct address_space *mapping = file->f_mapping;
pgoff_t index = vmf->pgoff;
struct folio *folio;
vm_fault_t ret = 0;

/* 先在页缓存(page cache)中查找 */
folio = filemap_get_folio(mapping, index);
if (likely(!IS_ERR(folio))) {
/* 找到了,尝试异步预读 */
if (!(vmf->flags & FAULT_FLAG_TRIED))
fpin = do_async_mmap_readahead(vmf, folio);
...
} else {
/* 页缓存缺失:计为 major fault,触发同步预读 */
count_vm_event(PGMAJFAULT);
ret = VM_FAULT_MAJOR;
fpin = do_sync_mmap_readahead(vmf);
retry_find:
folio = __filemap_get_folio(mapping, index,
FGP_CREAT|FGP_FOR_MMAP,
vmf->gfp_mask);
...
}
/* 锁页、验证 uptodate 状态,最终将页映射到 PTE */
...
}

流程总结:

  1. 页缓存命中(minor fault):直接拿到 folio,映射到 PTE,耗时极短(通常 < 1 μs)。
  2. 页缓存缺失(major fault):触发 do_sync_mmap_readahead,从磁盘读入,走 I/O 路径,耗时较长(取决于存储设备,NVMe 通常 100μs 量级,HDD 可达 10ms)。
  3. 读入完成后锁定 folio,调用 do_set_pte 将物理页帧号写入 PTE,完成映射。

值得注意的是,filemap_fault 中的 do_async_mmap_readaheaddo_sync_mmap_readahead 代表两种预读策略:

  • 同步预读:首次访问某页时,内核会提前读入后续若干页(由 ra.ra_pages 控制,默认 32 页 = 128 KB),以摊销 I/O 开销;
  • 异步预读:当访问到预读窗口末尾时,提前异步触发下一批次预读,隐藏 I/O 延迟。

对于顺序读场景,预读算法能将磁盘吞吐量接近理论上限;对于随机 mmap 访问(如数据库的随机读),可通过 madvise(MADV_RANDOM) 禁用预读,节省不必要的 I/O。

1.4 filemap_map_pages:预映射优化

map_pages 是一项重要性能优化:在处理单个缺页时,内核会顺带将相邻的已在页缓存中的页面一并映射,以减少后续缺页次数(fault-around)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// mm/filemap.c:3483
vm_fault_t filemap_map_pages(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff)
{
struct address_space *mapping = file->f_mapping;
XA_STATE(xas, &mapping->i_pages, start_pgoff);
struct folio *folio;

rcu_read_lock();
folio = first_map_page(mapping, &xas, end_pgoff);
...
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, addr, &vmf->ptl);
do {
page = folio_file_page(folio, xas.xa_index);
...
do_set_pte(vmf, page, addr); /* 批量写入 PTE */
update_mmu_cache(vma, addr, vmf->pte);
...
} while ((folio = next_map_page(mapping, &xas, end_pgoff)) != NULL);
...
}

通过 XArray 遍历页缓存,把连续页面批量 do_set_pte,一次性减少多次缺页开销。

1.5 MAP_PRIVATE 文件映射与 COW 语义

MAP_PRIVATE 文件映射是最典型的只读共享 + 写时复制场景:

  • 读时:多个进程的 PTE 指向同一份页缓存 folio,物理页只有一份。
  • 写时:内核将进程的写保护 PTE 标记为只读,第一次写时触发 do_wp_page,为该进程分配私有页并复制内容(详见第三节)。

这正是 Linux 进程加载动态库 .so 的工作方式:代码段 MAP_PRIVATE|PROT_READ|PROT_EXEC,所有进程共享同一份物理页;数据段对写入使用 COW,各进程独立修改各自的副本。

MAP_PRIVATEMAP_SHARED 的核心区别体现在 do_mmap 的 flag 检查中:

1
2
3
4
5
6
7
8
9
// mm/mmap.c:1336
case MAP_SHARED_VALIDATE:
...
vm_flags |= VM_SHARED | VM_MAYSHARE; /* 共享:写入同步到文件 */
...
case MAP_PRIVATE:
/* 私有:vm_flags 不含 VM_SHARED,写入触发 COW */
pgoff = addr >> PAGE_SHIFT; /* 匿名私有映射时 pgoff 用地址编码 */
break;

对于 MAP_PRIVATE|MAP_FILE(私有文件映射),COW 发生后产生的私有页不再属于页缓存,而是作为匿名页(MM_ANONPAGES)计入进程内存统计,这也是为什么 smaps 中私有写过的文件映射区域会出现 Private_Dirty 字段。


二、匿名共享内存:tmpfs 与 shmem

2.1 匿名共享映射的底层文件

当调用 mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0) 时,内核需要一个”虚拟文件”来管理共享页面。这个文件由 shmem_zero_setupmm/shmem.c)创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// mm/shmem.c:4332
int shmem_zero_setup(struct vm_area_struct *vma)
{
struct file *file;
loff_t size = vma->vm_end - vma->vm_start;

/* 在内核私有 tmpfs 挂载点上创建匿名文件 */
file = shmem_kernel_file_setup("dev/zero", size, vma->vm_flags);
if (IS_ERR(file))
return PTR_ERR(file);

if (vma->vm_file)
fput(vma->vm_file);
vma->vm_file = file;
vma->vm_ops = &shmem_anon_vm_ops; /* 使用 shmem 的 vm_ops */

return 0;
}

该文件使用内核私有的 shm_mnt tmpfs 挂载点,对用户不可见(clear_nlink 确保无目录项),但提供了完整的 inode/页缓存语义。

2.2 __shmem_file_setup:创建 tmpfs inode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// mm/shmem.c:4251
static struct file *__shmem_file_setup(struct vfsmount *mnt, const char *name,
loff_t size, unsigned long flags,
unsigned int i_flags)
{
struct inode *inode;
struct file *res;

if (size < 0 || size > MAX_LFS_FILESIZE)
return ERR_PTR(-EINVAL);

if (shmem_acct_size(flags, size)) /* 检查内存配额 */
return ERR_PTR(-ENOMEM);

/* 创建 S_IFREG 类型的 tmpfs inode */
inode = shmem_get_inode(&nop_mnt_idmap, mnt->mnt_sb, NULL,
S_IFREG | S_IRWXUGO, 0, flags);
...
inode->i_size = size;
clear_nlink(inode); /* 无目录项,unlinked */

res = alloc_file_pseudo(inode, mnt, name, O_RDWR,
&shmem_file_operations);
...
return res;
}

shmem_file_setup(公开 API)直接封装了上述函数,供 System V 共享内存、memfd_create 等使用。

2.3 shmem_fault:匿名共享内存缺页

shmem 使用自己的 vm_ops,缺页时调用 shmem_faultmm/shmem.c:2095):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static vm_fault_t shmem_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct inode *inode = file_inode(vma->vm_file);
gfp_t gfp = mapping_gfp_mask(inode->i_mapping);
struct folio *folio = NULL;
int err;
vm_fault_t ret = VM_FAULT_LOCKED;

/* 处理正在进行 fallocate hole-punch 的竞争 */
if (unlikely(inode->i_private)) {
...
schedule(); /* 等待 hole-punch 完成 */
...
}

/* 核心:分配或获取 shmem 页 */
err = shmem_get_folio_gfp(inode, vmf->pgoff, &folio, SGP_CACHE,
gfp, vma, vmf, &ret);
...
return ret;
}

shmem_get_folio_gfp 首先查找页缓存,若缺失则分配新物理页并加入页缓存。对于 MAP_SHARED 映射,所有映射同一 inode 同一偏移的 VMA 都会映射到同一物理页,这正是进程间通信的物理基础。

2.4 POSIX 共享内存与 System V 共享内存

**POSIX shm_open**:本质是在 /dev/shm(tmpfs 文件系统)上创建/打开普通文件,通过 mmap 映射。路径:shm_openopen("/dev/shm/name")mmapshmem_fault。POSIX 共享内存有文件系统可见性,可以通过 ls /dev/shm 查看,进程退出后(若未 shm_unlink)文件依然存在。

**System V shmget/shmat**:内核路径略有不同。shmget 最终调用 shmem_kernel_file_setup 创建内核私有 tmpfs 文件并保存在 struct shmid_kernel 中;shmat 调用 do_shmatdo_mmap,将该文件 mmap 进进程地址空间。两者底层都依赖 tmpfs/shmem 的页缓存,原理一致。System V 共享内存通过 ipcs -m 查看,生命周期独立于进程(直到显式 shmctl(IPC_RMID) 或系统重启)。

memfd_create(现代匿名共享内存):Linux 3.17 引入,创建一个无路径的匿名 tmpfs 文件描述符,可通过 /proc/PID/fd 传递给其他进程:

1
2
3
4
int fd = memfd_create("my_shm", MFD_CLOEXEC);
ftruncate(fd, SIZE);
void *ptr = mmap(NULL, SIZE, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
/* 通过 sendmsg/SCM_RIGHTS 将 fd 传给其他进程,实现共享 */

memfd_create 是目前推荐的进程间共享内存方式,结合了 POSIX shm 的易用性和匿名映射的安全性(无文件系统路径,无权限问题)。Android 的 Binder IPC 大量数据传输使用的 ashmem(现已迁移为 memfd_create)即是此机制。


三、fork() 与写时复制(COW)

COW 是 Linux 高效实现 fork() 的关键——fork 时不复制物理页,而是让父子进程共享同一份物理页,仅在写入时才真正复制,大幅减少 fork 开销。

3.1 copy_mm:fork 时处理 mm_struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// kernel/fork.c:1714
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;

tsk->mm = NULL;
tsk->active_mm = NULL;

oldmm = current->mm;
if (!oldmm)
return 0; /* 内核线程,无用户地址空间 */

if (clone_flags & CLONE_VM) {
/* pthread:直接共享父进程 mm,引用计数 +1 */
mmget(oldmm);
mm = oldmm;
} else {
/* fork:完整复制 mm */
mm = dup_mm(tsk, current->mm);
if (!mm)
return -ENOMEM;
}

tsk->mm = mm;
tsk->active_mm = mm;
...
return 0;
}

CLONE_VMpthread_create 使用)不复制 mm,线程与父进程共享整个地址空间;真正的 fork() 则调用 dup_mmdup_mmap

3.2 dup_mmap:复制所有 VMA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// kernel/fork.c:649
static __latent_entropy int dup_mmap(struct mm_struct *mm,
struct mm_struct *oldmm)
{
struct vm_area_struct *mpnt, *tmp;
VMA_ITERATOR(old_vmi, oldmm, 0);
VMA_ITERATOR(vmi, mm, 0);

/* 复制 mm 级别的元数据 */
mm->total_vm = oldmm->total_vm;
mm->data_vm = oldmm->data_vm;
mm->exec_vm = oldmm->exec_vm;
mm->stack_vm = oldmm->stack_vm;

/* 预分配 VMA 节点,避免循环中分配失败 */
retval = vma_iter_bulk_alloc(&vmi, oldmm->map_count);

for_each_vma(old_vmi, mpnt) {
if (mpnt->vm_flags & VM_DONTCOPY)
continue; /* 如 vdso 等不需要复制的区段 */

/* 分配新 VMA 结构体,复制父 VMA 字段 */
tmp = vm_area_dup(mpnt);
tmp->vm_mm = mm;

if (tmp->vm_flags & VM_WIPEONFORK)
tmp->anon_vma = NULL; /* 子进程中清零此区段 */
else if (anon_vma_fork(tmp, mpnt))
goto fail_nomem_anon_vma_fork;

/* 将 VMA 插入子进程 maple tree */
vma_iter_bulk_store(&vmi, tmp);
mm->map_count++;

/* 关键:复制页表并标记 COW */
if (!(tmp->vm_flags & VM_WIPEONFORK))
retval = copy_page_range(tmp, mpnt);
...
}
...
}

3.3 copy_page_range:写保护标记 COW

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// mm/memory.c:1251
copy_page_range(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma)
{
...
bool is_cow;

/* 判断此 VMA 是否需要 COW 语义 */
is_cow = is_cow_mapping(src_vma->vm_flags);

if (is_cow) {
/* 通知 MMU notifier,即将降低页面保护级别 */
mmu_notifier_range_init(&range, MMU_NOTIFY_PROTECTION_PAGE,
0, src_mm, addr, end);
mmu_notifier_invalidate_range_start(&range);
/* 原子序列计数:防止并发写入竞争 */
raw_write_seqcount_begin(&src_mm->write_protect_seq);
}

/* 递归遍历页表各级(pgd -> p4d -> pud -> pmd -> pte),
* 对每个可写 PTE,清除写权限位(wp_page_copy 时会恢复) */
dst_pgd = pgd_offset(dst_mm, addr);
src_pgd = pgd_offset(src_mm, addr);
do {
...
copy_p4d_range(dst_vma, src_vma, dst_pgd, src_pgd, addr, next);
...
} while (dst_pgd++, src_pgd++, addr = next, addr != end);
...
}

is_cow_mapping 判断条件:VMA 不带 VM_SHARED 且带有 VM_MAYWRITE(即 MAP_PRIVATE 可写映射)。对这类 VMA,copy_pte_range(深层函数)会将父子进程双方的 PTE 都改为只读(清除 _PAGE_RW 位),同时设置 PageAnonExclusive 等标记,为后续 COW 做准备。

3.4 do_wp_page:COW 写保护缺页处理

当父进程或子进程首次写入共享物理页时,触发写保护缺页,内核调用 do_wp_pagemm/memory.c:3324):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = NULL;

vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);

/* 共享映射(MAP_SHARED):直接标记可写,不 COW */
if (vma->vm_flags & (VM_SHARED | VM_MAYSHARE)) {
if (!vmf->page)
return wp_pfn_shared(vmf);
return wp_page_shared(vmf);
}

if (vmf->page)
folio = page_folio(vmf->page);

/* 私有匿名页:判断能否复用(reuse)还是必须复制(copy) */
if (folio && folio_test_anon(folio)) {
/* 该页对此进程独占(PageAnonExclusive)→ 直接复用 */
if (PageAnonExclusive(vmf->page))
goto reuse;

/* 引用计数 > 3 说明有其他进程也映射了此页 → 必须复制 */
if (folio_test_ksm(folio) || folio_ref_count(folio) > 3)
goto copy;

/* 再次精确检查引用计数(含 swap cache) */
if (folio_ref_count(folio) > 1 + folio_test_swapcache(folio))
goto copy;

/* 只有一个引用者,可以直接复用 */
page_move_anon_rmap(vmf->page, vma);
folio_unlock(folio);
reuse:
wp_page_reuse(vmf); /* 直接恢复可写权限,不分配新页 */
return 0;
}
copy:
folio_get(folio);
pte_unmap_unlock(vmf->pte, vmf->ptl);
return wp_page_copy(vmf); /* 分配新页,复制内容 */
}

3.5 wp_page_reuse:单引用者快路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mm/memory.c:3006
static inline void wp_page_reuse(struct vm_fault *vmf)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
pte_t entry;

/* 重置 NUMA 平衡信息 */
if (page)
page_cpupid_xchg_last(page, (1 << LAST_CPUPID_SHIFT) - 1);

flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
entry = pte_mkyoung(vmf->orig_pte);
entry = maybe_mkwrite(pte_mkdirty(entry), vma); /* 重新标记可写+脏 */
if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
update_mmu_cache(vma, vmf->address, vmf->pte);
pte_unmap_unlock(vmf->pte, vmf->ptl);
count_vm_event(PGREUSE);
}

wp_page_reuse 只修改 PTE 标志位(从只读恢复到可写),无需分配新物理页,是 COW 场景中最快的路径。

3.6 wp_page_copy:真正的 COW 复制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// mm/memory.c:3050
static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct mm_struct *mm = vma->vm_mm;
struct folio *old_folio = NULL;
struct folio *new_folio = NULL;
pte_t entry;

if (is_zero_pfn(pte_pfn(vmf->orig_pte))) {
/* 零页(zero page)直接分配清零页 */
new_folio = vma_alloc_zeroed_movable_folio(vma, vmf->address);
} else {
/* 分配新物理页 */
new_folio = vma_alloc_folio(GFP_HIGHUSER_MOVABLE, 0, vma,
vmf->address, false);
/* 从用户态复制内容到新页 */
ret = __wp_page_copy_user(&new_folio->page, vmf->page, vmf);
...
}

/* cgroup 记账、设置 uptodate */
mem_cgroup_charge(new_folio, mm, GFP_KERNEL);
__folio_mark_uptodate(new_folio);

/* 重新获取 PTE 锁,验证 PTE 未被其他 CPU 修改 */
vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
if (likely(pte_same(*vmf->pte, vmf->orig_pte))) {
/* 更新页面统计(匿名页计数) */
if (!folio_test_anon(old_folio)) {
dec_mm_counter(mm, mm_counter_file(&old_folio->page));
inc_mm_counter(mm, MM_ANONPAGES);
}
/* 将新页写入 PTE,标记为可写+脏 */
entry = mk_pte(&new_folio->page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
...
set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
/* 更新 rmap,解除对旧页的引用 */
page_remove_rmap(vmf->page, vma, false);
...
}
...
}

COW 完整流程:分配新物理页 → 复制旧页内容 → 更新 PTE 指向新页 → 递减旧页引用计数

3.7 _mapcount 与引用计数

每个 struct page(folio)维护两个关键计数:

字段 含义
page->_refcount 物理页总引用计数,包含 page cache、PTE 映射、内核直接引用等
page->_mapcount PTE 映射计数,即有多少条 PTE 指向此物理页

do_wp_page 中,folio_ref_count(folio) > 3 这个阈值:

  • 1 = page cache 自身持有
  • 1 = 调用方临时持有(folio_get
  • 1 = 当前 PTE 映射

超出这个值说明有其他进程(或内核)也引用了该页,不能复用,必须 COW 复制。

_mapcount_refcount 的关系值得细说:_mapcount == 0 表示只有一条 PTE 映射(_mapcount 初始值为 -1,每新增一个 PTE 映射加 1,所以 0 = 1 个映射)。page_mapcount(page) 返回 _mapcount + 1。COW 发生后,旧页的 _mapcount 减 1(page_remove_rmap),若减到 -1 说明无任何 PTE 映射,页可以被回收或放入 LRU 等待复用。

这两个计数器的协同工作保证了多进程共享页面时的引用安全:只要 _refcount > 0 物理页就不会被释放,只要 _mapcount >= 0 就表明有用户态进程的 PTE 指向它。


四、sendfile 与零拷贝

4.1 传统文件发送的拷贝开销

不使用 sendfile 时,read() + write() 的路径:

  1. 磁盘 → DMA → 内核页缓存(1次 DMA)
  2. 内核页缓存 → 用户态缓冲区(1次 CPU 拷贝)
  3. 用户态缓冲区 → 内核 socket 发送缓冲区(1次 CPU 拷贝)
  4. 内核 socket 缓冲区 → 网卡 DMA(1次 DMA)

2次 CPU 拷贝 + 2次 DMA + 4次上下文切换

4.2 do_sendfile:零拷贝系统调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// fs/read_write.c:1180
static ssize_t do_sendfile(int out_fd, int in_fd, loff_t *ppos,
size_t count, loff_t max)
{
struct fd in, out;
struct pipe_inode_info *opipe;
loff_t pos, out_pos;
ssize_t retval;

in = fdget(in_fd);
...
out = fdget(out_fd);
...

opipe = get_pipe_info(out.file, true);
if (!opipe) {
/* out_fd 不是 pipe:使用 splice_direct */
retval = do_splice_direct(in.file, &pos, out.file, &out_pos,
count, fl);
} else {
/* out_fd 是 pipe:使用 splice_to_pipe */
retval = splice_file_to_pipe(in.file, opipe, &pos, count, fl);
}
...
}

do_splice_direct 最终调用 splice_direct_to_actor

4.3 splice_direct_to_actor:内部管道传输

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// fs/splice.c:918
ssize_t splice_direct_to_actor(struct file *in, struct splice_desc *sd,
splice_direct_actor *actor)
{
struct pipe_inode_info *pipe;

/* 复用进程私有的 splice_pipe,避免每次分配 */
pipe = current->splice_pipe;
if (unlikely(!pipe)) {
pipe = alloc_pipe_info();
...
current->splice_pipe = pipe;
}

/* 循环:from in → pipe → to out */
do {
size_t read_len;
loff_t pos = sd->pos, prev_pos = pos;

/* 步骤1:将 in 文件的页引用移入内部 pipe(page 级别,不复制内容) */
ret = do_splice_to(in, &pos, pipe, len, flags);
...
/* 步骤2:actor 将 pipe 中的页引用传给 out(如 tcp_sendpage) */
ret = actor(pipe, sd);
...
} while (len);
...
}

关键在于:do_splice_to 操作的是页引用(folio/page 指针),而非内存拷贝;tcp_sendpage(网络发送路径)同样通过 skb_fill_page_desc 将页直接插入 skb,DMA 引擎直接从页缓存发送数据。

零拷贝路径:磁盘 DMA → 页缓存 → 网卡 DMA(0次 CPU 拷贝,2次上下文切换)。

“零拷贝”的准确含义:消除了用户态 ↔ 内核态之间的 CPU 内存拷贝,数据始终驻留在内核页缓存,通过页引用传递,网卡通过 DMA 直接读取。


五、mremap 与 brk:地址空间的动态调整

5.1 sys_brk:堆的扩展与收缩

堆内存(malloc 的底层)通过 brk(2) 系统调用管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// mm/mmap.c:189
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
struct mm_struct *mm = current->mm;
unsigned long newbrk, oldbrk;

newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);

if (oldbrk == newbrk) {
mm->brk = brk;
goto success;
}

/* 收缩堆:释放 [newbrk, oldbrk) 的映射 */
if (brk <= mm->brk) {
mm->brk = brk;
ret = do_vma_munmap(&vmi, brkvma, newbrk, oldbrk, &uf, true);
...
goto success;
}

/* 扩展堆:检查 rlimit 限制,调用 do_brk_flags 分配匿名 VMA */
if (check_brk_limits(oldbrk, newbrk - oldbrk))
goto out;
...
}

brk 不会立即分配物理内存,只是扩展 VMA 的虚拟范围;实际的物理页分配推迟到首次访问时的缺页处理(匿名页 COW 路径)。

5.2 mremapMREMAP_MAYMOVE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// mm/mremap.c:896
SYSCALL_DEFINE5(mremap, unsigned long, addr, unsigned long, old_len,
unsigned long, new_len, unsigned long, flags,
unsigned long, new_addr)
{
...
if (flags & ~(MREMAP_FIXED | MREMAP_MAYMOVE | MREMAP_DONTUNMAP))
return ret;

/* MREMAP_FIXED 必须配合 MREMAP_MAYMOVE */
if (flags & MREMAP_FIXED && !(flags & MREMAP_MAYMOVE))
return ret;
...
}

MREMAP_MAYMOVE 允许内核在新地址空间找不到连续虚拟空间时移动 VMA,对应 move_vma

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// mm/mremap.c:571
static unsigned long move_vma(struct vm_area_struct *vma,
unsigned long old_addr, unsigned long old_len,
unsigned long new_len, unsigned long new_addr,
bool *locked, unsigned long flags, ...)
{
struct mm_struct *mm = vma->vm_mm;
struct vm_area_struct *new_vma;
unsigned long new_pgoff;

/* 检查 map_count 限制(移动可能导致 VMA 分裂 +3) */
if (mm->map_count >= sysctl_max_map_count - 3)
return -ENOMEM;

/* KSM:移动前拆解 KSM 页,避免新位置出现重复页面 */
err = ksm_madvise(vma, old_addr, old_addr + old_len,
MADV_UNMERGEABLE, &vm_flags);
...
/* 在新地址建立 VMA,批量迁移页表项(move_page_tables) */
new_vma = copy_vma(&vma, new_addr, new_len, new_pgoff, &need_rmap_locks);
moved_len = move_page_tables(vma, old_addr, new_vma, new_addr,
old_len, need_rmap_locks, false);
...
/* 取消旧 VMA 中的映射 */
do_vma_munmap(&vmi, vma, old_addr, old_addr + old_len, ...);
...
}

move_page_tables 是批量 PTE 搬移的核心,它逐级遍历页表,尽量以整块 PTE 页(而非逐条)的方式迁移,避免逐条拷贝的高开销。


六、内存保护与 mprotect

6.1 do_mprotect_pkey:修改 VMA 权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// mm/mprotect.c:731
static int do_mprotect_pkey(unsigned long start, size_t len,
unsigned long prot, int pkey)
{
struct mmu_gather tlb;
struct vma_iterator vmi;

len = PAGE_ALIGN(len);
...
if (mmap_write_lock_killable(current->mm))
return -EINTR;

/* 初始化 mmu_gather,用于批量 TLB flush */
tlb_gather_mmu(&tlb, current->mm);
nstart = start;

for_each_vma_range(vmi, vma, end) {
unsigned long newflags;

/* 计算新的 vm_flags */
newflags = calc_vm_prot_bits(prot, new_vma_pkey);
newflags |= (vma->vm_flags & ~mask_off_old_flags);

/* 安全检查:不能赋予超出 MAY* 的权限 */
if ((newflags & ~(newflags >> 4)) & VM_ACCESS_FLAGS) {
error = -EACCES;
break;
}

/* mprotect_fixup:实际修改 VMA 的 vm_flags,
* 并对已映射的 PTE 修改保护位 */
error = mprotect_fixup(&vmi, &tlb, vma, &prev,
nstart, tmp, newflags);
...
}
/* 统一执行 TLB flush(批量,比逐页 flush 高效) */
tlb_finish_mmu(&tlb);
...
}

6.2 权限降低与 TLB flush

mprotect_fixup 调用 change_protection 遍历 PTE,将可写页改为只读(或降低其他权限)。权限降低(write → read)必须 TLB flush,否则 CPU 可能使用旧缓存的可写 TLB 条目绕过保护。

Linux 使用 mmu_gather(TLB lazy flush 机制)批量收集需要 flush 的地址范围,最后一次性 flush,避免单次 mprotect 对数千页逐一操作 TLB 的性能损耗。

6.3 SEGV 信号的产生路径

当进程尝试写入只读保护页(如写 .text 段,或写 mprotect 后的只读区域),缺页处理流程:

  1. 硬件触发写保护 #PF
  2. do_user_addr_faulthandle_mm_faultdo_wp_page
  3. 若 VMA 没有写权限(!(vma->vm_flags & VM_WRITE)),则 bad_areaforce_sig_fault(SIGSEGV, ...)
  4. 进程收到 SIGSEGV,默认动作:终止(或触发 SIGSEGV handler)

6.4 mprotect 的典型使用场景

JIT 编译器:V8、JVM 等 JIT 引擎的典型做法是:先 mmap(MAP_ANONYMOUS|PROT_READ|PROT_WRITE) 分配内存,写入机器码,再 mprotect(PROT_READ|PROT_EXEC) 切换为可执行。这是为了遵循 W^X(Write XOR Execute)安全策略,防止代码注入攻击。

1
2
3
4
5
6
// JIT 引擎分配可执行内存的典型模式
void *code_buf = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(code_buf, machine_code, code_size); // 写入 JIT 生成的机器码
mprotect(code_buf, size, PROT_READ | PROT_EXEC); // 切换为只读可执行
// 之后 code_buf 可以被调用,但不能再写入

Guard pages(栈溢出检测):在栈底部设置不可访问的保护页(PROT_NONE),当栈溢出访问该页时触发 SIGSEGV,这比不设置保护页时悄悄覆盖数据要安全得多。glibc 的 pthread_create 默认为每个线程栈末尾设置 guard page。

内存安全检测:AddressSanitizer 使用 shadow memory + mprotect 来检测内存越界访问,通过将 redzone 区域设为 PROT_NONE,任何对其的访问都会立即触发 SIGSEGV 并被 ASan 的信号处理器捕获,输出详细的错误报告。


七、诊断方法

7.1 /proc/PID/maps/proc/PID/smaps_rollup

1
2
3
4
5
6
7
8
9
10
11
12
# 查看进程地址空间布局
cat /proc/$(pidof nginx)/maps

# 输出示例:
# 地址范围 权限 偏移 设备 inode 路径
# 55a3b2c00000-55a3b2e00000 r--p 00000000 08:01 131076 /usr/sbin/nginx
# 55a3b2e00000-55a3b3200000 r-xp 00200000 08:01 131076 /usr/sbin/nginx
# 7f3a40000000-7f3a42000000 rw-p 00000000 00:00 0 [heap]
# 7ffd12345000-7ffd12366000 rw-p 00000000 00:00 0 [stack]

# 汇总内存统计(RSS、PSS、USS 等)
cat /proc/$(pidof nginx)/smaps_rollup

关键字段含义:

字段 含义
RSS 常驻物理内存(包含共享页)
PSS Proportional Set Size,共享页按引用者均摊
USS Unique Set Size,进程独占的物理内存
Shared_Clean/Dirty 与其他进程共享的 clean/dirty 页
Private_Clean/Dirty 进程私有的 clean/dirty 页(COW 后产生)

7.2 strace 追踪内存操作

1
2
3
4
5
6
7
8
# 追踪目标进程的内存相关系统调用
strace -e trace=mmap,munmap,mprotect,brk -p <PID>

# 典型输出示例:
# mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3a4000
# mprotect(0x55a3b2e00000, 2097152, PROT_READ|PROT_EXEC) = 0
# brk(0x55a3b4200000) = 0x55a3b4200000
# munmap(0x7f3a4000, 4096) = 0

7.3 pmap -X:详细内存映射

1
2
3
pmap -X $(pidof python3)
# 输出包含:RSS、PSS、Referenced、Anonymous、LazyFree、ShmemPmdMapped 等详细字段
# 可以清晰看到每个 VMA 的物理内存占用情况

7.4 bpftrace 追踪 mmap 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 统计所有 mmap 调用的映射大小分布
sudo bpftrace -e '
kprobe:do_mmap {
@sizes = hist(arg2); /* arg2 = len */
}
interval:s:10 {
print(@sizes);
clear(@sizes);
}'

# 追踪 COW 发生频率(wp_page_copy = 真正的 COW 拷贝)
sudo bpftrace -e '
kprobe:wp_page_copy {
@cow_count[comm] = count();
}
interval:s:5 {
print(@cow_count);
}'

# 追踪缺页类型(major vs minor)
sudo bpftrace -e '
tracepoint:exceptions:page_fault_user {
@[args->error_code & 0x2 ? "write" : "read"] = count();
}'

7.5 内存泄漏排查

Valgrind(工具链)

1
2
valgrind --leak-check=full --track-origins=yes ./your_program
# 检测堆内存泄漏,输出详细的分配调用栈

AddressSanitizer(编译时插桩)

1
2
3
4
gcc -fsanitize=address -g -O1 your_program.c -o your_program
./your_program
# ASAN 可检测:堆溢出、use-after-free、double-free、stack 溢出等
# 运行时开销约 2x,适合测试环境

bpftrace 追踪 mmap 泄漏

1
2
3
4
5
6
7
8
9
10
11
# 追踪未被 munmap 的 mmap(简化示例)
sudo bpftrace -e '
kprobe:do_mmap / retval > 0 / {
@[pid, comm, retval] = nsecs;
}
kprobe:__do_munmap {
delete(@[pid, comm, arg1]); /* arg1 = addr */
}
END {
print(@); /* 剩余的即为潜在泄漏 */
}'

八、关键数据结构总结

8.1 VMA 与 mm_struct 的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
task_struct
└── mm_struct
├── mm_mt (maple tree) ← 存储所有 VMA(6.1+ 替代红黑树)
├── brk ← 堆顶位置
├── start_brk ← 堆起始位置
├── mmap_base ← mmap 区域基址
└── write_protect_seq ← fork COW 序列计数

vm_area_struct (VMA)
├── vm_start, vm_end ← 虚拟地址范围
├── vm_flags ← VM_READ/WRITE/EXEC/SHARED/...
├── vm_file ← 文件映射时指向 struct file
├── vm_pgoff ← 文件偏移(页单位)
├── vm_ops ← fault/map_pages/mprotect 等回调
└── anon_vma ← 匿名页反向映射

8.2 三类内存映射的对比

类型 标志 物理页来源 写入行为 典型用途
文件私有映射 MAP_PRIVATE|MAP_FILE 页缓存 COW,产生匿名页 加载 ELF、动态库
文件共享映射 MAP_SHARED|MAP_FILE 页缓存 直接修改页缓存 数据库 mmap I/O
匿名私有映射 MAP_PRIVATE|MAP_ANONYMOUS 零页/新页 COW(fork 后) 堆、栈、JIT 代码
匿名共享映射 MAP_SHARED|MAP_ANONYMOUS tmpfs shmem 页 直接共享修改 父子进程 IPC

8.3 COW 完整状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fork() 后
父/子 VMA: VM_WRITE 清除(只读)
父/子 PTE: _PAGE_RW 清除(写保护)

进程写入 → #PF (写保护)

do_wp_page
├── VM_SHARED? → wp_page_shared(直接可写,不 COW)
├── PageAnonExclusive? → wp_page_reuse(恢复可写,不复制)
├── folio_ref_count == 1? → wp_page_reuse
└── else → wp_page_copy
├── vma_alloc_folio(分配新物理页)
├── __wp_page_copy_user(复制内容)
├── set_pte_at(更新 PTE)
└── page_remove_rmap(解除旧页映射)

8.4 mmap 性能最佳实践

选择合适的映射类型

  • 需要多次顺序读的大文件:优先考虑 mmap + madvise(MADV_SEQUENTIAL),利用预读减少 read() 系统调用开销。
  • 随机访问的数据库文件:mmap + madvise(MADV_RANDOM) 禁用预读,减少不必要的 I/O。
  • 频繁写入的场景:MAP_SHARED 配合 msync 控制刷盘时机,比 write() 减少一次内核态拷贝。

大页映射:对于大段连续内存(如机器学习推理的模型权重),可以使用 MAP_HUGETLB 请求 2MB 大页,减少 TLB 压力,显著提升大内存访问性能。Linux 也支持 Transparent HugePage(THP),通过 madvise(MADV_HUGEPAGE) 向内核暗示某段内存适合 THP 优化。

预热(mlock):对于延迟敏感的应用(如实时交易系统),可以在启动时 mlock 关键内存区域,防止其被换出(swap),避免运行时出现 major fault。


小结

本篇从 do_mmap 出发,沿着 vm_ops->faultfilemap_faultshmem_fault 三条路径深入解析了文件映射与共享内存的工作原理。在 COW 部分,通过 copy_page_range(写保护标记)→ do_wp_page(分支判断)→ wp_page_copy(真正复制)完整还原了 fork() 后的 COW 全链路。sendfile 的零拷贝通过页引用传递(而非内存拷贝)实现了文件到网卡的高效数据路径。mprotect 的 TLB flush 机制保证了权限降低后的内存安全性。

这些机制共同构成了 Linux 进程内存隔离与高效共享的核心基础。下一篇将介绍 NUMA 内存策略、大页(THP/HugePage)与内存压缩(zswap/zram)


参考源文件(Linux 6.4-rc1):

  • mm/mmap.cdo_mmapmmap_regionSYSCALL_DEFINE1(brk)
  • mm/filemap.cgeneric_file_mmapfilemap_faultfilemap_map_pages
  • mm/shmem.c__shmem_file_setupshmem_zero_setupshmem_fault
  • mm/memory.ccopy_page_rangedo_wp_pagewp_page_reusewp_page_copy
  • kernel/fork.ccopy_mmdup_mmap
  • mm/mremap.cSYSCALL_DEFINE5(mremap)move_vma
  • mm/mprotect.cdo_mprotect_pkey
  • fs/read_write.cdo_sendfile
  • fs/splice.csplice_direct_to_actor

本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均来自真实内核源文件。


一、为什么需要大页?

1.1 TLB Miss 的性能代价

CPU 访问内存经历两步:先查 TLB(Translation Lookaside Buffer)将虚拟地址翻译为物理地址,TLB 命中则直接访存;TLB Miss 时要走多级页表(x86-64 通常是 PGD → P4D → PUD → PMD → PTE),每级都是一次内存读操作,典型情况下一次 TLB Miss 耗费 50 ~ 100 个 CPU 周期,而 L1 Cache 命中只需 4 个周期。

x86-64 的虚拟地址翻译过程:一个 64 位虚拟地址被分割为 [PGD(9 bits)] [P4D(9 bits)] [PUD(9 bits)] [PMD(9 bits)] [PTE(9 bits)] [Offset(12 bits)]。每次缺 TLB 时,硬件 page table walker 依次访问四级页表,最多需要 4 次独立的内存访问(每次可能引发 L1/L2/L3 缓存缺失)。使用 2MB 大页时,翻译在 PMD 层终止,仅需访问 3 级页表;使用 1GB 大页时,在 PUD 层终止,仅需 2 级。

现代服务器 CPU 的 L2 TLB(STLB)通常有 1024 ~ 4096 个条目。以 4KB 页为单位,4096 个 TLB 条目只能覆盖 16 MB 地址空间。对于需要频繁访问数 GB 热数据的数据库引擎、JVM 堆、KVM 客户机内存,TLB Miss 率居高不下,成为主要性能瓶颈之一。

TLB 的组织结构通常分为两级:

  • L1 ITLB/DTLB:延迟 1~2 个周期,容量小(4KB 页通常 64 条目,2MB 大页 32 条目)。
  • L2 Unified TLB(STLB):延迟约 8 个周期,容量 1024~4096 条目,混合 4KB 和大页条目。
  • Page Table Walk:L2 TLB Miss 后触发,延迟 50~100 周期,受内存子系统影响巨大。

值得注意的是,大页在 L1 TLB 中通常独享专用条目,与 4KB 页分开管理,这意味着即便只使用少量大页,也能获得专属的 L1 TLB 保护,效益极高。

实测数据(Intel Xeon 4th Gen,64GB JVM 堆):

页大小 4KB 2MB 1GB
同等 TLB 条目覆盖范围 16 MB 8 GB 4 TB
TLB Miss 率 ~12% ~0.3% ~0%
吞吐量提升 基准 +18% +22%

1.2 页大小对比

页类型 大小 页表级别 arch 支持
普通页 4 KB PTE 所有
大页(Huge) 2 MB PMD x86-64, ARM64
巨页(Gigantic) 1 GB PUD x86-64

大页的关键优势在于:1 个 TLB 条目覆盖 2MB,等效于 512 个普通 PTE 条目,TLB 命中率大幅提升。

1.3 适合大页的场景

  • 数据库(Oracle、PostgreSQL、MySQL):Buffer Pool / Shared Memory 访问模式高度局部化,适合显式大页(HugeTLBFS)。
  • KVM 虚拟机:Host 端为 Guest 物理内存使用大页,EPT(Extended Page Table)条目减少,VM Exit 降低。
  • HPC / 科学计算:矩阵运算、FFT 等密集内存访问,THP 即可显著改善。
  • Java 应用:JVM 使用 -XX:+UseHugeTLBFS-XX:+UseTransparentHugePages 让 GC 管理的堆使用大页。

二、显式大页(HugeTLBFS)

HugeTLBFS 是 Linux 内核提供的显式大页机制。它的设计思路是:在系统初始化或运行时预先从 buddy 分配器申请若干连续大页,将它们维护在 hstate 的空闲链表中,然后通过一个伪文件系统(hugetlbfs)暴露给用户空间,用户以 mmap/shmget 等标准接口消费。

HugeTLBFS 的整体数据流如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sysctl vm.nr_hugepages = 1024


set_max_huge_pages(h, 1024)


alloc_fresh_hugetlb_folio() ──→ buddy 分配 512 个连续 4KB 页
│ 组成 order-9 compound page

prep_new_hugetlb_folio() ──→ 设置 HUGETLB_PAGE_DTOR,加入 hstate


hstate.hugepage_freelists[nid] ← 空闲大页链表

用户 mmap(MAP_HUGETLB)


hugetlb_reserve_pages() ──→ 从空闲计数中预留,更新 resv_huge_pages


缺页 hugetlb_fault() ──→ 从 freelist 摘取大页,填写 PTE(PMD 级)

2.1 struct hstate:大页池管理核心

内核为每种大页尺寸维护一个 struct hstate,定义在 include/linux/hugetlb.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* include/linux/hugetlb.h: line 693 */
#define HSTATE_NAME_LEN 32
/* Defines one hugetlb page size */
struct hstate {
struct mutex resize_lock;
int next_nid_to_alloc;
int next_nid_to_free;
unsigned int order; /* compound order,2MB = order 9 */
unsigned int demote_order;
unsigned long mask; /* 地址对齐掩码 */
unsigned long max_huge_pages; /* 最大大页数量 */
unsigned long nr_huge_pages; /* 系统中已有大页总数 */
unsigned long free_huge_pages; /* 当前空闲大页数量 */
unsigned long resv_huge_pages; /* 已预留但未分配的大页 */
unsigned long surplus_huge_pages; /* 超额分配(按需从 buddy 借用)*/
unsigned long nr_overcommit_huge_pages;
struct list_head hugepage_activelist;
struct list_head hugepage_freelists[MAX_NUMNODES]; /* 每 NUMA 节点的空闲链表 */
unsigned int max_huge_pages_node[MAX_NUMNODES];
unsigned int nr_huge_pages_node[MAX_NUMNODES];
unsigned int free_huge_pages_node[MAX_NUMNODES];
unsigned int surplus_huge_pages_node[MAX_NUMNODES];
char name[HSTATE_NAME_LEN];
};

字段含义:

  • nr_huge_pages:全局大页池的页面总数(/proc/meminfo 中的 HugePages_Total)。
  • free_huge_pages:当前可分配数(HugePages_Free)。
  • resv_huge_pages:已经通过 mmap(MAP_HUGETLB) 预留但尚未发生缺页的数量(HugePages_Rsvd)。
  • surplus_huge_pages:在 max_huge_pages 基础上超额从 buddy 系统临时借用的页数(HugePages_Surp)。
  • hugepage_freelists:按 NUMA 节点组织的空闲链表,优先从本节点分配大页以降低跨节点访问代价。

全局数组 hstates[] 保存所有注册的 hstate(mm/hugetlb.c 第 52 行):

1
struct hstate hstates[HUGE_MAX_HSTATE];

系统中可以同时存在多个 hstate,每种大页尺寸(2MB、1GB)对应一个。default_hstate 是默认的 2MB 大页,用户通过 /proc/sys/vm/nr_hugepages 控制的就是这个默认 hstate。hugepage_subpool 是文件系统级别的子池,对 hugetlbfs 挂载点设置 sizemin_size 参数时,子池负责在全局池和文件系统之间进行二次分配和配额管理。

resv_mapfile_region 一起记录哪些页偏移范围已经预留了大页。对于共享映射(如 shmget + SHM_HUGETLB),resv_map 挂在 inode 上,多个映射共享同一 resv_map;对于私有映射(MAP_PRIVATE | MAP_HUGETLB),每个 VMA 拥有独立的 resv_map,确保 COW 语义下预留计数正确。

2.2 alloc_fresh_hugetlb_folio:从 buddy 分配 compound page

当大页池需要扩充时,调用 alloc_fresh_hugetlb_foliomm/hugetlb.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* mm/hugetlb.c: line 2175 */
static struct folio *alloc_fresh_hugetlb_folio(struct hstate *h,
gfp_t gfp_mask, int nid, nodemask_t *nmask,
nodemask_t *node_alloc_noretry)
{
struct folio *folio;
bool retry = false;

retry:
if (hstate_is_gigantic(h))
folio = alloc_gigantic_folio(h, gfp_mask, nid, nmask);
else
folio = alloc_buddy_hugetlb_folio(h, gfp_mask,
nid, nmask, node_alloc_noretry);
if (!folio)
return NULL;
if (hstate_is_gigantic(h)) {
if (!prep_compound_gigantic_folio(folio, huge_page_order(h))) {
free_gigantic_folio(folio, huge_page_order(h));
if (!retry) {
retry = true;
goto retry;
}
return NULL;
}
}
prep_new_hugetlb_folio(h, folio, folio_nid(folio));
return folio;
}

对于 2MB 大页(非 gigantic),调用 alloc_buddy_hugetlb_folio,其核心是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* mm/hugetlb.c: line 2105 */
static struct folio *alloc_buddy_hugetlb_folio(struct hstate *h,
gfp_t gfp_mask, int nid, nodemask_t *nmask,
nodemask_t *node_alloc_noretry)
{
int order = huge_page_order(h); /* 2MB: order = 9 */
struct page *page;
...
gfp_mask |= __GFP_COMP|__GFP_NOWARN;
if (alloc_try_hard)
gfp_mask |= __GFP_RETRY_MAYFAIL;
...
page = __alloc_pages(gfp_mask, order, nid, nmask);
...
__count_vm_event(HTLB_BUDDY_PGALLOC);
return page_folio(page);
}

关键点:

  1. order = 9:从 buddy 分配 2^9 = 512 个连续物理页(2MB)。
  2. __GFP_COMP:将 512 个页面组成一个 compound page(复合页),head page 的 compound_order 设为 9,tail pages 指向 head。
  3. 分配后调用 prep_new_hugetlb_folio 设置析构函数(HUGETLB_PAGE_DTOR)并更新 hstate 计数器。

2.3 hugetlb_fault:大页缺页处理

用户访问 MAP_HUGETLB 区域触发缺页时,handle_mm_fault 会识别到该 VMA 对应 HugeTLBFS 并调用 hugetlb_faultmm/hugetlb.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* mm/hugetlb.c: line 6057 */
vm_fault_t hugetlb_fault(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
pte_t *ptep, entry;
spinlock_t *ptl;
vm_fault_t ret;
u32 hash;
pgoff_t idx;
struct page *page = NULL;
struct hstate *h = hstate_vma(vma);
...
/* 序列化同一 page 的并发缺页,防止竞态重复分配 */
mapping = vma->vm_file->f_mapping;
idx = vma_hugecache_offset(h, vma, haddr);
hash = hugetlb_fault_mutex_hash(mapping, idx);
mutex_lock(&hugetlb_fault_mutex_table[hash]);

hugetlb_vma_lock_read(vma);
ptep = huge_pte_alloc(mm, vma, haddr, huge_page_size(h));
if (!ptep) {
hugetlb_vma_unlock_read(vma);
mutex_unlock(&hugetlb_fault_mutex_table[hash]);
return VM_FAULT_OOM;
}

entry = huge_ptep_get(ptep);
if (huge_pte_none_mostly(entry))
/* PTE 为空:调用 hugetlb_no_page 完成物理页分配与映射 */
return hugetlb_no_page(mm, vma, mapping, idx, address, ptep,
entry, flags);
...
}

hugetlb_fault 使用 per-page 互斥锁hugetlb_fault_mutex_table,4096 个桶的哈希表)序列化对同一大页的并发缺页,避免重复分配。PTE 为空时转入 hugetlb_no_page 完成实际的页分配、页表填充工作。

2.4 hugetlb_reserve_pages:mmap 时的预留

调用 mmap(MAP_HUGETLB) 时,内核并不立即分配物理大页,而是先预留

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* mm/hugetlb.c: line 6845 */
bool hugetlb_reserve_pages(struct inode *inode,
long from, long to,
struct vm_area_struct *vma,
vm_flags_t vm_flags)
{
long chg = -1, add = -1;
struct hstate *h = hstate_inode(inode);
struct hugepage_subpool *spool = subpool_inode(inode);
struct resv_map *resv_map;
...
/* VM_NORESERVE:跳过预留,留到缺页时再尝试 */
if (vm_flags & VM_NORESERVE)
return true;

if (!vma || vma->vm_flags & VM_MAYSHARE) {
/* 共享映射:基于 inode resv_map 计算需新增预留数 */
resv_map = inode_resv_map(inode);
chg = region_chg(resv_map, from, to, &regions_needed);
} else {
/* 私有映射:完整预留整个区间 */
resv_map = resv_map_alloc();
...
chg = to - from;
set_vma_resv_map(vma, resv_map);
set_vma_resv_flags(vma, HPAGE_RESV_OWNER);
}
...
}

预留机制确保 mmap 成功即意味着将来的缺页一定能得到大页,避免在运行时因大页不足而 OOM。hstate.resv_huge_pages 记录当前预留数量,与 free_huge_pages 共同决定是否还能新增预留。

可分配的大页数量判断逻辑:当 free_huge_pages - resv_huge_pages > 0 时可以新增预留;当全局大页池耗尽但配置了 nr_overcommit_hugepages 时,允许临时从 buddy 分配额外的 surplus 大页(surplus_huge_pages),一旦使用完毕后归还 buddy 而不是放回大页池。这种 overcommit 机制可以平滑应对短时的大页需求峰值。

2.5 应用层使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <sys/mman.h>
#include <stdio.h>
#include <string.h>

#define HUGE_SIZE (2UL * 1024 * 1024) /* 2MB */
#define MAP_HUGE_2MB (21 << MAP_HUGE_SHIFT)

int main(void)
{
/* 系统准备:echo 64 > /proc/sys/vm/nr_hugepages */
void *p = mmap(NULL, HUGE_SIZE,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB | MAP_HUGE_2MB,
-1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return 1;
}
memset(p, 0x42, HUGE_SIZE); /* 触发缺页,分配实际大页 */
printf("addr = %p\n", p);
munmap(p, HUGE_SIZE);
return 0;
}

通过 HugeTLBFS 挂载点也可以使用文件方式访问大页:

1
2
3
mkdir -p /mnt/hugepages
mount -t hugetlbfs nodev /mnt/hugepages
# 应用打开该目录下的文件并 mmap 即可得到大页映射

三、透明大页(THP)

3.1 工作原理

THP(Transparent Huge Pages)让内核无需应用修改就能自动使用 2MB 大页。其核心思想是:在缺页时直接分配 2MB PMD 大页;在进程运行期间,khugepaged 后台守护线程扫描已存在的 4KB 页,尝试将 512 个连续物理页合并(collapse)成一个 2MB 大页。

THP 与 HugeTLBFS 最本质的区别在于管理主体:HugeTLBFS 由用户空间显式控制大页的申请和使用;THP 完全由内核透明管理,用户程序无需感知大页的存在。这种透明性带来了极大的易用性,但也引入了新的复杂性——内核必须在合适的时机自动拆分和合并大页,而这些操作可能在应用程序的关键路径上产生意料之外的开销。

THP 支持两种内存类型:

  • 匿名内存(Anonymous):mmap(MAP_ANONYMOUS) 或进程堆/栈,是 THP 最主要的使用场景。
  • 共享内存(Shmem/tmpfs):/dev/shmmemfd_create 等,需要单独配置 /sys/kernel/mm/transparent_hugepage/shmem_enabled

文件背景内存(page cache)目前不支持 THP,因为文件系统的块 I/O 和页缓存管理对 2MB 粒度有较高的复杂度要求。

THP 的标志字变量(mm/huge_memory.c 第 57 行):

1
2
3
4
5
6
7
8
9
10
unsigned long transparent_hugepage_flags __read_mostly =
#ifdef CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS
(1<<TRANSPARENT_HUGEPAGE_FLAG)|
#endif
#ifdef CONFIG_TRANSPARENT_HUGEPAGE_MADVISE
(1<<TRANSPARENT_HUGEPAGE_REQ_MADV_FLAG)|
#endif
(1<<TRANSPARENT_HUGEPAGE_DEFRAG_REQ_MADV_FLAG)|
(1<<TRANSPARENT_HUGEPAGE_DEFRAG_KHUGEPAGED_FLAG)|
(1<<TRANSPARENT_HUGEPAGE_USE_ZERO_PAGE_FLAG);

3.2 do_huge_pmd_anonymous_page:THP 缺页路径

当匿名映射发生缺页,且 VMA 满足 THP 条件(大小 >= 2MB、地址对齐)时,handle_mm_faultdo_huge_pmd_anonymous_pagemm/huge_memory.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* mm/huge_memory.c: line 779 */
vm_fault_t do_huge_pmd_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
gfp_t gfp;
struct folio *folio;
unsigned long haddr = vmf->address & HPAGE_PMD_MASK; /* 2MB 对齐 */

if (!transhuge_vma_suitable(vma, haddr))
return VM_FAULT_FALLBACK;
if (unlikely(anon_vma_prepare(vma)))
return VM_FAULT_OOM;
/* 将该 VMA 加入 khugepaged 扫描列表 */
khugepaged_enter_vma(vma, vma->vm_flags);

/* 只读缺页且允许 zero page:先映射 huge zero page */
if (!(vmf->flags & FAULT_FLAG_WRITE) &&
!mm_forbids_zeropage(vma->vm_mm) &&
transparent_hugepage_use_zero_page()) {
...
set_huge_zero_page(pgtable, vma->vm_mm, vma,
haddr, vmf->pmd, zero_page);
...
return ret;
}

/* 写缺页:分配真正的 2MB folio */
gfp = vma_thp_gfp_mask(vma);
folio = vma_alloc_folio(gfp, HPAGE_PMD_ORDER, vma, haddr, true);
if (unlikely(!folio)) {
count_vm_event(THP_FAULT_FALLBACK);
return VM_FAULT_FALLBACK; /* 回退到 4KB 页 */
}
return __do_huge_pmd_anonymous_page(vmf, &folio->page, gfp);
}

HPAGE_PMD_ORDER = 9vma_alloc_folio 从 buddy 分配 512 页连续物理内存。分配失败时以 VM_FAULT_FALLBACK 回退,内核继续处理 4KB 缺页,保证应用程序正常运行。

3.3 __do_huge_pmd_anonymous_page:填写 PMD 页表项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/* mm/huge_memory.c: line 651 */
static vm_fault_t __do_huge_pmd_anonymous_page(struct vm_fault *vmf,
struct page *page, gfp_t gfp)
{
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = page_folio(page);
pgtable_t pgtable;
unsigned long haddr = vmf->address & HPAGE_PMD_MASK;
vm_fault_t ret = 0;

/* mem_cgroup 计费 */
if (mem_cgroup_charge(folio, vma->vm_mm, gfp)) {
folio_put(folio);
count_vm_event(THP_FAULT_FALLBACK_CHARGE);
return VM_FAULT_FALLBACK;
}

pgtable = pte_alloc_one(vma->vm_mm); /* 为 pte 页表页预留(COW 时拆分用)*/
...
clear_huge_page(page, vmf->address, HPAGE_PMD_NR); /* 清零 512 页 */
__folio_mark_uptodate(folio);

vmf->ptl = pmd_lock(vma->vm_mm, vmf->pmd);
if (unlikely(!pmd_none(*vmf->pmd))) {
goto unlock_release; /* 并发缺页已处理,放弃 */
} else {
pmd_t entry;
...
/* 构造 2MB PMD 表项 */
entry = mk_huge_pmd(page, vma->vm_page_prot);
entry = maybe_pmd_mkwrite(pmd_mkdirty(entry), vma);
folio_add_new_anon_rmap(folio, vma, haddr);
folio_add_lru_vma(folio, vma);
/* 将 pte 页表页存入 PMD "deposit" 供后续拆分使用 */
pgtable_trans_huge_deposit(vma->vm_mm, vmf->pmd, pgtable);
set_pmd_at(vma->vm_mm, haddr, vmf->pmd, entry);
update_mmu_cache_pmd(vma, vmf->address, vmf->pmd);
add_mm_counter(vma->vm_mm, MM_ANONPAGES, HPAGE_PMD_NR);
mm_inc_nr_ptes(vma->vm_mm);
spin_unlock(vmf->ptl);
count_vm_event(THP_FAULT_ALLOC);
}
return 0;
...
}

关键实现细节:

  1. mk_huge_pmd 在 PMD 表项中设置 _PAGE_PSE(Page Size Extension)位,告知 MMU 此 PMD 直接映射 2MB 物理页,不再向下走 PTE 级。
  2. pgtable_trans_huge_deposit 把预先分配的 PTE 页表页”存入” PMD 旁,为未来 COW 拆分时复用。
  3. set_pmd_at 是一个内存屏障写,确保在 TLB 更新前物理页内容(clear_huge_page 的零化)对所有 CPU 可见。

3.4 THP 的分裂(split_huge_page)

当需要对 THP 的一部分进行操作(如 munmap 非 2MB 对齐区域、部分 mprotect、发生 ptrace、被 KSM 扫描、内存迁移)时,必须先将 2MB THP 拆回 512 个 4KB 页。THP 的拆分分为两个层面:

  • PMD 级拆分__split_huge_pmd):只修改页表,将 PMD 大页表项拆成 512 个 PTE,物理页内存布局不变,compound page 继续存在。
  • 物理页级拆分split_huge_page_to_list):将 compound page 拆分为 512 个独立的 4KB struct page,更新 rmap、LRU、引用计数,物理内存组织发生实质变化。

两种拆分通常配合使用:先 PMD 级拆分解除大页表项,再视需要决定是否进行物理页级拆分。

入口函数 split_huge_page_to_listmm/huge_memory.c,第 2637 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int split_huge_page_to_list(struct page *page, struct list_head *list)
{
struct folio *folio = page_folio(page);
...
VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio);
VM_BUG_ON_FOLIO(!folio_test_large(folio), folio);

if (folio_test_anon(folio)) {
anon_vma = folio_get_anon_vma(folio);
anon_vma_lock_write(anon_vma); /* 防止并发 split/collapse */
} else {
mapping = folio->mapping;
...
}
...
__split_huge_page(page, list, end); /* 核心拆分逻辑 */
...
}

__split_huge_page 将 compound page 的每个 tail page 重新初始化为独立的 4KB 页,依次更新 rmap、LRU 链表、引用计数,最后调用 __split_huge_page_tail 处理每个 tail page。

3.5 __split_huge_pmd:PMD 级拆分

在 COW 或 munmap 时,还需要在页表层面将 PMD 大页表项拆成 512 个 PTE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* mm/huge_memory.c: line 2266 */
void __split_huge_pmd(struct vm_area_struct *vma, pmd_t *pmd,
unsigned long address, bool freeze, struct folio *folio)
{
spinlock_t *ptl;
struct mmu_notifier_range range;

/* 通知 MMU Notifier(如 KVM、IOMMU)此范围即将变更 */
mmu_notifier_range_init(&range, MMU_NOTIFY_CLEAR, 0, vma->vm_mm,
address & HPAGE_PMD_MASK,
(address & HPAGE_PMD_MASK) + HPAGE_PMD_SIZE);
mmu_notifier_invalidate_range_start(&range);
ptl = pmd_lock(vma->vm_mm, pmd);

if (pmd_trans_huge(*pmd) || pmd_devmap(*pmd) ||
is_pmd_migration_entry(*pmd)) {
if (folio && folio != page_folio(pmd_page(*pmd)))
goto out;
__split_huge_pmd_locked(vma, pmd, range.start, freeze);
}
out:
spin_unlock(ptl);
mmu_notifier_invalidate_range_only_end(&range);
}

__split_huge_pmd_locked 从 PMD 的 “deposit” 中取出预存的 PTE 页表页,填充 512 个 PTE 条目后将 PMD 表项替换为指向该 PTE 页表页的普通 PMD 指针,同时刷新 TLB。


四、THP 的 COW 处理

4.1 do_huge_pmd_wp_page:THP 写时复制

当父进程 fork() 后子进程写入 THP 映射区域,触发写保护缺页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/* mm/huge_memory.c: line 1294 */
vm_fault_t do_huge_pmd_wp_page(struct vm_fault *vmf)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct folio *folio;
struct page *page;
unsigned long haddr = vmf->address & HPAGE_PMD_MASK;
pmd_t orig_pmd = vmf->orig_pmd;

vmf->ptl = pmd_lockptr(vma->vm_mm, vmf->pmd);
...
spin_lock(vmf->ptl);
...
page = pmd_page(orig_pmd);
folio = page_folio(page);

/* 快速路径:该页已经是排他匿名页(只有一个引用),直接标脏重用 */
if (PageAnonExclusive(page))
goto reuse;

...
/* 引用计数 == 1:可以直接 reuse(无其他进程共享)*/
if (folio_ref_count(folio) == 1) {
pmd_t entry;
page_move_anon_rmap(page, vma);
folio_unlock(folio);
reuse:
if (unlikely(unshare)) {
spin_unlock(vmf->ptl);
return 0;
}
entry = pmd_mkyoung(orig_pmd);
entry = maybe_pmd_mkwrite(pmd_mkdirty(entry), vma);
if (pmdp_set_access_flags(vma, haddr, vmf->pmd, entry, 1))
update_mmu_cache_pmd(vma, vmf->address, vmf->pmd);
spin_unlock(vmf->ptl);
return 0;
}

unlock_fallback:
folio_unlock(folio);
spin_unlock(vmf->ptl);
fallback:
/* 存在其他共享者:先拆分 THP,再走普通 4KB COW 路径 */
__split_huge_pmd(vma, vmf->pmd, vmf->address, false, NULL);
return VM_FAULT_FALLBACK;
}

COW 处理有两条路径:

  • 快速路径(reuse):若 THP 无共享(引用计数为 1 或 PageAnonExclusive),直接将 PMD 标为 dirty + writable,无需复制,O(1) 完成。
  • 慢速路径(fallback):存在共享时,调用 __split_huge_pmd 将 2MB PMD 拆成 512 个 PTE,然后返回 VM_FAULT_FALLBACK,由上层按 4KB 粒度完成 COW 复制,只复制实际被写的那 1 个 4KB 页。

这是 THP 相比 HugeTLBFS 的一个重要差异:HugeTLBFS 的 COW 必须复制整个 2MB 页(代价高昂),而 THP COW 可以回退到 4KB 粒度,只复制被写的页,节省 511 个页的内存复制开销

fork() 后的 THP 生命周期:父进程调用 fork() 时,子进程以写保护方式共享父进程的 THP,PMD 表项标记为只读。当任意一方发生写操作时,触发 do_huge_pmd_wp_page:若此时引用计数为 1(另一方已经退出),走快速路径直接解除写保护;若双方均存在,则拆分 PMD 并按 4KB 粒度 COW。这套机制使得 fork() + exec() 的典型模式(子进程很快 exec)不会引发大页整体复制,性能开销与 4KB 页一致。


五、khugepaged:后台大页合并

5.1 守护线程结构

khugepaged 是内核专用的后台线程,负责将已存在的 4KB 页合并为 2MB THP。它维护一个全局扫描游标:

1
2
3
4
5
6
/* mm/khugepaged.c: line 129 */
struct khugepaged_scan {
struct list_head mm_head; /* 所有候选 mm 的链表 */
struct khugepaged_mm_slot *mm_slot;/* 当前正在扫描的 mm */
unsigned long address; /* 当前扫描地址 */
};

主要调优参数(均可通过 sysfs 配置):

1
2
3
4
5
6
7
8
9
/* mm/khugepaged.c: line 70 */
/* default scan 8*512 pte (or vmas) every 30 second */
static unsigned int khugepaged_pages_to_scan __read_mostly;
static unsigned int khugepaged_scan_sleep_millisecs __read_mostly = 10000; /* 10s */
/* during fragmentation poll the hugepage allocator once every minute */
static unsigned int khugepaged_alloc_sleep_millisecs __read_mostly = 60000; /* 60s */
static unsigned int khugepaged_max_ptes_none __read_mostly;
static unsigned int khugepaged_max_ptes_swap __read_mostly;
static unsigned int khugepaged_max_ptes_shared __read_mostly;

5.2 khugepaged_scan_mm_slot:扫描 mm 槽位

每当进程调用 mmap 创建新的匿名 VMA 且满足 THP 条件时,khugepaged_enter_vma(由 do_huge_pmd_anonymous_page 调用,见上文 mm/huge_memory.c 第 790 行)会将该进程的 mm_struct 注册到 khugepaged_scan.mm_head 链表。khugepaged 线程从该链表轮询,依次扫描每个 mm 中的 VMA。

khugepaged_do_scan 循环调用 khugepaged_scan_mm_slot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* mm/khugepaged.c: line 2420 */
static unsigned int khugepaged_scan_mm_slot(unsigned int pages, int *result,
struct collapse_control *cc)
{
struct vma_iterator vmi;
struct khugepaged_mm_slot *mm_slot;
struct mm_struct *mm;
struct vm_area_struct *vma;
int progress = 0;
...
mm = slot->mm;
if (unlikely(!mmap_read_trylock(mm)))
goto breakouterloop_mmap_lock; /* 锁竞争时跳过本 mm */

vma_iter_init(&vmi, mm, khugepaged_scan.address);
for_each_vma(vmi, vma) {
...
if (!hugepage_vma_check(vma, vma->vm_flags, false, false, true))
goto skip;

hstart = round_up(vma->vm_start, HPAGE_PMD_SIZE);
hend = round_down(vma->vm_end, HPAGE_PMD_SIZE);
...
while (khugepaged_scan.address < hend) {
...
*result = hpage_collapse_scan_pmd(mm, vma,
khugepaged_scan.address,
&mmap_locked, cc);
...
}
}
...
}

5.3 hpage_collapse_scan_pmd:检查 PMD 区域是否可合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* mm/khugepaged.c: line 1237 */
static int hpage_collapse_scan_pmd(struct mm_struct *mm,
struct vm_area_struct *vma,
unsigned long address, bool *mmap_locked,
struct collapse_control *cc)
{
pmd_t *pmd;
pte_t *pte, *_pte;
int result = SCAN_FAIL, referenced = 0;
int none_or_zero = 0, shared = 0;
...
pte = pte_offset_map_lock(mm, pmd, address, &ptl);
/* 扫描 512 个 PTE */
for (_address = address, _pte = pte; _pte < pte + HPAGE_PMD_NR;
_pte++, _address += PAGE_SIZE) {
pte_t pteval = *_pte;
if (is_swap_pte(pteval)) {
++unmapped;
if (!cc->is_khugepaged ||
unmapped <= khugepaged_max_ptes_swap) {
continue; /* 允许少量 swap 页 */
} else {
result = SCAN_EXCEED_SWAP_PTE;
goto out_unmap;
}
}
if (pte_none(pteval) || is_zero_pfn(pte_pfn(pteval))) {
++none_or_zero;
if (!userfaultfd_armed(vma) &&
(!cc->is_khugepaged ||
none_or_zero <= khugepaged_max_ptes_none)) {
continue; /* 允许少量空页 */
} else {
result = SCAN_EXCEED_NONE_PTE;
goto out_unmap;
}
}
...
if (pte_write(pteval))
writable = true;
...
}
...
}

扫描逻辑:

  1. 逐一检查 512 个 PTE,允许一定数量的 swap 页(khugepaged_max_ptes_swap)和空页(khugepaged_max_ptes_none)存在。
  2. 若 PTE 带有 uffd-wp(userfaultfd 写保护)则放弃合并。
  3. 通过检查后,调用 collapse_huge_page 分配新 2MB 大页,将 512 个 4KB 页的内容复制进去,替换 PMD 表项。

5.4 collapse_huge_page:实际合并流程

hpage_collapse_scan_pmd 检查通过后,由 collapse_huge_pagemm/khugepaged.c 第 1079 行)完成实际的合并操作:

  1. 分配新 2MB 大页:调用 alloc_charge_hpage 从 buddy 分配 order-9 页面。
  2. 隔离 512 个 4KB 页:调用 __collapse_huge_page_isolate,逐页从 LRU 链表摘除,检查引用计数,处于 swap 中的页面执行 swapin__collapse_huge_page_swapin)。
  3. 复制内容__collapse_huge_page_copy 将 512 个 4KB 页的内容逐页复制进新的 2MB folio。
  4. 替换页表:在 mmap_write_lock 保护下,用一条 PMD 大页表项替换原来的 512 个 PTE,刷新 TLB。
  5. 释放旧 4KB 页:原来的 512 个 4KB 物理页引用计数归零后归还 buddy。

整个合并过程需要持有目标 mmmmap_write_lock,因此对应用程序有短暂的阻塞影响(通常微秒级)。这也是为什么高延迟敏感场景建议关闭 khugepaged 的主要原因——不可预知的合并时机会引入随机延迟。


六、THP 控制参数

6.1 sysfs 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看当前模式
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never
# always: 对所有匿名映射启用 THP
# madvise: 仅对 madvise(MADV_HUGEPAGE) 标记的区域启用
# never: 完全禁用 THP

# 内存碎片整理策略
cat /sys/kernel/mm/transparent_hugepage/defrag
# always defer defer+madvise [madvise] never
# always: 缺页时同步等待内存压缩,延迟高
# defer: 唤醒 kcompactd,缺页失败直接回退 4KB
# madvise: 仅对 MADV_HUGEPAGE 区域同步压缩
# never: 从不压缩,完全依赖物理连续内存

transparent_hugepage_flags 中各比特位对应 defrag 策略(mm/huge_memory.c 第 738 行 vma_thp_gfp_mask):不同 defrag 模式会向 vma_alloc_folio 传递不同的 gfp 标志,控制内存分配器的压缩行为。

6.2 madvise 精细控制

1
2
3
4
5
/* 对特定内存区域启用 THP(即使全局为 madvise 模式)*/
madvise(addr, len, MADV_HUGEPAGE);

/* 对特定区域禁用 THP(即使全局为 always 模式)*/
madvise(addr, len, MADV_NOHUGEPAGE);

在数据库场景中,通常对 Buffer Pool 使用 MADV_HUGEPAGE,对其他小内存结构使用 MADV_NOHUGEPAGE 避免碎片化。

6.3 khugepaged 调优参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# khugepaged 每次扫描多少页(默认 4096)
echo 4096 > /sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan

# 两次扫描之间的睡眠时间(毫秒,默认 10000ms)
echo 10000 > /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs

# 分配大页失败后的等待时间(毫秒,默认 60000ms)
echo 60000 > /sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs

# 允许 512 个 PTE 中有多少个是空的(默认 511 表示几乎无限制)
echo 511 > /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none

# 允许 512 个 PTE 中有多少个在 swap 中
echo 0 > /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_swap

七、HugePage 在数据库中的实践

7.1 显式大页配置

系统配置(持久化):

1
2
3
4
5
6
7
# 在 /etc/sysctl.conf 中设置
vm.nr_hugepages = 1024 # 预留 1024 个 2MB 大页(共 2GB)
vm.nr_overcommit_hugepages = 128 # 允许超额 128 个

# NUMA 场景下按节点分配
echo 512 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo 512 > /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages

PostgreSQL 配置:

1
2
3
# postgresql.conf
huge_pages = on # 使用大页(MAP_HUGETLB)
shared_buffers = 2GB # 共享缓冲区,将全部由大页承载

PostgreSQL 在 shmget/mmap 共享内存时会优先传递 SHM_HUGETLB 标志;若大页不足则回退普通页(huge_pages = try)或直接报错退出(huge_pages = on)。

Oracle 数据库:

1
2
3
4
# /etc/security/limits.conf
oracle soft memlock unlimited
oracle hard memlock unlimited
# Oracle 使用 mlock + HugeTLBFS 锁定 SGA(System Global Area)

MySQL InnoDB(MariaDB 10.5+):

1
2
3
# my.cnf
large_pages = ON # 等同于 MAP_HUGETLB
innodb_buffer_pool_size = 16G

7.2 KVM 虚拟机使用大页

libvirt 配置(XML):

1
2
3
4
5
6
<memoryBacking>
<hugepages>
<page size="2048" unit="KiB" nodeset="0-1"/>
</hugepages>
<locked/> <!-- mlock,防止被换出 -->
</memoryBacking>

QEMU 会对 Guest RAM 执行 mmap(MAP_HUGETLB),EPT(Extended Page Table)中的 Level-2(对应 Host PMD)条目直接为 2MB 大页,减少 EPT 走表层级,VM Exit 频率可降低 10% ~ 30%。

7.3 THP 对数据库的负面影响

THP 对数据库工作负载有几个典型负面效应:

  1. 延迟抖动(Latency Spikes)khugepaged 合并时会短暂持有 anon_vma_lock_write,高并发场景下与应用线程竞争,产生几毫秒的随机延迟。
  2. 内存碎片化加剧:THP 需要连续 2MB 物理内存,频繁分配/释放后系统碎片化导致 THP 分配失败,触发同步内存压缩(defrag=always 时),造成长时间停顿。
  3. fork() COW 放大:若 THP 被多进程共享(如 PostgreSQL 的 fork() 后 worker),COW 时即便只修改 1 个字节也需拆分整个 2MB THP 的页表,增加额外开销。

数据库推荐配置:

1
2
3
4
5
6
7
8
9
10
11
# 方案一:完全关闭 THP(最保险)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 方案二:关闭 khugepaged 自动合并,但保留缺页时的 THP 分配
echo 0 > /sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan

# 方案三(高级):仅对 Buffer Pool 使用显式大页,其余关闭 THP
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
# 应用层对 Buffer Pool 调用 madvise(MADV_HUGEPAGE)
# 对其他内存调用 madvise(MADV_NOHUGEPAGE)

开机自动禁用(systemd):

1
2
3
4
5
6
7
8
9
10
11
# /etc/systemd/system/disable-thp.service
[Unit]
Description=Disable Transparent Huge Pages

[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/defrag"

[Install]
WantedBy=multi-user.target

八、诊断与性能分析

8.1 /proc/meminfo 大页字段

1
grep -i huge /proc/meminfo

输出示例:

1
2
3
4
5
6
7
8
9
10
11
AnonHugePages:    614400 kB   # THP 匿名大页(单位 KB,614400/2048 = 300 个 2MB THP)
ShmemHugePages: 0 kB # shmem/tmpfs THP
ShmemPmdMapped: 0 kB
FileHugePages: 0 kB
FilePmdMapped: 0 kB
HugePages_Total: 1024 # HugeTLBFS 总大页数
HugePages_Free: 768 # 空闲大页数
HugePages_Rsvd: 64 # 已预留但未分配(mmap 已预留,缺页未触发)
HugePages_Surp: 0 # surplus 超额大页(临时从 buddy 借用)
Hugepagesize: 2048 kB
Hugetlb: 2097152 kB # HugeTLBFS 总占用内存

8.2 /proc/PID/smaps 进程级诊断

1
cat /proc/$(pgrep postgres | head -1)/smaps | grep -A20 "heap"

关键字段:

1
2
3
4
7f8000000000-7f8080000000 rw-p  ...  [heap]
Size: 524288 kB
AnonHugePages: 520192 kB # 该 VMA 中使用了多少 THP
THPeligible: 1 # 该区域是否满足 THP 条件(1=是)

8.3 /proc/sys/vm/nr_hugepages 动态调整

1
2
3
4
5
6
7
8
# 查看当前配置
cat /proc/sys/vm/nr_hugepages

# 运行时增加大页(需要足够的连续物理内存)
echo 2048 > /proc/sys/vm/nr_hugepages

# NUMA 节点专属配置
echo 1024 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages

注意:大页预留应在系统启动早期(内存碎片化程度低时)完成;运行中的系统若内存碎片严重,增加 nr_hugepages 可能只能部分满足。

8.4 numastat 查看 per-NUMA 大页统计

1
numastat -m

输出:

1
2
3
4
5
                          Node 0          Node 1           Total
--------------- --------------- ---------------
HugePages_Total 512.00 512.00 1024.00
HugePages_Free 384.00 384.00 768.00
HugePages_Surp 0.00 0.00 0.00

NUMA 不均衡时,跨节点大页分配会增加内存访问延迟,应确保大页按应用所在 NUMA 节点均匀分配。

8.5 bpftrace 追踪 THP 分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 追踪 THP 缺页分配(do_huge_pmd_anonymous_page)
bpftrace -e '
kprobe:do_huge_pmd_anonymous_page {
@[comm] = count();
}
interval:s:5 {
print(@);
clear(@);
}
'

# 追踪 THP 分配失败(回退到 4KB 页)
bpftrace -e '
tracepoint:huge_memory:mm_khugepaged_scan_pmd_entry {
printf("khugepaged scan: %s pid=%d addr=%lx\n",
comm, pid, args->address);
}'

# 统计 THP 分配成功/失败事件
bpftrace -e '
tracepoint:huge_memory:mm_anon_huge_fault_alloc {
@thp_alloc[comm] = count();
}
tracepoint:huge_memory:mm_anon_huge_fault_fallback {
@thp_fallback[comm] = count();
}
END {
print(@thp_alloc); print(@thp_fallback);
}'

也可通过 /proc/vmstat 快速获取系统级 THP 统计:

1
grep -i thp /proc/vmstat
1
2
3
4
5
thp_fault_alloc              45231     # THP 缺页分配成功次数
thp_fault_fallback 3021 # THP 分配失败回退到 4KB 次数
thp_collapse_alloc 1284 # khugepaged 合并成功次数
thp_collapse_alloc_failed 87 # khugepaged 合并失败次数
thp_split_page 342 # THP 被拆分次数

thp_fault_fallback 占比高说明物理内存碎片严重,应考虑调整 defrag 策略或提前预留大页;thp_split_page 过高说明存在频繁 COW 或 munmap 非对齐区域,应排查应用的内存使用模式。


九、总结

Linux 大页机制从两个维度解决 TLB Miss 问题:

特性 HugeTLBFS(显式大页) THP(透明大页)
使用方式 需应用显式调用 MAP_HUGETLB 内核自动,应用无感知
支持大小 2MB、1GB 2MB(匿名/shmem)
内存锁定 预留后不可回收 可被拆分、换出
COW 代价 复制整个 2MB 回退 4KB,只复制被写的页
碎片影响 需启动时预留(影响小) 运行时分配,受碎片影响大
适用场景 数据库 Buffer、KVM Guest RAM HPC、JVM、通用服务
调优难度 中(需规划容量) 高(defrag/khugepaged 参数)

对于延迟敏感的数据库(Oracle、PostgreSQL),推荐:

  1. 使用 HugeTLBFS(显式 2MB 大页)承载 Buffer Pool/SGA。
  2. 关闭 THP 或至少关闭 khugepagedpages_to_scan=0),消除随机延迟抖动。

对于吞吐优先的 HPC、大内存 Java 应用,推荐:

  1. 开启 THP(enabled=alwaysenabled=madvise)。
  2. defrag=defer+madvise:避免同步内存压缩阻塞缺页路径。
  3. 适当增大 khugepaged/pages_to_scan,加快后台合并速度。

参考源文件:

  • mm/hugetlb.c
  • mm/huge_memory.c
  • mm/khugepaged.c
  • include/linux/hugetlb.h

延伸阅读:

本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均来自真实内核文件。

内存是操作系统最核心的资源之一。当物理内存不足时,内核必须决定哪些页面可以释放、哪些必须保留——这个过程叫做内存回收(Memory Reclaim)。本文深入剖析 Linux 内存回收子系统的完整链路:从 LRU 链表组织、kswapd 后台线程、直接回收路径,到 Swap 换出、RMAP 反向映射、OOM Killer,以及内存碎片整理(Compaction)。


一、LRU 页面链表

1.1 五种 LRU 链表

Linux 内核将所有可回收的用户态页面按照访问活跃度类型组织成五条 LRU(Least Recently Used)链表,定义于 include/linux/mmzone.h

1
2
3
4
5
6
7
8
9
10
11
12
13
// include/linux/mmzone.h
#define LRU_BASE 0
#define LRU_ACTIVE 1
#define LRU_FILE 2

enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};

五条链表的语义如下:

链表 含义
LRU_INACTIVE_ANON 不活跃的匿名页(栈、堆等),回收时需要换出到 swap
LRU_ACTIVE_ANON 活跃的匿名页,近期被访问过
LRU_INACTIVE_FILE 不活跃的文件页(page cache),若干净可直接释放
LRU_ACTIVE_FILE 活跃的文件页,近期被访问过
LRU_UNEVICTABLE 不可回收页,如 mlock() 锁定的页

Active/Inactive 双链表设计的关键价值:若所有页面都在同一条链表上,扫描时很容易将那些历史上访问频繁但近期”恰好”没有被访问的页面错误淘汰,产生 thrashing。双链表通过”降级”而非直接淘汰来保护热页面:页面必须先从 active 链表移到 inactive 链表,在 inactive 链表上”熬过”若干次扫描后才会被回收候选。

1.2 per-node LRU 向量:struct lruvec

每个 NUMA 节点(pg_data_t)都维护一个 struct lruvec,其中包含上述五条链表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// include/linux/mmzone.h
struct lruvec {
struct list_head lists[NR_LRU_LISTS];
/* per lruvec lru_lock for memcg */
spinlock_t lru_lock;
/*
* These track the cost of reclaiming one LRU - file or anon -
* over the other. As the observed cost of reclaiming one LRU
* increases, the reclaim scan balance tips toward the other.
*/
unsigned long anon_cost;
unsigned long file_cost;
/* Non-resident age, driven by LRU movement */
atomic_long_t nonresident_age;
/* Refaults at the time of last reclaim cycle */
unsigned long refaults[ANON_AND_FILE];
/* Various lruvec state flags (enum lruvec_flags) */
unsigned long flags;
#ifdef CONFIG_LRU_GEN
/* evictable pages divided into generations */
struct lru_gen_folio lrugen;
/* to concurrently iterate lru_gen_mm_list */
struct lru_gen_mm_state mm_state;
#endif
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
};

lruvec 中的 nonresident_age 字段是工作集检测的核心计数器——每次 LRU 发生移动时递增,用于计算 refault distance(见第四节)。anon_costfile_cost 动态记录回收匿名页和文件页各自的代价,平衡扫描压力。

1.3 folio_lru_list:判断 folio 应当位于哪条链表

include/linux/mm_inline.h 中的 folio_lru_list() 封装了 LRU 链表判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// include/linux/mm_inline.h
static __always_inline enum lru_list folio_lru_list(struct folio *folio)
{
enum lru_list lru;

VM_BUG_ON_FOLIO(folio_test_active(folio) && folio_test_unevictable(folio), folio);

if (folio_test_unevictable(folio))
return LRU_UNEVICTABLE;

lru = folio_is_file_lru(folio) ? LRU_INACTIVE_FILE : LRU_INACTIVE_ANON;
if (folio_test_active(folio))
lru += LRU_ACTIVE;

return lru;
}

判断依据是 folio->flags 中的两个核心标志位:

  • **PG_active**:置位时表示该页面在 active 链表上,是近期访问过的热页面。
  • **PG_referenced**:访问引用标志,用于实现二次机会(second-chance)算法。当 inactive 链表上的页面被扫描时,若 PG_referenced 已置位,则将其提升回 active 链表并清除标志;若未置位,才将其作为回收候选。

二、kswapd 后台回收

2.1 kswapd 的职责

kswapd 是内核中每个 NUMA 节点对应的一个内核线程(kswapd0kswapd1…),它在后台默默维护各 zone 的空闲页面水位,避免内存分配路径上的延迟。它的核心逻辑在 mm/vmscan.ckswapd() 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// mm/vmscan.c
static int kswapd(void *p)
{
unsigned int alloc_order, reclaim_order;
unsigned int highest_zoneidx = MAX_NR_ZONES - 1;
pg_data_t *pgdat = (pg_data_t *)p;
struct task_struct *tsk = current;
const struct cpumask *cpumask = cpumask_of_node(pgdat->node_id);

if (!cpumask_empty(cpumask))
set_cpus_allowed_ptr(tsk, cpumask);

tsk->flags |= PF_MEMALLOC | PF_KSWAPD;
set_freezable();

WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
atomic_set(&pgdat->nr_writeback_throttled, 0);
for ( ; ; ) {
bool ret;

alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);

kswapd_try_sleep:
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
highest_zoneidx);

/* Read the new order and highest_zoneidx */
alloc_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);
WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);

ret = try_to_freeze();
if (kthread_should_stop())
break;

if (ret)
continue;

trace_mm_vmscan_kswapd_wake(pgdat->node_id, highest_zoneidx,
alloc_order);
reclaim_order = balance_pgdat(pgdat, alloc_order,
highest_zoneidx);
if (reclaim_order < alloc_order)
goto kswapd_try_sleep;
}

tsk->flags &= ~(PF_MEMALLOC | PF_KSWAPD);
return 0;
}

kswapd 的主循环逻辑很清晰:

  1. 尝试睡眠(kswapd_try_to_sleep),直到某个 zone 的空闲页低于 WMARK_HIGH,被 wakeup_kswapd() 唤醒。
  2. 调用 balance_pgdat() 对该 NUMA 节点执行回收。
  3. 若回收到的 order 低于请求 order(高阶分配不满足),则再次尝试睡眠等待 kcompactd 完成碎片整理。

PF_MEMALLOC 标志让 kswapd 在分配内存时可绕过部分水位检查,避免回收过程本身因内存不足而卡死。

2.2 balance_pgdat:对单节点执行回收

balance_pgdat() 是 kswapd 的核心回收函数,其签名为:

1
2
// mm/vmscan.c(第 7347 行)
static int balance_pgdat(pg_data_t *pgdat, int order, int highest_zoneidx)

它的工作流程:

  1. 统计各 zone 的 watermark_boost,决定是否需要 boost 回收。
  2. 以优先级(priority)从 DEF_PRIORITY(默认 12)逐步降低,每轮调用 kswapd_shrink_node(),直到水位满足或优先级耗尽。
  3. 优先级越低,每次扫描的 LRU 页面比例越高(扫描范围 = 链表长度 >> priority),回收压力越大。
  4. 回收结束后唤醒所有因水位不足而被阻塞的用户进程。

2.3 kswapd_shrink_node

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// mm/vmscan.c(第 7267 行)
static bool kswapd_shrink_node(pg_data_t *pgdat,
struct scan_control *sc)
{
struct zone *zone;
int z;

/* Reclaim a number of pages proportional to the number of zones */
sc->nr_to_reclaim = 0;
for (z = 0; z <= sc->reclaim_idx; z++) {
zone = pgdat->node_zones + z;
if (!managed_zone(zone))
continue;

sc->nr_to_reclaim += max(high_wmark_pages(zone), SWAP_CLUSTER_MAX);
}

/*
* Historically care was taken to put equal pressure on all zones but
* now pressure is applied based on node LRU order.
*/
shrink_node(pgdat, sc);

if (sc->order && sc->nr_reclaimed >= compact_gap(sc->order))
sc->order = 0;

return sc->nr_scanned >= sc->nr_to_reclaim;
}

kswapd_shrink_node 计算出本轮需要回收的页面数(与各 zone 的高水位之和成正比),然后调用 shrink_node() 实际执行回收,最后返回是否已扫描了足够多的页面。


三、直接回收(Direct Reclaim)

3.1 调用链路

当内存分配器(__alloc_pages())在 slow path 里仍无法满足分配请求时,分配上下文会直接参与内存回收,这就是直接回收。其调用路径为:

1
2
3
4
5
6
7
8
__alloc_pages_slowpath()
└─> __alloc_pages_direct_reclaim()
└─> try_to_free_pages()
└─> do_try_to_free_pages()
└─> shrink_zones()
└─> shrink_node()
└─> shrink_node_memcgs()
└─> shrink_lruvec()

3.2 try_to_free_pages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// mm/vmscan.c(第 6998 行)
unsigned long try_to_free_pages(struct zonelist *zonelist, int order,
gfp_t gfp_mask, nodemask_t *nodemask)
{
unsigned long nr_reclaimed;
struct scan_control sc = {
.nr_to_reclaim = SWAP_CLUSTER_MAX,
.gfp_mask = current_gfp_context(gfp_mask),
.reclaim_idx = gfp_zone(gfp_mask),
.order = order,
.nodemask = nodemask,
.priority = DEF_PRIORITY,
.may_writepage = !laptop_mode,
.may_unmap = 1,
.may_swap = 1,
};
// ...
if (throttle_direct_reclaim(sc.gfp_mask, zonelist, nodemask))
return 1;

set_task_reclaim_state(current, &sc.reclaim_state);
trace_mm_vmscan_direct_reclaim_begin(order, sc.gfp_mask);

nr_reclaimed = do_try_to_free_pages(zonelist, &sc);

trace_mm_vmscan_direct_reclaim_end(nr_reclaimed);
set_task_reclaim_state(current, NULL);

return nr_reclaimed;
}

struct scan_control 是贯穿整个回收子系统的控制结构,记录回收目标数量、GFP 标志、是否允许 writepage、是否允许 unmap、是否允许 swap 等所有策略参数。

3.3 do_try_to_free_pages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// mm/vmscan.c(第 6780 行)
static unsigned long do_try_to_free_pages(struct zonelist *zonelist,
struct scan_control *sc)
{
int initial_priority = sc->priority;
// ...
retry:
do {
if (!sc->proactive)
vmpressure_prio(sc->gfp_mask, sc->target_mem_cgroup,
sc->priority);
sc->nr_scanned = 0;
shrink_zones(zonelist, sc);

if (sc->nr_reclaimed >= sc->nr_to_reclaim)
break;
if (sc->compaction_ready)
break;

if (sc->priority < DEF_PRIORITY - 2)
sc->may_writepage = 1;
} while (--sc->priority >= 0);
// ...
}

do_try_to_free_pagesbalance_pgdat 的策略相同:优先级从 12 逐级降低,每轮调用 shrink_zones() 遍历 zonelist 中的每个 NUMA 节点,调用 shrink_node()。值得注意的是,优先级低于 DEF_PRIORITY - 2 时,即使处于 laptop_mode 也会强制开启 writepage。


四、LRU 页面扫描与回收

4.1 shrink_inactive_list:扫描 inactive 链表

shrink_inactive_list() 是内存回收的核心逻辑,它从 inactive LRU 链表中隔离页面,尝试回收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// mm/vmscan.c(第 2556 行)
static unsigned long shrink_inactive_list(unsigned long nr_to_scan,
struct lruvec *lruvec, struct scan_control *sc,
enum lru_list lru)
{
LIST_HEAD(folio_list);
unsigned long nr_scanned;
unsigned int nr_reclaimed = 0;
unsigned long nr_taken;
struct reclaim_stat stat;
bool file = is_file_lru(lru);
// ...
lru_add_drain();
spin_lock_irq(&lruvec->lru_lock);

nr_taken = isolate_lru_folios(nr_to_scan, lruvec, &folio_list,
&nr_scanned, sc, lru);

__mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, nr_taken);
// 统计 PGSCAN_KSWAPD 或 PGSCAN_DIRECT 事件
spin_unlock_irq(&lruvec->lru_lock);

if (nr_taken == 0)
return 0;

nr_reclaimed = shrink_folio_list(&folio_list, pgdat, sc, &stat, false);

spin_lock_irq(&lruvec->lru_lock);
move_folios_to_lru(lruvec, &folio_list);
__mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, -nr_taken);
// 统计 PGSTEAL_KSWAPD 或 PGSTEAL_DIRECT 事件
spin_unlock_irq(&lruvec->lru_lock);

lru_note_cost(lruvec, file, stat.nr_pageout, nr_scanned - nr_reclaimed);
mem_cgroup_uncharge_list(&folio_list);
free_unref_page_list(&folio_list);
// ...
}

整体流程:

  1. 调用 isolate_lru_folios() 在持锁状态下从 LRU 链表隔离一批页面到临时链表 folio_list
  2. 释放锁后,调用 shrink_folio_list()(6.4 中已替代旧的 shrink_page_list)处理每个页面的实际回收。
  3. 重新持锁,将未能回收的页面放回 LRU(move_folios_to_lru)。
  4. 调用 lru_note_cost() 更新 anon/file 回收代价,动态平衡后续扫描比例。

4.2 shrink_folio_list:逐页面决策

shrink_folio_list() 对每个隔离出的页面做细粒度决策:

  • 匿名页(Anon):先调用 add_to_swap() 分配 swap slot 并加入 swap cache,再通过 try_to_unmap() 解除所有 PTE 映射,最后调用 swap_writepage() 将页面数据写入 swap 分区。
  • 文件页(File):若页面是脏的(folio_test_dirty),则调用 pageout() → writeback;若页面已经干净,则直接从 page cache 中移除并释放物理页帧。

4.3 isolate_lru_folios:从 LRU 隔离页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// mm/vmscan.c(第 2293 行)
static unsigned long isolate_lru_folios(unsigned long nr_to_scan,
struct lruvec *lruvec, struct list_head *dst,
unsigned long *nr_scanned, struct scan_control *sc,
enum lru_list lru)
{
struct list_head *src = &lruvec->lists[lru];
unsigned long nr_taken = 0;
// ...
while (scan < nr_to_scan && !list_empty(src)) {
struct folio *folio;

folio = lru_to_folio(src);
// ...
if (!folio_test_lru(folio))
goto move;
if (!sc->may_unmap && folio_mapped(folio))
goto move;

if (unlikely(!folio_try_get(folio)))
goto move;

if (!folio_test_clear_lru(folio)) {
folio_put(folio);
goto move;
}
nr_taken += nr_pages;
// 移动到 dst 链表
}
// ...
}

isolate_lru_folios 从链表尾部(最老的页面)开始扫描,对每个候选 folio 做一系列检查:是否仍在 LRU 上、是否允许 unmap、是否能成功获取引用、是否能清除 LRU 标志(确保独占处理权)。通过检查后才从链表摘下,加入隔离列表 dst

4.4 refault distance:工作集检测

内核通过 shadow entry(影子条目) 机制来检测被错误淘汰的页面(refault)。当一个页面从 page cache 被逐出时,内核不会立刻删除其在 xarray 中的槽位,而是在原位置存入一个 shadow entry,包含当前 nonresident_age 的快照(即”驱逐时间戳”)。

当该文件区域被再次访问(page fault)时,workingset_refault() 被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// mm/workingset.c(第 396 行)
void workingset_refault(struct folio *folio, void *shadow)
{
// ...
unpack_shadow(shadow, &memcgid, &pgdat, &eviction, &workingset);
eviction <<= bucket_order;
// ...
refault = atomic_long_read(&eviction_lruvec->nonresident_age);
// ...
refault_distance = (refault - eviction) & EVICTION_MASK;
// ...
if (refault_distance > workingset_size)
goto out; // refault 距离太远,不值得激活

folio_set_active(folio); // 直接提升为 active,跳过 inactive 熬炼
// ...
}

refault distance = 从页面被逐出到被重新访问,LRU 总共发生了多少次移动(nonresident_age 增量)。若这个距离小于当前工作集大小(active + inactive 链表长度),说明该页面本应留在内存中,内核将直接把它提升为 active,防止反复 thrashing。


五、Swap 机制

5.1 swap_entry_t:swap 槽位引用

swap_entry_t 是一个 32/64 位的不透明整数值,编码了 swap 类型(设备索引)和偏移量(槽位号):

1
2
3
4
// include/linux/swapops.h
typedef struct {
unsigned long val;
} swp_entry_t;

其二进制布局:高位为 swap 类型 ID(SWP_TYPE_SHIFT 位),低位为 offset(页面在 swap 分区中的页号)。

5.2 add_to_swap_cache

当匿名页即将被换出时,需要先通过 add_to_swap() 分配一个 swap slot(swp_entry_t),再调用 add_to_swap_cache() 将页面插入 swap cache(swapper_space 的 xarray):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// mm/swap_state.c(第 88 行)
int add_to_swap_cache(struct folio *folio, swp_entry_t entry,
gfp_t gfp, void **shadowp)
{
struct address_space *address_space = swap_address_space(entry);
pgoff_t idx = swp_offset(entry);
XA_STATE_ORDER(xas, &address_space->i_pages, idx, folio_order(folio));
unsigned long i, nr = folio_nr_pages(folio);
void *old;

xas_set_update(&xas, workingset_update_node);

VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio);
VM_BUG_ON_FOLIO(folio_test_swapcache(folio), folio);
VM_BUG_ON_FOLIO(!folio_test_swapbacked(folio), folio);

folio_ref_add(folio, nr);
folio_set_swapcache(folio);

do {
xas_lock_irq(&xas);
xas_create_range(&xas);
if (xas_error(&xas))
goto unlock;
for (i = 0; i < nr; i++) {
old = xas_load(&xas);
if (xa_is_value(old)) {
if (shadowp)
*shadowp = old;
}
set_page_private(folio_page(folio, i), entry.val + i);
xas_store(&xas, folio);
xas_next(&xas);
}
address_space->nrpages += nr;
__node_stat_mod_folio(folio, NR_FILE_PAGES, nr);
__lruvec_stat_mod_folio(folio, NR_SWAPCACHE, nr);
unlock:
xas_unlock_irq(&xas);
} while (xas_nomem(&xas, gfp));
// ...
}

注意此函数会将 page->private 设为 swp_entry_t 的值,这样当页面从 swap 读回时(lookup_swap_cache),内核可以通过 entry 在 swap cache 中查找已有的内存拷贝,避免重复 I/O。

5.3 匿名页换出全流程

1
2
3
4
shrink_folio_list()
├─> add_to_swap() // 分配 swap slot,加入 swap cache
├─> try_to_unmap() // 解除所有进程的 PTE 映射(RMAP)
└─> swap_writepage() // 异步将页面内容写入 swap 分区

页面换入(swap-in)时,缺页中断触发 do_swap_page(),先查 swap cache(lookup_swap_cache),命中则直接重建 PTE;未命中则从 swap 分区读回,经历一次 major fault。


六、RMAP(反向映射)

6.1 为什么需要 RMAP

页面回收的一个关键步骤是:找到所有映射了该页面的 PTE,将它们全部改为 swap entry 或置为无效。这就需要从物理页面反向找到所有的虚拟地址映射——这就是 RMAP(Reverse Mapping) 的作用。

6.2 struct anon_vma:匿名页反向映射

匿名页的 RMAP 通过 struct anon_vma 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// include/linux/rmap.h(第 31 行)
struct anon_vma {
struct anon_vma *root; /* Root of this anon_vma tree */
struct rw_semaphore rwsem; /* W: modification, R: walking the list */
atomic_t refcount;

unsigned long num_children;
unsigned long num_active_vmas;

struct anon_vma *parent; /* Parent of this anon_vma */

/* Interval tree of private "related" vmas */
struct rb_root_cached rb_root;
};

每个匿名 VMA 关联一个 anon_vma,后者通过区间树(interval tree)组织所有可能映射了该匿名页的 VMA 集合。当进程 fork 时,子进程的 VMA 会链接到父进程的 anon_vma,形成树状结构,确保 COW 后的页面也能被正确追踪。

文件页的反向映射则依赖 struct address_space(即 inode->i_mapping)中的 i_mmap 区间树,记录所有映射了该文件页的 VMA。

6.3 page_add_anon_rmap

当新的匿名页面通过 PTE 映射到某个 VMA 时,调用 page_add_anon_rmap() 注册 RMAP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// mm/rmap.c(第 1214 行)
void page_add_anon_rmap(struct page *page, struct vm_area_struct *vma,
unsigned long address, rmap_t flags)
{
struct folio *folio = page_folio(page);
atomic_t *mapped = &folio->_nr_pages_mapped;
int nr = 0, nr_pmdmapped = 0;
bool compound = flags & RMAP_COMPOUND;
bool first = true;

if (likely(!compound)) {
first = atomic_inc_and_test(&page->_mapcount);
nr = first;
// ...
}
// ...
if (likely(!folio_test_ksm(folio))) {
if (first)
__page_set_anon_rmap(folio, page, vma, address,
!!(flags & RMAP_EXCLUSIVE));
// ...
}
}

_mapcount 记录有多少个 PTE 映射了该页面。__page_set_anon_rmap()page->mapping 指向对应的 anon_vma,这是反向查找的入口。

6.4 try_to_unmap_one:解除 PTE 映射

try_to_unmap() 通过 rmap_walk 遍历所有映射了目标 folio 的 VMA,对每个 VMA 调用 try_to_unmap_one()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// mm/rmap.c(第 1451 行)
static bool try_to_unmap_one(struct folio *folio, struct vm_area_struct *vma,
unsigned long address, void *arg)
{
struct mm_struct *mm = vma->vm_mm;
DEFINE_FOLIO_VMA_WALK(pvmw, folio, vma, address, 0);
pte_t pteval;
bool anon_exclusive, ret = true;
struct mmu_notifier_range range;
enum ttu_flags flags = (enum ttu_flags)(long)arg;
// ...
mmu_notifier_invalidate_range_start(&range);

while (page_vma_mapped_walk(&pvmw)) {
/* Unexpected PMD-mapped THP? */
VM_BUG_ON_FOLIO(!pvmw.pte, folio);
// ...
// 清除 PTE,写入 swap entry 或置零
// 刷新 TLB
// 调用 page_remove_rmap() 减少 _mapcount
}
// ...
}

对于匿名页,PTE 被替换成 swap entry(编码了 swp_entry_t),后续访问触发缺页中断时内核可识别 swap entry 并发起换入。对于文件页,PTE 直接被清零(置为 not-present),访问时触发 minor fault 从 page cache 重建。


七、OOM Killer

7.1 触发条件

当所有回收尝试(kswapd + 直接回收)都告失败,__alloc_pages_slowpath() 最终调用 out_of_memory(),即 OOM Killer 的入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// mm/oom_kill.c(第 1106 行)
bool out_of_memory(struct oom_control *oc)
{
unsigned long freed = 0;

if (oom_killer_disabled)
return false;

if (!is_memcg_oom(oc)) {
blocking_notifier_call_chain(&oom_notify_list, 0, &freed);
if (freed > 0 && !is_sysrq_oom(oc))
/* Got some memory back in the last second. */
return true;
}

if (task_will_free_mem(current)) {
mark_oom_victim(current);
queue_oom_reaper(current);
return true;
}

if (oc->gfp_mask && !(oc->gfp_mask & __GFP_FS) && !is_memcg_oom(oc))
return true;

oc->constraint = constrained_alloc(oc);
if (oc->constraint != CONSTRAINT_MEMORY_POLICY)
oc->nodemask = NULL;
check_panic_on_oom(oc);

if (!is_memcg_oom(oc) && sysctl_oom_kill_allocating_task &&
current->mm && !oom_unkillable_task(current) &&
oom_cpuset_eligible(current, oc) &&
current->signal->oom_score_adj != OOM_SCORE_ADJ_MIN) {
get_task_struct(current);
oc->chosen = current;
oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)");
return true;
}

select_bad_process(oc);
if (!oc->chosen) {
dump_header(oc, NULL);
pr_warn("Out of memory and no killable processes...\n");
if (!is_sysrq_oom(oc) && !is_memcg_oom(oc))
panic("System is deadlocked on memory\n");
}
if (oc->chosen && oc->chosen != (void *)-1UL)
oom_kill_process(oc, !is_memcg_oom(oc) ? "Out of memory" :
"Memory cgroup out of memory");
return !!oc->chosen;
}

out_of_memory 会先检查各类快速出口(OOM killer 被禁用、通知链释放了内存、当前进程即将退出、GFP 标志不允许 FS 操作等),都无法处理时才真正选择受害者进程。

7.2 oom_badness:评分公式

select_bad_process() 遍历所有进程,对每个进程调用 oom_evaluate_task(),后者使用 oom_badness() 计算分数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// mm/oom_kill.c(第 201 行)
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;

if (oom_unkillable_task(p))
return LONG_MIN;

p = find_lock_task_mm(p);
if (!p)
return LONG_MIN;

adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN ||
test_bit(MMF_OOM_SKIP, &p->mm->flags) ||
in_vfork(p)) {
task_unlock(p);
return LONG_MIN;
}

/*
* The baseline for the badness score is the proportion of RAM that each
* task's rss, pagetable and swap space use.
*/
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
task_unlock(p);

/* Normalize to oom_score_adj units */
adj *= totalpages / 1000;
points += adj;

return points;
}

评分公式的核心:

1
2
score = RSS + Swap使用量 + 页表占用页数
score += oom_score_adj × (total_pages / 1000)
  • RSS(Resident Set Size):进程常驻内存大小,RSS 越大,杀掉后释放的内存越多,得分越高。
  • swap 使用量:进程已换出到 swap 的页面数也计入。
  • 页表:页表本身占用的物理内存也计入。
  • **oom_score_adj**:范围 [-1000, 1000],通过 /proc/PID/oom_score_adj 调整。设为 -1000 相当于豁免(返回 LONG_MIN),设为 1000 则大幅增加被杀概率。

得分最高(内存占用最大、oom_score_adj 最高)的进程会被选中为受害者。

7.3 oom_kill_process:发送 SIGKILL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// mm/oom_kill.c(第 1013 行)
static void oom_kill_process(struct oom_control *oc, const char *message)
{
struct task_struct *victim = oc->chosen;
struct mem_cgroup *oom_group;
// ...
task_lock(victim);
if (task_will_free_mem(victim)) {
mark_oom_victim(victim);
queue_oom_reaper(victim);
task_unlock(victim);
put_task_struct(victim);
return;
}
task_unlock(victim);

if (__ratelimit(&oom_rs))
dump_header(oc, victim);

oom_group = mem_cgroup_get_oom_group(victim, oc->memcg);

__oom_kill_process(victim, message);

if (oom_group) {
mem_cgroup_scan_tasks(oom_group, oom_kill_memcg_member,
(void *)message);
mem_cgroup_put(oom_group);
}
}

__oom_kill_process() 会:

  1. 调用 count_vm_event(OOM_KILL) 计数。
  2. 先向受害者发送 SIGKILL
  3. 如果该进程的 mm 没有被其他线程共享,将其加入 OOM reaper 队列。OOM reaper 是专门的内核线程(oom_reaper),它在受害者进程的 mm 上调用 exit_mmap() 强制释放内存,而不等待进程自然退出,确保内存被快速回收。

当设置了 memory.oom.group = 1 的 cgroup 中有进程触发 OOM 时,oom_kill_process 会杀死整个 cgroup 中的所有进程。


八、内存压缩(Compaction)

8.1 碎片化问题

Linux buddy allocator 使用的伙伴系统天然会产生外部碎片:即使总空闲页数充足,也可能无法满足高阶(order > 0)的连续内存分配请求,因为可用页面被分散在不连续的物理地址上。内存压缩(Compaction)通过迁移可移动页面来合并空闲块,解决这一问题。

8.2 compact_zone

compact_zone() 是内存压缩的核心函数,它使用两个扫描指针:

  • migrate scanner:从低地址向高地址扫描,寻找可迁移的页面(MIGRATE_MOVABLE 类型)。
  • free scanner:从高地址向低地址扫描,寻找空闲页面块作为迁移目标。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// mm/compaction.c(第 2317 行)
compact_zone(struct compact_control *cc, struct capture_control *capc)
{
enum compact_result ret;
unsigned long start_pfn = cc->zone->zone_start_pfn;
unsigned long end_pfn = zone_end_pfn(cc->zone);
// ...
cc->total_migrate_scanned = 0;
cc->total_free_scanned = 0;
// ...
cc->migrate_pfn = cc->zone->compact_cached_migrate_pfn[sync];
cc->free_pfn = cc->zone->compact_cached_free_pfn;
// ...

while ((ret = compact_finished(cc)) == COMPACT_CONTINUE) {
int err;
unsigned long start_pfn = cc->migrate_pfn;

// 1. 隔离可迁移页面(isolate_migratepages_block)
// 2. 隔离空闲页面(isolate_freepages)
// 3. 执行页面迁移(migrate_pages)
// 4. 释放迁移后的空闲页面回 buddy allocator
}
// ...
}

迁移完成后,低地址区域腾出了连续的空闲块,高阶分配得以满足。

8.3 kcompactd:后台压缩线程

与 kswapd 类似,每个 NUMA 节点有一个 kcompactd 后台线程。当 kswapd 完成回收但高阶分配仍然失败时,kswapd 会调用 wakeup_kcompactd() 唤醒 kcompactd,后者调用 compact_zone() 执行后台压缩,提高高阶分配成功率而不影响前台服务的延迟。


九、诊断方法

9.1 /proc/meminfo 关键字段解读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ cat /proc/meminfo
MemTotal: 16384000 kB # 总物理内存
MemFree: 512000 kB # 完全空闲(buddy 中的页)
MemAvailable: 4096000 kB # 实际可用(含可回收 cache),比 MemFree 更准确
Buffers: 128000 kB # 块设备的 buffer cache
Cached: 6144000 kB # 文件 page cache(不含 Buffers 和 SwapCached)
SwapCached: 64000 kB # 已换出但仍在 swap cache 中的页
Active: 5120000 kB # active LRU 总量(anon + file)
Inactive: 3072000 kB # inactive LRU 总量(anon + file)
Active(anon): 2048000 kB # active 匿名页
Inactive(anon): 1024000 kB # inactive 匿名页(换出候选)
Active(file): 3072000 kB # active 文件页
Inactive(file): 2048000 kB # inactive 文件页(回收候选)
Unevictable: 32000 kB # mlock 等不可回收页
Mlocked: 32000 kB # mlock 锁定的页
Dirty: 16000 kB # 脏页(待写回)
Writeback: 2000 kB # 正在写回的页
AnonPages: 3072000 kB # 匿名页总量
Mapped: 1024000 kB # 已映射到用户空间的文件页
Shmem: 512000 kB # 共享内存(tmpfs)
  • MemAvailable 远比 MemFree 更能反映系统真实可用内存,它综合考虑了 page cache 和 slab 的可回收部分。
  • Dirty 通常意味着 I/O 子系统跟不上写入速度,可能导致回收时的写回等待。
  • Writeback 长时间非零 表明正在发生大量回写,内存压力较高。

9.2 vmstat 实时监控

1
2
3
4
5
6
# -w 宽格式,1 秒刷新一次
$ vmstat -w 1

procs ---memory--- ---swap-- ---io--- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 1 102400 512000 64000 3072000 128 256 1024 2048 3000 5000 30 10 55 5 0

关键字段:

  • si(swap-in):每秒从 swap 换入的页数。非零表示系统正在积极换入。
  • so(swap-out):每秒换出到 swap 的页数。长期非零说明内存严重不足。
  • **bi/bo**:块设备读写(pages/s),结合 si/so 可判断是 swap I/O 还是文件 I/O。

9.3 /proc/vmstat 细粒度统计

1
2
3
4
5
6
$ cat /proc/vmstat | grep -E 'pgsteal|pgscan|pgmajfault'
pgscan_kswapd 1234567 # kswapd 扫描的页数
pgscan_direct 123456 # 直接回收扫描的页数
pgsteal_kswapd 1000000 # kswapd 实际回收的页数
pgsteal_direct 90000 # 直接回收实际回收的页数
pgmajfault 5678 # major page fault 次数(需要磁盘 I/O 的缺页)

pgscan / pgsteal 的比值反映回收效率:比值越接近 1,说明扫描的页面几乎都能成功回收;比值远大于 1 说明大量扫描都是无效的(页面被重新激活或跳过)。

9.4 sar -B:内存回收统计

1
2
3
4
5
$ sar -B 1 5
Linux 6.4.0 ...

14:30:01 pgpgin/s pgpgout/s fault/s majflt/s pgfree/s pgscank/s pgscand/s pgsteal/s %vmeff
14:30:02 256.0 512.0 1024.0 2.0 2048.0 4096.0 128.0 3800.0 90.33

%vmeff = pgsteal / (pgscank + pgscand) × 100,即回收效率百分比。低于 50% 时说明系统内存压力异常高,大量页面反复被扫描却无法回收。

9.5 BPFtrace 追踪 OOM

1
2
3
4
5
6
7
8
# 追踪 OOM kill 事件,打印被杀进程名和 PID
$ bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill: comm=%s pid=%d\n", comm, pid); }'

# 追踪 oom_badness 的评分过程
$ bpftrace -e '
kretprobe:oom_badness {
printf("oom_badness: pid=%d retval=%ld\n", pid, retval);
}'

9.6 dmesg 查看 OOM 日志

1
2
3
4
5
6
7
$ dmesg | grep -i oom
[123456.789] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),
cpuset=/,mems_allowed=0,global_oom,task_memcg=/docker/abc123,
task=mysqld,pid=12345,uid=999
[123456.790] Out of memory: Killed process 12345 (mysqld) total-vm:4194304kB,
anon-rss:3145728kB, file-rss:131072kB, shmem-rss:0kB,
UID:999 pgtables:8192kB oom_score_adj:0

OOM 日志包含:触发约束类型、目标 memcg 路径、被杀进程名/PID、内存分布(total-vm/anon-rss/file-rss)和 oom_score_adj 值,是排查内存泄漏和容量规划的重要依据。


十、总结:内存回收全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
内存分配请求(alloc_pages)

▼ 快速路径失败
__alloc_pages_slowpath

├─── 唤醒 kswapd ──────────────────────────────┐
│ │
├─── 直接回收 (try_to_free_pages) │ kswapd 后台回收
│ │ │ balance_pgdat
│ ▼ │ kswapd_shrink_node
│ shrink_node │ shrink_node
│ │ └───────────────────────
│ shrink_lruvec
│ │
│ ┌────┴────────────┐
│ │ │
│ shrink_active_list shrink_inactive_list
│ (降级到 inactive) │
│ ├── isolate_lru_folios
│ ├── shrink_folio_list
│ │ ├── 匿名页 → add_to_swap → try_to_unmap → swap_writepage
│ │ └── 文件页 → 脏则 writeback,干净则直接释放
│ └── 未回收页放回 LRU

├─── 内存压缩 (kcompactd / direct compact)

└─── OOM Killer (out_of_memory)

select_bad_process (oom_badness 评分)

oom_kill_process → SIGKILL → OOM reaper

Linux 内存回收机制是一个精巧的多层次系统:LRU 链表提供了基于访问热度的页面分层;kswapd 在水位告急前就开始预防性回收;直接回收作为最后的”同步”手段兜底;refault distance 防止误杀热页面;RMAP 确保能在常数时间内找到并解除所有 PTE 映射;OOM Killer 在万不得已时牺牲”最不重要”的进程换取系统继续运行。理解这些机制的协同工作方式,是编写内存高效程序和调优 Linux 系统不可或缺的基础。


参考源文件(Linux 6.4-rc1):

  • mm/vmscan.c — 回收主逻辑(6.4-rc1 约 8000 行)
  • mm/oom_kill.c — OOM Killer
  • mm/rmap.c — 反向映射
  • mm/compaction.c — 内存碎片整理
  • mm/swap_state.c — swap cache 管理
  • mm/workingset.c — 工作集检测
  • include/linux/mmzone.h — zone/lruvec 数据结构
  • include/linux/rmap.h — RMAP 数据结构
  • include/linux/mm_inline.h — LRU 内联函数

本文基于 Linux 6.4-rc1 源码(commit ac9a78681b92),系统梳理进程虚拟地址空间的数据结构、多级页表的实现细节、mmap 系统调用的完整路径,以及缺页异常的处理流程。所有代码片段均来自真实内核源文件。

一、进程虚拟地址空间布局

1.1 struct mm_struct:进程的内存管理核心

每个进程拥有独立的虚拟地址空间,其元数据由 struct mm_struct 描述,定义在 include/linux/mm_types.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// include/linux/mm_types.h
struct mm_struct {
struct {
struct maple_tree mm_mt; /* VMA 的 Maple Tree 索引 */
unsigned long mmap_base; /* mmap 区域的基地址 */
unsigned long mmap_legacy_base;/* 自底向上分配时的 mmap 基地址 */
unsigned long task_size; /* 用户态地址空间上限 */
pgd_t *pgd; /* 页全局目录(顶级页表)指针 */

atomic_t mm_users; /* 使用该 mm 的用户数 */
atomic_t mm_count; /* mm_struct 引用计数 */

int map_count; /* VMA 数量 */
spinlock_t page_table_lock; /* 保护页表及部分计数器 */
struct rw_semaphore mmap_lock; /* 保护 VMA 链表/树的读写锁 */

unsigned long start_code, end_code; /* 代码段范围 */
unsigned long start_data, end_data; /* 初始化数据段范围 */
unsigned long start_brk, brk; /* 堆的起始地址和当前顶端 */
unsigned long start_stack; /* 栈的起始地址 */
unsigned long arg_start, arg_end; /* 命令行参数范围 */
unsigned long env_start, env_end; /* 环境变量范围 */
} __randomize_layout;
unsigned long cpu_bitmap[];
};

值得注意的是,Linux 6.1 起用 Maple Treemm_mt)取代了旧的红黑树 + 链表双结构来管理 VMA,查找性能从 O(log n) 提升,同时减少了锁竞争。mmap_lock 是一把读写信号量,读路径(find_vma 等查询)持读锁,写路径(新建/删除 VMA)持写锁。

1.2 典型 64 位进程虚拟地址空间

在 x86-64 四级页表模式下(LA48),用户态地址空间为 0 ~ 0x0000_7fff_ffff_ffff(128 TB),布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
高地址
┌─────────────────────────────┐ 0xFFFF_FFFF_FFFF_FFFF
│ 内核空间 │ (用户态不可访问)
├─────────────────────────────┤ 0xFFFF_8000_0000_0000
│ (非规范地址空洞) │
├─────────────────────────────┤ 0x0000_7FFF_FFFF_FFFF
│ 栈 (向下增长) │ ← start_stack
│ ... │
├─────────────────────────────┤
│ mmap / 动态库映射区 │ ← mmap_base
│ ... │
├─────────────────────────────┤
│ 堆 (向上增长) │ start_brk → brk
├─────────────────────────────┤
│ BSS 段 (.bss) │
│ 数据段 (.data) │ start_data ~ end_data
├─────────────────────────────┤
│ 代码段 (.text) │ start_code ~ end_code
└─────────────────────────────┘ 0x0000_0000_0040_0000(通常)
低地址

五级页表(LA57)将用户态空间扩展至 128 PB,通过 CONFIG_X86_5LEVEL 开启,运行时由 pgtable_l5_enabled() 检测 CPU 的 LA57 特性位决定是否激活。


二、VMA:虚拟内存区域

2.1 struct vm_area_struct

VMA 是虚拟地址空间的最小管理单元,每段具有相同属性(权限、映射文件)的连续地址区间对应一个 struct vm_area_struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// include/linux/mm_types.h
struct vm_area_struct {
union {
struct {
unsigned long vm_start; /* VMA 起始地址(包含) */
unsigned long vm_end; /* VMA 结束地址(不包含) */
};
};

struct mm_struct *vm_mm; /* 所属进程的 mm_struct */
pgprot_t vm_page_prot; /* 页保护属性(由 vm_flags 派生) */
const vm_flags_t vm_flags; /* 访问权限与行为标志 */

/* 文件映射 interval tree 节点 */
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;

struct list_head anon_vma_chain; /* RMAP 匿名映射链 */
struct anon_vma *anon_vma; /* 匿名映射反向映射锚点 */

const struct vm_operations_struct *vm_ops; /* 操作函数表 */

unsigned long vm_pgoff; /* 在文件中的页偏移量 */
struct file *vm_file; /* 映射的文件(匿名映射为 NULL) */
void *vm_private_data; /* 驱动私有数据 */
} __randomize_layout;

vm_flags 常用标志(定义在 include/linux/mm.h):

1
2
3
4
5
6
7
#define VM_READ     0x00000001   /* 可读 */
#define VM_WRITE 0x00000002 /* 可写 */
#define VM_EXEC 0x00000004 /* 可执行 */
#define VM_SHARED 0x00000008 /* 共享映射 */
#define VM_MAYREAD 0x00000010 /* 允许设置 VM_READ */
#define VM_MAYWRITE 0x00000020 /* 允许设置 VM_WRITE */
#define VM_MAYEXEC 0x00000040 /* 允许设置 VM_EXEC */

VM_SHAREDVM_WRITE 的组合决定了写时复制(COW)行为:MAP_PRIVATE | PROT_WRITE 的 VMA 不含 VM_SHARED,写入时会触发 COW;MAP_SHARED | PROT_WRITEVM_SHARED,写入直接反映到文件。

2.2 struct vm_operations_struct

每类映射都通过 vm_ops 提供钩子,定义在 include/linux/mm.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct vm_operations_struct {
void (*open)(struct vm_area_struct *area); /* VMA 被复制(fork)时调用 */
void (*close)(struct vm_area_struct *area); /* VMA 被销毁时调用 */
int (*may_split)(struct vm_area_struct *area, unsigned long addr);
int (*mremap)(struct vm_area_struct *area);
int (*mprotect)(struct vm_area_struct *vma, unsigned long start,
unsigned long end, unsigned long newflags);
vm_fault_t (*fault)(struct vm_fault *vmf); /* 缺页时读入数据 */
vm_fault_t (*huge_fault)(struct vm_fault *vmf,
enum page_entry_size pe_size);
vm_fault_t (*map_pages)(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff); /* 预读多页 */
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf); /* 只读页变为可写前调用 */
vm_fault_t (*pfn_mkwrite)(struct vm_fault *vmf);
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);
const char *(*name)(struct vm_area_struct *vma); /* /proc/maps 中的名称 */
};

例如,ext4 文件映射使用 ext4_file_vm_ops,其 fault 钩子会调用 filemap_fault() 从页缓存读取数据;/dev/zero 使用 zero_vm_ops,其 fault 直接返回零页。

2.3 VMA 查找:find_vma

在 Linux 6.1 之后,find_vma 基于 Maple Tree 实现,源码在 mm/mmap.c

1
2
3
4
5
6
7
8
9
// mm/mmap.c  line 1858
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
unsigned long index = addr;

mmap_assert_locked(mm);
return mt_find(&mm->mm_mt, &index, ULONG_MAX);
}
EXPORT_SYMBOL(find_vma);

mt_find 在 Maple Tree 中找到第一个满足 key >= addr 的区间节点。若返回的 VMA 满足 vma->vm_start <= addr < vma->vm_end,说明 addr 落在该 VMA 内;若 addr < vma->vm_start,则 addr 在一个”空洞”里,返回的是下一个 VMA。调用方在缺页异常处理中需要额外验证前者条件。


三、mmap 系统调用路径

3.1 调用链总览

1
2
3
4
5
6
用户态 mmap(2)
→ sys_mmap_pgoff / SYSCALL_DEFINE6(mmap_pgoff)
→ ksys_mmap_pgoff() [mm/mmap.c]
→ vm_mmap_pgoff()
→ do_mmap() [mm/mmap.c]
→ mmap_region() [mm/mmap.c]

3.2 ksys_mmap_pgoff:文件描述符解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// mm/mmap.c  line 1402
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
{
struct file *file = NULL;
unsigned long retval;

if (!(flags & MAP_ANONYMOUS)) {
audit_mmap_fd(fd, flags);
file = fget(fd); /* 从 fd 获取 struct file */
if (!file)
return -EBADF;
if (is_file_hugepages(file)) {
len = ALIGN(len, huge_page_size(hstate_file(file)));
} else if (unlikely(flags & MAP_HUGETLB)) {
retval = -EINVAL;
goto out_fput;
}
} else if (flags & MAP_HUGETLB) {
/* 匿名大页:通过 hugetlbfs 创建内存文件 */
...
}
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:
if (file)
fput(file);
return retval;
}

匿名映射(MAP_ANONYMOUS)时 file 为 NULL,pgoff 对私有匿名映射被设置为 addr >> PAGE_SHIFT(在 do_mmap 中),用于 anon_vma 的索引。

3.3 do_mmap:权限检查与标志计算

do_mmapmm/mmap.c line 1222)是核心逻辑层,负责:

  1. 调用 get_unmapped_area() 找到合适的地址区间(ASLR 随机化在此发生);
  2. 通过 calc_vm_prot_bits / calc_vm_flag_bits 将 POSIX PROT_*MAP_* 转换为内核 VM_* 标志;
  3. 对文件映射验证文件模式(FMODE_READ / FMODE_WRITE);
  4. MAP_SHARED 文件映射:添加 VM_SHARED | VM_MAYSHAREMAP_PRIVATE 文件映射:不添加 VM_SHARED,写入时触发 COW;
  5. 最终调用 mmap_region()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// mm/mmap.c  line 1289(关键片段)
vm_flags = calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
...
/* 文件映射:MAP_SHARED */
case MAP_SHARED:
vm_flags |= VM_SHARED | VM_MAYSHARE;
if (!(file->f_mode & FMODE_WRITE))
vm_flags &= ~(VM_MAYWRITE | VM_SHARED);
fallthrough;
/* 文件映射:MAP_PRIVATE */
case MAP_PRIVATE:
if (!(file->f_mode & FMODE_READ))
return -EACCES;
break;

3.4 mmap_region:创建 VMA

mmap_regionmm/mmap.c line 2547)是真正分配 VMA 的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// mm/mmap.c  line 2547(核心流程)
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma = NULL;
...
/* 尝试合并相邻 VMA(减少碎片) */
if (vma && !vma_expand(&vmi, vma, merge_start, merge_end, vm_pgoff, next)) {
khugepaged_enter_vma(vma, vm_flags);
goto expanded;
}

cannot_expand:
/* 分配新 VMA */
vma = vm_area_alloc(mm);
vma->vm_start = addr;
vma->vm_end = end;
vm_flags_init(vma, vm_flags);
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;

if (file) {
/* 文件映射:调用 file->f_op->mmap() 设置 vm_ops */
vma->vm_file = get_file(file);
error = call_mmap(file, vma); /* file->f_op->mmap(file, vma) */
...
} else if (vm_flags & VM_SHARED) {
/* 匿名共享映射:通过 shmem_zero_setup 创建 tmpfs 文件 */
error = shmem_zero_setup(vma);
} else {
/* 匿名私有映射:vma_set_anonymous 将 vm_ops 设为 NULL */
vma_set_anonymous(vma);
}
...
}

MAP_PRIVATE vs MAP_SHARED 的本质区别

特性 MAP_PRIVATE MAP_SHARED
vm_flags 无 VM_SHARED 含 VM_SHARED
写时行为 COW:产生进程私有副本 直接修改底层页,其他进程可见
文件刷盘 修改不回写文件 修改最终通过 writeback 回写
匿名映射 vm_ops = NULL 通过 shmem 实现共享

四、多级页表结构

4.1 x86-64 五级页表层级

x86-64 采用分级页表将 57 位(五级)或 48 位(四级)虚拟地址翻译为物理地址。各级索引位划分如下(五级,arch/x86/include/asm/pgtable_64_types.h):

1
2
3
4
5
6
7
虚拟地址 [56:0](57 位):
bits[56:48] → PGD 索引(9 bit,512 项)
bits[47:39] → P4D 索引(9 bit,512 项)
bits[38:30] → PUD 索引(9 bit,512 项) PUD_SHIFT = 30
bits[29:21] → PMD 索引(9 bit,512 项) PMD_SHIFT = 21
bits[20:12] → PTE 索引(9 bit,512 项)
bits[11:0] → 页内偏移(12 bit,4 KB)

对应的类型定义(arch/x86/include/asm/pgtable_64_types.h):

1
2
3
4
5
6
7
8
9
10
11
typedef unsigned long  pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long p4dval_t;
typedef unsigned long pgdval_t;

typedef struct { pteval_t pte; } pte_t;
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { p4dval_t p4d; } p4d_t;
typedef struct { pgdval_t pgd; } pgd_t; /* 定义在 pgtable_types.h */

使用强类型结构体而非裸 unsigned long,可以让编译器在混用不同层级页表项时产生类型错误,是内核防御性编程的典型实践。

4.2 页表项位域

x86-64 每个页表项(PTE)为 64 位,关键位定义在 arch/x86/include/asm/pgtable_types.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define _PAGE_BIT_PRESENT   0   /* P:页存在 */
#define _PAGE_BIT_RW 1 /* R/W:可写 */
#define _PAGE_BIT_USER 2 /* U/S:用户态可访问 */
#define _PAGE_BIT_PWT 3 /* PWT:页写直通 */
#define _PAGE_BIT_PCD 4 /* PCD:禁用页缓存 */
#define _PAGE_BIT_ACCESSED 5 /* A:已访问(CPU 置位) */
#define _PAGE_BIT_DIRTY 6 /* D:已写脏(CPU 置位) */
#define _PAGE_BIT_PSE 7 /* PS:大页(PMD=2MB / PUD=1GB) */
#define _PAGE_BIT_GLOBAL 8 /* G:全局页(TLB 切换不刷新) */
#define _PAGE_BIT_NX 63 /* NX/XD:不可执行(需 EFER.NXE=1) */

#define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
#define _PAGE_RW (_AT(pteval_t, 1) << _PAGE_BIT_RW)
#define _PAGE_USER (_AT(pteval_t, 1) << _PAGE_BIT_USER)
#define _PAGE_ACCESSED (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)
#define _PAGE_PSE (_AT(pteval_t, 1) << _PAGE_BIT_PSE)
#define _PAGE_NX (_AT(pteval_t, 1) << _PAGE_BIT_NX)

典型用户页保护配置(arch/x86/include/asm/pgtable_types.h):

1
2
3
4
/* 可读不可写不可执行的用户共享页 */
#define PAGE_SHARED __pg(__PP|__RW|_USR|___A|__NX|0|0|0)
/* 只读可执行的代码页 */
#define PAGE_READONLY_EXEC __pg(__PP|0|_USR|___A|0|0|0|0)

4.3 页表遍历宏

include/linux/pgtable.h 提供了标准的多级页表遍历接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 从 mm->pgd 获取 PGD 项 */
#define pgd_offset(mm, address) pgd_offset_pgd((mm)->pgd, (address))

/* 依次向下索引各级 */
static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address) {
return pud_pgtable(*pud) + pmd_index(address);
}
static inline pud_t *pud_offset(p4d_t *p4d, unsigned long address) {
return p4d_pgtable(*p4d) + pud_index(address);
}

/* 非高端内存场景下 pte_offset_map 等价于 pte_offset_kernel */
#define pte_offset_map(dir, address) pte_offset_kernel((dir), (address))

一次完整的地址翻译调用链(见 include/linux/pgtable.h line 153):

1
2
3
4
5
6
7
8
static inline pmd_t *pmd_off(struct mm_struct *mm, unsigned long va) {
return pmd_offset(
pud_offset(
p4d_offset(
pgd_offset(mm, va), va),
va),
va);
}

五、缺页异常处理

5.1 入口:exc_page_fault

CPU 触发 #PF 异常后,进入 IDT 注册的处理函数(arch/x86/mm/fault.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// arch/x86/mm/fault.c  line 1546
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
unsigned long address = read_cr2(); /* 从 CR2 读取发生缺页的线性地址 */
irqentry_state_t state;

prefetchw(&current->mm->mmap_lock); /* 预取锁,减少缓存 miss */

if (kvm_handle_async_pf(regs, (u32)address))
return;

state = irqentry_enter(regs);
instrumentation_begin();
handle_page_fault(regs, error_code, address);
instrumentation_end();
...
}

handle_page_fault 判断缺页地址属于内核空间还是用户空间,用户空间路径调用 do_user_addr_fault,最终到达通用的 handle_mm_fault

5.2 __handle_mm_fault:分配页表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// mm/memory.c  line 4997
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
struct vm_fault vmf = {
.vma = vma,
.address = address & PAGE_MASK,
.flags = flags,
.pgoff = linear_page_index(vma, address),
.gfp_mask = __get_fault_gfp_mask(vma),
};
struct mm_struct *mm = vma->vm_mm;
pgd_t *pgd;
p4d_t *p4d;

/* 自顶向下分配缺失的页表页 */
pgd = pgd_offset(mm, address);
p4d = p4d_alloc(mm, pgd, address);
if (!p4d)
return VM_FAULT_OOM;

vmf.pud = pud_alloc(mm, p4d, address);
if (!vmf.pud)
return VM_FAULT_OOM;

/* 检查是否可用 1 GB 大页 */
if (pud_none(*vmf.pud) &&
hugepage_vma_check(vma, vm_flags, false, true, true)) {
ret = create_huge_pud(&vmf);
if (!(ret & VM_FAULT_FALLBACK))
return ret;
}

vmf.pmd = pmd_alloc(mm, vmf.pud, address);
if (!vmf.pmd)
return VM_FAULT_OOM;

/* 检查是否可用 2 MB 透明大页 */
if (pmd_none(*vmf.pmd) &&
hugepage_vma_check(vma, vm_flags, false, true, true)) {
ret = create_huge_pmd(&vmf);
if (!(ret & VM_FAULT_FALLBACK))
return ret;
}

return handle_pte_fault(&vmf);
}

5.3 handle_pte_fault:分发具体缺页类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// mm/memory.c  line 4893
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
pte_t entry;

if (unlikely(pmd_none(*vmf->pmd))) {
/* PMD 为空,PTE 尚未分配,延迟到具体 fault handler */
vmf->pte = NULL;
} else {
vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
vmf->orig_pte = *vmf->pte;
barrier();
if (pte_none(vmf->orig_pte)) {
pte_unmap(vmf->pte);
vmf->pte = NULL;
}
}

if (!vmf->pte)
return do_pte_missing(vmf); /* PTE 缺失:匿名页或文件页 */

if (!pte_present(vmf->orig_pte))
return do_swap_page(vmf); /* 页在 swap,需换入 */

if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf); /* NUMA 迁移提示 */

/* PTE 存在但写保护 → COW */
if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
if (!pte_write(entry))
return do_wp_page(vmf);
}
...
}

do_pte_missing 会进一步区分:

1
2
3
4
// mm/memory.c  line 3640(do_pte_missing 内部)
if (vma->vm_ops)
return do_fault(vmf); /* 文件映射缺页 */
return do_anonymous_page(vmf); /* 匿名映射缺页 */

5.4 匿名缺页:do_anonymous_page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// mm/memory.c  line 4031
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct folio *folio;
pte_t entry;

/* 读缺页:映射零页(zero page),避免分配物理页 */
if (!(vmf->flags & FAULT_FLAG_WRITE) && !mm_forbids_zeropage(vma->vm_mm)) {
entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
vma->vm_page_prot));
/* ... 安装 PTE 后返回 */
goto setpte;
}

/* 写缺页:分配并清零新物理页 */
if (unlikely(anon_vma_prepare(vma)))
goto oom;
folio = vma_alloc_zeroed_movable_folio(vma, vmf->address);
if (!folio)
goto oom;

entry = mk_pte(&folio->page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));

vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
vmf->address, &vmf->ptl);
/* 安装 PTE */
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
...
}

零页优化:进程第一次读未初始化匿名内存时,内核将虚拟地址映射到一个全局共享的只读零页(my_zero_pfn),物理内存实际零分配。当写入发生时,才触发 COW 分配真实物理页。

5.5 文件缺页:__do_fault

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// mm/memory.c  line 4150
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret;

/* 预分配 PTE 页表页,避免在持锁情况下分配内存导致死锁 */
if (pmd_none(*vmf->pmd) && !vmf->prealloc_pte) {
vmf->prealloc_pte = pte_alloc_one(vma->vm_mm);
if (!vmf->prealloc_pte)
return VM_FAULT_OOM;
}

/* 调用 VMA 的 fault 钩子(如 filemap_fault)从页缓存读取数据 */
ret = vma->vm_ops->fault(vmf);
...
return ret;
}

对于 ext4/xfs 等文件系统,vm_ops->fault 最终调用 filemap_fault,先在 page cache 中查找,命中则直接返回(minor fault);否则提交 I/O 并等待(major fault)。

5.6 写时复制:do_wp_page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// mm/memory.c  line 3324
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = NULL;

vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);

/* 共享映射:标记页可写,通知文件系统 */
if (vma->vm_flags & (VM_SHARED | VM_MAYSHARE)) {
if (!vmf->page)
return wp_pfn_shared(vmf);
return wp_page_shared(vmf);
}

/* 私有映射:若页面为进程独占,可直接复用 */
if (folio && folio_test_anon(folio)) {
if (PageAnonExclusive(vmf->page))
goto reuse; /* 跳过复制,直接设置写权限 */
}

/* 通用路径:分配新页,复制数据,安装新 PTE */
return wp_page_copy(vmf);
}

wp_page_copy 分配一个新的物理页,调用 __wp_page_copy_user 复制内容,然后将原来的只读 PTE 替换为指向新页的可写 PTE,并 flush TLB 使旧映射失效。


六、TLB 管理

6.1 TLB 的作用

TLB(Translation Lookaside Buffer)是 CPU 内部对最近页表查找结果的缓存,避免每次内存访问都遍历多级页表(每级一次内存读取)。x86-64 通常有独立的 L1 iTLB / dTLB 以及共享的 L2 TLB。

当内核修改页表(unmap、mprotect、mremap 等)后,必须使相关 TLB 条目失效(TLB shootdown),否则其他 CPU 可能使用过时的映射。

6.2 TLB 刷新接口

1
2
3
4
5
6
7
8
/* 刷新整个进程的 TLB(进程退出、exec 等) */
flush_tlb_mm(mm);

/* 刷新指定地址范围的 TLB(munmap、mprotect) */
flush_tlb_range(vma, start, end);

/* 刷新单个页面 */
flush_tlb_page(vma, address);

x86 的 SMP TLB shootdown 通过 IPI(Inter-Processor Interrupt)通知其他 CPU 执行 INVLPG 指令(针对单页)或 MOV CR3(针对整个地址空间)。

6.3 PCID 优化

传统 MOV CR3 切换进程时会完全刷新 TLB,代价高昂。Intel Haswell 以后支持 PCID(Process Context Identifier),允许 TLB 条目携带 12 位的进程标识,切换时通过设置 CR3 的第 63 位为 0 来保留其他进程的 TLB 条目:

  • 内核在 switch_mm_irqs_off 中维护 PCID → mm 的映射;
  • 每个 CPU 可缓存最多 6 个活跃 PCID(内核实现中);
  • Meltdown 修复引入了内核/用户 PCID 对(Kaiser/PTI),切换额外开销约 100 ns。

6.4 透明大页(THP)

THP(Transparent Huge Pages)允许匿名映射自动使用 2 MB PMD 大页,由 __handle_mm_fault 中的 create_huge_pmd 触发,实际调用 do_huge_pmd_anonymous_page

  1. 分配 512 个连续物理页(order-9 compound page);
  2. 设置 PMD 项的 _PAGE_PSE 位,指向 2 MB 物理基地址;
  3. TLB 条目覆盖 2 MB,减少 TLB miss 次数;
  4. 写时发生 COW 时通过 __split_huge_pmd 降级为 4 KB 页。

七、诊断方法

7.1 /proc/PID/maps

/proc/PID/maps 列出进程所有 VMA,每行对应一个 vm_area_struct

1
2
3
4
5
6
7
地址范围               权限   偏移量   设备  inode   文件路径
7f3a4c000000-7f3a4c021000 r--p 00000000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c021000-7f3a4c176000 r-xp 00021000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c176000-7f3a4c1c4000 r--p 00176000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c1c4000-7f3a4c1c5000 r--p 001c3000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c1c5000-7f3a4c1c8000 rw-p 001c4000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7fff8e200000-7fff8e221000 rw-p 00000000 00:00 0 [stack]

权限字段含义:r(VM_READ)、w(VM_WRITE)、x(VM_EXEC)、p(MAP_PRIVATE)/ s(MAP_SHARED)。

7.2 /proc/PID/smaps

smapsmaps 基础上提供每个 VMA 的内存统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
7f3a4c021000-7f3a4c176000 r-xp 00021000 fd:01 1234567  /lib/.../libc.so.6
Size: 1364 kB # VMA 虚拟大小
KernelPageSize: 4 kB # 内核页大小
MMUPageSize: 4 kB # MMU 页大小
Rss: 892 kB # 常驻内存(已映射物理页)
Pss: 124 kB # 按共享比例分摊的常驻内存
Shared_Clean: 892 kB # 共享且未脏页
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 892 kB # 近期被访问的页
Anonymous: 0 kB # 匿名页数量
LazyFree: 0 kB # madvise(MADV_FREE) 标记待回收
AnonHugePages: 0 kB # 透明大页匿名部分
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Swap: 0 kB # 换出到 swap 的页

7.3 /proc/PID/pagemap

pagemap 是虚拟地址到物理帧(PFN)的映射接口,每个虚拟页对应一个 8 字节条目:

  • bit 63:页存在(Present)
  • bit 62:页换出到 swap
  • bit 61:文件映射或共享匿名页
  • bits 54:0:若存在,为 PFN;若换出,为 swap 偏移

示例读取脚本:

1
2
3
4
5
6
7
8
9
10
11
12
# 查看进程虚拟地址 0x400000 对应的物理帧号
python3 -c "
import struct, os, sys
pid = int(sys.argv[1])
va = 0x400000
with open(f'/proc/{pid}/pagemap', 'rb') as f:
f.seek((va >> 12) * 8)
entry = struct.unpack('Q', f.read(8))[0]
present = (entry >> 63) & 1
pfn = entry & ((1 << 55) - 1)
print(f'present={present}, pfn=0x{pfn:x}, phys=0x{pfn << 12:x}')
" <PID>

7.4 /proc/PID/status 内存字段

1
2
3
4
5
6
7
VmPeak:   102400 kB   # 历史峰值虚拟内存
VmSize: 98304 kB # 当前虚拟内存大小
VmRSS: 12288 kB # 常驻物理内存(= RssAnon + RssFile + RssShmem)
RssAnon: 8192 kB # 匿名页 RSS
RssFile: 4096 kB # 文件映射 RSS
RssShmem: 0 kB # 共享内存 RSS
VmSwap: 1024 kB # 换出到 swap 的大小

7.5 pmap -x

1
pmap -x <PID>

输出中 RSS 列为常驻集大小,Dirty 列为脏页大小(写入但未回写),Mapping 列为文件名或 [ anon ]/ [ stack ]

7.6 bpftrace 追踪缺页

1
2
3
4
5
6
7
8
9
10
11
12
# 追踪某进程的缺页异常,打印触发地址和故障类型
bpftrace -e '
kprobe:handle_mm_fault /pid == $1/ {
printf("fault addr=0x%lx flags=0x%x comm=%s\n",
arg1, arg2, comm);
}' <PID>

# 追踪 do_anonymous_page(匿名缺页)调用频率
bpftrace -e 'kprobe:do_anonymous_page { @[comm] = count(); }'

# 追踪 do_wp_page(写时复制)
bpftrace -e 'kprobe:do_wp_page { @[comm] = count(); }'

7.7 perf 统计缺页频率

1
2
3
4
5
6
# 统计目标进程的缺页次数(运行 5 秒)
perf stat -e page-faults,major-faults,minor-faults -p <PID> -- sleep 5

# 采样 page-faults 事件,生成火焰图
perf record -e page-faults -g -p <PID> -- sleep 10
perf script | stackcollapse-perf.pl | flamegraph.pl > pagefault.svg

minor-faults(次缺页):页表项缺失但数据已在内存(如零页映射、文件已在 page cache),无需 I/O;major-faults(主缺页):需要从磁盘读取数据,延迟高达毫秒级。


八、源码阅读路线

理解本文涉及的机制,建议按以下顺序阅读源码:

文件 关键内容
include/linux/mm_types.h mm_structvm_area_structvm_fault 等核心数据结构
include/linux/mm.h vm_operations_struct、VM_* 标志、find_vma/find_vma_intersection 等内联函数
include/linux/pgtable.h pgd_offsetpmd_offsetpte_offset_map 宏与页表遍历函数
arch/x86/include/asm/pgtable_types.h x86 页表项位定义、pgd_t/pte_t 类型
arch/x86/include/asm/pgtable_64_types.h x86-64 各级页表移位常量(PGDIR_SHIFTPMD_SHIFT 等)
mm/mmap.c do_mmapmmap_regionfind_vmavma_merge 实现
mm/memory.c handle_mm_faulthandle_pte_faultdo_anonymous_pagedo_faultdo_wp_page
arch/x86/mm/fault.c x86 缺页异常入口 exc_page_faulthandle_page_fault

总结

虚拟内存是现代操作系统最重要的抽象之一。Linux 通过 mm_struct 和 VMA 体系将进程的虚拟地址空间划分为具有语义的区段,通过多级页表(四级/五级)在硬件层面实现地址翻译,通过缺页异常机制实现按需分页(demand paging)、写时复制(COW)和内存映射文件(mmap)。TLB 则是整个体系的性能关键,PCID 和 Huge Page 是两种重要的 TLB 优化手段。

理解这套机制不仅有助于写出更高效的用户态程序(合理使用 mmap、mlock、madvise),也是分析内存泄漏、性能瓶颈、OOM 问题的基础。后续文章将深入讨论物理内存分配器(Buddy System 与 SLAB)以及内存回收(Kswapd 与 LRU 算法)。

在上一篇文章中,我们梳理了 Linux Buddy 系统如何以页为单位管理物理内存。然而内核中大量数据结构(task_structinodedentry……)都远小于一页(4 KiB),若每次都向 Buddy 系统申请整页,会造成严重的内部碎片。为此 Linux 在 Buddy 之上引入了 Slab 分配器层,专为固定大小的内核对象服务。

本文基于 Linux 6.4-rc1commit ac9a78681b92)源码,深入剖析三种 Slab 实现的设计哲学、SLUB 的核心数据结构与分配/释放路径,并延伸到 vmalloc 虚拟连续内存分配。

Read more »

物理内存是操作系统最基础的资源之一,而 Linux 内核的内存管理子系统正是围绕着如何高效、可靠地组织与分配这些物理页帧展开的。本文基于 Linux 6.4-rc1 源码,系统性地剖析物理内存的组织模型、zone 水位机制、伙伴分配器的核心算法,以及 Per-CPU 页帧缓存、GFP 标志体系和 OOM Killer 的工作原理,并给出实用的诊断方法。

Read more »

BPF CPU Usage High issue

Objective

Problem Statement

https://docs.google.com/document/d/1HlvGBoT8gL3LToCIB8KH88EG4toh7YiiQnNApNyosOo/edit

https://docs.google.com/document/d/1ibjJWueVClKet0by0fWbFHz2vEfj3viL5ss8byJuuA4/edit#heading=h.ubx3xrz6e41r

We met the bpf cpu high issue many times after the cilium conntrack table was full. It impacts the L4/L7 traffic reliability and also impacts the reliability confidence about cilium and the cilium rollout on tlb/gateway nodes.

We have taken some measures to try to avoid the conntrack table from getting full. However, if the load further increases or if the garbage collection is delayed for some reason, the conntrack table can still become full, which will trigger this issue. Therefore, we aim to address this problem fundamentally.

Read more »

The disk-quota controller is a Kubernetes component that manages disk quotas for various resources in a Kubernetes cluster, including:

  1. emptyDir volumes
  2. Container root directories
  3. Container log directories
Read more »

网络问题是线上故障中最难定位的一类。症状千变万化——P99 延迟突然抖动、某个服务间歇性超时、容器之间偶发丢包——而根因可能藏在协议栈的任何一层:网卡驱动的 ring buffer 溢出、内核 backlog 队列打满、TCP 重传引发的滑动窗口收缩、iptables 规则误命中,乃至 NUMA 拓扑导致的中断不均衡。本文是本系列第十篇,聚焦于工具链与实战:从 ss 的每个输出字段到 bpftrace 脚本,从 /proc/net 的原始数字到三个完整的排查案例,构建一套系统化的网络诊断方法论。

Read more »

在前几篇的基础上,本文聚焦于 Linux 网络栈中性能极限的挖掘:从硬件卸载、多队列扩展,到内核旁路(Kernel Bypass)技术,再到发送路径的零拷贝优化,最后落地到实际的诊断工具链。所有代码片段均取自 Linux 6.4-rc1 源码树,并附有源文件路径与行号,供读者对照阅读。

Read more »
0%