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