前言

在 Linux 进程管理体系中,信号(Signal)是最古老也最核心的异步通知机制,而进程间通信(IPC)则是多进程协作的基础设施。本文基于 Linux 6.4-rc1 内核源码,深入剖析信号的数据结构、发送路径、处理流程,以及 pipe、POSIX 消息队列、System V IPC、UNIX Domain Socket 和 futex 的内核实现。理解这些机制,是系统编程、性能调优和内核调试的必备基础。


一、信号机制:数据结构全景

1.1 sigset_t:信号集位图

信号集的底层表示是一个位图数组。x86-64 下 _NSIG = 64_NSIG_BPW = 64,因此 _NSIG_WORDS = 1,整个集合用一个 64 位整数表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// include/linux/signal.h
static inline void sigaddset(sigset_t *set, int _sig)
{
unsigned long sig = _sig - 1;
if (_NSIG_WORDS == 1)
set->sig[0] |= 1UL << sig;
else
set->sig[sig / _NSIG_BPW] |= 1UL << (sig % _NSIG_BPW);
}

static inline int sigismember(sigset_t *set, int _sig)
{
unsigned long sig = _sig - 1;
if (_NSIG_WORDS == 1)
return 1 & (set->sig[0] >> sig);
else
return 1 & (set->sig[sig / _NSIG_BPW] >> (sig % _NSIG_BPW));
}

信号编号从 1 开始,因此位操作时先减 1。信号 1-31 是传统不可靠信号(POSIX 标准信号),32-64 是实时信号(SIGRTMIN=34, SIGRTMAX=64)。

1.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
28
29
30
31
32
33
34
35
// include/linux/signal_types.h

// 待处理信号队列:位图 + 链表
struct sigpending {
struct list_head list; // sigqueue 链表(实时信号排队)
sigset_t signal; // 位图(标记哪些信号已投递)
};

// 单个排队信号节点
struct sigqueue {
struct list_head list;
int flags;
kernel_siginfo_t info; // 包含 si_signo/si_code/si_pid 等
struct ucounts *ucounts;
};

// 用户态信号处理器描述
struct sigaction {
__sighandler_t sa_handler; // SIG_DFL / SIG_IGN / 用户函数
unsigned long sa_flags; // SA_RESTART / SA_SIGINFO / SA_NODEFER ...
sigset_t sa_mask; // 处理期间额外屏蔽的信号
};

struct k_sigaction {
struct sigaction sa;
};

// 信号处理器数组(线程组共享)
// include/linux/sched/signal.h
struct sighand_struct {
spinlock_t siglock;
refcount_t count; // 引用计数,线程共享
wait_queue_head_t signalfd_wqh;
struct k_sigaction action[_NSIG]; // 64 个信号的处理器数组
};

sighand_struct 是线程组内所有线程共享的,当调用 sigaction() 修改某个信号的处理器时,整个线程组都受影响。

1.3 signal_struct:进程级信号状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// include/linux/sched/signal.h
struct signal_struct {
refcount_t sigcnt;
atomic_t live;
int nr_threads;
struct list_head thread_head;

wait_queue_head_t wait_chldexit; // wait4() 等待子进程退出

struct task_struct *curr_target; // 信号负载均衡目标线程

/* 进程级共享 pending 队列(发给整个线程组的信号) */
struct sigpending shared_pending;

int group_exit_code;
int group_stop_count;
unsigned int flags; // SIGNAL_GROUP_EXIT / SIGNAL_STOP_STOPPED ...
// ...
};

每个 task_struct 还拥有自己私有的 task->pending,用于接收 tgkill/tkill 等指定线程的信号。信号投递时,shared_pending 由线程组中任意一个线程处理,task->pending 只能由目标线程处理。


二、信号发送路径

2.1 kill() 到 __send_signal_locked 的调用链

1
2
3
4
5
6
7
8
kill(pid, sig)
└── sys_kill()
└── kill_something_info()
├── kill_pid_info() → 发给单进程
└── __kill_pgrp_info() → 发给进程组
└── group_send_sig_info()
└── send_signal_locked()
└── __send_signal_locked()

进程组信号发送的核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kernel/signal.c:1453
int __kill_pgrp_info(int sig, struct kernel_siginfo *info, struct pid *pgrp)
{
struct task_struct *p = NULL;
int retval, success;

success = 0;
retval = -ESRCH;
do_each_pid_task(pgrp, PIDTYPE_PGID, p) {
int err = group_send_sig_info(sig, info, p, PIDTYPE_PGID);
success |= !err;
retval = err;
} while_each_pid_task(pgrp, PIDTYPE_PGID, p);
return success ? 0 : retval;
}

2.2 __send_signal_locked:信号入队核心

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/signal.c:1078
static int __send_signal_locked(int sig, struct kernel_siginfo *info,
struct task_struct *t, enum pid_type type, bool force)
{
struct sigpending *pending;
struct sigqueue *q;
int override_rlimit;

lockdep_assert_held(&t->sighand->siglock);

if (!prepare_signal(sig, t, force))
goto ret; // 信号被忽略,直接返回

// 进程组信号进 shared_pending,线程信号进私有 pending
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;

// 不可靠信号(1-31):已经在 pending 中则不重复入队
if (legacy_queue(pending, sig))
goto ret;

// 实时信号:分配 sigqueue 节点加入链表
if (sig < SIGRTMIN)
override_rlimit = (is_si_special(info) || info->si_code >= 0);
else
override_rlimit = 0;

q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit, 0);
if (q) {
list_add_tail(&q->list, &pending->list); // 加入等待链表
copy_siginfo(&q->info, info);
} else if (sig >= SIGRTMIN && info->si_code != SI_USER) {
// 实时信号队列溢出,返回 EAGAIN
return -EAGAIN;
}

out_set:
signalfd_notify(t, sig);
sigaddset(&pending->signal, sig); // 在位图中置位
complete_signal(sig, t, type); // 选择目标线程并唤醒
return 0;
}

可靠信号 vs 不可靠信号的关键差异在第 1073-1076 行的 legacy_queue()

1
2
3
4
static inline bool legacy_queue(struct sigpending *signals, int sig)
{
return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);
}

信号 1-31(sig < SIGRTMIN=34)如果 pending 位图已置位,新的投递会被静默丢弃。而实时信号(34-64)不受此约束,每次都会分配新的 sigqueue 节点入链表,从而实现多次投递的可靠排队。

2.3 signal_wake_up:唤醒目标进程

1
2
3
4
5
6
7
8
9
10
11
12
// kernel/signal.c:763
void signal_wake_up_state(struct task_struct *t, unsigned int state)
{
lockdep_assert_held(&t->sighand->siglock);

// 在 thread_info 中设置 TIF_SIGPENDING 标志
set_tsk_thread_flag(t, TIF_SIGPENDING);

// 唤醒处于 TASK_INTERRUPTIBLE 或 TASK_WAKEKILL 状态的线程
if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
kick_process(t); // 如果目标在其他 CPU 上运行,发送 IPI
}

TIF_SIGPENDING 标志设置后,目标进程在下次从内核返回用户态时(系统调用返回或中断返回)会检查并处理信号。

2.4 tgkill / tkill:向指定线程发送信号

tkill(tid, sig) 绕过线程组,直接向特定 tid 发送信号,信号进入 task->pending(私有队列)而非 signal->shared_pending。这对于多线程程序中精确控制信号投递至关重要,也是 pthread_kill() 的内核实现基础。


三、信号处理流程

3.1 信号处理时机

信号不是异步立即执行的,而是在进程从内核态返回用户态的”安全点”处理:

  • 系统调用返回前syscall_exit_to_user_mode()exit_to_user_mode_loop() → 检查 TIF_SIGPENDING
  • 中断返回前:硬件中断处理完毕返回用户态时检查

x86-64 的入口点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// arch/x86/kernel/signal.c:302
void arch_do_signal_or_restart(struct pt_regs *regs)
{
struct ksignal ksig;

if (get_signal(&ksig)) {
/* 有信号需要投递,调用 handle_signal */
handle_signal(&ksig, regs);
return;
}

/* 没有信号处理器,检查是否需要重启系统调用 */
if (syscall_get_nr(current, regs) != -1) {
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2; // 重新执行 syscall 指令
break;
// ...
}
}
restore_saved_sigmask();
}

3.2 get_signal:从 pending 队列取信号

get_signal() 是信号出队的核心函数(kernel/signal.c:2642),其工作流程:

  1. 检查 TIF_SIGPENDING 标志
  2. 调用 try_to_freeze() 处理冻结请求
  3. 调用 dequeue_signal()task->pendingsignal->shared_pending 取出最高优先级信号
  4. 检查信号处理器:若为 SIG_DFL 且是致命信号,执行默认行为(SIGKILL 直接在此终止进程)
  5. 返回 ksignal(含信号编号、info、处理器)

SIGKILLSIGSTOP 在此处被特殊处理——它们永远不会到达用户态信号处理器:

1
2
3
4
5
6
// kernel/signal.c:2709
if ((signal->flags & SIGNAL_GROUP_EXIT) || signal->group_exec_task) {
ksig->info.si_signo = signr = SIGKILL;
// 直接跳到 fatal 处理,不查 sighand->action
goto fatal;
}

3.3 handle_signal 与信号栈帧

当信号有用户态处理器时,handle_signal() 负责在用户栈上构建信号栈帧,并修改 pt_regs 使处理器返回用户态时跳转到信号处理函数:

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
// arch/x86/kernel/signal.c:224
static void handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
bool stepping, failed;
struct fpu *fpu = &current->thread.fpu;

/* 处理系统调用重启语义 */
if (syscall_get_nr(current, regs) != -1) {
switch (syscall_get_error(current, regs)) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->ax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
regs->ax = -EINTR;
break;
}
fallthrough;
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
}
}

failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
regs->flags &= ~(X86_EFLAGS_DF|X86_EFLAGS_RF|X86_EFLAGS_TF);
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}

3.4 rt_sigframe:信号栈帧布局

setup_rt_frame() 在用户栈上压入 struct rt_sigframe

1
2
3
4
5
6
7
// arch/x86/include/asm/sigframe.h:59
struct rt_sigframe {
char __user *pretcode; // 指向 sigreturn trampoline
struct ucontext uc; // 保存的用户态上下文(含 pt_regs)
struct siginfo info; // 信号信息
/* FP/XSAVE 状态紧跟其后 */
};

struct ucontext 内嵌 struct sigcontext,后者保存了所有通用寄存器(rax/rbx/…/rsp/rip/rflags)以及 FPU 状态指针。信号处理函数执行完毕后,调用 rt_sigreturn 系统调用,内核从 uc 中恢复完整的 CPU 状态,进程无缝回到被中断的执行点。


四、管道(pipe)

4.1 struct pipe_inode_info:环形缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// include/linux/pipe_fs_i.h:58
struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t rd_wait, wr_wait; // 读写等待队列
unsigned int head; // 写入位置(生产者指针)
unsigned int tail; // 读取位置(消费者指针)
unsigned int max_usage; // 可用槽数上限
unsigned int ring_size; // 环形数组总槽数(必须是 2 的幂)
unsigned int readers; // 当前读端 fd 引用计数
unsigned int writers; // 当前写端 fd 引用计数
struct pipe_buffer *bufs; // 环形缓冲区数组
struct user_struct *user;
// ...
};

// 每个缓冲区槽指向一个物理页
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags; // PIPE_BUF_FLAG_CAN_MERGE 等
unsigned long private;
};

默认 ring_size = PIPE_DEF_BUFFERS = 16,每槽一个 4KB 页,管道默认容量 64KB。headtail 不做掩码,允许自然溢出环绕,访问时用 index & (ring_size - 1) 取模。

