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