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
0%