3. 进程管理源码分析:task_struct、fork/exec 与 CFS 调度器

进程管理,说白了就是内核如何“管人”。每个进程都是一个执行单元,内核得知道它是谁、在干嘛、该给多少CPU时间。今天我们就从最核心的数据结构 task_struct 开始,一路看到进程创建和调度。

3.1 task_struct:进程的“身份证”

每个进程在内核里都有一个 task_struct 实例。我刚开始看源码时,第一反应是——这结构体也太大了!几百个字段,看着就头大。但你想想看,内核要管理进程的所有信息,不堆在一起反而更麻烦。

核心字段可以分成几类:

类别 关键字段 作用
标识 pid, tgid 进程ID和线程组ID
状态 state TASK_RUNNING / TASK_INTERRUPTIBLE 等
调度 se, rt, dl CFS、实时、Deadline 调度实体
内存 mm, active_mm 进程地址空间
文件 files 打开的文件描述符表
信号 pending, blocked 待处理信号和信号掩码

嗯,这里要注意:tgid 和 pid 的区别。对于主线程,两者相等;对于子线程,pid 不同但 tgid 相同。我曾经在调试一个多线程程序时,发现 ps 显示的 PID 和内核日志对不上,查了半天才发现是 tgid 的锅。

关键点: task_struct 通过 thread_info 与内核栈绑定。每个进程的内核栈底部都藏着 thread_info,里面存了 task 指针。这样内核可以通过栈指针快速找到当前进程——性能敏感路径上少一次内存访问。

3.2 进程创建:fork 与 exec

进程创建的核心系统调用是 fork 和 exec。很多人以为 fork 就是复制,其实没那么简单。

3.2.1 fork 的写时复制(COW)

传统 fork 会把父进程的整个地址空间复制一遍,太浪费了。Linux 用了写时复制:fork 后父子进程共享物理内存页,但页表标记为只读。谁先写,谁触发缺页异常,内核才真正复制那页。

核心流程:

  1. _do_fork() 调用 copy_process()
  2. copy_process() 复制 task_struct,分配内核栈
  3. dup_task_struct() 复制父进程的 task_struct
  4. copy_mm() 复制内存描述符,设置 COW 标志
  5. wake_up_new_task() 将新进程加入就绪队列

避坑指南: 我曾经在嵌入式项目中遇到 fork 后子进程卡死的问题。排查发现是父进程持有某个互斥锁,子进程复制了锁的状态但没复制持有者信息,导致死锁。记住:fork 后子进程只复制了锁的“尸体”,没复制“灵魂”。

3.2.2 exec 系列:换壳

exec 不创建新进程,它把当前进程的地址空间清空,重新加载可执行文件。核心函数是 exec_binprm(),它会调用各种二进制格式的加载器(ELF、a.out 等)。

ELF 加载的关键步骤:

  • load_elf_binary() 解析 ELF 头
  • 映射代码段、数据段到内存
  • 设置栈和辅助向量(AT_RANDOM 等)
  • 最后跳转到入口点

我个人习惯在调试 exec 问题时,用 strace 看系统调用序列,再用 gdb 在 load_elf_binary() 设断点。有一次发现某个程序启动慢,就是因为动态链接器加载了太多共享库——每个库都要 mmap,开销不小。

3.3 CFS 调度器:公平不是平均

CFS(Completely Fair Scheduler)是 Linux 默认调度器。它的设计哲学很简单:让每个进程获得 CPU 时间的“公平份额”。但公平不是平均——优先级高的进程应该多分点。

3.3.1 虚拟运行时间

CFS 的核心概念是 vruntime(虚拟运行时间)。每个调度实体(sched_entity)都有一个 vruntime,它等于实际运行时间除以优先级权重。优先级越高,权重越大,vruntime 增长越慢。

调度决策就是选 vruntime 最小的进程运行。CFS 用红黑树来维护就绪队列,vruntime 最小的节点在最左边。

关键公式: vruntime += delta_exec * NICE_0_LOAD / se->load.weight

其中 NICE_0_LOAD 是 nice 值为 0 时的权重(1024)。优先级每差 1,权重变化约 10%。

3.3.2 调度周期与时间片

CFS 没有固定时间片。它根据进程数量和调度延迟计算一个“目标延迟”:

  • 默认调度延迟:sysctl_sched_latency(6ms)
  • 最小粒度:sysctl_sched_min_granularity(0.75ms)
  • 每个进程的时间片 = 调度延迟 / 进程数

如果进程太多,时间片会小于最小粒度,这时 CFS 会限制调度延迟,保证每个进程至少运行一个最小粒度。

注意: CFS 的“公平”是统计意义上的。短期看,某个进程可能被“饿死”几毫秒,但长期来看,每个进程都能获得公平份额。我遇到过有人抱怨 CFS 不公平,结果发现是进程频繁睡眠导致 vruntime 被补偿了——这不是调度器的问题,是进程自身行为导致的。

3.3.3 调度类与优先级

Linux 的调度器是分层的:

调度类 优先级范围 适用场景
Stop 最高 停止 CPU,用于热插拔等
Deadline 实时 硬实时任务
Real-Time 0-99 软实时任务
CFS 100-139 普通进程
Idle 最低 空闲进程

每个 CPU 都有一个运行队列 struct rq,里面包含各个调度类的就绪队列。调度时,按优先级从高到低检查每个调度类,有就绪进程就选它。

3.4 核心流程:进程创建与调度

下面这张图展示了从 fork 到调度执行的全过程:

进程创建与调度流程 进程创建与调度核心流程 fork() 系统调用 copy_process() 复制 task_struct 设置 COW 标志 wake_up_new_task() 加入 CFS 就绪队列 调度器选择进程 红黑树最左节点 context_switch() 进程开始运行 说明:fork 创建进程后,通过 wake_up_new_task 加入就绪队列,调度器在 tick 或主动调度时选择 vruntime 最小的进程执行

3.5 调试技巧:如何跟踪进程创建和调度

最后分享几个我常用的调试方法:

  • ftrace:echo function_graph > current_tracer,然后跟踪 _do_fork 和 schedule 函数
  • perf sched:perf sched record 记录调度事件,perf sched latency 查看延迟
  • 内核断点:在 context_switch 或 pick_next_task_fair 设断点,观察 vruntime 变化

个人经验: 我曾经用 ftrace 跟踪一个进程为什么被频繁调度。发现是进程每次 read() 后都主动 sched_yield(),导致它不断让出 CPU。去掉那个 sched_yield() 后,性能提升了 30%。

进程管理是内核最核心的模块之一。理解了 task_struct、fork/exec 和 CFS 调度器,你就掌握了 Linux 内核的“骨架”。下次遇到进程相关的问题,不妨从这几个角度入手分析。