一、全书概览

「开发内功修炼」不是一本传统意义上的书,而是一套从微信公众号连载文章演化而来的 Linux 内核深度学习体系。作者张彦飞(飞哥)把 CPU、内存、磁盘、网络四大模块拆开来讲,每篇从一个实际工作中的问题出发,一路追到内核源码层面。进程管理篇是其中第三个大模块,聚焦的问题很直接:你写的代码是怎么变成进程跑起来的?进程之间怎么切换?系统调用到底花多少时间?协程为什么比线程快?

整套内容的出发点很朴素——飞哥自己在线上维护 Redis 时,把短连接改成长连接后开始追问:6000 条并发连接会不会把服务器搞崩?这个问题没任何一本书能给出完整答案,所以他翻内核源码、做压测、查论文,最后把整个思考过程写成了文章。

进程管理篇的核心线索是"开销"。进程创建的开销、进程切换的开销、系统调用的开销、软中断的开销——搞清楚这些开销从哪来、有多大,你就知道该怎么写高性能程序了。

知识体系定位:这本书/系列介于《深入理解计算机系统》和《深入理解Linux内核》之间。CSAPP 偏理论和计算机体系结构,LKD 偏源码实现,而「开发内功修炼」走的是"问题驱动 + 源码验证 + 性能实测"这条路。读起来比 LKD 轻松,比普通的技术博客又有深度得多。


二、逐章要点

2.1 进程是如何创建出来的

这一篇回答的是最基础也最容易被忽视的问题:当你敲下 ./my_program 或者代码里调用 fork() 的时候,内核到底干了什么?

核心过程拆成几步:

  1. fork() 系统调用:父进程调用 fork,内核在进程描述符(task_struct)中分配新的 pid,复制父进程的大部分信息(文件描述符、信号处理、内存映射等),然后返回。子进程从 fork 返回的位置继续执行。
  2. execve() 系统调用:用新的程序替换当前进程的地址空间。内核会读取 ELF 文件头,建立新的内存映射,设置程序入口点。
  3. 进程地址空间搭建:内核为进程创建虚拟地址空间,加载代码段、数据段,初始化堆和栈。BSS 段清零,堆从 brk 位置开始向上生长。

这里有个很容易混淆的地方:fork 出来的子进程和父进程共享物理内存页(写时复制,Copy-on-Write),不是真的把所有内存都复制一份。只有当某一方尝试写入的时候,内核才会真正分配新的物理页。这个机制让 fork 的成本远比想象中低。

2.2 你写的代码是如何跑起来的

从编译到执行的完整链路:

  • 编译阶段:C/C++ 源码 → 预处理器 → 编译器生成汇编 → 汇编器生成目标文件 → 链接器生成可执行文件。链接器负责解析符号引用、地址重定位。
  • 加载阶段:用户在 shell 里敲命令,shell 调用 fork() 创建子进程,子进程调用 execve() 加载程序。内核解析 ELF 格式,把各个段映射到虚拟地址空间。
  • 执行阶段:CPU 从入口点开始取指执行。函数调用通过栈帧管理,每次调用压入返回地址和参数,返回时弹出。

值得注意的细节:动态链接的程序在启动时还需要加载共享库(ld-linux.so),这会带来额外的页错误和延迟。这也是为什么静态链接的程序在某些场景下启动更快。

2.3 线程和进程的联系与区别

这是面试高频题,但大部分回答都停在"线程是进程内的执行单元"这个层面。飞哥从内核视角给出了更精确的描述:

  • 在 Linux 内核里,线程和进程本质上都是 task_struct。内核不区分进程和线程,只区分共享资源的程度。创建线程用的是 clone() 系统调用,通过参数控制哪些资源需要共享(内存、文件描述符、信号处理等)。
  • 进程 = 独立地址空间的 task_struct
  • 线程 = 共享地址空间的 task_struct

pthread_create 底层就是调用 clone(),传入 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND 等标志位。fork() 则是不传这些共享标志的 clone()

所以线程比进程轻量在哪?不在内核的数据结构(都是 task_struct),而在不需要复制页表不需要写时复制机制。线程创建后直接共享父进程的页表,省掉了大量的内存管理开销。

2.4 进程是如何被调度执行的