4.2 pipe_write:数据写入

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
// fs/pipe.c:416(关键片段)
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
struct pipe_inode_info *pipe = filp->private_data;
unsigned int head;

__pipe_lock(pipe);

if (!pipe->readers) {
send_sig(SIGPIPE, current, 0); // 无读端则发 SIGPIPE
ret = -EPIPE;
goto out;
}

head = pipe->head;
was_empty = pipe_empty(head, pipe->tail);

/* 尝试合并到上一个 buf(小写入优化) */
if (chars && !was_empty) {
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
ret = copy_page_from_iter(buf->page, offset, chars, from);
buf->len += ret;
}
}

for (;;) {
head = pipe->head;
if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
struct pipe_buffer *buf = &pipe->bufs[head & mask];
/* 分配新页,写入数据,推进 head */
pipe->head = head + 1;
} else {
/* 管道满,等待读者消费 */
if (wait_event_interruptible_exclusive(pipe->wr_wait,
pipe_writable(pipe)) < 0)
break;
}
}
// ...
}

4.3 管道容量控制

1
/proc/sys/fs/pipe-max-size  # 默认 1048576(1MB),非 root 用户上限

通过 fcntl(fd, F_SETPIPE_SZ, size) 可以动态调整单个管道容量。内核将请求的 size 向上取整到 2 的幂(不超过 pipe_max_size),然后 krealloc 扩展 bufs 数组。

4.4 splice 与零拷贝

splice(2) 系统调用利用管道的 pipe_buffer 结构实现文件到 socket 的零拷贝:数据以页引用的方式在管道内传递,不需要 memcpy 到用户缓冲区。vmsplice 可以将用户态内存页”赠送”给管道(PIPE_BUF_FLAG_GIFT),配合 splice 实现用户态到 socket 的全程零拷贝传输。


五、POSIX 消息队列

5.1 基于 tmpfs 的实现

POSIX 消息队列挂载在独立的 mqueue 文件系统(基于 tmpfs)。每个消息队列对应一个 mqueue_inode_info

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ipc/mqueue.c:134
struct mqueue_inode_info {
spinlock_t lock;
struct inode vfs_inode;
wait_queue_head_t wait_q;

struct rb_root msg_tree; // 消息按优先级存储(红黑树)
struct rb_node *msg_tree_rightmost; // 最高优先级节点(O(1) 访问)
struct posix_msg_tree_node *node_cache;
struct mq_attr attr;

struct sigevent notify; // 消息到达时的通知方式
struct pid *notify_owner;
// 等待发送 / 等待接收的任务队列
struct ext_wait_queue e_wait_q[2];

unsigned long qsize; // 队列已用内存
};

5.2 优先级队列

1
2
3
4
5
6
// ipc/mqueue.c:61
struct posix_msg_tree_node {
struct rb_node rb_node; // 插入红黑树的节点
struct list_head msg_list; // 相同优先级的消息链表
int priority;
};

mq_send() 时,先在红黑树中查找对应优先级节点,若不存在则创建新节点插入;mq_receive() 直接取 msg_tree_rightmost(最右节点即最高优先级),时间复杂度 O(log P)(P 为不同优先级数量),同优先级内 FIFO 顺序。


六、System V IPC

6.1 信号量:struct sem_array

1
2
3
4
5
6
7
8
9
10
11
12
13
// ipc/sem.c:114
struct sem_array {
struct kern_ipc_perm sem_perm; // IPC 权限(key/uid/gid/mode)
time64_t sem_ctime;
struct list_head pending_alter; // 待执行的修改操作队列
struct list_head pending_const; // 待执行的只读操作队列
struct list_head list_id; // undo 请求链表
int sem_nsems; // 信号量个数
int complex_count;
unsigned int use_global_lock;

struct sem sems[]; // 信号量值数组(柔性数组)
} __randomize_layout;

semop() 的原子性保证:操作执行前检查整个操作集合能否同时满足(”all-or-nothing”语义),不能满足则整体入 pending_alter 睡眠等待。内核还支持 SEM_UNDO 标志,进程退出时自动回滚所有 semop 操作,防止死锁。

6.2 消息队列:struct msg_queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ipc/msg.c:49
struct msg_queue {
struct kern_ipc_perm q_perm;
time64_t q_stime; // 最后 msgsnd 时间
time64_t q_rtime; // 最后 msgrcv 时间
unsigned long q_cbytes; // 当前队列字节数
unsigned long q_qnum; // 消息数量
unsigned long q_qbytes; // 最大字节限制
struct pid *q_lspid; // 最后发送者 PID
struct pid *q_lrpid; // 最后接收者 PID

struct list_head q_messages; // 消息链表
struct list_head q_receivers; // 等待接收的进程
struct list_head q_senders; // 等待发送的进程(队列满时阻塞)
} __randomize_layout;

msgsnd() 将消息附加到 q_messages 链表尾部;msgrcv() 支持按 msgtype 过滤接收(正数接收指定类型,负数接收类型绝对值最小的,0 接收任意最早消息)。

6.3 共享内存

shmget()/shmat() 基于 tmpfsshmem)实现。shmget() 在 shmem 文件系统上创建一个匿名文件,shmat() 调用 do_mmap() 将该文件的页面映射到进程虚拟地址空间。多个进程 shmat() 同一个 shmid,它们的虚拟地址映射到相同的物理页(零拷贝),通过共享页表实现数据直接共享。


七、UNIX Domain Socket

7.1 struct unix_sock

1
2
3
4
5
6
7
8
9
10
11
12
13
// include/net/af_unix.h:56
struct unix_sock {
struct sock sk; // 必须是第一个成员
struct unix_address *addr; // 绑定的路径名地址
struct path path; // 对应的 dentry/inode
struct mutex iolock, bindlock;
struct sock *peer; // 连接的对端 socket
struct list_head link;
atomic_long_t inflight; // 传输中的 FD 数量
spinlock_t lock;
struct socket_wq peer_wq;
struct scm_stat scm_stat;
};

7.2 unix_stream_sendmsg:内存直传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// net/unix/af_unix.c:2160
static int unix_stream_sendmsg(struct socket *sock, struct msghdr *msg, size_t len)
{
struct sock *sk = sock->sk;
struct sock *other = unix_peer(sk); // 对端 socket

err = scm_send(sock, msg, &scm, false); // 处理辅助数据(SCM_RIGHTS 等)

while (sent < len) {
// 分配 sk_buff,将用户数据拷贝进去
skb = sock_alloc_send_pskb(sk, size - data_len, data_len, ...);
err = unix_scm_to_skb(&scm, skb, !fds_sent); // 附加文件描述符
err = skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, size);

unix_state_lock(other);
skb_queue_tail(&other->sk_receive_queue, skb); // 直接放入对端接收队列
unix_state_unlock(other);
other->sk_data_ready(other); // 唤醒对端
}
}

UNIX Domain Socket 的关键优势:数据通过 sk_buff 在内核内存中传递,完全绕过网络协议栈(无 TCP/IP 头部处理、无校验和计算、无路由查找)。与管道相比,它支持双向通信和数据报语义(SOCK_DGRAM)。

7.3 文件描述符传递(SCM_RIGHTS)

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:快速用户空间互斥锁

8.1 设计哲学

futex(Fast Userspace muTEX)的核心洞察:在无竞争时完全在用户态完成,仅在竞争时陷入内核。glibc 的 pthread_mutex_lock() 底层就是 futex。

8.2 struct futex_hash_bucket

1
2
3
4
5
6
// kernel/futex/futex.h:45
struct futex_hash_bucket {
atomic_t waiters; // 该桶中等待者计数(用于快速路径优化)
spinlock_t lock;
struct plist_head chain; // 优先级排序的等待队列(用于 PI-futex)
} ____cacheline_aligned_in_smp;

内核维护一个全局哈希表 futex_queues,以 futex 变量的物理地址(对于 shared futex)或虚拟地址(对于 private futex)为 key 哈希到对应的 bucket。

8.3 FUTEX_WAIT:快速路径 vs 慢速路径

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
// kernel/futex/waitwake.c:632
int futex_wait(u32 __user *uaddr, unsigned int flags, u32 val,
ktime_t *abs_time, u32 bitset)
{
struct futex_hash_bucket *hb;
struct futex_q q = futex_q_init;

retry:
/* 慢速路径:进入内核,锁定 hash bucket */
ret = futex_wait_setup(uaddr, val, flags, &q, &hb);
/*
* futex_wait_setup 内部:
* 1. get_futex_key():计算哈希键
* 2. futex_q_lock(q):锁定 bucket,原子地增加 waiters 计数
* 3. 再次读取 *uaddr,若已不等于 val 则返回 -EWOULDBLOCK
* (防止在用户态检查和内核入队之间漏掉 wake)
*/
if (ret)
goto out;

/* 入队并调度睡眠,等待 FUTEX_WAKE */
futex_wait_queue(hb, &q, to);

if (!futex_unqueue(&q))
goto out; // 已被 wake,返回 0

if (!signal_pending(current))
goto retry; // 虚假唤醒,重试

ret = -ERESTARTSYS;
// ...
}

快速路径(用户态完成):pthread_mutex_lock()cmpxchg 原子地尝试将 futex 值从 0 改为线程 TID;成功则无需系统调用。只有 cmpxchg 失败(有竞争)时才调用 sys_futex(FUTEX_WAIT, ...)

8.4 FUTEX_WAKE:唤醒等待者

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
// kernel/futex/waitwake.c:143
int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset)
{
struct futex_hash_bucket *hb;
DEFINE_WAKE_Q(wake_q);

ret = get_futex_key(uaddr, flags & FLAGS_SHARED, &key, FUTEX_READ);

hb = futex_hash(&key);

/* 快速路径:bucket 没有等待者,直接返回 */
if (!futex_hb_waiters_pending(hb))
return ret;

spin_lock(&hb->lock);
plist_for_each_entry_safe(this, next, &hb->chain, list) {
if (futex_match(&this->key, &key)) {
futex_wake_mark(&wake_q, this); // 加入唤醒队列
if (++ret >= nr_wake)
break;
}
}
spin_unlock(&hb->lock);
wake_up_q(&wake_q); // 批量唤醒
return ret;
}

futex_hb_waiters_pending() 检查 hb->waiters 计数,若为 0 则跳过所有锁操作——这是 futex 在无竞争时保持低开销的关键优化。

8.5 pthread_mutex 的 futex 实现原理

1
2
3
4
5
6
7
8
9
10
11
12
pthread_mutex_lock():
1. cmpxchg(futex_addr, 0, tid) // 用户态原子操作,无系统调用
成功 → 获得锁,返回
失败 → 调用 sys_futex(FUTEX_WAIT, futex_addr, current_val)
内核将当前线程加入 hash bucket 的等待链表
调度出去睡眠

pthread_mutex_unlock():
1. atomic_store(futex_addr, 0) // 用户态释放
2. 如果 futex 值之前标记了 FUTEX_WAITERS:
调用 sys_futex(FUTEX_WAKE, futex_addr, 1)
内核从等待链表中取出一个线程唤醒

九、诊断方法与工具

9.1 信号诊断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 查看所有信号编号与名称
kill -l

# 追踪进程的信号收发
strace -e trace=signal -p <PID>

# 解读 /proc/PID/status 中的信号位图
cat /proc/$(pgrep mysqld)/status | grep -E "Sig(Blk|Pnd|Cgt|Ign)"
# 示例输出:
# SigPnd: 0000000000000000 # 待处理信号(线程私有)
# SigBlk: 0000000000000000 # 已屏蔽信号
# SigIgn: 0000000000001000 # 忽略的信号(bit 12 = SIGPIPE)
# SigCgt: 00000001800024fb # 有用户态处理器的信号
# 用 python 解码:python3 -c "v=0x00000001800024fb; [print(i+1) for i in range(64) if v>>i&1]"

