Virtlet learning

Key Components

  1. Virtlet Manager:
    • Implements the CRI interface for virtualization and image handling
    • Processes requests from kubelet
    • Sets up libvirt VM environment (virtual drives, network interfaces, resources)
    • Manages VM lifecycle through libvirt
  2. Tapmanager:
    • Controls VM networking using CNI
    • Takes setup requests from Virtlet manager
    • Runs DHCP server for each active VM
    • Serves requests from vmwrapper
    • Tears down VM networks upon Virtlet manager requests
  3. VMWrapper:
    • Run by libvirt, wraps the emulator (QEMU/KVM)
    • Requests tap file descriptor from tapmanager
    • Adds command line arguments for the emulator
    • Execs the emulator
  4. Libvirt:
    • Manages VM lifecycle
    • Provides API for VM operations
  5. QEMU/KVM:
    • The actual emulator that runs VMs
  6. CRI Proxy:
    • Allows running multiple CRI implementations on the same node
    • Routes requests to appropriate runtime (Virtlet or dockershim)

Volume Management

Virtlet supports various volume types:

  1. Root Volumes: The main VM disk
  2. Cloud-Init Volumes: For VM configuration
  3. Raw Devices: Direct access to host devices
  4. Kubernetes Volumes: Integration with Kubernetes volume system

The volume management is handled through:

  • VMVolumeSource interface
  • Various volume implementations (rootfs, cloudinit, raw, etc.)
  • Libvirt storage pools

Networking Architecture

VM Lifecycle Management

Code Flow

Detailed Code Flow Explanation

1. Pod Creation and Annotation Parsing

  • Starting Point: Kubernetes creates a pod with Virtlet-specific annotations
  • Key Files: pkg/metadata/types/annotations.go
  • Process:
    • The VirtletDiskDriver annotation specifies the disk driver type (virtio, scsi, or nvme)
    • Annotations are parsed in parsePodAnnotations method

2. Disk Driver Selection

  • Key Files: pkg/libvirttools/diskdriver.go
  • Process:
    • getDiskDriverFactory selects the appropriate driver factory based on the annotation
    • Driver factories: virtioBlkDriverFactory, scsiDriverFactory, or nvmeDriverFactory
    • Each factory creates a driver implementing the diskDriver interface

3. Volume Source and Volume Creation

  • Key Files: pkg/libvirttools/volumes.go, pkg/libvirttools/virtualization.go
  • Process:
    • volumeSource function creates VMVolume objects for each required volume
    • Volumes include root disk, cloud-init config, and additional volumes from flexvolume

4. Disk List Setup

  • Key Files: pkg/libvirttools/disklist.go
  • Process:
    • newDiskList creates a list of disk items, each with a driver and volume
    • diskList.setup calls volume.Setup() for each volume to get disk definitions
    • Each disk definition gets its target from the corresponding driver

5. Domain Creation

  • Key Files: pkg/libvirttools/virtualization.go
  • Process:
    • createDomain builds the libvirt domain XML structure
    • Disk definitions from diskList.setup are added to the domain devices
    • For NVMe disks, the target is set to nvmeXn1 with bus type nvme

6. Domain Definition and Start

  • Key Files: pkg/libvirttools/virtualization.go
  • Process:
    • Domain is defined in libvirt using DefineDomain
    • diskList.writeImages writes any necessary disk images (e.g., cloud-init)
    • Domain is started, launching QEMU with the configured devices

Device Mapping Details

For SCSI Disks (default):

  1. The domain includes a SCSI controller
  2. Disks are attached to this controller with names like sda, sdb
  3. SCSI addressing is used to connect disks to the controller

For virtio-blk Disks:

  1. Disks are attached directly to the PCI bus
  2. Disk names follow the pattern vda, vdb
  3. No controller is needed, simplifying the setup

Key Insights

  1. Modular Design: Virtlet uses a modular architecture with clear separation of concerns between components.
  2. Integration with Kubernetes: Fully implements the CRI interface, allowing seamless integration with Kubernetes.
  3. Networking: Uses CNI for network setup and runs a DHCP server for each VM.
  4. Volume Management: Flexible volume system supporting various volume types and Kubernetes volume integration.
  5. Resource Management: Supports CPU and memory limits, CPU pinning, and NUMA topology.