Linux 进程调度器的演进史本身就是一部架构进化史:

  • 2.4 内核:O(n) 调度器,每次调度遍历所有就绪进程,进程多了性能直线下降。
  • 2.6 内核:O(1) 调度器,用优先级数组实现恒定时间选择,但交互式进程的判断逻辑很复杂。
  • CFS(Completely Fair Scheduler):从 2.6.23 开始引入,用红黑树管理虚拟运行时间(vruntime),每次选 vruntime 最小的进程执行。思路是"给每个进程公平的 CPU 时间",但会根据 nice 值和权重进行分配。

CFS 的核心数据结构是红黑树,插入、删除、查找最小值都是 O(log n)。每个可运行进程都在这棵树上,调度器只需要取左子节点就能拿到"最该运行"的进程。

2.5 负载和 CPU 开销的关系

Linux 的 load average 不完全等于 CPU 使用率。负载高但 CPU 空闲的情况很常见——典型场景是大量进程在等 I/O。这些进程处于 D 状态(不可中断睡眠),不消耗 CPU,但会被计入负载。

理解这一点对排查线上问题很关键。如果你的服务器 load 很高但 CPU 使用率很低,问题大概率出在磁盘 I/O 或者网络 I/O 上,而不是 CPU。

2.6 PID 管理从 bitmap 到 radix-tree

旧版内核用 bitmap 管理 PID 分配,每个 PID 占一个 bit。但 PID 范围越来越大(最大到 4194304),bitmap 的内存占用和查找效率都成了问题。新版内核改用 radix-tree,分配和释放都是 O(log n),空间按需分配,不浪费。

这个改动看起来很小,但体现了内核开发的一个核心思路:当数据规模增长时,算法复杂度和空间效率同样重要

2.7 进程/线程切换的开销

一次上下文切换到底有多贵?飞哥实测的结果大致是:

  • 纯上下文切换(两个空循环进程互相切换):约 1-2 微秒
  • 如果涉及不同地址空间(进程间切换):额外增加 TLB 刷新的开销,可能到 3-10 微秒
  • 切换到不同 CPU 的 NUMA 节点:还要加上缓存失效的代价

这些数字的意义在于:如果你的程序每秒要切换几百万次上下文,光切换的开销就能吃掉好几个 CPU 核心。这也是为什么高并发服务器(Nginx、Redis)都尽量用单线程或少线程模型。

2.8 系统调用的开销

一次 getpid() 这样的简单系统调用,开销大约在 100-200 纳秒级别。但如果是 read()/write() 这种涉及数据拷贝的系统调用,开销会大得多,因为要把数据从内核空间拷贝到用户空间(或反过来)。

系统调用的开销主要来自:

  1. 用户态到内核态的切换(陷入内核)
  2. 权限检查和参数拷贝
  3. 执行内核函数
  4. 返回用户态(iret/sysexit)

gettimeofday() 在现代内核里已经通过 vDSO 优化为不需要陷入内核了,直接在用户态就能拿到结果。这个优化把一次调用从 200 纳秒降到了十几纳秒。

2.9 协程比线程牛在哪里

协程(Coroutine)的核心优势是切换在用户态完成,不需要内核参与。一次协程切换只需要保存和恢复少量寄存器(大约几十纳秒),而线程切换需要陷入内核(几微秒)。

Go 的 goroutine、Python 的 async/await、JavaScript 的 Promise 本质上都是协程的不同实现。Go 的 runtime 里有一个 M:N 的调度器,把 M 个 goroutine 映射到 N 个 OS 线程上,在用户态完成调度。

但协程不是万能的。如果你的任务是 CPU 密集型的,协程并没有优势——瓶颈在计算而不是切换。协程的甜区是 I/O 密集型的高并发场景。

2.10 信号管理

SIGPIPE 是一个很容易踩坑的信号。当你往一个已经关闭的 socket 写数据时,内核会发送 SIGPIPE 给进程,默认行为是终止进程。很多后端服务莫名其妙的崩溃都是这个原因。

解决方案很简单:signal(SIGPIPE, SIG_IGN)。但知道为什么会有这个信号,比知道怎么忽略它更重要。

2.11 CPU 利用率与容器

Linux 计算 CPU 利用率的方式是采样 /proc/stat 中的 jiffies 值,两次采样之间 CPU 在不同状态(user、system、idle、iowait 等)上花费的时间比例。