# bpftrace 追踪信号发送
bpftrace -e 'kprobe:__send_signal_locked { printf("sig=%d pid=%d->%d\n",
arg0, pid, ((struct task_struct*)arg2)->pid); }'

9.2 IPC 资源诊断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查看所有 System V IPC 资源(信号量/消息队列/共享内存)
ipcs -a

# 查看管道的 fd 使用情况
lsof | grep pipe | head -20

# 查看 POSIX 消息队列(需要挂载 mqueue)
ls -la /dev/mqueue/

# 追踪 futex 竞争热点
bpftrace -e 'kprobe:do_futex { @[ustack] = count(); }
interval:s:5 { print(@); clear(@); exit(); }'

# 统计 futex 系统调用耗时
perf stat -e 'syscalls:sys_enter_futex' -p <PID> -- sleep 10

9.3 /proc/PID/status 信号位图解读

信号位图是 64 位十六进制数,每个 bit 对应一个信号编号(bit N = 信号 N+1)。例如 SigIgn: 0000000000001000

1
2
0x1000 = 0001 0000 0000 0000 (binary)
bit 12 已置位 → 信号 13 (SIGPIPE) 被忽略

守护进程通常会忽略 SIGPIPESIGHUP,这在 SigIgn 中可以直接观察到。


十、各 IPC 机制对比

机制 方向 持久性 传输效率 主要用途
信号 单向通知 非持久 极低(仅编号) 异步事件通知
pipe 单向数据流 进程生命期 高(ring buffer) 父子进程数据流
UNIX Socket 双向 进程生命期 高(内存拷贝) 本机 C/S 通信、fd 传递
POSIX MQ 双向 内核持久 中(优先级排序) 有序异步消息传递
SysV 消息队列 双向 内核持久 传统 IPC
共享内存 双向 内核持久 极高(零拷贝) 大数据量共享
futex 同步原语 非持久 极高(无竞争零系统调用) 互斥锁/条件变量

总结

Linux 信号与 IPC 机制的设计体现了内核”机制与策略分离”的哲学:

  • 信号通过 sigpending 位图 + sigqueue 链表分别处理可靠性需求,借助 TIF_SIGPENDING 在内核返回用户态的安全点触发;
  • pipe 以环形 pipe_buffer 数组为核心,支持页级零拷贝(splice),默认容量 64KB 可动态调整;
  • futex 以用户态原子操作为快速路径、内核哈希等待队列为慢速路径,是现代高性能同步原语的基础;
  • UNIX Domain Socket 通过 sk_buff 在内核内完成内存传递,配合 SCM_RIGHTS 实现跨进程 fd 共享;
  • System V IPCPOSIX 消息队列 则提供了更结构化的进程间通信语义。

理解这些机制的内核实现,能够帮助我们在系统设计、性能调优和问题排查时做出更明智的技术选择。


参考源码

  • kernel/signal.c — 信号发送、处理核心
  • include/linux/signal_types.h — 信号数据结构定义
  • include/linux/sched/signal.hsighand_struct / signal_struct
  • arch/x86/kernel/signal.c — x86-64 信号帧构建
  • arch/x86/include/asm/sigframe.hstruct rt_sigframe
  • include/linux/pipe_fs_i.hpipe_inode_info / pipe_buffer
  • fs/pipe.c — 管道读写实现
  • kernel/futex/core.c — futex 哈希表
  • kernel/futex/waitwake.cfutex_wait / futex_wake
  • kernel/futex/futex.hfutex_hash_bucket
  • ipc/sem.c — System V 信号量
  • ipc/msg.c — System V 消息队列
  • ipc/mqueue.c — POSIX 消息队列
  • net/unix/af_unix.c — UNIX Domain Socket
  • include/net/af_unix.hstruct unix_sock

本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均直接来自内核源文件。主要参考文件:kernel/sched/fair.ckernel/sched/sched.hkernel/sched/core.ckernel/sched/rt.c

Linux 调度器是内核中最核心也最复杂的子系统之一。自 2.6.23 版本引入 CFS(Completely Fair Scheduler,完全公平调度器)以来,它已成为处理普通进程(SCHED_NORMALSCHED_BATCH)调度的主要机制。本文将从数据结构出发,深入分析 CFS 的每一个核心算法,揭示”公平”背后的工程实现。


一、调度器框架:调度类与运行队列

1.1 调度类层次(struct sched_class

Linux 调度器采用面向对象的设计,通过 struct sched_class 抽象出调度策略接口。每种调度策略实现一套方法,调度器核心代码通过函数指针调用。

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
// kernel/sched/sched.h:2169
struct sched_class {

#ifdef CONFIG_UCLAMP_TASK
int uclamp_enabled;
#endif

void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
void (*yield_task) (struct rq *rq);
bool (*yield_to_task)(struct rq *rq, struct task_struct *p);

void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

struct task_struct *(*pick_next_task)(struct rq *rq);

void (*put_prev_task)(struct rq *rq, struct task_struct *p);
void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);

void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
void (*task_fork)(struct task_struct *p);
void (*task_dead)(struct task_struct *p);

void (*update_curr)(struct rq *rq);
/* ... */
};

各调度类按优先级从高到低排列,链接器脚本将它们放置在连续内存区域,for_each_class 宏按顺序遍历:

1
stop  >  dl  >  rt  >  fair  >  idle
  • stop:停止机器专用(CPU 热插拔、内核调试),优先级最高,永远抢占其他任务
  • dl:Deadline 调度(SCHED_DEADLINE),按截止时间调度实时任务
  • rt:实时调度(SCHED_FIFOSCHED_RR),基于优先级位图
  • fair:CFS 公平调度(SCHED_NORMALSCHED_BATCHSCHED_IDLE),本文主角
  • idle:CPU 空闲时运行 idle 线程
1
2
3
4
5
6
// kernel/sched/sched.h:2272
extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;

1.2 运行队列 struct rq

每个 CPU 有一个全局运行队列 struct rq,所有调度类的队列都嵌入其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// kernel/sched/sched.h:957
struct rq {
raw_spinlock_t __lock;

unsigned int nr_running; // 当前 CPU 可运行任务总数

struct cfs_rq cfs; // CFS 运行队列
struct rt_rq rt; // RT 运行队列
struct dl_rq dl; // DL 运行队列

struct task_struct __rcu *curr; // 当前正在运行的任务
struct task_struct *idle; // idle 任务
struct task_struct *stop; // stop 任务

u64 clock; // 队列时钟(纳秒)
u64 clock_task; // 任务时钟(排除 irq/steal)

atomic_t nr_iowait; // 等待 I/O 的任务数

/* SMP 负载均衡 */
unsigned long next_balance;
/* ... */
};

通过 cpu_rq(cpu) 宏获取指定 CPU 的 rq,通过 this_rq() 获取当前 CPU 的 rq

1.3 CFS 运行队列 struct cfs_rq

CFS 的核心数据结构,管理所有可运行的 CFS 调度实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// kernel/sched/sched.h:550
struct cfs_rq {
struct load_weight load; // 队列总权重
unsigned int nr_running; // 直接成员数
unsigned int h_nr_running; // 递归成员总数(组调度)

u64 exec_clock; // 已执行的 CPU 时间
u64 min_vruntime; // 队列中最小 vruntime(单调递增)

struct rb_root_cached tasks_timeline; // 红黑树(按 vruntime 排序)

struct sched_entity *curr; // 当前运行的调度实体
struct sched_entity *next; // 被标记为"优先运行"的实体
struct sched_entity *last; // 刚被抢占的实体(尽量恢复)
struct sched_entity *skip; // 应跳过的实体

#ifdef CONFIG_FAIR_GROUP_SCHED
struct rq *rq; // 所属 CPU 运行队列
struct task_group *tg; // 所属任务组
#endif
};

min_vruntime 是一个单调递增的基准线,代表队列中”最公平”的进度时间点,用于新进程 vruntime 初始化和跨 CPU 迁移时的规范化。

1.4 调度实体 struct sched_entity

每个普通进程(以及组调度中的任务组)都有一个 struct sched_entity,嵌入在 task_struct 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// include/linux/sched.h:549
struct sched_entity {
/* 负载均衡用 */
struct load_weight load; // 实体权重(由 nice 值决定)
struct rb_node run_node; // 红黑树节点
struct list_head group_node;
unsigned int on_rq; // 是否在运行队列中

u64 exec_start; // 本次运行开始时间
u64 sum_exec_runtime; // 累计实际运行时间
u64 vruntime; // 虚拟运行时间(CFS 核心)
u64 prev_sum_exec_runtime; // 上次切换时的累计时间

u64 nr_migrations; // 跨 CPU 迁移次数

#ifdef CONFIG_FAIR_GROUP_SCHED
int depth; // 层级深度
struct sched_entity *parent; // 父调度实体
struct cfs_rq *cfs_rq; // 所在 cfs_rq
struct cfs_rq *my_q; // 若为 group,则为其拥有的 cfs_rq
unsigned long runnable_weight;
#endif
};

vruntime 是 CFS 的核心字段,代表该实体”虚拟时间维度”上的运行进度。CFS 始终选择 vruntime 最小的实体运行,以维护公平性。


二、CFS 核心算法:vruntime 与权重归一化

2.1 vruntime 的概念

CFS 的核心思想是:如果有 N 个相同权重的进程,每个进程应该获得 1/N 的 CPU 时间。为了跟踪”公平进度”,引入 vruntime(虚拟运行时间)

1
vruntime += delta_exec × (NICE_0_LOAD / weight)

对于 nice=0 的进程(权重 1024),vruntime 增长速度等于真实时间。权重越大(nice 值越低),vruntime 增长越慢,因此会更频繁地被调度;权重越小(nice 值越高),vruntime 增长越快,被调度的频率越低。

2.2 权重表 sched_prio_to_weight

nice 值到权重的映射通过预计算表实现,相邻 nice 值之间权重比约为 1.25:

1
2
3
4
5
6
7
8
9
10
11
// kernel/sched/core.c:11459
const int sched_prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423,
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
};

nice=0 对应权重 1024(NICE_0_LOAD),这是归一化的基准值。nice=-20 的权重(88761)约是 nice=0(1024)的 86 倍,意味着 nice=-20 的进程能获得近 86 倍于 nice=0 进程的 CPU 时间。

2.3 calc_delta_fair:vruntime 增量计算

1
2
3
4
5
6
7
8
// kernel/sched/fair.c:709
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);

return delta;
}

__calc_delta 的计算公式为:

1
2
delta_vruntime = delta_exec × NICE_0_LOAD / weight
= delta_exec × 1024 / se->load.weight

为了避免浮点运算,内核通过预计算的逆权重表(sched_prio_to_wmult)将除法转化为乘法:delta × wmult >> 32

2.4 update_curr:实时更新 vruntime

每次时钟中断、调度切换时都会调用 update_curr 更新当前进程的运行统计:

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/sched/fair.c:897
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;

if (unlikely(!curr))
return;

delta_exec = now - curr->exec_start; // 本次运行的真实时间
if (unlikely((s64)delta_exec <= 0))
return;

curr->exec_start = now; // 重置开始时间

curr->sum_exec_runtime += delta_exec; // 累加真实运行时间

curr->vruntime += calc_delta_fair(delta_exec, curr); // 更新虚拟时间
update_min_vruntime(cfs_rq); // 更新队列基准线

if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
cgroup_account_cputime(curtask, delta_exec); // cgroup 计费
}

account_cfs_rq_runtime(cfs_rq, delta_exec); // 带宽控制计费
}

update_min_vruntime 维护 cfs_rq->min_vruntime 单调递增:它取当前运行实体的 vruntime 和红黑树中最左节点的 vruntime 二者中的较小值,但保证不回退。

2.5 CFS 红黑树操作

CFS 使用红黑树(struct rb_root_cached)以 vruntime 为键组织所有可运行实体。rb_root_cached 额外缓存了最左节点,使得 O(1) 时间内找到 vruntime 最小的实体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// kernel/sched/fair.c:643
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
rb_add_cached(&se->run_node, &cfs_rq->tasks_timeline, __entity_less);
}

static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
rb_erase_cached(&se->run_node, &cfs_rq->tasks_timeline);
}

struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);

if (!left)
return NULL;

return __node_2_se(left);
}

__entity_less 比较两个实体的 vruntime,确保红黑树按 vruntime 升序排列。最左节点始终是 vruntime 最小的实体,即最应被调度的进程。

注意:当前正在运行的实体(cfs_rq->curr不在红黑树中,只有就绪但未运行的实体才在树中。当前实体被切换出去时,才通过 put_prev_entity 重新插入树中。

2.6 pick_next_entity:选择下一个运行实体

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
// kernel/sched/fair.c:5084
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
struct sched_entity *left = __pick_first_entity(cfs_rq);
struct sched_entity *se;

/*
* 若 curr 仍在队列中且其 vruntime 小于树中最左节点,优先考虑 curr
*/
if (!left || (curr && entity_before(curr, left)))
left = curr;

se = left; /* 理想情况下运行最左实体 */

/* 避免运行 skip buddy(主动 yield 时标记)*/
if (cfs_rq->skip && cfs_rq->skip == se) {
struct sched_entity *second;
/* 尝试选次优实体 */
if (se == curr) {
second = __pick_first_entity(cfs_rq);
} else {
second = __pick_next_entity(se);
if (!second || (curr && entity_before(curr, second)))
second = curr;
}
if (second && wakeup_preempt_entity(second, left) < 1)
se = second;
}

/* next buddy:被标记为"下一个应运行"(wakeup 抢占提名) */
if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1) {
se = cfs_rq->next;
} else if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1) {
/* last buddy:尽量恢复刚被抢占的任务,减少上下文切换 */
se = cfs_rq->last;
}

return se;
}

这里引入了三个”buddy”机制:

  • skip:主动 sched_yield 的实体,本轮跳过
  • next:因 wakeup 抢占而被提名的实体,优先运行
  • last:刚刚被抢占的实体,若成本可接受则优先恢复,减少上下文切换开销

wakeup_preempt_entity 判断候选实体相对于最优实体的 vruntime 差距是否在允许范围内(wakeup_gran,基于 sysctl_sched_wakeup_granularity)。


三、进程入队与出队

3.1 enqueue_task_fair:进程变为 RUNNABLE 时入队

当进程从睡眠唤醒或新建进程时,enqueue_task_fair 被调用:

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
// kernel/sched/fair.c:6291
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
int task_new = !(flags & ENQUEUE_WAKEUP);

util_est_enqueue(&rq->cfs, p); // 更新利用率估计

for_each_sched_entity(se) {
if (se->on_rq)
break;
cfs_rq = cfs_rq_of(se);
enqueue_entity(cfs_rq, se, flags); // 插入 cfs_rq

cfs_rq->h_nr_running++;

if (cfs_rq_throttled(cfs_rq)) // 若队列被限流则停止向上传播
goto enqueue_throttle;

flags = ENQUEUE_WAKEUP;
}

/* 更新父层级的统计(组调度) */
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
update_load_avg(cfs_rq, se, UPDATE_TG);
update_cfs_group(se);
cfs_rq->h_nr_running++;
if (cfs_rq_throttled(cfs_rq))
goto enqueue_throttle;
}

add_nr_running(rq, 1); // 全局 nr_running +1
/* ... */
}

for_each_sched_entity 在非组调度情况下只迭代一次(直接返回进程本身),在组调度下则会沿父链向上遍历,将每一层的 cfs_rq 都更新。

内层的 enqueue_entity 完成实际插入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/sched/fair.c:4823
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);

if (renorm && cfs_rq->curr == se)
se->vruntime += cfs_rq->min_vruntime; // 规范化(迁移后)

update_curr(cfs_rq); // 先更新当前进程的 vruntime

if (renorm && cfs_rq->curr != se)
se->vruntime += cfs_rq->min_vruntime;

update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
account_entity_enqueue(cfs_rq, se); // 加入队列权重

if (flags & ENQUEUE_WAKEUP)
place_entity(cfs_rq, se, 0); // 设置唤醒进程的初始 vruntime

if (!curr)
__enqueue_entity(cfs_rq, se); // 插入红黑树
se->on_rq = 1;
}

3.2 place_entity:防止饥饿的 vruntime 初始化

新创建的进程或长期睡眠后唤醒的进程,其 vruntime 可能远小于 min_vruntime,若直接放入队列会持续抢占其他进程(因为它的 vruntime 最小)。place_entity 通过调整 vruntime 来防止这种情况:

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/sched/fair.c:4732
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime; // 以队列基准线为起点

if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se); // 新进程:额外惩罚一个 vslice

if (!initial) {
unsigned long thresh;

if (se_is_idle(se))
thresh = sysctl_sched_min_granularity;
else
thresh = sysctl_sched_latency; // 唤醒进程:补偿最多一个调度周期

if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1; // 只补偿一半,避免 sleeper 占便宜

vruntime -= thresh;
}

/* 确保不倒退(防止 vruntime 溢出边界情况) */
if (entity_is_long_sleeper(se))
se->vruntime = vruntime;
else
se->vruntime = max_vruntime(se->vruntime, vruntime);
}

这里有两种场景:

  1. 新进程initial=1):vruntime = min_vruntime + sched_vslice。新进程不能直接从 min_vruntime 起跑,需要额外支付一个”虚拟时间片”的”入场费”,防止 fork 炸弹抢占所有 CPU。

  2. 唤醒进程initial=0):vruntime = min_vruntime - thresh。睡眠进程被唤醒时,允许其 vruntime 比当前基准线略小(补偿其等待时间),但最多补偿一个调度周期(sysctl_sched_latency),避免长期睡眠者无限期抢占。

3.3 dequeue_task_fair:进程阻塞或被抢占时出队

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
// kernel/sched/fair.c:6384
static void dequeue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
int task_sleep = flags & DEQUEUE_SLEEP;

util_est_dequeue(&rq->cfs, p);

for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
dequeue_entity(cfs_rq, se, flags);

cfs_rq->h_nr_running--;

if (cfs_rq_throttled(cfs_rq))
goto dequeue_throttle;

/* 若父队列中还有其他实体,停止向上传播 */
if (cfs_rq->load.weight) {
se = parent_entity(se);
/* 进程主动睡眠时,提名父实体为 next_buddy,提升同组任务的调度优先级 */
if (task_sleep && se && !throttled_hierarchy(cfs_rq))
set_next_buddy(se);
break;
}
flags |= DEQUEUE_SLEEP;
}

/* 更新父层级统计 */
for_each_sched_entity(se) { /* ... */ }

sub_nr_running(rq, 1); // 全局 nr_running -1
}

四、调度周期与抢占机制

4.1 sched_slice:计算进程的调度时间片

CFS 没有固定时间片,而是根据进程权重动态计算应得的 CPU 时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// kernel/sched/fair.c:741
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
unsigned int nr_running = cfs_rq->nr_running;
u64 slice;

/* 调度周期:nr_running <= sched_nr_latency 时固定为 sysctl_sched_latency
* 超过时按比例拉伸:period = nr_running × min_granularity
*/
slice = __sched_period(nr_running + !se->on_rq);

/* 按权重比例分配:slice = period × (se->weight / cfs_rq->total_weight) */
for_each_sched_entity(se) {
struct load_weight *load;
struct cfs_rq *qcfs_rq = cfs_rq_of(se);
/* ... 计算权重比 ... */
slice = __calc_delta(slice, se->load.weight, load);
}

/* 确保不低于最小粒度 */
/* ... */
return slice;
}

默认参数(可通过 /proc/sys/kernel/sched_* 调整):

  • sysctl_sched_latency:调度周期,默认 6ms(nr_running <= 8 时)
  • sysctl_sched_min_granularity:最小运行粒度,默认 0.75ms
  • sysctl_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

4.2 时钟中断中的 CFS Tick:task_tick_fair

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// kernel/sched/fair.c:12064
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &curr->se;

for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
entity_tick(cfs_rq, se, queued); // 每层 cfs_rq 都更新
}

if (static_branch_unlikely(&sched_numa_balancing))
task_tick_numa(rq, curr); // NUMA 平衡检查

update_misfit_status(curr, rq);
update_overutilized_status(task_rq(curr));
}

entity_tick 中调用 check_preempt_tick 检查是否需要抢占:

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
// kernel/sched/fair.c:4993
static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
unsigned long ideal_runtime, delta_exec;
struct sched_entity *se;
s64 delta;

/* 当前进程的理想运行时间(sched_slice 与 sysctl_sched_latency 取小) */
ideal_runtime = min_t(u64, sched_slice(cfs_rq, curr), sysctl_sched_latency);

delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
if (delta_exec > ideal_runtime) {
resched_curr(rq_of(cfs_rq)); // 设置 TIF_NEED_RESCHED
clear_buddies(cfs_rq, curr);
return;
}

/* 若运行时间不足最小粒度,不抢占(避免频繁切换) */
if (delta_exec < sysctl_sched_min_granularity)
return;

/* 若当前进程的 vruntime 已超过红黑树最左节点 delta > ideal_runtime,抢占 */
se = __pick_first_entity(cfs_rq);
delta = curr->vruntime - se->vruntime;
if (delta < 0)
return;
if (delta > ideal_runtime)
resched_curr(rq_of(cfs_rq));
}

4.3 调度器 tick 调用链

1
2
3
4
5
6
7
8
9
timer interrupt
→ scheduler_tick() [kernel/sched/core.c]
→ curr->sched_class->task_tick()
→ task_tick_fair() [kernel/sched/fair.c]
→ entity_tick()
→ update_curr() // 更新 vruntime
→ check_preempt_tick() // 检查是否超时
→ resched_curr() // 设置 TIF_NEED_RESCHED
→ trigger_load_balance() // 触发负载均衡

scheduler_tick 的核心路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// kernel/sched/core.c:5602
void scheduler_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;

rq_lock(rq, &rf);
update_rq_clock(rq);
curr->sched_class->task_tick(rq, curr, 0); // 调用调度类的 tick 方法
calc_global_load_tick(rq);

rq_unlock(rq, &rf);

#ifdef CONFIG_SMP
rq->idle_balance = idle_cpu(cpu);
trigger_load_balance(rq); // 触发 SMP 负载均衡
#endif
}

4.4 唤醒抢占:check_preempt_wakeup

当一个进程被唤醒(wake_up_process)时,内核会检查是否应该抢占当前进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/sched/fair.c:7855
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
struct task_struct *curr = rq->curr;
struct sched_entity *se = &curr->se, *pse = &p->se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);

/* SCHED_BATCH/SCHED_IDLE 进程不会通过 wakeup 抢占 */
if (unlikely(p->policy != SCHED_NORMAL) || !sched_feat(WAKEUP_PREEMPTION))
return;

find_matching_se(&se, &pse); // 找到公共祖先层级

update_curr(cfs_rq_of(se));
if (wakeup_preempt_entity(se, pse) == 1) {
if (!next_buddy_marked)
set_next_buddy(pse); // 提名新任务为 next buddy
goto preempt;
}
return;

preempt:
resched_curr(rq); // 设置重调度标志
}

wakeup_preempt_entity 判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// kernel/sched/fair.c:7810
wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se)
{
s64 gran, vdiff = curr->vruntime - se->vruntime;

if (vdiff <= 0)
return -1; // 新任务 vruntime 更大,不抢占

gran = wakeup_gran(se); // 基于 sysctl_sched_wakeup_granularity 计算
if (vdiff > gran)
return 1; // 差距超过粒度阈值,触发抢占

return 0;
}