This architecture allows Virtlet to run VMs as if they were containers from Kubernetes’ perspective, providing a way to run legacy applications or workloads that require full virtualization in a Kubernetes environment.

Questions:

  1. Key Functions and Interfaces Between vmwrapper and tapmanager

Core Interface: FDClient/FDServer Protocol
The communication between vmwrapper and tapmanager is primarily facilitated through the FDClient/FDServer protocol, which allows passing file descriptors across process boundaries.

Key Functions in vmwrapper
In cmd/vmwrapper/vmwrapper.go:

// Main function that retrieves network FDs from tapmanager
if netFdKey != “” {
c := tapmanager.NewFDClient(fdSocketPath)
fds, marshaledData, err := c.GetFDs(netFdKey)
if err != nil {
glog.Errorf(“Failed to obtain tap fds for key %q: %v”, netFdKey, err)
os.Exit(1)
}

var descriptions \[\]tapmanager.InterfaceDescription  
if err := json.Unmarshal(marshaledData, \&descriptions); err \!= nil {  
    glog.Errorf("Failed to unmarshal network interface info: %v", err)  
    os.Exit(1)  
}  
// ...  

}
This code in vmwrapper:

Creates a new FDClient connected to tapmanager’s socket
Calls GetFDs() with the network key to retrieve file descriptors
Unmarshals the interface descriptions
Uses these FDs to configure QEMU network devices
Key Functions in tapmanager
In pkg/tapmanager/fdserver.go:

// FDServer.serveGet - handles GetFDs requests from clients
func (s *FDServer) serveGet(c *net.UnixConn, hdr *fdHeader) (*fdHeader, []byte, []byte, error) {
key := hdr.getKey()
fds, err := s.getFDs(key)
if err != nil {
return nil, nil, nil, err
}
info, err := s.source.GetInfo(key)
if err != nil {
return nil, nil, nil, fmt.Errorf(“can’t get key info: %v”, err)
}

// ... prepare and return file descriptors ...  
rights := syscall.UnixRights(fds...)  
return \&fdHeader{/\*...\*/}, info, rights, nil  

}
In pkg/tapmanager/tapfdsource.go:

// GetFDs implements GetFDs method of FDSource interface
func (s *TapFDSource) GetFDs(key string, data []byte) ([]int, []byte, error) {
var payload GetFDPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, nil, fmt.Errorf(“error unmarshalling GetFD payload: %v”, err)
}

// ... network namespace and CNI setup ...  
  
// Setup container side network  
if csn, err \= nettools.SetupContainerSideNetwork(netConfig, netNSPath, allLinks, s.enableSriov, hostNS); err \!= nil {  
    return nil, err  
}

// Marshal network configuration to return to client  
if respData, err \= json.Marshal(csn); err \!= nil {  
    return nil, fmt.Errorf("error marshalling net config: %v", err)  
}

// Collect file descriptors for tap devices  
for \_, i := range csn.Interfaces {  
    fds \= append(fds, int(i.Fo.Fd()))  
}  
  