在容器环境里,直接读 /proc/stat 拿到的是宿主机的 CPU 数据,不准确。正确做法是读 cgroup 里的 cpuacct.usage,它记录的是容器实际消耗的 CPU 时间。


三、关键概念速查

| 概念 | 说明 | |------|------| | task_struct | Linux 内核中进程/线程的描述符,包含进程状态、优先级、寄存器、打开的文件等信息 | | CFS | Completely Fair Scheduler,Linux 默认调度器,用红黑树按 vruntime 排序 | | vruntime | 虚拟运行时间,CFS 调度的核心指标,越小越优先 | | 写时复制(COW) | fork 时不复制物理内存页,写入时才分配新页 | | clone() | 创建线程/进程的系统调用,通过标志位控制共享程度 | | 上下文切换 | CPU 从一个进程切换到另一个进程,涉及保存/恢复寄存器、切换页表等 | | 系统调用 | 用户态程序请求内核服务的接口,需要陷入内核 | | vDSO | 虚拟动态共享对象,让某些系统调用(如 gettimeofday)在用户态完成 | | 协程 | 用户态的轻量级执行单元,切换不需要内核参与 | | 负载均值(load average) | 1/5/15 分钟内处于运行态和不可中断态的平均进程数 | | D 状态 | 不可中断睡眠状态,通常是等 I/O,计入负载但不消耗 CPU | | TLB | Translation Lookaside Buffer,页表缓存,进程切换时需要刷新 | | radix-tree | 内核中用于 PID 管理的数据结构,替代了旧的 bitmap 方案 | | cgroup | Control Group,用于限制和统计容器/进程组的资源使用 |


四、核心框架与模型

4.1 进程生命周期模型

创建(fork/exec) → 就绪(RUNNING队列) ↔ 运行(RUNNING)
                      ↓                  ↑
                   等待(阻塞I/O) ──────→ I/O完成唤醒
                      ↓
                   终止(exit) → 僵尸(ZOMBIE) → wait回收

进程有几种核心状态转换:新建→就绪→运行→阻塞→就绪→运行→终止。理解这个状态机是理解调度的基础。

4.2 CPU 开销分层模型

应用层函数调用:     ~几 ns
    ↓
系统调用(简单):     ~100-200 ns
    ↓
系统调用(复杂):     ~1-10 μs(含数据拷贝)
    ↓
线程上下文切换:     ~1-2 μs(同进程)/ ~3-10 μs(跨进程)
    ↓
进程创建(fork):     ~几十 μs(COW)~ ~几百 μs(完全复制)

这个分层模型的意义在于:每次你往下一层走,开销就增加一个数量级。写高性能程序的关键就是在保证功能的前提下,尽量停留在更高的层级。

4.3 CFS 调度模型

                    红黑树(按 vruntime 排序)
                           │
        ┌──────────────────┼──────────────────┐
        │                  │                  │
     小 vruntime      中等 vruntime      大 vruntime
     (优先执行)     (等待执行)       (执行较多)

CFS 的"公平"不是给每个进程相同的绝对时间,而是根据 nice 值分配不同的权重。nice 值越小(优先级越高),权重越大,分配到的实际 CPU 时间越多。

4.4 问题驱动学习模型

飞哥整个系列的方法论可以抽象为一个循环:

工作中遇到问题 → 提出疑问 → 查阅资料/内核源码 → 动手验证 → 总结输出 → 应用到实践
      ↑                                                              │
      └──────────────────────────────────────────────────────────────┘

这不是"先学完理论再实践",而是"从实践中长出理论"。这种方式产出的知识比单纯看书要扎实得多。


五、金句摘录

  1. "我们对工作中看似司空见惯的问题发起疑问,然后自己花时间去翻阅各种资料,进行深度思考,然后将相关理论知识再次进行测试验证,最后在项目中应用。"

  2. "当你开始深度思考一个具体问题的时候,你可能会发现,其实大部分时候没有任何一本现成的技术书籍能有完整的答案。"

  3. "这种对理论知识进行深度思考再加动手验证以后得来的知识在脑子里的印象特别深的。你几乎不太可能忘了它,因为它已经彻底内化到你的知识体系里了。"

  4. "在 Linux 内核里,线程和进程本质上都是 task_struct。内核不区分进程和线程,只区分共享资源的程度。"

  5. "Linux 中的负载高低和 CPU 开销并不完全对应。负载高但 CPU 空闲的情况很常见——典型场景是大量进程在等 I/O。"

  6. "理论+实践,两手抓,两手都要硬。"