只有当当前进程的 vruntime 超过唤醒进程 vruntime 一个 wakeup_gran 时才触发抢占,避免因微小的 vruntime 差异导致频繁切换。


五、组调度(Group Scheduling)与 cgroup

5.1 struct task_group:cgroup CPU 调度组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// kernel/sched/sched.h:369
struct task_group {
struct cgroup_subsys_state css; // cgroup 子系统状态

#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity **se; // 每个 CPU 的调度实体(该组在父队列中的代表)
struct cfs_rq **cfs_rq; // 每个 CPU 的 CFS 运行队列
unsigned long shares; // cpu.shares(cgroup v1)/ cpu.weight(v2)
#endif

struct task_group *parent;
struct list_head siblings;
struct list_head children;

struct cfs_bandwidth cfs_bandwidth; // CPU 带宽控制(quota/period)
};

5.2 层次化调度结构

组调度的核心思想是:每个 task_group每个 CPU 上都有独立的 cfs_rqsched_entity

1
2
3
4
5
6
7
8
root task_group
├── se[0] ← 在 CPU 0 的 root cfs_rq 中代表整个组
│ my_q → task_group A 的 CPU 0 cfs_rq
│ ├── task1->se
│ └── task2->se
└── se[1] ← 在 CPU 1 的 root cfs_rq 中代表整个组
my_q → task_group A 的 CPU 1 cfs_rq
└── task3->se

pick_next_task_fair 通过 do { se = pick_next_entity(cfs_rq); cfs_rq = group_cfs_rq(se); } while (cfs_rq) 循环,从根 cfs_rq 向下穿透,直到找到一个叶子级别的 sched_entity(即真正的进程)。

5.3 cpu.shares 与 cpu.weight

  • cgroup v1cpu.shares(默认 1024),等比例分配 CPU。两个组分别设置 512 和 1024,则第二个组能获得约两倍 CPU 时间。

  • cgroup v2cpu.weight(默认 100,范围 1-10000),语义相同但取值范围不同。

对应内核中的 task_group->shares 字段,通过 sched_group_set_shares 更新,最终调整 se->load.weight,从而影响 sched_slice 的计算。

5.4 CPU 带宽控制: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。

5.5 throttle_cfs_rq / unthrottle_cfs_rq

当 cgroup 消耗完配额时触发限流:

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/sched/fair.c:5400
static bool throttle_cfs_rq(struct cfs_rq *cfs_rq)
{
struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);

raw_spin_lock(&cfs_b->lock);
if (__assign_cfs_rq_runtime(cfs_b, cfs_rq, 1)) {
/* 有新配额分配,不需要限流 */
dequeue = 0;
} else {
/* 将 cfs_rq 加入限流链表 */
list_add_tail_rcu(&cfs_rq->throttled_list,
&cfs_b->throttled_cfs_rq);
}
raw_spin_unlock(&cfs_b->lock);

if (!dequeue)
return false;

/* 从父 cfs_rq 中移除该组的调度实体 */
se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];
for_each_sched_entity(se) {
dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);
/* ... */
}
sub_nr_running(rq, task_delta);
/* ... */
}

被限流的 cfs_rq 中的所有任务实际上被”虚拟阻塞”——它们仍在 cfs_rq 中,但其父调度实体已被从父 cfs_rq 移除,因此不会被调度。

配额补充由高精度定时器(hrtimer)在每个 cfs_period 结束时触发 sched_cfs_period_timer,调用 unthrottle_cfs_rq 解除限流,重新将组的调度实体加入父 cfs_rq。


六、负载均衡(Load Balance)

6.1 整体框架

SMP 系统中,CFS 通过周期性负载均衡将任务从繁忙 CPU 迁移到空闲 CPU,以实现全局公平性。触发时机:

  1. 周期性均衡scheduler_ticktrigger_load_balance → 软中断 SCHED_SOFTIRQrun_rebalance_domainsload_balance
  2. 空闲时均衡:CPU 进入 idle 时调用 load_balance 主动拉取任务
  3. 新任务唤醒select_task_rq_fair 选择负载最轻的 CPU

6.2 load_balance 主流程

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
// kernel/sched/fair.c:10733
static int load_balance(int this_cpu, struct rq *this_rq,
struct sched_domain *sd, enum cpu_idle_type idle,
int *continue_balancing)
{
struct sched_group *group;
struct rq *busiest;
struct lb_env env = {
.sd = sd,
.dst_cpu = this_cpu,
.dst_rq = this_rq,
.idle = idle,
/* ... */
};

cpumask_and(cpus, sched_domain_span(sd), cpu_active_mask);

group = find_busiest_group(&env); // 在调度域中找最繁忙的组
if (!group) goto out_balanced;

busiest = find_busiest_queue(&env, group); // 在组中找最繁忙的队列
if (!busiest) goto out_balanced;

env.src_cpu = busiest->cpu;
env.src_rq = busiest;

/* 迁移任务 */
ld_moved = detach_tasks(&env);
if (ld_moved) {
attach_tasks(&env);
/* ... */
}
/* ... */
}

6.3 find_busiest_groupfind_busiest_queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// kernel/sched/fair.c:10361
static struct sched_group *find_busiest_group(struct lb_env *env)
{
/* 统计每个 sched_group 的负载 */
update_sd_lb_stats(env, &sds);

/* 处理特殊情况:misfit task、asym packing、imbalanced group */
if (busiest->group_type == group_misfit_task) goto force_balance;
if (busiest->group_type == group_asym_packing) goto force_balance;

/* 计算不平衡量 */
calculate_imbalance(env, &sds);
return sds.busiest;
}

find_busiest_queue 在选定的 sched_group 中,选出运行队列权重最高(任务最重)的 CPU:

1
2
3
4
5
6
7
8
9
10
11
// kernel/sched/fair.c:10490
static struct rq *find_busiest_queue(struct lb_env *env,
struct sched_group *group)
{
/* 遍历组内各 CPU,找 runnable_load 最大且允许迁移的队列 */
for_each_cpu_and(i, sched_group_span(group), env->cpus) {
/* 计算 rq 的 runnable load,选择最大值 */
/* 排除亲和性不允许迁移的情况 */
}
return busiest;
}

6.4 调度域(Scheduling Domain)层次

负载均衡在调度域(sched_domain)层次上进行,从低到高:

1
2
3
4
SMT(超线程,同一物理核的逻辑核)
→ MC(多核,同一物理 CPU 的多个核)
→ NUMA(跨 NUMA 节点)
→ 系统级

每一层的均衡间隔、迁移代价、不平衡阈值都可以单独配置。

6.5 NUMA 感知调度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// kernel/sched/fair.c:3188
static void task_tick_numa(struct rq *rq, struct task_struct *curr)
{
/*
* 周期性扫描任务的内存访问模式,
* 通过页表保护位触发 NUMA hinting fault,
* 统计各 NUMA 节点的访问热度,
* 将任务迁移到访问最频繁的内存所在节点。
*/
now = curr->se.sum_exec_runtime;
period = (u64)curr->numa_scan_period * NSEC_PER_MSEC;

if (now > curr->node_stamp + period) {
curr->node_stamp += period;
if (!time_before(jiffies, curr->mm->numa_next_scan))
task_work_add(curr, work, TWA_RESUME); // 触发 NUMA 扫描
}
}

NUMA 均衡通过 task_numa_migrate 实现,综合考虑任务的内存访问热度(NUMA faults)和 CPU 负载,将任务迁移到数据所在的 NUMA 节点,减少跨节点内存访问延迟。


七、实时调度(RT Scheduler)概述

CFS 处理普通进程,rt_sched_class 处理实时进程(SCHED_FIFOSCHED_RR)。RT 任务永远优先于 CFS 任务运行。

7.1 SCHED_FIFO vs SCHED_RR

策略 特点
SCHED_FIFO 先入先出。只有主动放弃 CPU(sched_yield)、阻塞或被更高优先级任务抢占时才切换。时间片无限。
SCHED_RR 轮转调度。有固定时间片(默认 100ms),时间片耗尽后轮转到同优先级队列末尾。

RT 进程优先级为 1-99(sched_priority),99 最高。CFS 进程等效 RT 优先级为 0。

7.2 struct rt_prio_array:优先级位图

1
2
3
4
5
// kernel/sched/sched.h:273
struct rt_prio_array {
DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); // 位图:标记哪些优先级有任务
struct list_head queue[MAX_RT_PRIO]; // 每个优先级对应一个 FIFO 链表
};

RT 运行队列通过位图实现 O(1) 找到最高优先级:sched_find_first_bit(bitmap) 立即定位第一个置位的优先级,然后从对应链表头取出任务。

7.3 pick_next_task_rt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kernel/sched/rt.c:1787
static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
struct sched_rt_entity *rt_se;
struct rt_rq *rt_rq = &rq->rt;

do {
rt_se = pick_next_rt_entity(rt_rq); // 从位图找最高优先级任务
if (unlikely(!rt_se))
return NULL;
rt_rq = group_rt_rq(rt_se);
} while (rt_rq);

return rt_task_of(rt_se);
}

pick_next_rt_entity 通过 sched_find_first_bit(array->bitmap) 找到最高优先级,然后返回对应链表的第一个实体,时间复杂度 O(1)。


八、调度诊断与调优

8.1 查看和修改调度策略

1
2
3
4
5
6
7
8
9
10
11
# 查看进程调度策略和优先级
chrt -p $PID

# 将进程设为 SCHED_FIFO,优先级 50
chrt -f -p 50 $PID

# 将进程设为 SCHED_RR,优先级 20
chrt -r -p 20 $PID

# 将进程重置为 SCHED_OTHER(CFS)
chrt -o -p 0 $PID

8.2 调整 nice 值

1
2
3
4
5
6
7
8
# 调整运行中进程的 nice 值(需要 CAP_SYS_NICE 或降低 nice 值)
renice -n -5 -p $PID

# 以指定 nice 值启动进程
nice -n 10 ./my_process

# 查看所有进程的 nice 值
ps -eo pid,ni,comm --sort=ni

8.3 /proc/PID/sched:CFS 调度统计

1
cat /proc/$(pidof nginx)/sched

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
nginx (12345, #threads: 4)
---------------------------------------------------------
se.exec_start : 1234567890.123456
se.vruntime : 12345.678901
se.sum_exec_runtime : 1234567.890123
se.nr_migrations : 42
nr_switches : 1234
nr_voluntary_switches : 980
nr_involuntary_switches : 254
se.load.weight : 1024
se.avg.load_avg : 512
se.avg.util_avg : 256

关键字段:

  • vruntime:当前虚拟运行时间
  • sum_exec_runtime:累计实际 CPU 时间(ns)
  • nr_voluntary_switches:主动切换次数(I/O 等待等)
  • nr_involuntary_switches:被动抢占次数(时间片用完)
  • se.load.weight:当前权重(由 nice 值决定)

8.4 /proc/schedstat:全局调度统计

1
2
3
4
cat /proc/schedstat
# 每行对应一个 CPU
# 格式:cpu<N> 各种计数器...
# 字段含义参见 Documentation/scheduler/sched-stats.rst

结合 schedtooltuna 可更方便地解析。

8.5 perf sched:调度延迟分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 录制调度事件(需要 root)
perf sched record -a sleep 10

# 分析调度延迟
perf sched latency

# 输出示例:
# Task | Runtime ms | Switches | Average delay ms | Maximum delay ms |
# nginx:12345 | 123.456 | 100 | 0.123 | 5.678 |

# 查看调度时间线
perf sched timehist

# 分析 map
perf sched map

8.6 bpftrace 追踪调度决策

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
# 追踪 pick_next_task_fair 调用,输出被选中的进程
bpftrace -e '
kprobe:pick_next_task_fair {
$rq = (struct rq *)arg0;
if ($rq->cfs.nr_running > 0) {
printf("CPU %d: nr_running=%d\n",
cpu, $rq->cfs.nr_running);
}
}'

# 追踪调度延迟超过 1ms 的进程
bpftrace -e '
tracepoint:sched:sched_wakeup {
@wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
$prev_pid = args->prev_pid;
if (@wake[$prev_pid]) {
$latency = (nsecs - @wake[$prev_pid]) / 1000000;
if ($latency > 1) {
printf("pid %d comm %s latency %d ms\n",
$prev_pid, args->prev_comm, $latency);
}
delete(@wake[$prev_pid]);
}
}'

# 统计 vruntime 分布(需要 BTF 支持)
bpftrace -e '
kprobe:enqueue_task_fair {
$p = (struct task_struct *)arg1;
@vruntime = hist($p->se.vruntime / 1000000);
}'

8.7 cgroup CPU 统计

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
# cgroup v2
cat /sys/fs/cgroup/my_service/cpu.stat
# 输出:
# usage_usec 12345678 # 累计 CPU 使用时间(微秒)
# user_usec 8765432
# system_usec 3580246
# nr_periods 100 # 经历的带宽周期数
# nr_throttled 5 # 被限流的周期数
# throttled_usec 50000 # 被限流的总时间(微秒)
# nr_bursts 0
# burst_usec 0

# 查看/设置带宽限制
cat /sys/fs/cgroup/my_service/cpu.max
# 输出:50000 100000 (quota=50ms, period=100ms)

# 设置为 0.5 CPU
echo "50000 100000" > /sys/fs/cgroup/my_service/cpu.max

# 查看 cpu.weight(cgroup v2,对应 cpu.shares / 10.24)
cat /sys/fs/cgroup/my_service/cpu.weight

# cgroup v1
cat /sys/fs/cgroup/cpu/my_service/cpu.stat
cat /sys/fs/cgroup/cpu/my_service/cpu.shares

8.8 调度参数调优

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 查看 CFS 调度参数
sysctl -a | grep sched

# 调度延迟周期(默认 6000000 ns = 6ms)
sysctl kernel.sched_latency_ns

# 最小运行粒度(默认 750000 ns = 0.75ms)
# 进程运行不足此时间不会被抢占
sysctl kernel.sched_min_granularity_ns

# 唤醒抢占粒度(默认 1000000 ns = 1ms)
# 唤醒进程 vruntime 优势需超过此值才触发抢占
sysctl kernel.sched_wakeup_granularity_ns

# 延迟敏感场景(如实时音视频):降低粒度
sysctl -w kernel.sched_min_granularity_ns=500000
sysctl -w kernel.sched_wakeup_granularity_ns=500000
sysctl -w kernel.sched_latency_ns=3000000

# 吞吐量优先场景(如批处理):增大粒度,减少切换
sysctl -w kernel.sched_min_granularity_ns=2000000
sysctl -w kernel.sched_latency_ns=12000000

小结

CFS 的设计哲学在于将抽象的”公平”转化为具体的可计算量——vruntime。通过这个虚拟时间轴,内核无需维护复杂的优先级队列逻辑,只需一棵以 vruntime 为键的红黑树,就能以 O(log n) 的复杂度实现近乎完美的公平调度。

核心设计要点回顾:

  1. vruntime 归一化calc_delta_fair 用权重对真实时间进行缩放,权重大的进程 vruntime 增长慢,因此获得更多 CPU 时间,这是 CFS”公平”的数学基础。

  2. min_vruntime 锚点:单调递增的 min_vruntime 防止新进程/唤醒进程通过历史积累的低 vruntime 无限抢占,place_entity 在此基础上实现精细的”补偿-惩罚”策略。

  3. **红黑树 O(log n)**:rb_root_cached 缓存最左节点实现 O(1) 选取,__enqueue_entity/__dequeue_entity 维护 O(log n) 插入删除。

  4. 组调度层次化:每个 task_group 在每个 CPU 上都有独立的 cfs_rqsched_entity,通过多层遍历实现层次化公平,cgroup CPU 带宽控制基于此实现容器 CPU 限制。

  5. SMP 负载均衡:通过调度域层次(SMT → MC → NUMA)实现多粒度的负载均衡,NUMA 感知调度进一步优化内存局部性。

理解 CFS 的源码不仅有助于诊断调度延迟问题,也为容器化环境下合理设置 cpu.shares/cpu.weightcpu.cfs_quota_us 提供了理论基础。下一篇文章将深入分析 Linux 进程的内存管理机制,包括虚拟内存区域(VMA)、缺页异常处理和 OOM Killer 的实现。

基于 Linux 6.4-rc1 源码(commit ac9a78681b92),深入剖析进程创建的内核实现。

进程是操作系统最核心的抽象之一。每次你敲下一条 shell 命令,背后都会发生 fork + exec 这一对经典舞步。然而 “fork 复制父进程、exec 替换镜像” 这句话远未揭示全貌:写时复制如何让 fork 变得极其廉价?clonefork 共享同一条代码路径吗?exec 如何安全地销毁旧地址空间并跳进新程序?本文从 struct task_struct 出发,沿着 fork → clone → exec → exit 的脉络,逐函数拆解内核实现。


一、task_struct:进程的完整画像

每个进程(或线程)在内核中对应一个 struct task_struct,定义于 include/linux/sched.h。它既是调度的基本单元,也是内核追踪进程一切状态的”档案袋”。以下精选最关键的字段并加以注释。

1.1 进程状态

1
2
3
4
5
6
7
8
9
/* include/linux/sched.h: 87–107 */
#define TASK_RUNNING 0x00000000 /* 正在运行或就绪等待调度 */
#define TASK_INTERRUPTIBLE 0x00000001 /* 可中断睡眠,可被信号唤醒 */
#define TASK_UNINTERRUPTIBLE 0x00000002 /* 不可中断睡眠(D 状态),等待 I/O */
#define __TASK_STOPPED 0x00000004 /* 被 SIGSTOP/SIGTSTP 暂停 */
#define __TASK_TRACED 0x00000008 /* 被 ptrace 追踪 */
#define EXIT_DEAD 0x00000010 /* 正在被回收,即将消失 */
#define EXIT_ZOMBIE 0x00000020 /* 已退出但父进程尚未 wait() */
#define TASK_DEAD 0x00000080 /* 彻底死亡,即将释放 */

task_struct 中保存状态的字段为:

1
2
/* include/linux/sched.h: 747 */
unsigned int __state;

注意前缀双下划线——内核要求通过 READ_ONCE()/WRITE_ONCE() 或专用宏访问它,以防止编译器优化导致的竞态。

1.2 调度相关字段

1
2
3
4
5
6
7
8
9
10
/* include/linux/sched.h: 785–793 */
int prio; /* 动态优先级(受 nice 和 RT boost 影响) */
int static_prio; /* 静态优先级,由 nice 值决定 */
int normal_prio; /* 归一化优先级 */
unsigned int rt_priority; /* 实时优先级(0 = 非 RT) */

struct sched_entity se; /* CFS 调度实体(含 vruntime) */
struct sched_rt_entity rt; /* RT 调度实体 */
struct sched_dl_entity dl; /* Deadline 调度实体 */
const struct sched_class *sched_class; /* 指向所属调度类(fair/rt/dl/idle) */

sched_class 是一个虚函数表指针,实现了策略模式:CFS、RT、Deadline 各自实现 enqueue_taskdequeue_taskpick_next_task 等接口,调度器核心代码通过 sched_class 统一调用,无需 if/else 判断。

1.3 进程标识与亲缘关系

1
2
3
4
5
6
7
8
9
/* include/linux/sched.h: 963–987 */
pid_t pid; /* 进程 ID(线程唯一) */
pid_t tgid; /* 线程组 ID(即主线程 pid,getpid() 返回此值) */

struct task_struct __rcu *real_parent; /* 真实父进程(被 ptrace 时可能与 parent 不同) */
struct task_struct __rcu *parent; /* 当前父进程(SIGCHLD 的接收者) */
struct list_head children; /* 子进程链表头 */
struct list_head sibling; /* 挂入父进程 children 链表的节点 */
struct task_struct *group_leader; /* 线程组 leader(主线程) */

pid vs tgid:POSIX 要求同一进程内所有线程共享 PID,这在内核中通过 tgid 实现。getpid() 返回 tgidgettid() 返回 pidgroup_leader 永远指向主线程。

1.4 内存管理

1
2
3
/* include/linux/sched.h: 872–873 */
struct mm_struct *mm; /* 用户态地址空间(内核线程为 NULL) */
struct mm_struct *active_mm; /* 当前使用的 mm(借用上一进程的 mm 做 lazy TLB) */

内核线程 mm == NULL,但运行时 CPU 的 TLB 仍需要一个 mm,所以借用上一个用户进程的 active_mm,这就是 lazy TLB 优化的来源。

1.5 文件系统与文件描述符

1
2
3
/* include/linux/sched.h: 1087–1090 */
struct fs_struct *fs; /* 当前工作目录、根目录(chdir/chroot 影响此处) */
struct files_struct *files; /* 文件描述符表(引用计数共享) */

fs_structfiles_struct 各自有引用计数,clone(CLONE_FS)clone(CLONE_FILES) 时父子进程共享同一个实例而非复制。

1.6 信号处理

1
2
3
4
5
/* include/linux/sched.h: 1100–1106 */
struct signal_struct *signal; /* 线程组共享的信号信息(pending 队列等) */
struct sighand_struct *sighand; /* 信号处理函数表(可被 CLONE_SIGHAND 共享) */
sigset_t blocked; /* 被屏蔽的信号集合 */
struct sigpending pending; /* 该线程私有的 pending 信号队列 */

1.7 凭证与命名空间

1
2
3
4
5
6
/* include/linux/sched.h: 1057–1060 */
const struct cred __rcu *real_cred; /* 客观凭证(真实 UID/GID,setuid 前的值) */
const struct cred __rcu *cred; /* 有效凭证(权限检查使用此值) */

/* include/linux/sched.h: 1097 */
struct nsproxy *nsproxy; /* 指向命名空间集合(PID/Net/UTS/IPC/Mount/Time NS) */

cred 采用写时复制(COW):setuid() 会分配新的 cred 对象,而非修改现有的,这使得 RCU 读者可以无锁访问。


二、fork() 系统调用:从用户态到 kernel_clone

2.1 调用链全景

1
2
3
4
5
6
7
8
9
用户态: fork()
↓ glibc syscall wrapper
内核态: sys_fork (SYSCALL_DEFINE0)

kernel_clone(args)

copy_process(pid, trace, node, args) ← 核心:创建子进程描述符

wake_up_new_task(p) ← 将子进程加入运行队列

2.2 sys_fork 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* kernel/fork.c: 3000–3012 */
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};

return kernel_clone(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}

fork 不传任何 clone flags,这意味着子进程拥有独立的 mm、文件描述符表、信号处理器、命名空间——一个完整的独立进程。

2.3 kernel_clone:派发与收尾

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
/* kernel/fork.c: 2877–2962(节选) */
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
pid_t nr;

/* 决定向 ptracer 报告哪类事件 */
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
}

p = copy_process(NULL, trace, NUMA_NO_NODE, args); /* 创建子进程 */
if (IS_ERR(p))
return PTR_ERR(p);

pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid); /* 在当前 PID 命名空间中的虚拟 PID */

if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}

wake_up_new_task(p); /* 子进程入队,可以被调度了 */

if (clone_flags & CLONE_VFORK) {
/* 父进程在此阻塞,等待子进程 exec 或 exit */
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}

put_pid(pid);
return nr; /* 父进程返回子进程 PID,子进程返回 0(由 copy_thread 设置) */
}

2.4 copy_process:子进程的诞生

copy_process 是整个 fork 路径最复杂的函数(约 600 行),它按照严格顺序完成以下工作:

第一步:合法性检查

1
2
3
4
5
6
7
8
/* kernel/fork.c: 2263–2302(节选) */
/* CLONE_THREAD 必须同时设置 CLONE_SIGHAND */
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);

/* CLONE_SIGHAND 必须同时设置 CLONE_VM */
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);

这揭示了线程的必要条件:线程 = 共享信号处理器 = 共享地址空间,三者缺一不可。

第二步:dup_task_struct——克隆描述符与内核栈

1
2
/* kernel/fork.c: 2333 */
p = dup_task_struct(current, node);

dup_task_struct 的实现(kernel/fork.c: 1101–1190):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
struct task_struct *tsk;

tsk = alloc_task_struct_node(node); /* slab 分配 task_struct */
if (!tsk) return NULL;

err = arch_dup_task_struct(tsk, orig); /* memcpy task_struct */
if (err) goto free_tsk;

err = alloc_thread_stack_node(tsk, node); /* 分配独立内核栈(通常 16KB) */
if (err) goto free_tsk;

setup_thread_stack(tsk, orig); /* 复制 thread_info,更新 task 指针 */
clear_tsk_need_resched(tsk);
set_task_stack_end_magic(tsk); /* 在栈底写入魔数,用于溢出检测 */

#ifdef CONFIG_STACKPROTECTOR
tsk->stack_canary = get_random_canary(); /* 新的栈 canary,防止父子共享 */
#endif
refcount_set(&tsk->usage, 1);
return tsk;
}

关键点:此时 task_struct 是父进程的完整拷贝,但内核栈是全新分配的——否则父子进程会使用同一个内核栈,调度时会互相破坏。

第三步:各子系统的选择性复制

1
2
3
4
5
6
7
8
9
10
11
/* kernel/fork.c: 2492–2518 */
retval = copy_files(clone_flags, p, args->no_files);
retval = copy_fs(clone_flags, p);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
retval = copy_mm(clone_flags, p); /* COW 复制地址空间 */
retval = copy_namespaces(clone_flags, p);
retval = copy_thread(p, args); /* 架构相关:设置返回地址和寄存器 */

/* 分配 PID */
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid, args->set_tid_size);

copy_mm(kernel/fork.c: 1714)的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm = current->mm;

if (!oldmm) return 0; /* 内核线程无需地址空间 */

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

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

copy_files(kernel/fork.c: 1772):

1
2
3
4
5
6
7
8
9
10
11
12
13
static int copy_files(unsigned long clone_flags, struct task_struct *tsk, int no_files)
{
struct files_struct *oldf = current->files;

if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count); /* 线程:共享文件描述符表 */
goto out;
}

newf = dup_fd(oldf, NR_OPEN_MAX, &error); /* 进程:复制 fd 表 */
tsk->files = newf;
...
}

第四步:设置 pid/tgid/group_leader

1
2
3
4
5
6
7
8
9
/* kernel/fork.c: 2574–2581 */
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->group_leader = current->group_leader; /* 新线程的组长 = 父线程的组长 */
p->tgid = current->tgid; /* 线程组 ID 不变 */
} else {
p->group_leader = p; /* 新进程自己是组长 */
p->tgid = p->pid; /* tgid = 自己的 pid */
}

第五步:加入进程树

1
2
3
4
5
6
7
/* kernel/fork.c: 2638–2648 */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent; /* 与父进程平级 */
} else {
p->real_parent = current; /* 普通 fork:父进程为当前进程 */
p->exit_signal = args->exit_signal;
}

2.5 wake_up_new_task:子进程入队

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* kernel/sched/core.c: 4809–4848(节选) */
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;

raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING); /* 设为就绪态 */

#ifdef CONFIG_SMP
/* fork 均衡:选择最优 CPU,避免与父进程竞争同一核 */
__set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
#endif
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
post_init_entity_util_avg(p); /* 初始化 CFS 调度实体的 util_avg */

activate_task(rq, p, ENQUEUE_NOCLOCK); /* 将子进程加入运行队列 */
trace_sched_wakeup_new(p);
check_preempt_curr(rq, p, WF_FORK); /* 检查是否需要抢占当前任务 */

task_rq_unlock(rq, p, &rf);
}

至此,子进程已就绪,等待调度器选中它上 CPU 运行。


三、clone() 与线程创建

3.1 clone flags 语义

所有标志定义于 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(进程文件描述符)

3.2 pthread_create 的内核路径

glibcpthread_create 最终调用:

1
2
3
4
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD,
child_stack, &parent_tid, tls, &child_tid);

这些 flags 让内核:

  • 共享地址空间(VM)
  • 共享文件和信号处理
  • 加入同一线程组(THREAD)
  • 设置 TLS 段(SETTLS)
  • 在子线程退出时通过 CLONE_CHILD_CLEARTID 向 futex 地址写 0,唤醒等待 pthread_join 的线程

3.3 sys_clone 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* kernel/fork.c: 3045–3062 */
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = newsp,
.tls = tls,
};

return kernel_clone(&args); /* 与 fork 共享同一路径 */
}

forkvforkclone 在内核中最终都调用 kernel_clone,差异仅在传入的 kernel_clone_args 结构体。这是 Linux 内核”一个实现,多个接口”的典型范式。

3.4 copy_thread:fork 返回 0 的秘密

子进程在 fork 后从 ret_from_fork 开始运行,但为何 fork() 在子进程中返回 0?答案在 x86 的 copy_thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* arch/x86/kernel/process.c: 137–202(节选) */
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
struct pt_regs *childregs = task_pt_regs(p);
struct fork_frame *fork_frame = container_of(childregs, struct fork_frame, regs);
struct inactive_task_frame *frame = &fork_frame->frame;

/* 设置子进程从 ret_from_fork 开始执行 */
frame->ret_addr = (unsigned long) ret_from_fork;
p->thread.sp = (unsigned long) fork_frame;

/* 复制父进程的完整寄存器状态 */
*childregs = *current_pt_regs();

/* 关键:将 eax/rax 设为 0,这就是 fork() 在子进程返回 0 的实现 */
childregs->ax = 0;

if (sp)
childregs->sp = sp; /* 线程使用新栈 */
...
}

父进程的 raxkernel_clone 返回子进程 PID,子进程的 rax 被强制置 0。当子进程被调度到 CPU 上,它从 ret_from_fork 恢复执行,iretrax=0 作为系统调用返回值送回用户态——这就是 fork() 在子进程中返回 0 的完整机制。


四、exec() 系统调用:变身新程序

4.1 调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户态: execve(path, argv, envp)

sys_execve (SYSCALL_DEFINE3)

do_execve → do_execveat_common

alloc_bprm + bprm_mm_init ← 分配 linux_binprm,创建新 mm

bprm_execve

exec_binprm → search_binary_handler ← 遍历 binfmt 链表找加载器

load_elf_binary (fs/binfmt_elf.c) ← ELF 加载器

begin_new_exec ← 不可回头点:替换旧进程上下文

start_thread (arch) ← 设置入口地址,跳入新程序

4.2 do_execveat_common:准备 bprm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* fs/exec.c: 1886–1969(节选) */
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm;

bprm = alloc_bprm(fd, filename); /* 分配 linux_binprm,内含新 mm */

bprm->argc = count(argv, MAX_ARG_STRINGS);
bprm->envc = count(envp, MAX_ARG_STRINGS);

bprm_stack_limits(bprm); /* 检查 ARG_MAX 限制 */

/* 将 filename、envp、argv 依次压入新栈(从高地址向低地址) */
copy_string_kernel(bprm->filename, bprm); /* filename */
copy_strings(bprm->envc, envp, bprm); /* 环境变量 */
copy_strings(bprm->argc, argv, bprm); /* 参数 */

retval = bprm_execve(bprm, fd, filename, flags);
...
}

4.3 bprm_mm_init:提前准备新地址空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* fs/exec.c: 364–392 */
static int bprm_mm_init(struct linux_binprm *bprm)
{
struct mm_struct *mm = NULL;

bprm->mm = mm = mm_alloc(); /* 分配全新的 mm_struct */
if (!mm) goto err;

task_lock(current->group_leader);
bprm->rlim_stack = current->signal->rlim[RLIMIT_STACK]; /* 记录栈限制 */
task_unlock(current->group_leader);

err = __bprm_mm_init(bprm); /* 为新栈 mmap 一页临时映射 */
...
}

注意此时旧的 mm 仍然有效——新 mm 在 begin_new_exec 之前不会替换旧的,这样如果 load_elf_binary 失败,进程可以安全回滚。

4.4 search_binary_handler:寻找加载器

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/exec.c: 1715–1759(节选) */
static int search_binary_handler(struct linux_binprm *bprm)
{
struct linux_binfmt *fmt;

retval = prepare_binprm(bprm); /* 读取文件头 128 字节到 bprm->buf */
retval = security_bprm_check(bprm); /* LSM 安全检查 */

read_lock(&binfmt_lock);
list_for_each_entry(fmt, &formats, lh) { /* 遍历已注册的 binfmt */
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);

retval = fmt->load_binary(bprm); /* 调用对应加载器,如 load_elf_binary */

read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || retval != -ENOEXEC) {
read_unlock(&binfmt_lock);
return retval;
}
}
...
}

binfmt 链表按注册顺序排列,典型格式有 ELF(binfmt_elf)、脚本(binfmt_script,处理 #!)、misc(binfmt_misc,处理 .jar/.py 等)。

4.5 load_elf_binary:ELF 加载详解

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
/* fs/binfmt_elf.c: 823–(节选) */
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;

/* 魔数检查:\x7fELF */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) goto out;
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN) goto out;

elf_phdata = load_elf_phdrs(elf_ex, bprm->file); /* 读取程序头表 */

/* 扫描程序头,查找 PT_INTERP(动态链接器路径) */
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type != PT_INTERP) continue;
/* 读取 /lib64/ld-linux-x86-64.so.2 等路径 */
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
elf_read(bprm->file, elf_interpreter, ...);
interpreter = open_exec(elf_interpreter); /* 打开 ld.so */
break;
}

/* begin_new_exec:不可回头点,替换旧进程上下文 */
retval = begin_new_exec(bprm);
/* 从这里开始,旧地址空间已销毁,只有前进没有后退 */

/* 将 PT_LOAD segment 逐一 mmap 进新地址空间 */
for (i = 0, elf_ppnt = elf_phdata; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_LOAD) {
/* elf_map() 调用 do_mmap() 建立文件映射 */
...
}
}

/* 若有动态链接器,同样加载 ld.so 的 PT_LOAD */
if (interpreter) {
elf_entry = load_elf_interp(interp_elf_ex, interpreter, ...);
/* 入口地址改为 ld.so 的入口,而非程序自身的 e_entry */
}
...
}