return fds, respData, nil  

}
Key Interfaces
FDSource Interface
// FDSource denotes an ‘executive’ part for FDServer which
// creates and destroys (closes) the file descriptors and
// associated resources
type FDSource interface {
// GetFDs sets up file descriptors based on key and extra data
GetFDs(key string, data []byte) ([]int, []byte, error)

// Release destroys the file descriptor and associated resources  
Release(key string) error  
  
// GetInfo returns information to propagate back to FDClient  
GetInfo(key string) (\[\]byte, error)  
  
// Recover recovers FDSource's state after restart  
Recover(key string, data \[\]byte) error  
  
// RetrieveFDs retrieves file descriptors  
RetrieveFDs(key string) (\[\]int, error)  
  
// Stop stops goroutines associated with FDSource  
Stop() error  

}
FDManager Interface
// FDManager denotes an object that provides ‘master’-side
// functionality of FDClient
type FDManager interface {
// AddFDs adds new file descriptor to the FDManager
AddFDs(key string, data interface{}) ([]byte, error)

// ReleaseFDs makes FDManager close the file descriptor  
ReleaseFDs(key string) error  
  
// Recover recovers the state regarding the specified key  
Recover(key string, data interface{}) error  

}
InterfaceDescription Struct
// InterfaceDescription contains interface type with additional data
// needed to identify it
type InterfaceDescription struct {
Type network.InterfaceType `json:”type”`
HardwareAddr net.HardwareAddr `json:”mac”`
FdIndex int `json:”fdIndex”`
PCIAddress string `json:”pciAddress”`
}
Network Setup Functions
The actual network setup is handled by nettools.SetupContainerSideNetwork(), which:

Creates tap devices
Sets up bridges
Configures networking
Returns a ContainerSideNetwork structure with interface descriptions
Data Flow Between Components
Virtlet creates a VM and generates a network key
Virtlet passes this key to vmwrapper via environment variables
vmwrapper connects to tapmanager’s socket and calls GetFDs(key)
tapmanager calls TapFDSource.GetFDs() to set up networking
TapFDSource uses nettools to create and configure tap devices
TapFDSource returns file descriptors and interface descriptions
vmwrapper uses these to configure QEMU network devices
QEMU uses the file descriptors to communicate with the tap devices
This architecture allows for clean separation between the VM process and the network setup, with file descriptors being the primary interface between the components.

  1. Key Functions and Interfaces Between virtlet and vmwrapper

Key Functions and Interfaces Between Virtlet and vmwrapper
Overview
The interaction between Virtlet and vmwrapper is primarily through environment variables and the libvirt domain definition. Virtlet configures the VM domain and sets vmwrapper as the emulator, passing necessary configuration through environment variables.

Key Functions in Virtlet (VirtualizationTool)
Domain Creation
In pkg/libvirttools/virtualization.go, the CreateContainer method is responsible for defining the libvirt domain with vmwrapper as the emulator:

func (v *VirtualizationTool) CreateContainer(config *types.VMConfig, netFdKey string) (string, error) {
// …
settings := domainSettings{
domainUUID: domainUUID,
domainName: “virtlet-“ + domainUUID[:13] + “-“ + config.Name,
netFdKey: netFdKey,
// … other settings
}

domainDef := settings.createDomain(config)  
// ...  

}
Domain Definition
The createDomain method in domainSettings sets up the domain definition with vmwrapper as the emulator and passes configuration through environment variables:

func (ds *domainSettings) createDomain(config *types.VMConfig) *libvirtxml.Domain {
// …
domain := &libvirtxml.Domain{
Devices: &libvirtxml.DomainDeviceList{
Emulator: “/vmwrapper”,
// … other devices
},
// … other domain settings

    QEMUCommandline: \&libvirtxml.DomainQEMUCommandline{  
        Envs: \[\]libvirtxml.DomainQEMUCommandlineEnv{  
            {Name: vconfig.EmulatorEnvVarName, Value: emulator},  
            {Name: vconfig.NetKeyEnvVarName, Value: ds.netFdKey},  
            {Name: vconfig.ContainerIDEnvVarName, Value: config.DomainUUID},  
            {Name: vconfig.LogPathEnvVarName, Value: filepath.Join(config.LogDirectory, config.LogPath)},  
            {Name: vconfig.NetworkDeviceEnvVarName, Value: config.ParsedAnnotations.NetworkDevice},  
        },  
    },  
}  
// ...  
return domain  

}
Environment Variables (Communication Interface)
The key environment variables used for communication between Virtlet and vmwrapper are defined in pkg/config/constants.go:

const (
// ContainerIDEnvVarName contains name of env variable passed from virtlet to vmwrapper
ContainerIDEnvVarName = “VIRTLET_CONTAINER_ID”
// CpusetsEnvVarName contains name of env variable passed from virtlet to vmwrapper
CpusetsEnvVarName = “VIRTLET_CPUSETS”
// EmulatorEnvVarName contains name of env variable passed from virtlet to vmwrapper
EmulatorEnvVarName = “VIRTLET_EMULATOR”
// LogPathEnvVarName contains name of env variable passed from virtlet to vmwrapper
LogPathEnvVarName = “VIRTLET_CONTAINER_LOG_PATH”
// NetKeyEnvVarName contains name of env variable passed from virtlet to vmwrapper
NetKeyEnvVarName = “VIRTLET_NET_KEY”
// Network device
NetworkDeviceEnvVarName = “VIRTLET_NETWORK_DEVICE”
)
Key Functions in vmwrapper
In cmd/vmwrapper/vmwrapper.go, the main function processes these environment variables:

func main() {
// …
emulator := os.Getenv(config.EmulatorEnvVarName)
emulatorArgs := os.Args[1:]
var netArgs []string
if emulator == “” {
// this happens during ‘qemu -help’ invocation by libvirt
// (capability check)
emulator = defaultEmulator
} else {
netFdKey := os.Getenv(config.NetKeyEnvVarName)
// …
if netFdKey != “” {
c := tapmanager.NewFDClient(fdSocketPath)
fds, marshaledData, err := c.GetFDs(netFdKey)
// …
// Process network interfaces
// …
}
}

args := append(\[\]string{emulator}, emulatorArgs...)  
args \= append(args, netArgs...)  
env := os.Environ()  
if err := syscall.Exec(args\[0\], args, env); err \!= nil {  
    glog.Errorf("Can't exec emulator: %v", err)  
    os.Exit(1)  
}  

}
Data Flow Between Components
Virtlet creates a VM configuration with a unique domain UUID
Virtlet generates a network key for the VM
Virtlet defines a libvirt domain with:
/vmwrapper as the emulator
Environment variables containing configuration:
VIRTLET_EMULATOR: The actual QEMU emulator path
VIRTLET_NET_KEY: Key to retrieve network interfaces
VIRTLET_CONTAINER_ID: The domain UUID
VIRTLET_CONTAINER_LOG_PATH: Path for VM logs
VIRTLET_NETWORK_DEVICE: Network device type (e.g., “virtio”)
VIRTLET_CPUSETS: CPU sets for the VM (optional)
libvirt starts the domain, executing /vmwrapper with the environment variables
vmwrapper:
Reads the environment variables
Connects to tapmanager using the socket path
Retrieves network interfaces using the network key
Constructs QEMU command line arguments
Executes the actual QEMU emulator with the arguments
Key Interfaces
The primary interface between Virtlet and vmwrapper is the set of environment variables passed through the libvirt domain definition. These variables provide vmwrapper with all the information it needs to:

Identify the VM (container ID)
Locate the actual emulator to use
Set up networking by retrieving the appropriate file descriptors
Configure logging
Set CPU affinity (if specified)
This design allows Virtlet to remain in control of the VM configuration while delegating the actual execution and network setup to vmwrapper, which runs in a separate process.

Function Relationship

进程调度是 Linux 内核中最复杂也最关键的子系统之一。在生产环境中,”CPU 使用率 100% 但响应很慢”、”任务唤醒后等了几十毫秒才运行”、”某个进程长期卡在 D 状态”——这些问题的根因往往深藏在调度层。本文是本系列第五篇,聚焦于调度诊断的完整方法论:从 /proc 接口读取原始数据,到 perf sched 分析调度延迟,再到 bpftrace 精确追踪内核路径,最后结合四个典型生产案例给出可落地的排查流程与修复建议。

Read more »

容器技术的核心在于资源隔离与限制,而这一能力的底层支撑正是 Linux 内核的 cgroup(Control Group)机制。本文基于 Linux 6.4-rc1 源码,深入剖析 cgroup v2 的内核实现,涵盖统一层次框架、Memory/CPU/IO/PID 四大控制器的核心数据结构与关键路径,以及 cgroup namespace 与诊断方法。

Read more »

前言

在 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

延伸阅读:

0%