六、行动清单

即时行动(读完就做)

  • [ ] 用 strace 跟踪一个简单程序的启动过程,观察 fork/execve/brk/mmap 等系统调用的顺序
  • [ ] 用 perf stat 测量一次空循环的 fork() 调用耗时
  • [ ] 写两个进程互相 ping-pong(通过 pipe),用 perf 测量上下文切换的实际开销
  • [ ] 用 cat /proc/[pid]/status 查看 D 状态进程的数量,理解负载和 CPU 使用率的差异

短期学习(1-2 周)

  • [ ] 读 Linux 内核源码中 kernel/fork.ccopy_process 函数,对照飞哥的文章理解 fork 的完整流程
  • [ ] 读 kernel/sched/fair.c 中的 CFS 调度器实现,特别是 pick_next_task_fair 函数
  • [ ] 用 Go 写一个协程和线程的性能对比程序,实测切换开销的差异
  • [ ] 阅读《深入理解Linux进程与内存》实体书,和公众号文章交叉印证

中期实践(1-3 个月)

  • [ ] 搭建一个压测环境,实测不同线程数下 HTTP 服务器的 QPS 和延迟变化,找到最优线程数
  • [ ] 在自己的项目中引入 perfeBPF 工具,建立性能分析的习惯
  • [ ] 尝试用 vDSO 的知识优化高频系统调用(如时间获取)的路径
  • [ ] 研究一个真实的高性能开源项目(Nginx/Redis/Go runtime),看它是怎么处理进程/线程模型的

长期目标

  • [ ] 建立自己的 Linux 内核知识图谱,把进程管理、内存管理、网络、文件系统四个模块串起来
  • [ ] 关注 Linux 内核的演进(如新调度器、新的 pid 管理方案),保持对底层变化的敏感度

七、一句话总结

从"代码怎么跑"到"跑得多快",中间隔着一个完整的 Linux 内核进程管理体系——搞懂 fork、调度、上下文切换、系统调用这些底层开销,才是写高性能程序的真正基本功。


八、读者热议

适合谁读

  • 后端开发:尤其是做高并发服务的同学,理解进程/线程模型对架构设计有直接帮助
  • 运维/SRE:排查线上问题时需要理解 load average、CPU 使用率、上下文切换等指标的含义
  • 想进阶的开发者:已经会写代码,但想搞清楚代码到底是怎么在机器上跑起来的
  • 准备系统编程面试的同学:进程、线程、调度、系统调用是高频考点

读者评价摘录

  • "飞哥的文章是我见过最好的中文内核科普,没有之一。比大部分翻译书都好读,比纯博客有深度。"
  • "看完进程管理篇以后,终于理解了为什么 Nginx 要用多进程而不是多线程,为什么 Redis 是单线程的。不是作者偏执,是经过精确计算的选择。"
  • "之前一直搞不懂 load average 到底代表什么,看完这篇终于明白了——它不是 CPU 使用率,是运行态加不可中断态的进程数。"
  • "这个系列最大的价值不是教你某个知识点,而是教你一种思考方式:从工作中的问题出发,追到内核层面找答案。"

常见讨论

Q:这个系列和《深入理解Linux内核》有什么区别? A:LKD 是系统性地过一遍内核实现,适合当参考书翻。「开发内功修炼」是从具体问题切入,每篇都有明确的"为什么要看这个"。先看飞哥的文章建立直觉,再翻 LKD 深入细节,是比较好的路径。

Q:需要会 C 语言和内核源码吗? A:不需要。文章里有源码片段,但都是辅助理解用的。核心内容用图和文字就能看懂。当然如果你会 C 语言,对照源码读效果更好。

Q:实体书《深入理解Linux进程与内存》和公众号内容一样吗? A:实体书是重新组织过的,内容更系统,有配套的实验代码。公众号文章是零散的,但更新更快、覆盖面更广。两者互补,不是替代关系。


本文基于「开发内功修炼」GitHub 开源项目(github.com/yanfeizhang/coder-kung-fu)进程管理篇及相关文章整理。


相关笔记

同作者: 同主题: