Linux 进程管理深度剖析(四):cgroup v2 资源控制内核实现
容器技术的核心在于资源隔离与限制,而这一能力的底层支撑正是 Linux 内核的 cgroup(Control Group)机制。本文基于 Linux 6.4-rc1 源码,深入剖析 cgroup v2 的内核实现,涵盖统一层次框架、Memory/CPU/IO/PID 四大控制器的核心数据结构与关键路径,以及 cgroup namespace 与诊断方法。
容器技术的核心在于资源隔离与限制,而这一能力的底层支撑正是 Linux 内核的 cgroup(Control Group)机制。本文基于 Linux 6.4-rc1 源码,深入剖析 cgroup v2 的内核实现,涵盖统一层次框架、Memory/CPU/IO/PID 四大控制器的核心数据结构与关键路径,以及 cgroup namespace 与诊断方法。
在 Linux 进程管理体系中,信号(Signal)是最古老也最核心的异步通知机制,而进程间通信(IPC)则是多进程协作的基础设施。本文基于 Linux 6.4-rc1 内核源码,深入剖析信号的数据结构、发送路径、处理流程,以及 pipe、POSIX 消息队列、System V IPC、UNIX Domain Socket 和 futex 的内核实现。理解这些机制,是系统编程、性能调优和内核调试的必备基础。
信号集的底层表示是一个位图数组。x86-64 下 _NSIG = 64,_NSIG_BPW = 64,因此 _NSIG_WORDS = 1,整个集合用一个 64 位整数表示。
1 | // include/linux/signal.h |
信号编号从 1 开始,因此位操作时先减 1。信号 1-31 是传统不可靠信号(POSIX 标准信号),32-64 是实时信号(SIGRTMIN=34, SIGRTMAX=64)。
1 | // include/linux/signal_types.h |
sighand_struct 是线程组内所有线程共享的,当调用 sigaction() 修改某个信号的处理器时,整个线程组都受影响。
1 | // include/linux/sched/signal.h |
每个 task_struct 还拥有自己私有的 task->pending,用于接收 tgkill/tkill 等指定线程的信号。信号投递时,shared_pending 由线程组中任意一个线程处理,task->pending 只能由目标线程处理。
1 | kill(pid, sig) |
进程组信号发送的核心实现:
1 | // kernel/signal.c:1453 |
1 | // kernel/signal.c:1078 |
可靠信号 vs 不可靠信号的关键差异在第 1073-1076 行的 legacy_queue():
1 | static inline bool legacy_queue(struct sigpending *signals, int sig) |
信号 1-31(sig < SIGRTMIN=34)如果 pending 位图已置位,新的投递会被静默丢弃。而实时信号(34-64)不受此约束,每次都会分配新的 sigqueue 节点入链表,从而实现多次投递的可靠排队。
1 | // kernel/signal.c:763 |
TIF_SIGPENDING 标志设置后,目标进程在下次从内核返回用户态时(系统调用返回或中断返回)会检查并处理信号。
tkill(tid, sig) 绕过线程组,直接向特定 tid 发送信号,信号进入 task->pending(私有队列)而非 signal->shared_pending。这对于多线程程序中精确控制信号投递至关重要,也是 pthread_kill() 的内核实现基础。
信号不是异步立即执行的,而是在进程从内核态返回用户态的”安全点”处理:
syscall_exit_to_user_mode() → exit_to_user_mode_loop() → 检查 TIF_SIGPENDINGx86-64 的入口点:
1 | // arch/x86/kernel/signal.c:302 |
get_signal() 是信号出队的核心函数(kernel/signal.c:2642),其工作流程:
TIF_SIGPENDING 标志try_to_freeze() 处理冻结请求dequeue_signal() 从 task->pending 或 signal->shared_pending 取出最高优先级信号SIG_DFL 且是致命信号,执行默认行为(SIGKILL 直接在此终止进程)ksignal(含信号编号、info、处理器)SIGKILL 和 SIGSTOP 在此处被特殊处理——它们永远不会到达用户态信号处理器:
1 | // kernel/signal.c:2709 |
当信号有用户态处理器时,handle_signal() 负责在用户栈上构建信号栈帧,并修改 pt_regs 使处理器返回用户态时跳转到信号处理函数:
1 | // arch/x86/kernel/signal.c:224 |
setup_rt_frame() 在用户栈上压入 struct rt_sigframe:
1 | // arch/x86/include/asm/sigframe.h:59 |
struct ucontext 内嵌 struct sigcontext,后者保存了所有通用寄存器(rax/rbx/…/rsp/rip/rflags)以及 FPU 状态指针。信号处理函数执行完毕后,调用 rt_sigreturn 系统调用,内核从 uc 中恢复完整的 CPU 状态,进程无缝回到被中断的执行点。
1 | // include/linux/pipe_fs_i.h:58 |
默认 ring_size = PIPE_DEF_BUFFERS = 16,每槽一个 4KB 页,管道默认容量 64KB。head 和 tail 不做掩码,允许自然溢出环绕,访问时用 index & (ring_size - 1) 取模。
1 | // fs/pipe.c:416(关键片段) |
1 | /proc/sys/fs/pipe-max-size # 默认 1048576(1MB),非 root 用户上限 |
通过 fcntl(fd, F_SETPIPE_SZ, size) 可以动态调整单个管道容量。内核将请求的 size 向上取整到 2 的幂(不超过 pipe_max_size),然后 krealloc 扩展 bufs 数组。
splice(2) 系统调用利用管道的 pipe_buffer 结构实现文件到 socket 的零拷贝:数据以页引用的方式在管道内传递,不需要 memcpy 到用户缓冲区。vmsplice 可以将用户态内存页”赠送”给管道(PIPE_BUF_FLAG_GIFT),配合 splice 实现用户态到 socket 的全程零拷贝传输。
POSIX 消息队列挂载在独立的 mqueue 文件系统(基于 tmpfs)。每个消息队列对应一个 mqueue_inode_info:
1 | // ipc/mqueue.c:134 |
1 | // ipc/mqueue.c:61 |
mq_send() 时,先在红黑树中查找对应优先级节点,若不存在则创建新节点插入;mq_receive() 直接取 msg_tree_rightmost(最右节点即最高优先级),时间复杂度 O(log P)(P 为不同优先级数量),同优先级内 FIFO 顺序。
1 | // ipc/sem.c:114 |
semop() 的原子性保证:操作执行前检查整个操作集合能否同时满足(”all-or-nothing”语义),不能满足则整体入 pending_alter 睡眠等待。内核还支持 SEM_UNDO 标志,进程退出时自动回滚所有 semop 操作,防止死锁。
1 | // ipc/msg.c:49 |
msgsnd() 将消息附加到 q_messages 链表尾部;msgrcv() 支持按 msgtype 过滤接收(正数接收指定类型,负数接收类型绝对值最小的,0 接收任意最早消息)。
shmget()/shmat() 基于 tmpfs(shmem)实现。shmget() 在 shmem 文件系统上创建一个匿名文件,shmat() 调用 do_mmap() 将该文件的页面映射到进程虚拟地址空间。多个进程 shmat() 同一个 shmid,它们的虚拟地址映射到相同的物理页(零拷贝),通过共享页表实现数据直接共享。
1 | // include/net/af_unix.h:56 |
1 | // net/unix/af_unix.c:2160 |
UNIX Domain Socket 的关键优势:数据通过 sk_buff 在内核内存中传递,完全绕过网络协议栈(无 TCP/IP 头部处理、无校验和计算、无路由查找)。与管道相比,它支持双向通信和数据报语义(SOCK_DGRAM)。
UNIX Domain Socket 最独特的能力是通过 sendmsg() + SCM_RIGHTS 辅助消息传递文件描述符。发送端将 fd 列表附加到 struct scm_fp_list,内核在 unix_scm_to_skb() 中调用 get_file() 增加文件的引用计数,将 struct file * 指针存入 skb->cb(即 UNIXCB(skb).fp)。接收端通过 recvmsg() 取出后,内核在当前进程的文件描述符表中安装新的 fd,指向同一个 struct file 对象,实现跨进程 fd 共享。这是容器运行时、Chrome 沙箱和 systemd socket activation 等系统的核心机制。
futex(Fast Userspace muTEX)的核心洞察:在无竞争时完全在用户态完成,仅在竞争时陷入内核。glibc 的 pthread_mutex_lock() 底层就是 futex。
1 | // kernel/futex/futex.h:45 |
内核维护一个全局哈希表 futex_queues,以 futex 变量的物理地址(对于 shared futex)或虚拟地址(对于 private futex)为 key 哈希到对应的 bucket。
1 | // kernel/futex/waitwake.c:632 |
快速路径(用户态完成):pthread_mutex_lock() 用 cmpxchg 原子地尝试将 futex 值从 0 改为线程 TID;成功则无需系统调用。只有 cmpxchg 失败(有竞争)时才调用 sys_futex(FUTEX_WAIT, ...)。
1 | // kernel/futex/waitwake.c:143 |
futex_hb_waiters_pending() 检查 hb->waiters 计数,若为 0 则跳过所有锁操作——这是 futex 在无竞争时保持低开销的关键优化。
1 | pthread_mutex_lock(): |
1 | # 查看所有信号编号与名称 |
1 | # 查看所有 System V IPC 资源(信号量/消息队列/共享内存) |
信号位图是 64 位十六进制数,每个 bit 对应一个信号编号(bit N = 信号 N+1)。例如 SigIgn: 0000000000001000:
1 | 0x1000 = 0001 0000 0000 0000 (binary) |
守护进程通常会忽略 SIGPIPE 和 SIGHUP,这在 SigIgn 中可以直接观察到。
| 机制 | 方向 | 持久性 | 传输效率 | 主要用途 |
|---|---|---|---|---|
| 信号 | 单向通知 | 非持久 | 极低(仅编号) | 异步事件通知 |
| pipe | 单向数据流 | 进程生命期 | 高(ring buffer) | 父子进程数据流 |
| UNIX Socket | 双向 | 进程生命期 | 高(内存拷贝) | 本机 C/S 通信、fd 传递 |
| POSIX MQ | 双向 | 内核持久 | 中(优先级排序) | 有序异步消息传递 |
| SysV 消息队列 | 双向 | 内核持久 | 中 | 传统 IPC |
| 共享内存 | 双向 | 内核持久 | 极高(零拷贝) | 大数据量共享 |
| futex | 同步原语 | 非持久 | 极高(无竞争零系统调用) | 互斥锁/条件变量 |
Linux 信号与 IPC 机制的设计体现了内核”机制与策略分离”的哲学:
sigpending 位图 + sigqueue 链表分别处理可靠性需求,借助 TIF_SIGPENDING 在内核返回用户态的安全点触发;pipe_buffer 数组为核心,支持页级零拷贝(splice),默认容量 64KB 可动态调整;sk_buff 在内核内完成内存传递,配合 SCM_RIGHTS 实现跨进程 fd 共享;理解这些机制的内核实现,能够帮助我们在系统设计、性能调优和问题排查时做出更明智的技术选择。
kernel/signal.c — 信号发送、处理核心include/linux/signal_types.h — 信号数据结构定义include/linux/sched/signal.h — sighand_struct / signal_structarch/x86/kernel/signal.c — x86-64 信号帧构建arch/x86/include/asm/sigframe.h — struct rt_sigframeinclude/linux/pipe_fs_i.h — pipe_inode_info / pipe_bufferfs/pipe.c — 管道读写实现kernel/futex/core.c — futex 哈希表kernel/futex/waitwake.c — futex_wait / futex_wakekernel/futex/futex.h — futex_hash_bucketipc/sem.c — System V 信号量ipc/msg.c — System V 消息队列ipc/mqueue.c — POSIX 消息队列net/unix/af_unix.c — UNIX Domain Socketinclude/net/af_unix.h — struct unix_sock本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均直接来自内核源文件。主要参考文件:
kernel/sched/fair.c、kernel/sched/sched.h、kernel/sched/core.c、kernel/sched/rt.c。
Linux 调度器是内核中最核心也最复杂的子系统之一。自 2.6.23 版本引入 CFS(Completely Fair Scheduler,完全公平调度器)以来,它已成为处理普通进程(SCHED_NORMAL、SCHED_BATCH)调度的主要机制。本文将从数据结构出发,深入分析 CFS 的每一个核心算法,揭示”公平”背后的工程实现。
struct sched_class)Linux 调度器采用面向对象的设计,通过 struct sched_class 抽象出调度策略接口。每种调度策略实现一套方法,调度器核心代码通过函数指针调用。
1 | // kernel/sched/sched.h:2169 |
各调度类按优先级从高到低排列,链接器脚本将它们放置在连续内存区域,for_each_class 宏按顺序遍历:
1 | stop > dl > rt > fair > idle |
SCHED_DEADLINE),按截止时间调度实时任务SCHED_FIFO、SCHED_RR),基于优先级位图SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE),本文主角1 | // kernel/sched/sched.h:2272 |
struct rq每个 CPU 有一个全局运行队列 struct rq,所有调度类的队列都嵌入其中:
1 | // kernel/sched/sched.h:957 |
通过 cpu_rq(cpu) 宏获取指定 CPU 的 rq,通过 this_rq() 获取当前 CPU 的 rq。
struct cfs_rqCFS 的核心数据结构,管理所有可运行的 CFS 调度实体:
1 | // kernel/sched/sched.h:550 |
min_vruntime 是一个单调递增的基准线,代表队列中”最公平”的进度时间点,用于新进程 vruntime 初始化和跨 CPU 迁移时的规范化。
struct sched_entity每个普通进程(以及组调度中的任务组)都有一个 struct sched_entity,嵌入在 task_struct 中:
1 | // include/linux/sched.h:549 |
vruntime 是 CFS 的核心字段,代表该实体”虚拟时间维度”上的运行进度。CFS 始终选择 vruntime 最小的实体运行,以维护公平性。
CFS 的核心思想是:如果有 N 个相同权重的进程,每个进程应该获得 1/N 的 CPU 时间。为了跟踪”公平进度”,引入 vruntime(虚拟运行时间):
1 | vruntime += delta_exec × (NICE_0_LOAD / weight) |
对于 nice=0 的进程(权重 1024),vruntime 增长速度等于真实时间。权重越大(nice 值越低),vruntime 增长越慢,因此会更频繁地被调度;权重越小(nice 值越高),vruntime 增长越快,被调度的频率越低。
sched_prio_to_weightnice 值到权重的映射通过预计算表实现,相邻 nice 值之间权重比约为 1.25:
1 | // kernel/sched/core.c:11459 |
nice=0 对应权重 1024(NICE_0_LOAD),这是归一化的基准值。nice=-20 的权重(88761)约是 nice=0(1024)的 86 倍,意味着 nice=-20 的进程能获得近 86 倍于 nice=0 进程的 CPU 时间。
calc_delta_fair:vruntime 增量计算1 | // kernel/sched/fair.c:709 |
__calc_delta 的计算公式为:
1 | delta_vruntime = delta_exec × NICE_0_LOAD / weight |
为了避免浮点运算,内核通过预计算的逆权重表(sched_prio_to_wmult)将除法转化为乘法:delta × wmult >> 32。
update_curr:实时更新 vruntime每次时钟中断、调度切换时都会调用 update_curr 更新当前进程的运行统计:
1 | // kernel/sched/fair.c:897 |
update_min_vruntime 维护 cfs_rq->min_vruntime 单调递增:它取当前运行实体的 vruntime 和红黑树中最左节点的 vruntime 二者中的较小值,但保证不回退。
CFS 使用红黑树(struct rb_root_cached)以 vruntime 为键组织所有可运行实体。rb_root_cached 额外缓存了最左节点,使得 O(1) 时间内找到 vruntime 最小的实体。
1 | // kernel/sched/fair.c:643 |
__entity_less 比较两个实体的 vruntime,确保红黑树按 vruntime 升序排列。最左节点始终是 vruntime 最小的实体,即最应被调度的进程。
注意:当前正在运行的实体(cfs_rq->curr)不在红黑树中,只有就绪但未运行的实体才在树中。当前实体被切换出去时,才通过 put_prev_entity 重新插入树中。
pick_next_entity:选择下一个运行实体1 | // kernel/sched/fair.c:5084 |
这里引入了三个”buddy”机制:
sched_yield 的实体,本轮跳过wakeup_preempt_entity 判断候选实体相对于最优实体的 vruntime 差距是否在允许范围内(wakeup_gran,基于 sysctl_sched_wakeup_granularity)。
enqueue_task_fair:进程变为 RUNNABLE 时入队当进程从睡眠唤醒或新建进程时,enqueue_task_fair 被调用:
1 | // kernel/sched/fair.c:6291 |
for_each_sched_entity 在非组调度情况下只迭代一次(直接返回进程本身),在组调度下则会沿父链向上遍历,将每一层的 cfs_rq 都更新。
内层的 enqueue_entity 完成实际插入:
1 | // kernel/sched/fair.c:4823 |
place_entity:防止饥饿的 vruntime 初始化新创建的进程或长期睡眠后唤醒的进程,其 vruntime 可能远小于 min_vruntime,若直接放入队列会持续抢占其他进程(因为它的 vruntime 最小)。place_entity 通过调整 vruntime 来防止这种情况:
1 | // kernel/sched/fair.c:4732 |
这里有两种场景:
新进程(initial=1):vruntime = min_vruntime + sched_vslice。新进程不能直接从 min_vruntime 起跑,需要额外支付一个”虚拟时间片”的”入场费”,防止 fork 炸弹抢占所有 CPU。
唤醒进程(initial=0):vruntime = min_vruntime - thresh。睡眠进程被唤醒时,允许其 vruntime 比当前基准线略小(补偿其等待时间),但最多补偿一个调度周期(sysctl_sched_latency),避免长期睡眠者无限期抢占。
dequeue_task_fair:进程阻塞或被抢占时出队1 | // kernel/sched/fair.c:6384 |
sched_slice:计算进程的调度时间片CFS 没有固定时间片,而是根据进程权重动态计算应得的 CPU 时间:
1 | // kernel/sched/fair.c:741 |
默认参数(可通过 /proc/sys/kernel/sched_* 调整):
sysctl_sched_latency:调度周期,默认 6ms(nr_running <= 8 时)sysctl_sched_min_granularity:最小运行粒度,默认 0.75mssysctl_sched_nr_latency:超过此值后拉伸周期,默认 8举例:有 4 个相同 nice 值的进程,调度周期 6ms,每个进程的时间片为 6/4 = 1.5ms。若一个进程 nice=-5(权重 3121),另三个 nice=0(权重 1024),则 nice=-5 进程获得 6ms × 3121 / (3121 + 3×1024) ≈ 3ms。
task_tick_fair1 | // kernel/sched/fair.c:12064 |
entity_tick 中调用 check_preempt_tick 检查是否需要抢占:
1 | // kernel/sched/fair.c:4993 |
1 | timer interrupt |
scheduler_tick 的核心路径:
1 | // kernel/sched/core.c:5602 |
check_preempt_wakeup当一个进程被唤醒(wake_up_process)时,内核会检查是否应该抢占当前进程:
1 | // kernel/sched/fair.c:7855 |
wakeup_preempt_entity 判断逻辑:
1 | // kernel/sched/fair.c:7810 |
只有当当前进程的 vruntime 超过唤醒进程 vruntime 一个 wakeup_gran 时才触发抢占,避免因微小的 vruntime 差异导致频繁切换。
struct task_group:cgroup CPU 调度组1 | // kernel/sched/sched.h:369 |
组调度的核心思想是:每个 task_group 在每个 CPU 上都有独立的 cfs_rq 和 sched_entity。
1 | root task_group |
pick_next_task_fair 通过 do { se = pick_next_entity(cfs_rq); cfs_rq = group_cfs_rq(se); } while (cfs_rq) 循环,从根 cfs_rq 向下穿透,直到找到一个叶子级别的 sched_entity(即真正的进程)。
cgroup v1:cpu.shares(默认 1024),等比例分配 CPU。两个组分别设置 512 和 1024,则第二个组能获得约两倍 CPU 时间。
cgroup v2:cpu.weight(默认 100,范围 1-10000),语义相同但取值范围不同。
对应内核中的 task_group->shares 字段,通过 sched_group_set_shares 更新,最终调整 se->load.weight,从而影响 sched_slice 的计算。
cpu.cfs_quota_us / cpu.cfs_period_us带宽控制允许限制一个 cgroup 在一个周期内最多使用多少 CPU 时间,超出即被限流(throttle)直到下一个周期。
相关内核参数:
cpu.cfs_period_us:周期长度,默认 100ms(100000 μs)cpu.cfs_quota_us:每周期可用 CPU 时间,-1 表示不限制例如设置 quota=50000, period=100000 表示该 cgroup 最多使用 0.5 个 CPU。
throttle_cfs_rq / unthrottle_cfs_rq当 cgroup 消耗完配额时触发限流:
1 | // kernel/sched/fair.c:5400 |
被限流的 cfs_rq 中的所有任务实际上被”虚拟阻塞”——它们仍在 cfs_rq 中,但其父调度实体已被从父 cfs_rq 移除,因此不会被调度。
配额补充由高精度定时器(hrtimer)在每个 cfs_period 结束时触发 sched_cfs_period_timer,调用 unthrottle_cfs_rq 解除限流,重新将组的调度实体加入父 cfs_rq。
SMP 系统中,CFS 通过周期性负载均衡将任务从繁忙 CPU 迁移到空闲 CPU,以实现全局公平性。触发时机:
scheduler_tick → trigger_load_balance → 软中断 SCHED_SOFTIRQ → run_rebalance_domains → load_balanceload_balance 主动拉取任务select_task_rq_fair 选择负载最轻的 CPUload_balance 主流程1 | // kernel/sched/fair.c:10733 |
find_busiest_group 与 find_busiest_queue1 | // kernel/sched/fair.c:10361 |
find_busiest_queue 在选定的 sched_group 中,选出运行队列权重最高(任务最重)的 CPU:
1 | // kernel/sched/fair.c:10490 |
负载均衡在调度域(sched_domain)层次上进行,从低到高:
1 | SMT(超线程,同一物理核的逻辑核) |
每一层的均衡间隔、迁移代价、不平衡阈值都可以单独配置。
1 | // kernel/sched/fair.c:3188 |
NUMA 均衡通过 task_numa_migrate 实现,综合考虑任务的内存访问热度(NUMA faults)和 CPU 负载,将任务迁移到数据所在的 NUMA 节点,减少跨节点内存访问延迟。
CFS 处理普通进程,rt_sched_class 处理实时进程(SCHED_FIFO 和 SCHED_RR)。RT 任务永远优先于 CFS 任务运行。
| 策略 | 特点 |
|---|---|
SCHED_FIFO |
先入先出。只有主动放弃 CPU(sched_yield)、阻塞或被更高优先级任务抢占时才切换。时间片无限。 |
SCHED_RR |
轮转调度。有固定时间片(默认 100ms),时间片耗尽后轮转到同优先级队列末尾。 |
RT 进程优先级为 1-99(sched_priority),99 最高。CFS 进程等效 RT 优先级为 0。
struct rt_prio_array:优先级位图1 | // kernel/sched/sched.h:273 |
RT 运行队列通过位图实现 O(1) 找到最高优先级:sched_find_first_bit(bitmap) 立即定位第一个置位的优先级,然后从对应链表头取出任务。
pick_next_task_rt1 | // kernel/sched/rt.c:1787 |
pick_next_rt_entity 通过 sched_find_first_bit(array->bitmap) 找到最高优先级,然后返回对应链表的第一个实体,时间复杂度 O(1)。
1 | # 查看进程调度策略和优先级 |
1 | # 调整运行中进程的 nice 值(需要 CAP_SYS_NICE 或降低 nice 值) |
/proc/PID/sched:CFS 调度统计1 | cat /proc/$(pidof nginx)/sched |
输出示例:
1 | nginx (12345, #threads: 4) |
关键字段:
vruntime:当前虚拟运行时间sum_exec_runtime:累计实际 CPU 时间(ns)nr_voluntary_switches:主动切换次数(I/O 等待等)nr_involuntary_switches:被动抢占次数(时间片用完)se.load.weight:当前权重(由 nice 值决定)/proc/schedstat:全局调度统计1 | cat /proc/schedstat |
结合 schedtool 或 tuna 可更方便地解析。
perf sched:调度延迟分析1 | # 录制调度事件(需要 root) |
1 | # 追踪 pick_next_task_fair 调用,输出被选中的进程 |
1 | # cgroup v2 |
1 | # 查看 CFS 调度参数 |
CFS 的设计哲学在于将抽象的”公平”转化为具体的可计算量——vruntime。通过这个虚拟时间轴,内核无需维护复杂的优先级队列逻辑,只需一棵以 vruntime 为键的红黑树,就能以 O(log n) 的复杂度实现近乎完美的公平调度。
核心设计要点回顾:
vruntime 归一化:calc_delta_fair 用权重对真实时间进行缩放,权重大的进程 vruntime 增长慢,因此获得更多 CPU 时间,这是 CFS”公平”的数学基础。
min_vruntime 锚点:单调递增的 min_vruntime 防止新进程/唤醒进程通过历史积累的低 vruntime 无限抢占,place_entity 在此基础上实现精细的”补偿-惩罚”策略。
**红黑树 O(log n)**:rb_root_cached 缓存最左节点实现 O(1) 选取,__enqueue_entity/__dequeue_entity 维护 O(log n) 插入删除。
组调度层次化:每个 task_group 在每个 CPU 上都有独立的 cfs_rq 和 sched_entity,通过多层遍历实现层次化公平,cgroup CPU 带宽控制基于此实现容器 CPU 限制。
SMP 负载均衡:通过调度域层次(SMT → MC → NUMA)实现多粒度的负载均衡,NUMA 感知调度进一步优化内存局部性。
理解 CFS 的源码不仅有助于诊断调度延迟问题,也为容器化环境下合理设置 cpu.shares/cpu.weight 和 cpu.cfs_quota_us 提供了理论基础。下一篇文章将深入分析 Linux 进程的内存管理机制,包括虚拟内存区域(VMA)、缺页异常处理和 OOM Killer 的实现。
基于 Linux 6.4-rc1 源码(commit ac9a78681b92),深入剖析进程创建的内核实现。
进程是操作系统最核心的抽象之一。每次你敲下一条 shell 命令,背后都会发生 fork + exec 这一对经典舞步。然而 “fork 复制父进程、exec 替换镜像” 这句话远未揭示全貌:写时复制如何让 fork 变得极其廉价?clone 与 fork 共享同一条代码路径吗?exec 如何安全地销毁旧地址空间并跳进新程序?本文从 struct task_struct 出发,沿着 fork → clone → exec → exit 的脉络,逐函数拆解内核实现。
每个进程(或线程)在内核中对应一个 struct task_struct,定义于 include/linux/sched.h。它既是调度的基本单元,也是内核追踪进程一切状态的”档案袋”。以下精选最关键的字段并加以注释。
1 | /* include/linux/sched.h: 87–107 */ |
task_struct 中保存状态的字段为:
1 | /* include/linux/sched.h: 747 */ |
注意前缀双下划线——内核要求通过 READ_ONCE()/WRITE_ONCE() 或专用宏访问它,以防止编译器优化导致的竞态。
1 | /* include/linux/sched.h: 785–793 */ |
sched_class 是一个虚函数表指针,实现了策略模式:CFS、RT、Deadline 各自实现 enqueue_task、dequeue_task、pick_next_task 等接口,调度器核心代码通过 sched_class 统一调用,无需 if/else 判断。
1 | /* include/linux/sched.h: 963–987 */ |
pid vs tgid:POSIX 要求同一进程内所有线程共享 PID,这在内核中通过 tgid 实现。getpid() 返回 tgid,gettid() 返回 pid。group_leader 永远指向主线程。
1 | /* include/linux/sched.h: 872–873 */ |
内核线程 mm == NULL,但运行时 CPU 的 TLB 仍需要一个 mm,所以借用上一个用户进程的 active_mm,这就是 lazy TLB 优化的来源。
1 | /* include/linux/sched.h: 1087–1090 */ |
fs_struct 和 files_struct 各自有引用计数,clone(CLONE_FS) 或 clone(CLONE_FILES) 时父子进程共享同一个实例而非复制。
1 | /* include/linux/sched.h: 1100–1106 */ |
1 | /* include/linux/sched.h: 1057–1060 */ |
cred 采用写时复制(COW):setuid() 会分配新的 cred 对象,而非修改现有的,这使得 RCU 读者可以无锁访问。
1 | 用户态: fork() |
1 | /* kernel/fork.c: 3000–3012 */ |
fork 不传任何 clone flags,这意味着子进程拥有独立的 mm、文件描述符表、信号处理器、命名空间——一个完整的独立进程。
1 | /* kernel/fork.c: 2877–2962(节选) */ |
copy_process 是整个 fork 路径最复杂的函数(约 600 行),它按照严格顺序完成以下工作:
第一步:合法性检查
1 | /* kernel/fork.c: 2263–2302(节选) */ |
这揭示了线程的必要条件:线程 = 共享信号处理器 = 共享地址空间,三者缺一不可。
第二步:dup_task_struct——克隆描述符与内核栈
1 | /* kernel/fork.c: 2333 */ |
dup_task_struct 的实现(kernel/fork.c: 1101–1190):
1 | static struct task_struct *dup_task_struct(struct task_struct *orig, int node) |
关键点:此时 task_struct 是父进程的完整拷贝,但内核栈是全新分配的——否则父子进程会使用同一个内核栈,调度时会互相破坏。
第三步:各子系统的选择性复制
1 | /* kernel/fork.c: 2492–2518 */ |
copy_mm(kernel/fork.c: 1714)的逻辑:
1 | static int copy_mm(unsigned long clone_flags, struct task_struct *tsk) |
copy_files(kernel/fork.c: 1772):
1 | static int copy_files(unsigned long clone_flags, struct task_struct *tsk, int no_files) |
第四步:设置 pid/tgid/group_leader
1 | /* kernel/fork.c: 2574–2581 */ |
第五步:加入进程树
1 | /* kernel/fork.c: 2638–2648 */ |
1 | /* kernel/sched/core.c: 4809–4848(节选) */ |
至此,子进程已就绪,等待调度器选中它上 CPU 运行。
所有标志定义于 include/uapi/linux/sched.h:
| 标志 | 含义 |
|---|---|
CLONE_VM |
共享地址空间(mm_struct),线程的必要条件 |
CLONE_FS |
共享文件系统信息(cwd、root、umask) |
CLONE_FILES |
共享文件描述符表 |
CLONE_SIGHAND |
共享信号处理函数表,CLONE_VM 的前提 |
CLONE_THREAD |
加入同一线程组(共享 tgid),CLONE_SIGHAND 的前提 |
CLONE_NEWNS |
新建 Mount 命名空间(容器隔离的基础) |
CLONE_NEWPID |
新建 PID 命名空间(容器内 PID 从 1 开始) |
CLONE_VFORK |
父进程挂起,直到子进程 exec 或 exit |
CLONE_PIDFD |
在父进程返回一个 pidfd(进程文件描述符) |
glibc 中 pthread_create 最终调用:
1 | clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | |
这些 flags 让内核:
CLONE_CHILD_CLEARTID 向 futex 地址写 0,唤醒等待 pthread_join 的线程1 | /* kernel/fork.c: 3045–3062 */ |
fork、vfork、clone 在内核中最终都调用 kernel_clone,差异仅在传入的 kernel_clone_args 结构体。这是 Linux 内核”一个实现,多个接口”的典型范式。
子进程在 fork 后从 ret_from_fork 开始运行,但为何 fork() 在子进程中返回 0?答案在 x86 的 copy_thread:
1 | /* arch/x86/kernel/process.c: 137–202(节选) */ |
父进程的 rax 由 kernel_clone 返回子进程 PID,子进程的 rax 被强制置 0。当子进程被调度到 CPU 上,它从 ret_from_fork 恢复执行,iret 时 rax=0 作为系统调用返回值送回用户态——这就是 fork() 在子进程中返回 0 的完整机制。
1 | 用户态: execve(path, argv, envp) |
1 | /* fs/exec.c: 1886–1969(节选) */ |
1 | /* fs/exec.c: 364–392 */ |
注意此时旧的 mm 仍然有效——新 mm 在 begin_new_exec 之前不会替换旧的,这样如果 load_elf_binary 失败,进程可以安全回滚。
1 | /* fs/exec.c: 1715–1759(节选) */ |
binfmt 链表按注册顺序排列,典型格式有 ELF(binfmt_elf)、脚本(binfmt_script,处理 #!)、misc(binfmt_misc,处理 .jar/.py 等)。
1 | /* fs/binfmt_elf.c: 823–(节选) */ |
ELF 加载后的栈布局(从高地址到低地址):
1 | 高地址 |
auxv(辅助向量)向 ld.so 传递内核信息:AT_PHDR 指向程序头表在内存中的位置,AT_ENTRY 是程序真实入口(ld.so 完成重定位后跳转到此),AT_RANDOM 是 16 字节随机数用于 PIE/ASLR 种子。
begin_new_exec(fs/exec.c: 1244)是 exec 过程的分水岭,在此之后:
exec_mmap(bprm->mm) 将旧 mm 替换为新 mm,旧地址空间被销毁flush_signal_handlers() 将所有非 SIG_IGN 的信号处理器重置为 SIG_DFLO_CLOEXEC 的文件描述符comm)为新程序的 basename1 | /* kernel/exit.c: 806–924(节选) */ |
1 | /* kernel/exit.c: 532–567 */ |
mmput() 只是递减引用计数。若进程有共享同一 mm 的线程(线程组),mm 不会被立刻释放,直到最后一个线程退出。
1 | /* kernel/exit.c: 730–764(节选) */ |
进程在 exit_state = EXIT_ZOMBIE 后:task_struct 仍然存在于内存中,保留 PID、退出码等信息,等待父进程调用 wait() 来回收。这就是”僵尸进程”。
1 | /* kernel/exit.c: 1801–1812 */ |
kernel_wait4 → do_wait → wait_consider_task:扫描子进程列表,找到 EXIT_ZOMBIE 状态的子进程,收集退出码后调用 release_task() 释放 task_struct,PID 归还 pidmap。
当父进程先于子进程退出,子进程成为孤儿:
1 | /* kernel/exit.c: 618–651 */ |
这就是为什么容器内 PID 1 必须正确处理 SIGCHLD 并调用 wait()——否则容器内所有孤儿进程都会成为其僵尸子进程,逐渐耗尽内核资源。
fork 时调用 dup_mm → dup_mmap,后者遍历父进程的所有 VMA(虚拟内存区域),将每个页表项拷贝到子进程,但同时将父子两边的 PTE 都标记为只读并清除 dirty bit。
当任一方尝试写入,CPU 触发缺页异常(Protection Fault),内核的 do_wp_page 函数被调用:
_mapcount(被多少 PTE 引用)_mapcount == 1(只有自己引用),直接将 PTE 改回可写(”破坏单映射”)_mapcount > 1,分配新物理页,拷贝内容,更新 PTE 为新页并设为可写这样,只有真正被写入的页面才会发生物理复制,大量只读的代码页、rodata 页永远不会被复制。
如果紧接着 fork 就要 exec,COW 的页表复制本身也是浪费,vfork 为此而生:
1 | /* kernel/fork.c: 3016–3025 */ |
CLONE_VM:父子共享同一 mm,不复制页表CLONE_VFORK:父进程阻塞在 wait_for_vfork_done() 直到子进程调用 exec 或 _exitvfork 的约束极为严格:子进程只能调用 exec 系列或 _exit,不得修改任何全局状态,也不得 return 出创建它的函数——因为父子共享栈和 mm,任何写操作都会污染父进程。
即使不用 vfork,普通 fork 后立即 exec,实际代价也很小:
fork 时仅复制页表结构(4级页表的高层节点),并将所有 PTE 标记为 COW 只读exec 调用 begin_new_exec → exec_mmap,直接将新 mm 替换旧 mm,mmput(old_mm) 递减引用计数后释放旧页表,所有 COW 只读标记都随之消失这就是为什么 shell 每秒可以创建数千个子进程,而内存用量并不会暴增。
1 | $ cat /proc/1234/status |
1 | $ ls /proc/1234/task/ |
1 | $ pstree -p 1 |
1 | # 追踪 fork/clone/execve(-f 跟踪子进程) |
1 | # 追踪所有进程创建,打印父子 PID 和进程名 |
1 | $ perf stat -e task-clock,context-switches,cpu-migrations,page-faults \ |
1 | $ cat /proc/1234/maps | head -5 |
r--p 和 r-xp 中的 p(private)表示这是写时复制映射,fork 后父子进程共享这些物理页,直到写入为止。
| 操作 | 内核入口 | 共享资源 | 代价 |
|---|---|---|---|
fork() |
kernel_clone({.exit_signal=SIGCHLD}) |
无(COW 页表) | 页表复制 + 子进程入队 |
vfork() |
`kernel_clone({CLONE_VFORK | CLONE_VM})` | mm 完全共享 |
pthread_create |
`clone(CLONE_VM | CLONE_THREAD | …)` |
execve() |
do_execveat_common |
无(全新 mm) | ELF 加载 + 页表建立 |
从 fork 时 dup_task_struct 的内核栈分配,到 copy_thread 中 childregs->ax = 0 让子进程”看到” 返回值 0,再到 exec 路径上 begin_new_exec 的不可回头设计,以及 exit_zombie 状态下 task_struct 对父进程的等待——这条完整的进程生命周期展现了 Linux 内核在正确性、性能和可观测性之间精妙的平衡。
include/linux/sched.h — struct task_struct 定义kernel/fork.c — fork/clone/vfork 实现fs/exec.c — execve 实现fs/binfmt_elf.c — ELF 加载器kernel/exit.c — 进程退出kernel/sched/core.c — wake_up_new_taskarch/x86/kernel/process.c — x86 copy_threadinclude/uapi/linux/sched.h — CLONE_* 标志定义内存问题是生产系统中最难排查的故障类型之一。症状可能表现为 OOM 崩溃、响应延迟飙升、Swap 风暴,也可能是长达数天才显现的缓慢内存泄漏。本文从内核数据结构出发,系统讲解 /proc/meminfo、/proc/vmstat 的每个字段含义,结合五大实战案例与 bpftrace 诊断脚本,构建一套完整的内存诊断与调优方法论。
本系列前几篇文章分别介绍了虚拟内存布局、物理内存分配器、页表体系与缺页异常处理机制。本篇继续深入,聚焦于三个紧密相关的主题:mmap 文件映射(把文件直接映射到进程地址空间)、共享内存(多进程通过同一块物理页通信)以及写时复制(COW)(fork() 后父子进程高效共享内存的核心机制)。
理解这些机制对于系统编程、性能调优和内核开发都至关重要。mmap 是高性能 I/O 和数据库(如 SQLite WAL 模式、RocksDB mmap 读)的底层利器;COW 让 fork() 的成本从”复制整个进程内存”降至”几乎可以忽略不计”;共享内存则是进程间通信(IPC)延迟最低的手段,Redis 的 RDB 持久化、Nginx 的 worker 与 master 进程通信都依赖于此。
所有代码片段均基于 Linux 6.4-rc1(commit ac9a78681b92)。
do_mmap:建立映射的入口用户态调用 mmap(2) 后,经过 ksys_mmap_pgoff 进入内核核心逻辑 do_mmap(mm/mmap.c)。
1 | // mm/mmap.c:1222 |
几个关键点:
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。
generic_file_mmap:设置 VMA 操作集1 | // mm/filemap.c:3594 |
generic_file_vm_ops 定义了该 VMA 的缺页处理函数集,其中最重要的两个成员是:
1 | // mm/filemap.c:3585-3592 |
vm_ops 在此设置完毕,后续进程访问该地址段时触发缺页异常,内核便会调用 filemap_fault 来完成实际的物理页映射。
filemap_fault:文件 mmap 缺页处理当进程首次访问 mmap 映射区域时,由于 PTE 为空,硬件触发 #PF,内核调用链最终到达 filemap_fault(mm/filemap.c:3243):
1 | vm_fault_t filemap_fault(struct vm_fault *vmf) |
流程总结:
do_sync_mmap_readahead,从磁盘读入,走 I/O 路径,耗时较长(取决于存储设备,NVMe 通常 100μs 量级,HDD 可达 10ms)。do_set_pte 将物理页帧号写入 PTE,完成映射。值得注意的是,filemap_fault 中的 do_async_mmap_readahead 与 do_sync_mmap_readahead 代表两种预读策略:
ra.ra_pages 控制,默认 32 页 = 128 KB),以摊销 I/O 开销;对于顺序读场景,预读算法能将磁盘吞吐量接近理论上限;对于随机 mmap 访问(如数据库的随机读),可通过 madvise(MADV_RANDOM) 禁用预读,节省不必要的 I/O。
filemap_map_pages:预映射优化map_pages 是一项重要性能优化:在处理单个缺页时,内核会顺带将相邻的已在页缓存中的页面一并映射,以减少后续缺页次数(fault-around)。
1 | // mm/filemap.c:3483 |
通过 XArray 遍历页缓存,把连续页面批量 do_set_pte,一次性减少多次缺页开销。
MAP_PRIVATE 文件映射是最典型的只读共享 + 写时复制场景:
do_wp_page,为该进程分配私有页并复制内容(详见第三节)。这正是 Linux 进程加载动态库 .so 的工作方式:代码段 MAP_PRIVATE|PROT_READ|PROT_EXEC,所有进程共享同一份物理页;数据段对写入使用 COW,各进程独立修改各自的副本。
MAP_PRIVATE 与 MAP_SHARED 的核心区别体现在 do_mmap 的 flag 检查中:
1 | // mm/mmap.c:1336 |
对于 MAP_PRIVATE|MAP_FILE(私有文件映射),COW 发生后产生的私有页不再属于页缓存,而是作为匿名页(MM_ANONPAGES)计入进程内存统计,这也是为什么 smaps 中私有写过的文件映射区域会出现 Private_Dirty 字段。
当调用 mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0) 时,内核需要一个”虚拟文件”来管理共享页面。这个文件由 shmem_zero_setup(mm/shmem.c)创建:
1 | // mm/shmem.c:4332 |
该文件使用内核私有的 shm_mnt tmpfs 挂载点,对用户不可见(clear_nlink 确保无目录项),但提供了完整的 inode/页缓存语义。
__shmem_file_setup:创建 tmpfs inode1 | // mm/shmem.c:4251 |
shmem_file_setup(公开 API)直接封装了上述函数,供 System V 共享内存、memfd_create 等使用。
shmem_fault:匿名共享内存缺页shmem 使用自己的 vm_ops,缺页时调用 shmem_fault(mm/shmem.c:2095):
1 | static vm_fault_t shmem_fault(struct vm_fault *vmf) |
shmem_get_folio_gfp 首先查找页缓存,若缺失则分配新物理页并加入页缓存。对于 MAP_SHARED 映射,所有映射同一 inode 同一偏移的 VMA 都会映射到同一物理页,这正是进程间通信的物理基础。
**POSIX shm_open**:本质是在 /dev/shm(tmpfs 文件系统)上创建/打开普通文件,通过 mmap 映射。路径:shm_open → open("/dev/shm/name") → mmap → shmem_fault。POSIX 共享内存有文件系统可见性,可以通过 ls /dev/shm 查看,进程退出后(若未 shm_unlink)文件依然存在。
**System V shmget/shmat**:内核路径略有不同。shmget 最终调用 shmem_kernel_file_setup 创建内核私有 tmpfs 文件并保存在 struct shmid_kernel 中;shmat 调用 do_shmat → do_mmap,将该文件 mmap 进进程地址空间。两者底层都依赖 tmpfs/shmem 的页缓存,原理一致。System V 共享内存通过 ipcs -m 查看,生命周期独立于进程(直到显式 shmctl(IPC_RMID) 或系统重启)。
memfd_create(现代匿名共享内存):Linux 3.17 引入,创建一个无路径的匿名 tmpfs 文件描述符,可通过 /proc/PID/fd 传递给其他进程:
1 | int fd = memfd_create("my_shm", MFD_CLOEXEC); |
memfd_create 是目前推荐的进程间共享内存方式,结合了 POSIX shm 的易用性和匿名映射的安全性(无文件系统路径,无权限问题)。Android 的 Binder IPC 大量数据传输使用的 ashmem(现已迁移为 memfd_create)即是此机制。
COW 是 Linux 高效实现 fork() 的关键——fork 时不复制物理页,而是让父子进程共享同一份物理页,仅在写入时才真正复制,大幅减少 fork 开销。
copy_mm:fork 时处理 mm_struct1 | // kernel/fork.c:1714 |
CLONE_VM(pthread_create 使用)不复制 mm,线程与父进程共享整个地址空间;真正的 fork() 则调用 dup_mm → dup_mmap。
dup_mmap:复制所有 VMA1 | // kernel/fork.c:649 |
copy_page_range:写保护标记 COW1 | // mm/memory.c:1251 |
is_cow_mapping 判断条件:VMA 不带 VM_SHARED 且带有 VM_MAYWRITE(即 MAP_PRIVATE 可写映射)。对这类 VMA,copy_pte_range(深层函数)会将父子进程双方的 PTE 都改为只读(清除 _PAGE_RW 位),同时设置 PageAnonExclusive 等标记,为后续 COW 做准备。
do_wp_page:COW 写保护缺页处理当父进程或子进程首次写入共享物理页时,触发写保护缺页,内核调用 do_wp_page(mm/memory.c:3324):
1 | static vm_fault_t do_wp_page(struct vm_fault *vmf) |
wp_page_reuse:单引用者快路径1 | // mm/memory.c:3006 |
wp_page_reuse 只修改 PTE 标志位(从只读恢复到可写),无需分配新物理页,是 COW 场景中最快的路径。
wp_page_copy:真正的 COW 复制1 | // mm/memory.c:3050 |
COW 完整流程:分配新物理页 → 复制旧页内容 → 更新 PTE 指向新页 → 递减旧页引用计数。
_mapcount 与引用计数每个 struct page(folio)维护两个关键计数:
| 字段 | 含义 |
|---|---|
page->_refcount |
物理页总引用计数,包含 page cache、PTE 映射、内核直接引用等 |
page->_mapcount |
PTE 映射计数,即有多少条 PTE 指向此物理页 |
在 do_wp_page 中,folio_ref_count(folio) > 3 这个阈值:
folio_get)超出这个值说明有其他进程(或内核)也引用了该页,不能复用,必须 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 时,read() + write() 的路径:
共 2次 CPU 拷贝 + 2次 DMA + 4次上下文切换。
do_sendfile:零拷贝系统调用1 | // fs/read_write.c:1180 |
do_splice_direct 最终调用 splice_direct_to_actor。
splice_direct_to_actor:内部管道传输1 | // fs/splice.c:918 |
关键在于:do_splice_to 操作的是页引用(folio/page 指针),而非内存拷贝;tcp_sendpage(网络发送路径)同样通过 skb_fill_page_desc 将页直接插入 skb,DMA 引擎直接从页缓存发送数据。
零拷贝路径:磁盘 DMA → 页缓存 → 网卡 DMA(0次 CPU 拷贝,2次上下文切换)。
“零拷贝”的准确含义:消除了用户态 ↔ 内核态之间的 CPU 内存拷贝,数据始终驻留在内核页缓存,通过页引用传递,网卡通过 DMA 直接读取。
sys_brk:堆的扩展与收缩堆内存(malloc 的底层)通过 brk(2) 系统调用管理:
1 | // mm/mmap.c:189 |
brk 不会立即分配物理内存,只是扩展 VMA 的虚拟范围;实际的物理页分配推迟到首次访问时的缺页处理(匿名页 COW 路径)。
mremap 与 MREMAP_MAYMOVE1 | // mm/mremap.c:896 |
MREMAP_MAYMOVE 允许内核在新地址空间找不到连续虚拟空间时移动 VMA,对应 move_vma:
1 | // mm/mremap.c:571 |
move_page_tables 是批量 PTE 搬移的核心,它逐级遍历页表,尽量以整块 PTE 页(而非逐条)的方式迁移,避免逐条拷贝的高开销。
do_mprotect_pkey:修改 VMA 权限1 | // mm/mprotect.c:731 |
mprotect_fixup 调用 change_protection 遍历 PTE,将可写页改为只读(或降低其他权限)。权限降低(write → read)必须 TLB flush,否则 CPU 可能使用旧缓存的可写 TLB 条目绕过保护。
Linux 使用 mmu_gather(TLB lazy flush 机制)批量收集需要 flush 的地址范围,最后一次性 flush,避免单次 mprotect 对数千页逐一操作 TLB 的性能损耗。
当进程尝试写入只读保护页(如写 .text 段,或写 mprotect 后的只读区域),缺页处理流程:
#PFdo_user_addr_fault → handle_mm_fault → do_wp_page!(vma->vm_flags & VM_WRITE)),则 bad_area → force_sig_fault(SIGSEGV, ...)SIGSEGV,默认动作:终止(或触发 SIGSEGV handler)JIT 编译器:V8、JVM 等 JIT 引擎的典型做法是:先 mmap(MAP_ANONYMOUS|PROT_READ|PROT_WRITE) 分配内存,写入机器码,再 mprotect(PROT_READ|PROT_EXEC) 切换为可执行。这是为了遵循 W^X(Write XOR Execute)安全策略,防止代码注入攻击。
1 | // JIT 引擎分配可执行内存的典型模式 |
Guard pages(栈溢出检测):在栈底部设置不可访问的保护页(PROT_NONE),当栈溢出访问该页时触发 SIGSEGV,这比不设置保护页时悄悄覆盖数据要安全得多。glibc 的 pthread_create 默认为每个线程栈末尾设置 guard page。
内存安全检测:AddressSanitizer 使用 shadow memory + mprotect 来检测内存越界访问,通过将 redzone 区域设为 PROT_NONE,任何对其的访问都会立即触发 SIGSEGV 并被 ASan 的信号处理器捕获,输出详细的错误报告。
/proc/PID/maps 与 /proc/PID/smaps_rollup1 | # 查看进程地址空间布局 |
关键字段含义:
| 字段 | 含义 |
|---|---|
RSS |
常驻物理内存(包含共享页) |
PSS |
Proportional Set Size,共享页按引用者均摊 |
USS |
Unique Set Size,进程独占的物理内存 |
Shared_Clean/Dirty |
与其他进程共享的 clean/dirty 页 |
Private_Clean/Dirty |
进程私有的 clean/dirty 页(COW 后产生) |
1 | # 追踪目标进程的内存相关系统调用 |
pmap -X:详细内存映射1 | pmap -X $(pidof python3) |
1 | # 统计所有 mmap 调用的映射大小分布 |
Valgrind(工具链):
1 | valgrind --leak-check=full --track-origins=yes ./your_program |
AddressSanitizer(编译时插桩):
1 | gcc -fsanitize=address -g -O1 your_program.c -o your_program |
bpftrace 追踪 mmap 泄漏:
1 | # 追踪未被 munmap 的 mmap(简化示例) |
1 | task_struct |
| 类型 | 标志 | 物理页来源 | 写入行为 | 典型用途 |
|---|---|---|---|---|
| 文件私有映射 | 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 |
1 | fork() 后 |
选择合适的映射类型:
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->fault、filemap_fault、shmem_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.c:do_mmap、mmap_region、SYSCALL_DEFINE1(brk)mm/filemap.c:generic_file_mmap、filemap_fault、filemap_map_pagesmm/shmem.c:__shmem_file_setup、shmem_zero_setup、shmem_faultmm/memory.c:copy_page_range、do_wp_page、wp_page_reuse、wp_page_copykernel/fork.c:copy_mm、dup_mmapmm/mremap.c:SYSCALL_DEFINE5(mremap)、move_vmamm/mprotect.c:do_mprotect_pkeyfs/read_write.c:do_sendfilefs/splice.c:splice_direct_to_actor本文基于 Linux 6.4-rc1(commit
ac9a78681b92)源码,所有代码片段均来自真实内核源文件。
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 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% |
| 页类型 | 大小 | 页表级别 | arch 支持 |
|---|---|---|---|
| 普通页 | 4 KB | PTE | 所有 |
| 大页(Huge) | 2 MB | PMD | x86-64, ARM64 |
| 巨页(Gigantic) | 1 GB | PUD | x86-64 |
大页的关键优势在于:1 个 TLB 条目覆盖 2MB,等效于 512 个普通 PTE 条目,TLB 命中率大幅提升。
-XX:+UseHugeTLBFS 或 -XX:+UseTransparentHugePages 让 GC 管理的堆使用大页。HugeTLBFS 是 Linux 内核提供的显式大页机制。它的设计思路是:在系统初始化或运行时预先从 buddy 分配器申请若干连续大页,将它们维护在 hstate 的空闲链表中,然后通过一个伪文件系统(hugetlbfs)暴露给用户空间,用户以 mmap/shmget 等标准接口消费。
HugeTLBFS 的整体数据流如下:
1 | sysctl vm.nr_hugepages = 1024 |
内核为每种大页尺寸维护一个 struct hstate,定义在 include/linux/hugetlb.h:
1 | /* include/linux/hugetlb.h: line 693 */ |
字段含义:
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 挂载点设置 size 和 min_size 参数时,子池负责在全局池和文件系统之间进行二次分配和配额管理。
resv_map 与 file_region 一起记录哪些页偏移范围已经预留了大页。对于共享映射(如 shmget + SHM_HUGETLB),resv_map 挂在 inode 上,多个映射共享同一 resv_map;对于私有映射(MAP_PRIVATE | MAP_HUGETLB),每个 VMA 拥有独立的 resv_map,确保 COW 语义下预留计数正确。
当大页池需要扩充时,调用 alloc_fresh_hugetlb_folio(mm/hugetlb.c):
1 | /* mm/hugetlb.c: line 2175 */ |
对于 2MB 大页(非 gigantic),调用 alloc_buddy_hugetlb_folio,其核心是:
1 | /* mm/hugetlb.c: line 2105 */ |
关键点:
order = 9:从 buddy 分配 2^9 = 512 个连续物理页(2MB)。__GFP_COMP:将 512 个页面组成一个 compound page(复合页),head page 的 compound_order 设为 9,tail pages 指向 head。prep_new_hugetlb_folio 设置析构函数(HUGETLB_PAGE_DTOR)并更新 hstate 计数器。用户访问 MAP_HUGETLB 区域触发缺页时,handle_mm_fault 会识别到该 VMA 对应 HugeTLBFS 并调用 hugetlb_fault(mm/hugetlb.c):
1 | /* mm/hugetlb.c: line 6057 */ |
hugetlb_fault 使用 per-page 互斥锁(hugetlb_fault_mutex_table,4096 个桶的哈希表)序列化对同一大页的并发缺页,避免重复分配。PTE 为空时转入 hugetlb_no_page 完成实际的页分配、页表填充工作。
调用 mmap(MAP_HUGETLB) 时,内核并不立即分配物理大页,而是先预留:
1 | /* mm/hugetlb.c: line 6845 */ |
预留机制确保 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 机制可以平滑应对短时的大页需求峰值。
1 | #include <sys/mman.h> |
通过 HugeTLBFS 挂载点也可以使用文件方式访问大页:
1 | mkdir -p /mnt/hugepages |
THP(Transparent Huge Pages)让内核无需应用修改就能自动使用 2MB 大页。其核心思想是:在缺页时直接分配 2MB PMD 大页;在进程运行期间,khugepaged 后台守护线程扫描已存在的 4KB 页,尝试将 512 个连续物理页合并(collapse)成一个 2MB 大页。
THP 与 HugeTLBFS 最本质的区别在于管理主体:HugeTLBFS 由用户空间显式控制大页的申请和使用;THP 完全由内核透明管理,用户程序无需感知大页的存在。这种透明性带来了极大的易用性,但也引入了新的复杂性——内核必须在合适的时机自动拆分和合并大页,而这些操作可能在应用程序的关键路径上产生意料之外的开销。
THP 支持两种内存类型:
mmap(MAP_ANONYMOUS) 或进程堆/栈,是 THP 最主要的使用场景。/dev/shm、memfd_create 等,需要单独配置 /sys/kernel/mm/transparent_hugepage/shmem_enabled。文件背景内存(page cache)目前不支持 THP,因为文件系统的块 I/O 和页缓存管理对 2MB 粒度有较高的复杂度要求。
THP 的标志字变量(mm/huge_memory.c 第 57 行):
1 | unsigned long transparent_hugepage_flags __read_mostly = |
当匿名映射发生缺页,且 VMA 满足 THP 条件(大小 >= 2MB、地址对齐)时,handle_mm_fault 走 do_huge_pmd_anonymous_page(mm/huge_memory.c):
1 | /* mm/huge_memory.c: line 779 */ |
HPAGE_PMD_ORDER = 9,vma_alloc_folio 从 buddy 分配 512 页连续物理内存。分配失败时以 VM_FAULT_FALLBACK 回退,内核继续处理 4KB 缺页,保证应用程序正常运行。
1 | /* mm/huge_memory.c: line 651 */ |
关键实现细节:
mk_huge_pmd 在 PMD 表项中设置 _PAGE_PSE(Page Size Extension)位,告知 MMU 此 PMD 直接映射 2MB 物理页,不再向下走 PTE 级。pgtable_trans_huge_deposit 把预先分配的 PTE 页表页”存入” PMD 旁,为未来 COW 拆分时复用。set_pmd_at 是一个内存屏障写,确保在 TLB 更新前物理页内容(clear_huge_page 的零化)对所有 CPU 可见。当需要对 THP 的一部分进行操作(如 munmap 非 2MB 对齐区域、部分 mprotect、发生 ptrace、被 KSM 扫描、内存迁移)时,必须先将 2MB THP 拆回 512 个 4KB 页。THP 的拆分分为两个层面:
__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_list(mm/huge_memory.c,第 2637 行):
1 | int split_huge_page_to_list(struct page *page, struct list_head *list) |
__split_huge_page 将 compound page 的每个 tail page 重新初始化为独立的 4KB 页,依次更新 rmap、LRU 链表、引用计数,最后调用 __split_huge_page_tail 处理每个 tail page。
在 COW 或 munmap 时,还需要在页表层面将 PMD 大页表项拆成 512 个 PTE:
1 | /* mm/huge_memory.c: line 2266 */ |
__split_huge_pmd_locked 从 PMD 的 “deposit” 中取出预存的 PTE 页表页,填充 512 个 PTE 条目后将 PMD 表项替换为指向该 PTE 页表页的普通 PMD 指针,同时刷新 TLB。
当父进程 fork() 后子进程写入 THP 映射区域,触发写保护缺页:
1 | /* mm/huge_memory.c: line 1294 */ |
COW 处理有两条路径:
PageAnonExclusive),直接将 PMD 标为 dirty + writable,无需复制,O(1) 完成。__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 是内核专用的后台线程,负责将已存在的 4KB 页合并为 2MB THP。它维护一个全局扫描游标:
1 | /* mm/khugepaged.c: line 129 */ |
主要调优参数(均可通过 sysfs 配置):
1 | /* mm/khugepaged.c: line 70 */ |
每当进程调用 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 | /* mm/khugepaged.c: line 2420 */ |
1 | /* mm/khugepaged.c: line 1237 */ |
扫描逻辑:
khugepaged_max_ptes_swap)和空页(khugepaged_max_ptes_none)存在。uffd-wp(userfaultfd 写保护)则放弃合并。collapse_huge_page 分配新 2MB 大页,将 512 个 4KB 页的内容复制进去,替换 PMD 表项。hpage_collapse_scan_pmd 检查通过后,由 collapse_huge_page(mm/khugepaged.c 第 1079 行)完成实际的合并操作:
alloc_charge_hpage 从 buddy 分配 order-9 页面。__collapse_huge_page_isolate,逐页从 LRU 链表摘除,检查引用计数,处于 swap 中的页面执行 swapin(__collapse_huge_page_swapin)。__collapse_huge_page_copy 将 512 个 4KB 页的内容逐页复制进新的 2MB folio。mmap_write_lock 保护下,用一条 PMD 大页表项替换原来的 512 个 PTE,刷新 TLB。整个合并过程需要持有目标 mm 的 mmap_write_lock,因此对应用程序有短暂的阻塞影响(通常微秒级)。这也是为什么高延迟敏感场景建议关闭 khugepaged 的主要原因——不可预知的合并时机会引入随机延迟。
1 | # 查看当前模式 |
transparent_hugepage_flags 中各比特位对应 defrag 策略(mm/huge_memory.c 第 738 行 vma_thp_gfp_mask):不同 defrag 模式会向 vma_alloc_folio 传递不同的 gfp 标志,控制内存分配器的压缩行为。
1 | /* 对特定内存区域启用 THP(即使全局为 madvise 模式)*/ |
在数据库场景中,通常对 Buffer Pool 使用 MADV_HUGEPAGE,对其他小内存结构使用 MADV_NOHUGEPAGE 避免碎片化。
1 | # khugepaged 每次扫描多少页(默认 4096) |
系统配置(持久化):
1 | # 在 /etc/sysctl.conf 中设置 |
PostgreSQL 配置:
1 | # postgresql.conf |
PostgreSQL 在 shmget/mmap 共享内存时会优先传递 SHM_HUGETLB 标志;若大页不足则回退普通页(huge_pages = try)或直接报错退出(huge_pages = on)。
Oracle 数据库:
1 | # /etc/security/limits.conf |
MySQL InnoDB(MariaDB 10.5+):
1 | # my.cnf |
libvirt 配置(XML):
1 | <memoryBacking> |
QEMU 会对 Guest RAM 执行 mmap(MAP_HUGETLB),EPT(Extended Page Table)中的 Level-2(对应 Host PMD)条目直接为 2MB 大页,减少 EPT 走表层级,VM Exit 频率可降低 10% ~ 30%。
THP 对数据库工作负载有几个典型负面效应:
khugepaged 合并时会短暂持有 anon_vma_lock_write,高并发场景下与应用线程竞争,产生几毫秒的随机延迟。defrag=always 时),造成长时间停顿。fork() COW 放大:若 THP 被多进程共享(如 PostgreSQL 的 fork() 后 worker),COW 时即便只修改 1 个字节也需拆分整个 2MB THP 的页表,增加额外开销。数据库推荐配置:
1 | # 方案一:完全关闭 THP(最保险) |
开机自动禁用(systemd):
1 | # /etc/systemd/system/disable-thp.service |
1 | grep -i huge /proc/meminfo |
输出示例:
1 | AnonHugePages: 614400 kB # THP 匿名大页(单位 KB,614400/2048 = 300 个 2MB THP) |
1 | cat /proc/$(pgrep postgres | head -1)/smaps | grep -A20 "heap" |
关键字段:
1 | 7f8000000000-7f8080000000 rw-p ... [heap] |
1 | # 查看当前配置 |
注意:大页预留应在系统启动早期(内存碎片化程度低时)完成;运行中的系统若内存碎片严重,增加 nr_hugepages 可能只能部分满足。
1 | numastat -m |
输出:
1 | Node 0 Node 1 Total |
NUMA 不均衡时,跨节点大页分配会增加内存访问延迟,应确保大页按应用所在 NUMA 节点均匀分配。
1 | # 追踪 THP 缺页分配(do_huge_pmd_anonymous_page) |
也可通过 /proc/vmstat 快速获取系统级 THP 统计:
1 | grep -i thp /proc/vmstat |
1 | thp_fault_alloc 45231 # 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),推荐:
khugepaged(pages_to_scan=0),消除随机延迟抖动。对于吞吐优先的 HPC、大内存 Java 应用,推荐:
enabled=always 或 enabled=madvise)。defrag=defer+madvise:避免同步内存压缩阻塞缺页路径。khugepaged/pages_to_scan,加快后台合并速度。参考源文件:
mm/hugetlb.cmm/huge_memory.cmm/khugepaged.cinclude/linux/hugetlb.h延伸阅读:
本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均来自真实内核文件。
内存是操作系统最核心的资源之一。当物理内存不足时,内核必须决定哪些页面可以释放、哪些必须保留——这个过程叫做内存回收(Memory Reclaim)。本文深入剖析 Linux 内存回收子系统的完整链路:从 LRU 链表组织、kswapd 后台线程、直接回收路径,到 Swap 换出、RMAP 反向映射、OOM Killer,以及内存碎片整理(Compaction)。
Linux 内核将所有可回收的用户态页面按照访问活跃度和类型组织成五条 LRU(Least Recently Used)链表,定义于 include/linux/mmzone.h:
1 | // include/linux/mmzone.h |
五条链表的语义如下:
| 链表 | 含义 |
|---|---|
LRU_INACTIVE_ANON |
不活跃的匿名页(栈、堆等),回收时需要换出到 swap |
LRU_ACTIVE_ANON |
活跃的匿名页,近期被访问过 |
LRU_INACTIVE_FILE |
不活跃的文件页(page cache),若干净可直接释放 |
LRU_ACTIVE_FILE |
活跃的文件页,近期被访问过 |
LRU_UNEVICTABLE |
不可回收页,如 mlock() 锁定的页 |
Active/Inactive 双链表设计的关键价值:若所有页面都在同一条链表上,扫描时很容易将那些历史上访问频繁但近期”恰好”没有被访问的页面错误淘汰,产生 thrashing。双链表通过”降级”而非直接淘汰来保护热页面:页面必须先从 active 链表移到 inactive 链表,在 inactive 链表上”熬过”若干次扫描后才会被回收候选。
每个 NUMA 节点(pg_data_t)都维护一个 struct lruvec,其中包含上述五条链表:
1 | // include/linux/mmzone.h |
lruvec 中的 nonresident_age 字段是工作集检测的核心计数器——每次 LRU 发生移动时递增,用于计算 refault distance(见第四节)。anon_cost 和 file_cost 动态记录回收匿名页和文件页各自的代价,平衡扫描压力。
include/linux/mm_inline.h 中的 folio_lru_list() 封装了 LRU 链表判断逻辑:
1 | // include/linux/mm_inline.h |
判断依据是 folio->flags 中的两个核心标志位:
PG_active**:置位时表示该页面在 active 链表上,是近期访问过的热页面。PG_referenced**:访问引用标志,用于实现二次机会(second-chance)算法。当 inactive 链表上的页面被扫描时,若 PG_referenced 已置位,则将其提升回 active 链表并清除标志;若未置位,才将其作为回收候选。kswapd 是内核中每个 NUMA 节点对应的一个内核线程(kswapd0、kswapd1…),它在后台默默维护各 zone 的空闲页面水位,避免内存分配路径上的延迟。它的核心逻辑在 mm/vmscan.c 的 kswapd() 函数中:
1 | // mm/vmscan.c |
kswapd 的主循环逻辑很清晰:
kswapd_try_to_sleep),直到某个 zone 的空闲页低于 WMARK_HIGH,被 wakeup_kswapd() 唤醒。balance_pgdat() 对该 NUMA 节点执行回收。PF_MEMALLOC 标志让 kswapd 在分配内存时可绕过部分水位检查,避免回收过程本身因内存不足而卡死。
balance_pgdat() 是 kswapd 的核心回收函数,其签名为:
1 | // mm/vmscan.c(第 7347 行) |
它的工作流程:
watermark_boost,决定是否需要 boost 回收。DEF_PRIORITY(默认 12)逐步降低,每轮调用 kswapd_shrink_node(),直到水位满足或优先级耗尽。1 | // mm/vmscan.c(第 7267 行) |
kswapd_shrink_node 计算出本轮需要回收的页面数(与各 zone 的高水位之和成正比),然后调用 shrink_node() 实际执行回收,最后返回是否已扫描了足够多的页面。
当内存分配器(__alloc_pages())在 slow path 里仍无法满足分配请求时,分配上下文会直接参与内存回收,这就是直接回收。其调用路径为:
1 | __alloc_pages_slowpath() |
1 | // mm/vmscan.c(第 6998 行) |
struct scan_control 是贯穿整个回收子系统的控制结构,记录回收目标数量、GFP 标志、是否允许 writepage、是否允许 unmap、是否允许 swap 等所有策略参数。
1 | // mm/vmscan.c(第 6780 行) |
do_try_to_free_pages 与 balance_pgdat 的策略相同:优先级从 12 逐级降低,每轮调用 shrink_zones() 遍历 zonelist 中的每个 NUMA 节点,调用 shrink_node()。值得注意的是,优先级低于 DEF_PRIORITY - 2 时,即使处于 laptop_mode 也会强制开启 writepage。
shrink_inactive_list() 是内存回收的核心逻辑,它从 inactive LRU 链表中隔离页面,尝试回收:
1 | // mm/vmscan.c(第 2556 行) |
整体流程:
isolate_lru_folios() 在持锁状态下从 LRU 链表隔离一批页面到临时链表 folio_list。shrink_folio_list()(6.4 中已替代旧的 shrink_page_list)处理每个页面的实际回收。move_folios_to_lru)。lru_note_cost() 更新 anon/file 回收代价,动态平衡后续扫描比例。shrink_folio_list() 对每个隔离出的页面做细粒度决策:
add_to_swap() 分配 swap slot 并加入 swap cache,再通过 try_to_unmap() 解除所有 PTE 映射,最后调用 swap_writepage() 将页面数据写入 swap 分区。folio_test_dirty),则调用 pageout() → writeback;若页面已经干净,则直接从 page cache 中移除并释放物理页帧。1 | // mm/vmscan.c(第 2293 行) |
isolate_lru_folios 从链表尾部(最老的页面)开始扫描,对每个候选 folio 做一系列检查:是否仍在 LRU 上、是否允许 unmap、是否能成功获取引用、是否能清除 LRU 标志(确保独占处理权)。通过检查后才从链表摘下,加入隔离列表 dst。
内核通过 shadow entry(影子条目) 机制来检测被错误淘汰的页面(refault)。当一个页面从 page cache 被逐出时,内核不会立刻删除其在 xarray 中的槽位,而是在原位置存入一个 shadow entry,包含当前 nonresident_age 的快照(即”驱逐时间戳”)。
当该文件区域被再次访问(page fault)时,workingset_refault() 被调用:
1 | // mm/workingset.c(第 396 行) |
refault distance = 从页面被逐出到被重新访问,LRU 总共发生了多少次移动(nonresident_age 增量)。若这个距离小于当前工作集大小(active + inactive 链表长度),说明该页面本应留在内存中,内核将直接把它提升为 active,防止反复 thrashing。
swap_entry_t 是一个 32/64 位的不透明整数值,编码了 swap 类型(设备索引)和偏移量(槽位号):
1 | // include/linux/swapops.h |
其二进制布局:高位为 swap 类型 ID(SWP_TYPE_SHIFT 位),低位为 offset(页面在 swap 分区中的页号)。
当匿名页即将被换出时,需要先通过 add_to_swap() 分配一个 swap slot(swp_entry_t),再调用 add_to_swap_cache() 将页面插入 swap cache(swapper_space 的 xarray):
1 | // mm/swap_state.c(第 88 行) |
注意此函数会将 page->private 设为 swp_entry_t 的值,这样当页面从 swap 读回时(lookup_swap_cache),内核可以通过 entry 在 swap cache 中查找已有的内存拷贝,避免重复 I/O。
1 | shrink_folio_list() |
页面换入(swap-in)时,缺页中断触发 do_swap_page(),先查 swap cache(lookup_swap_cache),命中则直接重建 PTE;未命中则从 swap 分区读回,经历一次 major fault。
页面回收的一个关键步骤是:找到所有映射了该页面的 PTE,将它们全部改为 swap entry 或置为无效。这就需要从物理页面反向找到所有的虚拟地址映射——这就是 RMAP(Reverse Mapping) 的作用。
匿名页的 RMAP 通过 struct anon_vma 实现:
1 | // include/linux/rmap.h(第 31 行) |
每个匿名 VMA 关联一个 anon_vma,后者通过区间树(interval tree)组织所有可能映射了该匿名页的 VMA 集合。当进程 fork 时,子进程的 VMA 会链接到父进程的 anon_vma,形成树状结构,确保 COW 后的页面也能被正确追踪。
文件页的反向映射则依赖 struct address_space(即 inode->i_mapping)中的 i_mmap 区间树,记录所有映射了该文件页的 VMA。
当新的匿名页面通过 PTE 映射到某个 VMA 时,调用 page_add_anon_rmap() 注册 RMAP:
1 | // mm/rmap.c(第 1214 行) |
_mapcount 记录有多少个 PTE 映射了该页面。__page_set_anon_rmap() 将 page->mapping 指向对应的 anon_vma,这是反向查找的入口。
try_to_unmap() 通过 rmap_walk 遍历所有映射了目标 folio 的 VMA,对每个 VMA 调用 try_to_unmap_one():
1 | // mm/rmap.c(第 1451 行) |
对于匿名页,PTE 被替换成 swap entry(编码了 swp_entry_t),后续访问触发缺页中断时内核可识别 swap entry 并发起换入。对于文件页,PTE 直接被清零(置为 not-present),访问时触发 minor fault 从 page cache 重建。
当所有回收尝试(kswapd + 直接回收)都告失败,__alloc_pages_slowpath() 最终调用 out_of_memory(),即 OOM Killer 的入口:
1 | // mm/oom_kill.c(第 1106 行) |
out_of_memory 会先检查各类快速出口(OOM killer 被禁用、通知链释放了内存、当前进程即将退出、GFP 标志不允许 FS 操作等),都无法处理时才真正选择受害者进程。
select_bad_process() 遍历所有进程,对每个进程调用 oom_evaluate_task(),后者使用 oom_badness() 计算分数:
1 | // mm/oom_kill.c(第 201 行) |
评分公式的核心:
1 | score = RSS + Swap使用量 + 页表占用页数 |
oom_score_adj**:范围 [-1000, 1000],通过 /proc/PID/oom_score_adj 调整。设为 -1000 相当于豁免(返回 LONG_MIN),设为 1000 则大幅增加被杀概率。得分最高(内存占用最大、oom_score_adj 最高)的进程会被选中为受害者。
1 | // mm/oom_kill.c(第 1013 行) |
__oom_kill_process() 会:
count_vm_event(OOM_KILL) 计数。SIGKILL。oom_reaper),它在受害者进程的 mm 上调用 exit_mmap() 强制释放内存,而不等待进程自然退出,确保内存被快速回收。当设置了 memory.oom.group = 1 的 cgroup 中有进程触发 OOM 时,oom_kill_process 会杀死整个 cgroup 中的所有进程。
Linux buddy allocator 使用的伙伴系统天然会产生外部碎片:即使总空闲页数充足,也可能无法满足高阶(order > 0)的连续内存分配请求,因为可用页面被分散在不连续的物理地址上。内存压缩(Compaction)通过迁移可移动页面来合并空闲块,解决这一问题。
compact_zone() 是内存压缩的核心函数,它使用两个扫描指针:
1 | // mm/compaction.c(第 2317 行) |
迁移完成后,低地址区域腾出了连续的空闲块,高阶分配得以满足。
与 kswapd 类似,每个 NUMA 节点有一个 kcompactd 后台线程。当 kswapd 完成回收但高阶分配仍然失败时,kswapd 会调用 wakeup_kcompactd() 唤醒 kcompactd,后者调用 compact_zone() 执行后台压缩,提高高阶分配成功率而不影响前台服务的延迟。
1 | $ cat /proc/meminfo |
MemAvailable 远比 MemFree 更能反映系统真实可用内存,它综合考虑了 page cache 和 slab 的可回收部分。Dirty 大 通常意味着 I/O 子系统跟不上写入速度,可能导致回收时的写回等待。Writeback 长时间非零 表明正在发生大量回写,内存压力较高。1 | # -w 宽格式,1 秒刷新一次 |
关键字段:
si(swap-in):每秒从 swap 换入的页数。非零表示系统正在积极换入。so(swap-out):每秒换出到 swap 的页数。长期非零说明内存严重不足。bi/bo**:块设备读写(pages/s),结合 si/so 可判断是 swap I/O 还是文件 I/O。1 | $ cat /proc/vmstat | grep -E 'pgsteal|pgscan|pgmajfault' |
pgscan / pgsteal 的比值反映回收效率:比值越接近 1,说明扫描的页面几乎都能成功回收;比值远大于 1 说明大量扫描都是无效的(页面被重新激活或跳过)。
1 | $ sar -B 1 5 |
%vmeff = pgsteal / (pgscank + pgscand) × 100,即回收效率百分比。低于 50% 时说明系统内存压力异常高,大量页面反复被扫描却无法回收。
1 | # 追踪 OOM kill 事件,打印被杀进程名和 PID |
1 | $ dmesg | grep -i oom |
OOM 日志包含:触发约束类型、目标 memcg 路径、被杀进程名/PID、内存分布(total-vm/anon-rss/file-rss)和 oom_score_adj 值,是排查内存泄漏和容量规划的重要依据。
1 | 内存分配请求(alloc_pages) |
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 Killermm/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 系统调用的完整路径,以及缺页异常的处理流程。所有代码片段均来自真实内核源文件。
每个进程拥有独立的虚拟地址空间,其元数据由 struct mm_struct 描述,定义在 include/linux/mm_types.h:
1 | // include/linux/mm_types.h |
值得注意的是,Linux 6.1 起用 Maple Tree(mm_mt)取代了旧的红黑树 + 链表双结构来管理 VMA,查找性能从 O(log n) 提升,同时减少了锁竞争。mmap_lock 是一把读写信号量,读路径(find_vma 等查询)持读锁,写路径(新建/删除 VMA)持写锁。
在 x86-64 四级页表模式下(LA48),用户态地址空间为 0 ~ 0x0000_7fff_ffff_ffff(128 TB),布局如下:
1 | 高地址 |
五级页表(LA57)将用户态空间扩展至 128 PB,通过 CONFIG_X86_5LEVEL 开启,运行时由 pgtable_l5_enabled() 检测 CPU 的 LA57 特性位决定是否激活。
VMA 是虚拟地址空间的最小管理单元,每段具有相同属性(权限、映射文件)的连续地址区间对应一个 struct vm_area_struct:
1 | // include/linux/mm_types.h |
vm_flags 常用标志(定义在 include/linux/mm.h):
1 | #define VM_READ 0x00000001 /* 可读 */ |
VM_SHARED 与 VM_WRITE 的组合决定了写时复制(COW)行为:MAP_PRIVATE | PROT_WRITE 的 VMA 不含 VM_SHARED,写入时会触发 COW;MAP_SHARED | PROT_WRITE 含 VM_SHARED,写入直接反映到文件。
每类映射都通过 vm_ops 提供钩子,定义在 include/linux/mm.h:
1 | struct vm_operations_struct { |
例如,ext4 文件映射使用 ext4_file_vm_ops,其 fault 钩子会调用 filemap_fault() 从页缓存读取数据;/dev/zero 使用 zero_vm_ops,其 fault 直接返回零页。
在 Linux 6.1 之后,find_vma 基于 Maple Tree 实现,源码在 mm/mmap.c:
1 | // mm/mmap.c line 1858 |
mt_find 在 Maple Tree 中找到第一个满足 key >= addr 的区间节点。若返回的 VMA 满足 vma->vm_start <= addr < vma->vm_end,说明 addr 落在该 VMA 内;若 addr < vma->vm_start,则 addr 在一个”空洞”里,返回的是下一个 VMA。调用方在缺页异常处理中需要额外验证前者条件。
1 | 用户态 mmap(2) |
1 | // mm/mmap.c line 1402 |
匿名映射(MAP_ANONYMOUS)时 file 为 NULL,pgoff 对私有匿名映射被设置为 addr >> PAGE_SHIFT(在 do_mmap 中),用于 anon_vma 的索引。
do_mmap(mm/mmap.c line 1222)是核心逻辑层,负责:
get_unmapped_area() 找到合适的地址区间(ASLR 随机化在此发生);calc_vm_prot_bits / calc_vm_flag_bits 将 POSIX PROT_* 和 MAP_* 转换为内核 VM_* 标志;FMODE_READ / FMODE_WRITE);MAP_SHARED 文件映射:添加 VM_SHARED | VM_MAYSHARE;MAP_PRIVATE 文件映射:不添加 VM_SHARED,写入时触发 COW;mmap_region()。1 | // mm/mmap.c line 1289(关键片段) |
mmap_region(mm/mmap.c line 2547)是真正分配 VMA 的函数:
1 | // mm/mmap.c line 2547(核心流程) |
MAP_PRIVATE vs MAP_SHARED 的本质区别:
| 特性 | MAP_PRIVATE | MAP_SHARED |
|---|---|---|
| vm_flags | 无 VM_SHARED | 含 VM_SHARED |
| 写时行为 | COW:产生进程私有副本 | 直接修改底层页,其他进程可见 |
| 文件刷盘 | 修改不回写文件 | 修改最终通过 writeback 回写 |
| 匿名映射 | vm_ops = NULL | 通过 shmem 实现共享 |
x86-64 采用分级页表将 57 位(五级)或 48 位(四级)虚拟地址翻译为物理地址。各级索引位划分如下(五级,arch/x86/include/asm/pgtable_64_types.h):
1 | 虚拟地址 [56:0](57 位): |
对应的类型定义(arch/x86/include/asm/pgtable_64_types.h):
1 | typedef unsigned long pteval_t; |
使用强类型结构体而非裸 unsigned long,可以让编译器在混用不同层级页表项时产生类型错误,是内核防御性编程的典型实践。
x86-64 每个页表项(PTE)为 64 位,关键位定义在 arch/x86/include/asm/pgtable_types.h:
1 | #define _PAGE_BIT_PRESENT 0 /* P:页存在 */ |
典型用户页保护配置(arch/x86/include/asm/pgtable_types.h):
1 | /* 可读不可写不可执行的用户共享页 */ |
include/linux/pgtable.h 提供了标准的多级页表遍历接口:
1 | /* 从 mm->pgd 获取 PGD 项 */ |
一次完整的地址翻译调用链(见 include/linux/pgtable.h line 153):
1 | static inline pmd_t *pmd_off(struct mm_struct *mm, unsigned long va) { |
CPU 触发 #PF 异常后,进入 IDT 注册的处理函数(arch/x86/mm/fault.c):
1 | // arch/x86/mm/fault.c line 1546 |
handle_page_fault 判断缺页地址属于内核空间还是用户空间,用户空间路径调用 do_user_addr_fault,最终到达通用的 handle_mm_fault。
1 | // mm/memory.c line 4997 |
1 | // mm/memory.c line 4893 |
do_pte_missing 会进一步区分:
1 | // mm/memory.c line 3640(do_pte_missing 内部) |
1 | // mm/memory.c line 4031 |
零页优化:进程第一次读未初始化匿名内存时,内核将虚拟地址映射到一个全局共享的只读零页(my_zero_pfn),物理内存实际零分配。当写入发生时,才触发 COW 分配真实物理页。
1 | // mm/memory.c line 4150 |
对于 ext4/xfs 等文件系统,vm_ops->fault 最终调用 filemap_fault,先在 page cache 中查找,命中则直接返回(minor fault);否则提交 I/O 并等待(major fault)。
1 | // mm/memory.c line 3324 |
wp_page_copy 分配一个新的物理页,调用 __wp_page_copy_user 复制内容,然后将原来的只读 PTE 替换为指向新页的可写 PTE,并 flush TLB 使旧映射失效。
TLB(Translation Lookaside Buffer)是 CPU 内部对最近页表查找结果的缓存,避免每次内存访问都遍历多级页表(每级一次内存读取)。x86-64 通常有独立的 L1 iTLB / dTLB 以及共享的 L2 TLB。
当内核修改页表(unmap、mprotect、mremap 等)后,必须使相关 TLB 条目失效(TLB shootdown),否则其他 CPU 可能使用过时的映射。
1 | /* 刷新整个进程的 TLB(进程退出、exec 等) */ |
x86 的 SMP TLB shootdown 通过 IPI(Inter-Processor Interrupt)通知其他 CPU 执行 INVLPG 指令(针对单页)或 MOV CR3(针对整个地址空间)。
传统 MOV CR3 切换进程时会完全刷新 TLB,代价高昂。Intel Haswell 以后支持 PCID(Process Context Identifier),允许 TLB 条目携带 12 位的进程标识,切换时通过设置 CR3 的第 63 位为 0 来保留其他进程的 TLB 条目:
switch_mm_irqs_off 中维护 PCID → mm 的映射;THP(Transparent Huge Pages)允许匿名映射自动使用 2 MB PMD 大页,由 __handle_mm_fault 中的 create_huge_pmd 触发,实际调用 do_huge_pmd_anonymous_page:
_PAGE_PSE 位,指向 2 MB 物理基地址;__split_huge_pmd 降级为 4 KB 页。/proc/PID/maps 列出进程所有 VMA,每行对应一个 vm_area_struct:
1 | 地址范围 权限 偏移量 设备 inode 文件路径 |
权限字段含义:r(VM_READ)、w(VM_WRITE)、x(VM_EXEC)、p(MAP_PRIVATE)/ s(MAP_SHARED)。
smaps 在 maps 基础上提供每个 VMA 的内存统计:
1 | 7f3a4c021000-7f3a4c176000 r-xp 00021000 fd:01 1234567 /lib/.../libc.so.6 |
pagemap 是虚拟地址到物理帧(PFN)的映射接口,每个虚拟页对应一个 8 字节条目:
示例读取脚本:
1 | # 查看进程虚拟地址 0x400000 对应的物理帧号 |
1 | VmPeak: 102400 kB # 历史峰值虚拟内存 |
1 | pmap -x <PID> |
输出中 RSS 列为常驻集大小,Dirty 列为脏页大小(写入但未回写),Mapping 列为文件名或 [ anon ]/ [ stack ]。
1 | # 追踪某进程的缺页异常,打印触发地址和故障类型 |
1 | # 统计目标进程的缺页次数(运行 5 秒) |
minor-faults(次缺页):页表项缺失但数据已在内存(如零页映射、文件已在 page cache),无需 I/O;major-faults(主缺页):需要从磁盘读取数据,延迟高达毫秒级。
理解本文涉及的机制,建议按以下顺序阅读源码:
| 文件 | 关键内容 |
|---|---|
include/linux/mm_types.h |
mm_struct、vm_area_struct、vm_fault 等核心数据结构 |
include/linux/mm.h |
vm_operations_struct、VM_* 标志、find_vma/find_vma_intersection 等内联函数 |
include/linux/pgtable.h |
pgd_offset、pmd_offset、pte_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_SHIFT、PMD_SHIFT 等) |
mm/mmap.c |
do_mmap、mmap_region、find_vma、vma_merge 实现 |
mm/memory.c |
handle_mm_fault、handle_pte_fault、do_anonymous_page、do_fault、do_wp_page |
arch/x86/mm/fault.c |
x86 缺页异常入口 exc_page_fault → handle_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_struct、inode、dentry……)都远小于一页(4 KiB),若每次都向 Buddy 系统申请整页,会造成严重的内部碎片。为此 Linux 在 Buddy 之上引入了 Slab 分配器层,专为固定大小的内核对象服务。
本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,深入剖析三种 Slab 实现的设计哲学、SLUB 的核心数据结构与分配/释放路径,并延伸到 vmalloc 虚拟连续内存分配。