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 后父子进程共享物理内存页,但页表标记为只读。谁先写,谁触发缺页异常,内核才真正复制那页。
核心流程:
_do_fork()调用copy_process()copy_process()复制task_struct,分配内核栈dup_task_struct()复制父进程的task_structcopy_mm()复制内存描述符,设置 COW 标志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 到调度执行的全过程:
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 内核的“骨架”。下次遇到进程相关的问题,不妨从这几个角度入手分析。