ELF 加载后的栈布局(从高地址到低地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
高地址
┌─────────────────────────────────┐
│ NULL(栈结束标记) │
│ envp 字符串数据 │
│ argv 字符串数据 │
│ filename 字符串 │
│ AT_NULL(auxv 结束) │
│ 辅助向量 auxv(AT_PHDR/AT_ENTRY/│
│ AT_PAGESZ/AT_RANDOM…) │
│ NULL(envp 结束) │
│ envp[n-1] ... envp[0] │
│ NULL(argv 结束) │
│ argv[argc-1] ... argv[0] │
│ argc │ ← rsp 初始指向此处
└─────────────────────────────────┘
低地址

auxv(辅助向量)向 ld.so 传递内核信息:AT_PHDR 指向程序头表在内存中的位置,AT_ENTRY 是程序真实入口(ld.so 完成重定位后跳转到此),AT_RANDOM 是 16 字节随机数用于 PIE/ASLR 种子。

4.6 begin_new_exec:不可回头点

begin_new_exec(fs/exec.c: 1244)是 exec 过程的分水岭,在此之后:

  • 调用 exec_mmap(bprm->mm) 将旧 mm 替换为新 mm,旧地址空间被销毁
  • 调用 flush_signal_handlers() 将所有非 SIG_IGN 的信号处理器重置为 SIG_DFL
  • 关闭所有标有 O_CLOEXEC 的文件描述符
  • 更新进程名(comm)为新程序的 basename
  • 若此时发生错误,进程将以 SIGSEGV 终止(因为旧环境已消失)

五、进程退出:do_exit 与僵尸进程

5.1 do_exit 的退出流程

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
/* kernel/exit.c: 806–924(节选) */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;

exit_signals(tsk); /* 设置 PF_EXITING 标志,阻止新信号投递 */

group_dead = atomic_dec_and_test(&tsk->signal->live);
/* group_dead = true 说明这是线程组的最后一个线程 */

tsk->exit_code = code;

exit_mm(); /* 解除地址空间:tsk->mm = NULL,mmput() 递减引用计数 */
exit_sem(tsk); /* 释放 System V 信号量 */
exit_shm(tsk); /* 解除共享内存段 */
exit_files(tsk); /* 关闭所有文件描述符(files_struct 引用计数 -1) */
exit_fs(tsk); /* 释放 fs_struct 引用 */
exit_task_namespaces(tsk); /* 释放命名空间引用 */
exit_thread(tsk); /* 释放架构相关资源(如 FPU 状态) */

perf_event_exit_task(tsk);
cgroup_exit(tsk);

exit_notify(tsk, group_dead); /* 通知父进程,进入 ZOMBIE 状态 */

do_task_dead(); /* 调用 schedule() 永不返回 */
}

5.2 exit_mm:地址空间的释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kernel/exit.c: 532–567 */
static void exit_mm(void)
{
struct mm_struct *mm = current->mm;

exit_mm_release(current, mm); /* 清理 futex robust list 等 */
if (!mm) return;

sync_mm_rss(mm);
mmap_read_lock(mm);
mmgrab_lazy_tlb(mm); /* 转换为 lazy TLB 进程 */

task_lock(current);
current->mm = NULL; /* 脱离地址空间 */
enter_lazy_tlb(mm, current); /* 告知 CPU 使用 lazy TLB 模式 */
task_unlock(current);

mmap_read_unlock(mm);
mm_update_next_owner(mm); /* 将 mm->owner 转移给其他共享线程 */
mmput(mm); /* 引用计数 -1;若为 0 则真正释放页表 */
}

mmput() 只是递减引用计数。若进程有共享同一 mm 的线程(线程组),mm 不会被立刻释放,直到最后一个线程退出。

5.3 exit_notify:从 RUNNING 到 ZOMBIE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* kernel/exit.c: 730–764(节选) */

write_lock_irq(&tasklist_lock);
forget_original_parent(tsk, &dead); /* 将子进程过继给 init 或 subreaper */

tsk->exit_state = EXIT_ZOMBIE; /* 进入僵尸态 */

if (thread_group_leader(tsk)) {
/* 向父进程发送 SIGCHLD(或 exit_signal 指定的信号) */
autoreap = do_notify_parent(tsk, tsk->exit_signal);
}

if (autoreap) {
tsk->exit_state = EXIT_DEAD; /* 父进程忽略 SIGCHLD,直接变 DEAD */
}
write_unlock_irq(&tasklist_lock);

进程在 exit_state = EXIT_ZOMBIE 后:task_struct 仍然存在于内存中,保留 PID、退出码等信息,等待父进程调用 wait() 来回收。这就是”僵尸进程”。

5.4 wait4 与 do_wait:父进程回收子进程

1
2
3
4
5
6
7
8
9
10
11
12
/* kernel/exit.c: 1801–1812 */
SYSCALL_DEFINE4(wait4, pid_t, upid, int __user *, stat_addr,
int, options, struct rusage __user *, ru)
{
struct rusage r;
long err = kernel_wait4(upid, stat_addr, options, ru ? &r : NULL);
if (err > 0) {
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
return err;
}

kernel_wait4 → do_wait → wait_consider_task:扫描子进程列表,找到 EXIT_ZOMBIE 状态的子进程,收集退出码后调用 release_task() 释放 task_struct,PID 归还 pidmap。

5.5 孤儿进程:find_new_reaper

当父进程先于子进程退出,子进程成为孤儿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kernel/exit.c: 618–651 */
static struct task_struct *find_new_reaper(struct task_struct *father,
struct task_struct *child_reaper)
{
/* 优先:父进程线程组内的存活线程 */
thread = find_alive_thread(father);
if (thread) return thread;

/* 其次:祖先中标记了 is_child_subreaper 的进程(如 systemd/docker init) */
if (father->signal->has_child_subreaper) {
for (reaper = father->real_parent; ...; reaper = reaper->real_parent) {
if (reaper->signal->is_child_subreaper) {
thread = find_alive_thread(reaper);
if (thread) return thread;
}
}
}

/* 最终:PID 命名空间的 init 进程(pid=1) */
return child_reaper;
}

这就是为什么容器内 PID 1 必须正确处理 SIGCHLD 并调用 wait()——否则容器内所有孤儿进程都会成为其僵尸子进程,逐渐耗尽内核资源。


六、写时复制(COW):fork 廉价的秘密

6.1 COW 的实现原理

fork 时调用 dup_mm → dup_mmap,后者遍历父进程的所有 VMA(虚拟内存区域),将每个页表项拷贝到子进程,但同时将父子两边的 PTE 都标记为只读并清除 dirty bit。

当任一方尝试写入,CPU 触发缺页异常(Protection Fault),内核的 do_wp_page 函数被调用:

  1. 检查页面的 _mapcount(被多少 PTE 引用)
  2. _mapcount == 1(只有自己引用),直接将 PTE 改回可写(”破坏单映射”)
  3. _mapcount > 1,分配新物理页,拷贝内容,更新 PTE 为新页并设为可写

这样,只有真正被写入的页面才会发生物理复制,大量只读的代码页、rodata 页永远不会被复制。

6.2 fork + exec 的极致优化:vfork

如果紧接着 fork 就要 exec,COW 的页表复制本身也是浪费,vfork 为此而生:

1
2
3
4
5
6
7
8
9
/* kernel/fork.c: 3016–3025 */
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
  • CLONE_VM:父子共享同一 mm,不复制页表
  • CLONE_VFORK:父进程阻塞在 wait_for_vfork_done() 直到子进程调用 exec_exit

vfork 的约束极为严格:子进程只能调用 exec 系列或 _exit,不得修改任何全局状态,也不得 return 出创建它的函数——因为父子共享栈和 mm,任何写操作都会污染父进程。

6.3 为何 fork + exec 几乎不复制内存

即使不用 vfork,普通 fork 后立即 exec,实际代价也很小:

  1. fork 时仅复制页表结构(4级页表的高层节点),并将所有 PTE 标记为 COW 只读
  2. exec 调用 begin_new_exec → exec_mmap,直接将新 mm 替换旧 mm,mmput(old_mm) 递减引用计数后释放旧页表,所有 COW 只读标记都随之消失
  3. 整个过程:父进程没有任何页面被真正复制,唯一的开销是页表遍历和清理

这就是为什么 shell 每秒可以创建数千个子进程,而内存用量并不会暴增。


七、诊断方法

7.1 /proc/PID/status:进程状态全览

1
2
3
4
5
6
7
8
9
10
11
$ cat /proc/1234/status
Name: nginx
State: S (sleeping)
Tgid: 1234 # 线程组 ID = 主线程 PID
Pid: 1234 # 当前线程 PID
PPid: 1 # 父进程 PID
Threads: 4 # 线程数(CLONE_THREAD 创建的)
VmRSS: 12488 kB # 物理内存使用量(Resident Set Size)
VmSize: 458752 kB # 虚拟内存总量
SigBlk: 0000000000000000 # 被阻塞的信号集(位图,16进制)
SigCgt: 0000000180014a03 # 有自定义处理器的信号集

7.2 /proc/PID/task/:查看所有线程

1
2
3
4
5
6
$ ls /proc/1234/task/
1234 1235 1236 1237 # 每个子目录对应一个线程(tid)

$ cat /proc/1234/task/1235/status | grep -E "^(Pid|Tgid):"
Tgid: 1234 # 所有线程 tgid 相同
Pid: 1235 # 各线程 pid 不同

7.3 pstree -p:可视化进程树

1
2
3
4
5
6
$ pstree -p 1
systemd(1)─┬─sshd(892)───sshd(1041)───bash(1042)───pstree(2300)
├─nginx(1234)─┬─nginx(1235)
│ ├─nginx(1236)
│ └─nginx(1237)
└─...

7.4 strace -f:追踪进程创建系统调用

1
2
3
4
# 追踪 fork/clone/execve(-f 跟踪子进程)
$ strace -f -e trace=fork,clone,clone3,execve ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], 0x7ffd.../* 40 vars */) = 0
# 显示实际加载的 ELF 文件和参数

7.5 bpftrace:动态追踪 copy_process

1
2
3
4
5
6
7
# 追踪所有进程创建,打印父子 PID 和进程名
$ bpftrace -e '
kretprobe:copy_process
/retval != 0/ {
printf("fork: ppid=%d pcomm=%s cpid=%d\n",
pid, comm, ((struct task_struct *)retval)->pid);
}'

7.6 perf stat:量化进程创建开销

1
2
3
4
5
6
7
8
9
$ perf stat -e task-clock,context-switches,cpu-migrations,page-faults \
-r 5 bash -c 'for i in $(seq 100); do /bin/true; done'

Performance counter stats for 'bash -c ...' (5 runs):

312.45 msec task-clock # CPU 时间
850 context-switches # 上下文切换(每次 fork/exec 各一次)
2 cpu-migrations # 跨核迁移
14230 page-faults # 缺页(含 COW 触发)

7.7 /proc/PID/maps:观察 COW 页面状态

1
2
3
4
5
6
$ cat /proc/1234/maps | head -5
55a3c2000000-55a3c2001000 r--p 00000000 fd:01 123456 /usr/bin/nginx
55a3c2001000-55a3c2100000 r-xp 00001000 fd:01 123456 /usr/bin/nginx # 代码段只读
55a3c2100000-55a3c2120000 r--p 00100000 fd:01 123456 /usr/bin/nginx # rodata 只读
55a3c2121000-55a3c2122000 rw-p 00120000 fd:01 123456 /usr/bin/nginx # data 可写
7f8a00000000-7f8a00020000 rw-p 00000000 00:00 0 [heap]

r--pr-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 加载 + 页表建立

forkdup_task_struct 的内核栈分配,到 copy_threadchildregs->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_task
  • arch/x86/kernel/process.c — x86 copy_thread
  • include/uapi/linux/sched.h — CLONE_* 标志定义

内存问题是生产系统中最难排查的故障类型之一。症状可能表现为 OOM 崩溃、响应延迟飙升、Swap 风暴,也可能是长达数天才显现的缓慢内存泄漏。本文从内核数据结构出发,系统讲解 /proc/meminfo/proc/vmstat 的每个字段含义,结合五大实战案例与 bpftrace 诊断脚本,构建一套完整的内存诊断与调优方法论。

Read more »

本系列前几篇文章分别介绍了虚拟内存布局、物理内存分配器、页表体系与缺页异常处理机制。本篇继续深入,聚焦于三个紧密相关的主题: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 »
0%