编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 计算机原理

  • 网络协议

  • 操作系统

    • README
    • OS进程与线程原理
      • 01.工作案例引入
        • 1.1 Go服务半夜崩了
        • 1.2 为什么要学进程线程
      • 02.进程基本概念
        • 2.1 程序vs进程
        • 2.2 进程组成详解
        • 2.3 进程的地址空间
        • 2.4 进程资源分配单位
      • 03.进程状态与模型
        • 3.1 进程五状态模型
        • 3.2 进程七状态模型
        • 3.3 状态切换的触发条件
        • 3.4 僵尸进程与孤儿进程
      • 04.进程控制块PCB
        • 4.1 PCB里存了什么
        • 4.2 task结构体
        • 4.3 PCB的组织方式
      • 05.线程——轻量级进程
        • 5.1 为什么需要线程
        • 5.2 线程定义与特点
        • 5.3 线程组成结构
        • 5.4 进程与线程的对比
      • 06.线程的实现模型
        • 6.1 用户态线程模型
        • 6.2 内核态线程模型
        • 6.3 混合线程模型
        • 6.4 三种模型对比
      • 07.上下文切换机制
        • 7.1 什么是上下文切换
        • 7.2 进程上下文切换
        • 7.3 线程上下文切换
        • 7.4 系统调用与切换
        • 7.5 上下文切换的代价
      • 08.进程创建与fork
        • 8.1 fork的语义
        • 8.2 写时拷贝COW
        • 8.3 fork的坑
        • 8.4 exec替换程序
      • 09.协程并发模型
        • 9.1 协程基本概念
        • 9.2 协程vs线程
        • 9.3 有栈协程与无栈协程
        • 9.4 协程调度机制
      • 10.Web服务器案例
        • 10.1 场景需求分析
        • 10.2 方案一多进程模型
        • 10.3 方案二多线程模型
        • 10.4 方案三线程池模型
        • 10.5 方案四协程模型
        • 10.6 四种方案横向对比
        • 10.7 知识图谱回顾
      • 11.思考题与作业
        • 11.1 基础思考题目
        • 11.2 进阶思考题目
        • 11.3 动手实践作业
    • OS处理器调度策略
    • OS进程间通信机制
    • OS同步与互斥机制
    • OS内存管理的原理
    • OS的虚拟内存机制
    • OS的文件系统原理
    • OS的输入输出模型
    • OS的设备驱动基础
    • OS的容器与虚拟化
  • 数据库原理

  • 计算机
  • 操作系统
杨充
2021-08-03
目录

OS进程与线程原理

# OS进程与线程原理

从"程序怎么跑起来"讲起——进程模型、线程模型、协程、上下文切换

# 目录介绍

  • 01.工作案例引入
    • 1.1 Go服务半夜崩了
    • 1.2 为什么要学进程线程
  • 02.进程基本概念
    • 2.1 程序vs进程
    • 2.2 进程组成详解
    • 2.3 进程的地址空间
    • 2.4 进程资源分配单位
  • 03.进程状态与模型
    • 3.1 进程五状态模型
    • 3.2 进程七状态模型
    • 3.3 状态切换的触发条件
    • 3.4 僵尸进程与孤儿进程
  • 04.进程控制块PCB
    • 4.1 PCB里存了什么
    • 4.2 task_struct结构
    • 4.3 PCB的组织方式
  • 05.线程——轻量级进程
    • 5.1 为什么需要线程
    • 5.2 线程定义与特点
    • 5.3 线程组成结构
    • 5.4 进程与线程的对比
  • 06.线程的实现模型
    • 6.1 用户态线程模型
    • 6.2 内核态线程模型
    • 6.3 混合线程模型
    • 6.4 三种模型对比
  • 07.上下文切换机制
    • 7.1 什么是上下文切换
    • 7.2 进程上下文切换
    • 7.3 线程上下文切换
    • 7.4 系统调用与切换
    • 7.5 上下文切换的代价
  • 08.进程创建与fork
    • 8.1 fork的语义
    • 8.2 写时拷贝COW
    • 8.3 fork的坑
    • 8.4 exec替换程序
  • 09.协程并发模型
    • 9.1 协程基本概念
    • 9.2 协程vs线程
    • 9.3 有栈协程与无栈协程
    • 9.4 协程调度机制
  • 10.Web服务器案例
    • 10.1 场景需求分析
    • 10.2 方案一多进程模型
    • 10.3 方案二多线程模型
    • 10.4 方案三线程池模型
    • 10.5 方案四协程模型
    • 10.6 四种方案横向对比
    • 10.7 知识图谱回顾
  • 11.思考题与作业
    • 11.1 基础思考题目
    • 11.2 进阶思考题目
    • 11.3 动手实践作业

# 01.工作案例引入

# 1.1 Go服务半夜崩了

场景:小王是一名工作两年的后端工程师,负责公司的一个Go微服务。某天凌晨两点,监控告警炸了:服务的P99延迟从 20ms 飙升到 3s,部分请求直接 502。值班同事紧急重启,无效。

小王登上服务器,htop 一看:

Tasks: 47, 4813 thr; 2 running
Load average: 96.31 72.15 45.88
1
2

疑惑:才 47 个进程,怎么开出了 4813 个线程?负载 96,但 CPU 使用率却不高,明明没有在做计算,为什么这么"忙"?

继续排查发现:每一个HTTP请求进来,代码里 go func() 创建了一个新的goroutine,但某个下游依赖超时 5 秒,goroutine 没有及时退出。高并发下,几千个goroutine全部堆积,Go调度器在这些goroutine之间频繁切换,系统几乎把所有时间都花在了"切换"上,真正做业务的时间少得可怜。

追问链:

  • "为什么 47 个进程能开 4813 个线程?" → 因为一个进程可以创建多个线程,线程共享进程资源,创建开销比进程小得多
  • "那为什么不直接用进程来隔离?" → 因为进程创建慢(fork开销大)、进程间通信复杂、进程切换代价远高于线程切换
  • "为什么线程的切换比进程快?" → 因为同一个进程的线程共享地址空间,切换时不需要刷新页表、TLB,也不需要替换内存映射
  • "Go明明是协程,为什么要提线程?" → 因为Go的goroutine最终还是要跑在线程上,Goroutine调度器(G-M-P模型)是在用户态实现的一套"线程之上的线程"
  • "那什么情况下该用多进程,什么时候该用多线程?" → 答案就藏在"隔离性 vs 效率"这个永恒的权衡里

这一连串问题,答案都写在"进程与线程原理"这一章里。

# 1.2 为什么要学进程线程

小王这次事故的根本问题不是代码写错了,而是对并发模型没有感觉。他知道 go func() 可以创建goroutine,但不知道:

flowchart LR
    A[go func] -->|创建| B[goroutine 协程]
    B -->|被调度到| C[OS 线程]
    C -->|CPU 调度| D[物理核心]
    C -->|大量线程| E[上下文切换风暴]
    E -->|CPU 空转| F[负载飙升/延迟爆炸]
    style E fill:#ff6b6b
    style F fill:#ff6b6b
1
2
3
4
5
6
7
8

本章的目标,就是把"并发执行的抽象"拆开给你看,让你建立起最核心的操作系统直觉:

  • 程序是怎么变成进程跑起来的?
  • 进程和线程到底差在哪里?
  • 为什么线程切换比进程快?快了多少?
  • 协程又是什么?和线程有什么关系?
  • 写并发代码时,怎么选进程/线程/协程?

带着这五个问题,我们开始。

# 02.进程基本概念

# 2.1 程序vs进程

疑惑:我写了一个 hello.c 编译成 a.out,双击运行。这个 a.out 是进程吗?

答疑:还不是。在运行之前,a.out 只是一堆二进制躺在硬盘上,它是一个程序(Program),是静态的。当你双击它(或者在终端执行 ./a.out),操作系统会创建对应的进程(Process),把程序加载到内存,分配资源,然后CPU开始执行。进程是程序的一次执行实例,是动态的。

程序(静态):a.out 文件,躺在硬盘上
    ↓ 操作系统加载
进程(动态):运行中的 a.out,占用内存、CPU、文件描述符等资源
1
2
3

类比理解:

程序 进程
类比 菜谱 厨师正在做这道菜
存储位置 硬盘 内存
生存周期 永久存在(除非删除文件) 有始有终(创建→运行→结束)
是否占用资源 不占用 占用CPU、内存、文件等
实体数量 一个程序可对应多个进程 如多个用户同时运行 vim,就是一个程序对应多个进程

# 2.2 进程组成详解

一个进程在操作系统眼里,由三部分组成:

flowchart TB
    P[进程 Process]
    P --> A[代码段 Text<br/>程序的机器指令<br/>只读]
    P --> B[数据段 Data<br/>全局变量、静态变量<br/>可读写]
    P --> C[进程控制块 PCB<br/>OS管理进程的数据结构<br/>内核空间]
    B --> B1[已初始化数据段]
    B --> B2[未初始化数据段 BSS]
    P --> D[堆 Heap<br/>动态分配的内存<br/>malloc/new]
    P --> E[栈 Stack<br/>函数调用栈<br/>局部变量]
1
2
3
4
5
6
7
8
9

代码段(Text Segment):存放程序的机器指令。这段内存是只读的,防止程序意外修改自身指令。

数据段(Data Segment):存放全局变量和静态变量。分为已初始化(.data)和未初始化(.bss)两部分。

堆(Heap):运行时动态分配的内存区域。malloc() / new 分配的内存来自堆,由程序员或GC管理。

栈(Stack):存放函数调用信息——局部变量、返回地址、参数。每次函数调用,在栈上压一个栈帧(Stack Frame);函数返回,弹出栈帧。

进程控制块(PCB):操作系统为每个进程维护的数据结构,存储了操作系统管理该进程所需的所有信息。这是操作系统"认识"一个进程的方式。

# 2.3 进程的地址空间

每个进程都觉得自己独占整个内存空间,这得益于虚拟内存机制。操作系统给每个进程画了一个假象:

高地址  0xFFFFFFFF
       ┌─────────────┐
       │   内核空间    │  ← 所有进程共享,用户态不可访问
       ├─────────────┤
       │    栈  ↓     │  ← 向下增长
       │             │
       │             │
       │    堆  ↑     │  ← 向上增长
       ├─────────────┤
       │  数据段      │  ← .data / .bss
       ├─────────────┤
       │  代码段      │  ← .text(只读)
低地址  0x00000000
1
2
3
4
5
6
7
8
9
10
11
12
13

关键点:

  • 每个进程有独立的虚拟地址空间,进程A看不到进程B的内存
  • 堆和栈之间的"空隙"是留给两者增长的,如果堆和栈相撞,就OOM了
  • 内核空间在每个进程的页表里都有映射,但用户态访问会触发段错误

# 2.4 进程资源分配单位

疑惑:操作系统说"进程是资源分配的基本单位",到底分配了什么?

答疑:"资源"这个词涵盖以下几个维度:

资源类型 具体内容 进程隔离的
内存资源 虚拟地址空间(代码/数据/堆/栈) 完全隔离
文件资源 打开的文件描述符表 各自独立
时间资源 CPU时间片 由调度器分配
信号处理 信号处理器表 各自独立
用户身份 UID/GID 各自独立
工作目录 当前工作目录 各自独立

论证:这就是为什么进程是隔离性最好的并发单位——假如一个进程内存泄漏了,崩溃了,另一个进程不受任何影响。但代价是跨进程通信非常贵(需要内核参与),进程的创建和销毁也很重。

结论:用一句话总结进程的本质——进程是程序的一次执行,是操作系统进行资源分配和调度的基本单位。理解这一点,是进入操作系统世界的第一把钥匙。

# 03.进程状态与模型

# 3.1 进程五状态模型

进程从创建到消亡,会经历多种状态。最基本的五状态模型:

stateDiagram-v2
    [*] --> 新建: 创建进程
    新建 --> 就绪: 初始化完成,等待CPU
    就绪 --> 运行: 调度器分配CPU
    运行 --> 就绪: 时间片用完
    运行 --> 阻塞: 等待某事件(IO/信号)
    阻塞 --> 就绪: 事件完成
    运行 --> 终止: 进程执行完毕
    终止 --> [*]: 回收资源
1
2
3
4
5
6
7
8
9
状态 含义 进程在干什么
新建(New) 进程正在被创建 分配PCB、分配内存、加载程序
就绪(Ready) 一切就绪,只等CPU 在就绪队列排队
运行(Running) 正在CPU上执行 执行指令
阻塞(Blocked) 等待某个事件 等IO、等信号、等锁释放
终止(Terminated) 进程执行完毕 等待父进程回收

关键理解:进程自己不会从"阻塞"直接跳到"运行"——必须先经过"就绪"。即使IO已经完成,CPU也可能正在运行别的进程,所以新就绪的进程要排队。

# 3.2 进程七状态模型

在有虚拟内存的系统上,五状态会扩展为七状态,增加了两个"挂起"状态:

        新建  →  就绪挂起  →  就绪  →  运行  →  终止
                  ↑ ↕          ↑ ↕       ↙
               阻塞挂起  ↔  阻塞
1
2
3

**挂起(Suspend)**意味着进程的某些数据被换出到硬盘(Swap),不再占用物理内存。

状态 放在哪 能跑吗
就绪 物理内存 能,排队等CPU
就绪挂起 硬盘 Swap 不能,需要先换入内存
阻塞 物理内存 不能,还在等IO
阻塞挂起 硬盘 Swap 不能,即要等IO,也没在内存

疑惑:为什么要设计"挂起"状态?直接让进程都在内存里不好吗?

答疑:当物理内存不足时,操作系统必须把一些暂时不用的进程数据换到硬盘上(Swap),腾出空间给更需要的进程。这就是为什么你的电脑开很多程序后,切换回一个很久没用的程序会"卡一下"——它的数据要从硬盘换回内存。

# 3.3 状态切换的触发条件

用一个真实的时间线来理解状态切换:

时间轴:一个网络请求的处理过程
─────────────────────────────────────────────────
T=0ms    进程创建,进入"新建"态
T=1ms    加载完成,进入"就绪"态(在就绪队列排队)
T=3ms    调度器选中,进入"运行"态(开始执行代码)
T=5ms    执行到 read(socket_fd, buf, 4096) 
         数据还没到 → 进入"阻塞"态(放弃CPU)
T=10ms   网卡收到数据,DMA传输完成,中断通知CPU
         数据到达 → 进入"就绪"态(重新排队)
T=12ms   调度器再次选中 → 进入"运行"态
T=15ms   处理完成,进程exit → 进入"终止"态
─────────────────────────────────────────────────
1
2
3
4
5
6
7
8
9
10
11
12

结论:进程大部分时间其实不在"运行",而是在"就绪"或"阻塞"。现代操作系统的调度器就是让多个进程在"就绪↔运行"之间频繁切换,造成"多个程序同时在跑"的假象。

# 3.4 僵尸进程与孤儿进程

疑惑:进程执行完了不就消失了?为什么还有"僵尸""孤儿"这种问题?

答疑:进程的终止不是瞬间完成的。父进程需要收到子进程的退出状态才能彻底清理子进程的PCB,否则子进程就会变成"僵尸"。

// 僵尸进程示例
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:执行完就走
        printf("child exiting...\n");
        return 0;
    } else {
        // 父进程:一直不wait,子进程变僵尸
        printf("parent sleeping, child will be zombie\n");
        sleep(30);
        // 如果这里依然不调用 wait(),子进程就是僵尸
    }
    return 0;
}

// 查看僵尸进程: ps aux | grep 'Z'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
类型 成因 后果 解决办法
僵尸进程 子进程已退出,父进程未调用 wait() 占用PCB槽位,浪费系统资源 父进程主动 wait(),或父进程退出让 init 收养
孤儿进程 父进程先于子进程退出 无严重后果,子进程被 init(PID=1) 收养 不需要特别处理,OS自动处理

记忆口诀:子比父先走,父不收尸 → 僵尸;父比子先走,子没爹了 → 孤儿。

# 04.进程控制块PCB

# 4.1 PCB里存了什么

进程控制块(Process Control Block, PCB)是操作系统用来管理进程的数据结构。你可以把PCB理解为进程的"身份证"——包含了操作系统管理该进程所需的一切信息。

flowchart TB
    PCB[进程控制块 PCB]
    PCB --> A[进程标识信息<br/>PID / PPID / UID / GID]
    PCB --> B[处理机状态信息<br/>通用寄存器 / PC / PSW / SP]
    PCB --> C[进程调度信息<br/>进程状态 / 优先级 / 时间片 / 调度参数]
    PCB --> D[进程控制信息<br/>地址空间 / 文件描述符 / 信号 / 工作目录]
    PCB --> E[进程组织信息<br/>链接指针 / 父子关系]
1
2
3
4
5
6
7

# 4.2 task结构体

在 Linux 内核中,PCB 的具体实现就是 task_struct 结构体。它大约有 1.7KB,包含了上百个字段。下面抽取一些关键的:

// Linux 内核中 task_struct 的关键字段(简化版)
struct task_struct {
    // ========= 标识信息 =========
    pid_t               pid;           // 进程ID(用户态可见)
    pid_t               tgid;          // 线程组ID(getpid() 返回这个)
    struct task_struct  *group_leader; // 线程组的主线程

    // ========= 状态与调度 =========
    volatile long       state;         // 进程状态(TASK_RUNNING等)
    int                 prio;          // 动态优先级
    int                 static_prio;   // 静态优先级(nice值)
    unsigned int        rt_priority;   // 实时优先级
    unsigned int         policy;       // 调度策略
    struct sched_entity  se;           // CFS调度实体

    // ========= 内存管理 =========
    struct mm_struct    *mm;          // 内存描述符(地址空间)

    // ========= 文件系统 =========
    struct files_struct *files;       // 打开的文件描述符表
    struct fs_struct    *fs;          // 文件系统信息(root/pwd)

    // ========= 信号处理 =========
    struct signal_struct *signal;     // 信号处理信息
    struct sighand_struct *sighand;   // 信号处理器表

    // ========= 组织关系 =========
    struct list_head    tasks;        // 所有进程的链表节点
    struct task_struct  *parent;      // 父进程
    struct list_head    children;     // 子进程链表
    struct list_head    sibling;      // 兄弟进程链表
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

关键细节——pid 和 tgid:

  • 对于进程:pid 和 tgid 相同,都是进程ID
  • 对于线程:pid 是线程ID,tgid 是所属进程(线程组)的ID
  • getpid() 返回的是 tgid——用户态视角,"线程属于哪个进程"就看 tgid

# 4.3 PCB的组织方式

操作系统如何快速找到某一个进程的PCB?常用的组织方式:

flowchart LR
    subgraph 就绪队列
        A[PCB_A] --> B[PCB_B] --> C[PCB_C]
    end
    subgraph 阻塞队列_磁盘IO
        D[PCB_D] --> E[PCB_E]
    end
    subgraph 阻塞队列_网络
        F[PCB_F] --> G[PCB_G]
    end
1
2
3
4
5
6
7
8
9
10
  • 就绪队列:所有"就绪"状态的进程PCB组成一个或一组链表,调度器从这里选下一个要跑的进程
  • 阻塞队列:按等待原因分多条队列(等磁盘、等网络、等锁),事件发生时把对应队列上的PCB移到就绪队列
  • PID哈希表:需要根据PID快速定位某进程PCB时,用哈希查找

# 05.线程——轻量级进程

# 5.1 为什么需要线程

疑惑:已经有进程能做并发(比如开多个nginx worker进程),为什么要引入线程?

答疑:因为进程太重了。我们来对比一下:

创建一个进程的开销:
  1. 分配新的虚拟地址空间(页表、VMA等)
  2. 复制父进程的地址空间(或使用COW简历映射)
  3. 分配新的文件描述符表
  4. 分配新的信号处理表
  5. 创建新的 task_struct(~1.7KB)
  → 总耗时:约 0.5~2ms

创建一个线程的开销:
  1. 复用当前进程的地址空间(共享 mm_struct)
  2. 共享文件描述符表、信号处理表
  3. 只分配线程自己的栈(通常 1~8MB)
  4. 创建新的 task_struct
  → 总耗时:约 10~50μs
1
2
3
4
5
6
7
8
9
10
11
12
13
14

论证:线程的创建比进程快约 20~100 倍,上下文切换也更快(因为不需要切换地址空间)。对于高并发场景(比如Web服务器每秒几千个请求),如果用进程,创建和销毁的开销就会吃掉大部分CPU;用线程,这些开销大幅降低。

# 5.2 线程定义与特点

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。

flowchart TB
    subgraph 进程[进程——资源分配单位]
        subgraph 线程1[线程1]
            S1[栈1]
            R1[寄存器1]
            PC1[PC1]
        end
        subgraph 线程2[线程2]
            S2[栈2]
            R2[寄存器2]
            PC2[PC2]
        end
        共享["共享资源<br/>代码段、数据段、堆<br/>文件描述符、信号处理"]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14

一个进程内的所有线程:

  • 共享:代码段、数据段、堆、文件描述符、信号处理器、当前工作目录
  • 独立拥有:栈、寄存器(包括PC)、线程ID、信号掩码、errno

关键结论:

进程 线程
资源分配 进程是资源分配的基本单位 线程不"拥有"资源,只使用进程的资源
调度 传统的调度单位 现代OS的调度单位(Linux调度的是task,不区分进程/线程)
隔离性 强,一个进程崩溃不影响其他 弱,一个线程崩溃可能拖垮整个进程
通信 IPC(管道、消息队列、共享内存) 直接读写共享内存(需要同步)

# 5.3 线程组成结构

每个线程最少需要拥有:

线程私有存储:
┌─────────────┐
│  线程ID TID  │  ← 在进程内唯一
│  程序计数器  │  ← 当前执行到哪条指令
│  寄存器组    │  ← 通用寄存器、状态寄存器
│  栈指针 SP   │  ← 指向线程自己的栈
│  线程栈      │  ← 存放局部变量和调用链
│  信号掩码    │  ← 决定哪些信号被屏蔽
│  errno       │  ← 线程安全,每个线程独立
└─────────────┘
1
2
3
4
5
6
7
8
9
10

# 5.4 进程与线程的对比

疑惑:面试常问"进程和线程的区别",标准答案是什么?

答疑:这不是一个死记硬背的八股题,你需要从四个维度去理解:

维度 进程 线程
资源 拥有独立的地址空间和系统资源 共享进程资源,只持有少量私有资源
创建销毁 重(fork+exec,~ms级) 轻(pthread_create,~μs级)
切换代价 大(需切换地址空间、页表、TLB) 小(同一进程的线程共享地址空间)
通信方式 需要IPC(管道/消息队列/共享内存) 直接读写共享变量(需同步)
独立性 独立运行,一个崩溃不影响其他 一个线程崩溃可能影响整个进程
并发粒度 粗粒度 细粒度

一句话总结:进程是"房东"(拥有房子),线程是"租客"(住在房子里,共享厨房客厅,有自己的卧室)。

# 06.线程的实现模型

# 6.1 用户态线程模型

用户级线程(User-Level Thread,ULT)完全在用户空间中实现,操作系统内核不知道线程的存在——内核只看到进程。

flowchart TB
    subgraph 用户空间
        U1[线程1] --> UT[用户态线程库]
        U2[线程2] --> UT
        U3[线程3] --> UT
    end
    subgraph 内核空间
        UT --> K[内核]
        K --> CPU[CPU]
    end
1
2
3
4
5
6
7
8
9
10

工作原理:用户态线程库(如早期的POSIX Threads库、Java早期Green Threads)负责在线程之间切换。切换时不需要陷入内核态,直接在用户态保存/恢复线程上下文。

优点:

  • 线程切换极快(不需要内核参与,纯用户态操作)
  • 不需要内核支持,跨平台性好
  • 每个进程可以自定义调度算法

缺点:

  • 一个线程发起阻塞式系统调用(如读磁盘),整个进程的所有线程都会被阻塞——因为内核不知道有其他线程存在,它认为进程在等待IO
  • 无法利用多核CPU并行——内核只给一个进程分配一个CPU
  • 不能做到真正的抢占式调度(除非用户态库实现了定时器中断模拟)

# 6.2 内核态线程模型

内核级线程(Kernel-Level Thread,KLT)由操作系统内核直接管理。内核知道每个线程的存在,调度也以线程为单位。

flowchart TB
    subgraph 用户空间
        A1[线程1] --> LWP1[LWP 轻量级进程]
        A2[线程2] --> LWP2[LWP 轻量级进程]
        A3[线程3] --> LWP3[LWP 轻量级进程]
    end
    subgraph 内核空间
        LWP1 --> K[内核线程调度]
        LWP2 --> K
        LWP3 --> K
        K --> CPU1[CPU 核心1]
        K --> CPU2[CPU 核心2]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13

内核线程 = 轻量级进程(LWP)

在Linux中,没有独立的"线程"数据结构——线程就是与父进程共享一些资源的轻量级进程(Lightweight Process, LWP)。创建线程的 clone() 系统调用,本质上是创建了一个新的 task_struct,但指定了与父task共享哪些资源:

// Linux创建线程的本质:clone() 共享资源
// pthread_create 底层调用:
clone(CLONE_VM |        // 共享地址空间
      CLONE_FS |        // 共享文件系统信息
      CLONE_FILES |     // 共享文件描述符
      CLONE_SIGHAND |   // 共享信号处理
      CLONE_THREAD,     // 放到同一线程组
      stack_addr);      // 新线程的栈
1
2
3
4
5
6
7
8

优点:

  • 可以真正利用多核CPU并行
  • 一个线程阻塞(比如IO等待),不影响同进程内其他线程被调度

缺点:

  • 创建、切换开销比用户级线程高(需要内核介入)
  • 每种操作都需要系统调用,增加了开销

# 6.3 混合线程模型

混合模型是用户级线程 + 内核级线程的折中方案:用户态线程被复用(multiplexed)到少量内核线程上。

flowchart TB
    subgraph 用户空间
        U1[用户线程1]
        U2[用户线程2]
        U3[用户线程3]
        U4[用户线程4]
        U5[用户线程5]
        UT[用户态调度器]
        U1 --> UT
        U2 --> UT
        U3 --> UT
        U4 --> UT
        U5 --> UT
    end
    subgraph 内核空间
        UT --> K1[内核线程1]
        UT --> K2[内核线程2]
        K1 --> C1[CPU核心1]
        K2 --> C2[CPU核心2]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

典型实现:

实现 用户级调度 内核级调度 特点
Go goroutine (G-M-P) G在M上调度 M是内核线程 抢占式、work stealing
Java虚拟线程(JDK 21) 虚拟线程复用到平台线程 平台线程=内核线程 无需池化、一请求一线程
Erlang进程 Actor模型 BEAM VM调度器 轻量、隔离、容错

# 6.4 三种模型对比

特性 用户级线程 内核级线程 混合模型
创建/切换速度 ★★★★★ 极快 ★★☆☆☆ 较慢 ★★★★☆ 快
多核并行 ✗ 不能 ✓ 能 ✓ 能
阻塞不拖累其他线程 ✗ 会拖累 ✓ 不拖累 ✓ 不拖累
内核支持 不需要 需要 需要
典型代表 早期Java Green Thread LinuxThreads, NPTL Go, Java Loom

技术演进路线:

1990s: 多进程(Apache prefork)
   ↓ 进程太重,切换代价高
2000s: 内核级线程(NPTL, pthreads)
   ↓ 线程也有开销,高并发时创建/切换仍然是瓶颈
2010s: 混合模型(Go goroutine, Erlang)
   ↓ 用用户态调度器在少量内核线程上复用大量协程
   → 用户态切协程几乎零开销,真正的"高并发"
1
2
3
4
5
6
7

# 07.上下文切换机制

# 7.1 什么是上下文切换

上下文切换(Context Switch)是操作系统将CPU从一个进程/线程的执行切换到另一个进程/线程的过程。

核心动作:保存当前任务的运行上下文,恢复下一个任务的运行上下文。

sequenceDiagram
    participant CPU as CPU
    participant T1 as 进程/线程 A
    participant OS as 操作系统内核
    participant T2 as 进程/线程 B

    Note over T1,CPU: A 正在运行
    CPU->>OS: ① 时钟中断 或 系统调用 或 IO阻塞
    OS->>CPU: ② 保存A的上下文(寄存器、PC、SP...→A的PCB)
    OS->>CPU: ③ 选择下一个要运行的任务B
    OS->>CPU: ④ 恢复B的上下文(B的PCB→寄存器、PC、SP...)
    OS->>CPU: ⑤ 从B上次停下的地方继续执行
    Note over T2,CPU: B 开始运行
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7.2 进程上下文切换

进程上下文切换需要保存/恢复的内容非常多:

进程A → 进程B 的切换过程:

1. 保存A的硬件上下文:
   ├── 通用寄存器(EAX, EBX, ...)
   ├── 段寄存器
   ├── 程序计数器 PC(指向A下一条要执行的指令)
   ├── 栈指针 SP
   └── 状态寄存器 PSW/EFLAGS

2. 切换地址空间(最重的部分):
   ├── 切换页表基址寄存器(CR3)
   ├── 刷新TLB(Translation Lookaside Buffer)
   └── 可能需要切换IO权限位图

3. 恢复B的硬件上下文:
   └── 从B的PCB中恢复所有寄存器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

最昂贵的部分:切换虚拟地址空间。这涉及到:

  • 修改 CR3 寄存器指向新的页表
  • TLB(快表)全部失效——这是最大的性能损耗
  • 新进程刚运行时,TLB全是Miss,每次内存访问都要走完整的页表遍历(至少多出几十个CPU周期的惩罚)

# 7.3 线程上下文切换

同一个进程内的线程切换就轻量得多,因为不需要切换地址空间:

线程A → 线程B(同一进程)的切换过程:

1. 保存A的硬件上下文(同进程切换)
2. 不需要切换CR3 ← 关键区别!
3. 不需要刷新TLB ← 关键区别!
4. 恢复B的硬件上下文

额外需要切换的(线程私有数据):
├── 内核栈指针
├── 用户态栈指针
└── 如果支持线程局部存储(TLS),需切换FS/GS段寄存器
1
2
3
4
5
6
7
8
9
10
11

# 7.4 系统调用与切换

疑惑:执行系统调用(比如 read())算不算是上下文切换?

答疑:不算。系统调用是模式切换(Mode Switch)——从用户态切到内核态,再切回用户态——但切换前后是同一个进程/线程在做自己的事,不涉及任务的替换。

模式切换(系统调用) 上下文切换(进程切换)
发生时机 主动调用(syscall指令) 被动发生(时钟中断/IO阻塞)
切换范围 用户态 ↔ 内核态 进程A 的上下文 → 进程B 的上下文
保存内容 只需保存用户态寄存器 保存全部硬件上下文
TLB 不受影响 全部失效(进程切换时)
开销 约0.1-1μs 约1-5μs(线程),约3-10μs(进程)

注意:虽然系统调用不触发上下文切换,但系统调用返回后,调度器可能会决定切换到另一个进程——这时候才会发生上下文切换。

# 7.5 上下文切换的代价

疑惑:上下文切换到底有多贵?这些开销从哪来?

以一次典型的进程切换为例,我们把开销拆开看:

一次进程上下文切换的耗时剖析(典型 x86-64 Linux):

直接开销(必须做的):
  ├── 保存/恢复寄存器           ~0.1μs
  ├── CR3写操作                 ~0.05μs
  ├── 选择下一个进程(调度算法)   ~0.2μs
  └── 合计直接开销             ~0.5-1μs

间接开销(后续访问惩罚):
  ├── TLB 全部失效,重填        ~5-50μs
  ├── L1/L2 Cache 冷启动        ~5-20μs
  └── 合计间接开销             ~10-70μs

总计:单次进程切换约 1~20μs,取决于Cache/TLB的重填情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14

实战数据——用 vmstat 看切换频率:

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 3  0      0 294872  85624 1834388    0    0     2    12    2    3  1  1 98  0  0
1
2
3
4
  • r:就绪队列长度(等待CPU的进程数)
  • cs:每秒上下文切换次数
  • 如果 cs > 10000(单核),说明切换太频繁,出现"上下文切换风暴"
  • 如果 r 远大于CPU核心数,说明系统严重过载

回到1.1节的案例:小王的服务器 Load average: 96,意味着平均有96个任务在等待CPU。配合48个核心,理论上已经超载2倍。再加上大量的上下文切换——CPU的时间绝大部分不是在做业务计算,而是在"保存→恢复→保存→恢复"之间空转。

# 08.进程创建与fork

# 8.1 fork的语义

在类Unix系统中,创建新进程的唯一方式是 fork()——一种"复制自身"的奇妙机制:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程:fork() 返回 0
        printf("I am child, pid=%d\n", getpid());
    } else if (pid > 0) {
        // 父进程:fork() 返回子进程的pid
        printf("I am parent, child pid=%d\n", pid);
    } else {
        // fork() 返回 -1,创建失败
        perror("fork failed");
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

fork的神奇之处:调用一次,返回两次——分别在父进程和子进程中返回不同的值。

sequenceDiagram
    participant P as 父进程
    participant K as 内核
    participant C as 子进程

    P->>K: fork()
    K->>K: ①检查资源是否足够
    K->>K: ②复制父进程的 task_struct
    K->>K: ③为子进程分配PID
    K->>K: ④复制页表(COW方式)
    K-->>P: ⑤父进程收到返回值 = 子进程PID
    K-->>C: ⑥子进程收到返回值 = 0
1
2
3
4
5
6
7
8
9
10
11
12

# 8.2 写时拷贝COW

疑惑:fork要复制父进程的整个地址空间(可能几GB),岂不是非常慢?

答疑:现代操作系统使用**写时拷贝(Copy-on-Write, COW)**技术,fork时并不立即复制内存,而是:

flowchart TB
    subgraph fork前
        PP[父进程页表] --> P1[物理页 A]
        PP --> P2[物理页 B]
    end

    subgraph fork后_写之前
        PP2[父进程页表] --> P1C["物理页 A<br/>(只读标记)"]
        CP[子进程页表] --> P1C
        PP2 --> P2C["物理页 B<br/>(只读标记)"]
        CP --> P2C
    end

    subgraph 子进程写页A时
        CP2[子进程页表] --> NEW["新物理页 A'<br/>(拷贝A的内容)"]
        PP3[父进程页表] --> P1R["物理页 A<br/>(恢复可写)"]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

COW的工作原理:

  1. fork() 时:不复制页的内容,只复制页表。父子的页都标记为只读
  2. 父子进程只读数据:相安无事,物理页共享
  3. 任一进程写数据:触发缺页保护异常,内核捕获异常,为该进程复制一页新物理页,恢复权限为可写
  4. 这样,大部分页面实际上从未被拷贝过

论证:

fork 的实际开销分析:
├── 复制 task_struct(~1.7KB)         ~1μs
├── 复制页表(不是页面内容)            ~10-50μs(取决于页表大小)
├── 分配PID                             ~1μs
└── 后续COW缺页(按需发生)            每页~2μs

如果进程有 1GB 内存,页表的大小约:
  1GB / 4KB = 262,144 页
  每页表项 8 字节 = ~2MB 页表
  fork时只复制这 2MB 页表,不是复制 1GB 数据!
1
2
3
4
5
6
7
8
9
10

# 8.3 fork的坑

坑1:多线程环境下fork

// 危险!多线程程序中的 fork
void *worker(void *arg) {
    pthread_mutex_lock(&global_lock);
    // ... 临界区代码 ...
    // 此时另一个线程调用了 fork()
    pthread_mutex_unlock(&global_lock);
}
1
2
3
4
5
6
7

如果在某线程持有锁的时候,另一个线程执行了 fork(),子进程会继承这把锁的锁定状态,但持有锁的那个线程在子进程中不存在了——这把锁永远不会被释放。这就是经典的"fork死锁"问题。

解决方案:在多线程程序中,fork之后子进程应尽快执行 exec(),因为 exec() 会用新程序替换整个地址空间,"清理"掉这些继承来的锁状态。

坑2:fork时复制文件描述符

fork() 会复制父进程的所有文件描述符。父子进程共享同一个文件偏移量。如果两个进程同时写同一个文件,可能出现交错:

// fork后父子同时写日志
int fd = open("log.txt", O_WRONLY | O_CREAT, 0644);
if (fork() == 0) {
    write(fd, "[child] hello\n", 14);
} else {
    write(fd, "[parent] world\n", 15);
}
// 文件内容可能是: "[chil[parenthewolrd\n] ello\n"(交错写)
1
2
3
4
5
6
7
8

# 8.4 exec替换程序

fork() 创建了父进程的副本,但子进程通常不想做和父进程一样的工作。exec() 系列函数用于用新程序完全替换当前进程的镜像:

#include <unistd.h>

pid_t pid = fork();
if (pid == 0) {
    // 子进程:换成 /bin/ls 程序
    execl("/bin/ls", "ls", "-l", NULL);
    // exec 成功后,下面的代码永远不会执行
    // 如果执行到了这里,说明 exec 失败了
    perror("exec failed");
    exit(1);
}
// 父进程继续原来的任务
1
2
3
4
5
6
7
8
9
10
11
12

exec 做了什么:

exec 的执行步骤:
1. 释放旧地址空间(清空代码段、数据段、堆、栈)
2. 从磁盘加载新程序,建立新的地址空间
3. 重置寄存器、PC设为新程序入口
4. 保留原有的 PID、文件描述符(除非设置了 FD_CLOEXEC)、信号掩码
5. 开始执行新程序
1
2
3
4
5
6

fork + exec = 创建新进程的标准姿势。这也是 popen()、system() 等高层接口的底层实现。

# 09.协程并发模型

# 9.1 协程基本概念

协程(Coroutine)是一种用户态的轻量级线程,它的调度完全由程序自己控制,不依赖操作系统。

flowchart TB
    subgraph 一个OS线程
        subgraph 协程调度器
            C1[协程1: 处理请求A]
            C2[协程2: 处理请求B]
            C3[协程3: 等待IO]
            C4[协程4: 空闲]
        end
    end
    C1 -.主动让出CPU.-> C2
    C2 -.主动让出CPU.-> C1
1
2
3
4
5
6
7
8
9
10
11

协程的核心特征:

特征 说明
用户态调度 协程切换不需要内核参与,不涉及系统调用
协作式 协程主动让出CPU(yield),不是被抢占
轻量 创建协程只需分配几KB的栈(甚至更少),远轻于线程
一比多 多个协程复用一个或少数几个OS线程

# 9.2 协程vs线程

疑惑:协程和线程到底有什么区别?为什么Go说goroutine比线程快?

答疑:用一张表说清楚:

维度 线程 协程
调度者 操作系统内核 用户态调度器(Go运行时/libco等)
切换代价 约1-5μs(涉及模式切换) 约几十ns(纯用户态寄存器保存/恢复)
栈大小 固定(Linux默认8MB) 可动态增长(Go协程初始2KB)
创建数量 受限于内存(万级) 轻松十万~百万级
抢占式 是(时钟中断抢占) 否(协作式,主动让出)
并发模型 一个任务一个线程 多个协程复用到少数线程
调度时机 时钟中断、IO等待、锁 yield点、IO操作自动切换

Go goroutine 的栈为什么能这么小?

普通线程栈:
  ┌──────────────┐
  │   8MB 栈      │  ← 固定分配,不管用不用
  │  (只用2KB)    │  ← 浪费了 99.98%
  └──────────────┘

Go goroutine 栈:
  ┌────┐ ← 初始 2KB(最小)
  │    │
  │    │  需要更多时,自动分配更大的栈
  │    │  并把旧栈内容复制过去(栈扩容)
  │    │
  └────┘
  最大可到 1GB
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 9.3 有栈协程与无栈协程

类型 有栈协程 (Stackful) 无栈协程 (Stackless)
实现方式 每个协程有独立的调用栈 依赖编译器转换(状态机)
挂起点 可在任意嵌套函数中挂起 只能在最外层函数挂起
内存开销 每个协程需要一个独立栈(可动态增长) 只需存储状态变量,开销极小
切换开销 需要保存/恢复 SP、BP 等寄存器 只需改变状态变量的值
典型代表 Go goroutine、libco、Boost.Coroutine C++20 coroutine、JS async/await、Rust async

有栈协程的切换示意:

// 有栈协程的手动切换(简化版 libco 风格)
void co_swap(co_ctx *curr, co_ctx *target) {
    // 保存当前协程的寄存器到 curr->regs
    asm volatile(
        "movq %%rsp, %0\n"  // 保存栈指针
        "movq %%rbp, %1\n"  // 保存帧指针
        "movq %%rbx, %2\n"  // 保存通用寄存器
        // ... 保存其他寄存器
        : "=m"(curr->regs[0]), "=m"(curr->regs[1]), ...
    );

    // 如果target之前没有被调度过,跳转到它的入口函数
    // 否则,恢复target的寄存器,让它从上次yield的地方继续
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 9.4 协程调度机制

以 Go 的 G-M-P 模型为例:

flowchart TB
    subgraph P[P——逻辑处理器]
        GRQ[本地运行队列<br/>G→G→G]
        M[当前绑定的 M]
    end

    subgraph 全局
        GRQ2[全局运行队列<br/>G→G→...]
    end

    M --> CPU[OS 线程 → CPU 物理核心]
    GRQ -.Work Stealing.-> GRQ2
    GRQ -.Work Stealing.-> GRQ2

    G1[G 阻塞在 syscall] -.P 被释放.-> NEWP[新的 P 接管其他 G]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • G(Goroutine):协程本身,只有简单的执行栈和状态
  • M(Machine):操作系统线程,G 在 M 上执行
  • P(Processor):逻辑处理器,持有 G 的本地运行队列,负责将 G 绑定到 M 上执行

Work Stealing(工作窃取):当某个 P 的本地队列空了,它会从全局队列或其他 P 的本地队列"偷"一些 G 过来执行。这保证了负载均衡。

# 10.Web服务器案例

# 10.1 场景需求分析

现在用一个贯穿整个进程/线程/协程知识体系的综合案例来收尾。

需求:设计一个 Web 服务器,需要处理大量并发HTTP请求。每个请求的处理逻辑是:解析请求 → 读数据库(或缓存) → 计算 → 返回响应。平均每个请求的处理时间是 10ms,其中 8ms 在等数据库返回(IO等待),2ms 在计算。

目标:在 4 核 CPU 的服务器上,支持每秒 10000 个请求。

flowchart LR
    Client[客户端] -->|HTTP Request| Server[Web Server]
    Server -->|查询| DB[(数据库)]
    DB -->|结果| Server
    Server -->|Response| Client
1
2
3
4
5

我们将用四种不同的并发模型来实现这个服务器,并逐一分析它们的优劣。

# 10.2 方案一多进程模型

设计:主进程 accept() 连接后 fork() 子进程处理,类似 Apache prefork 模式。

// 多进程模型伪代码
void run_server() {
    int listen_fd = create_listen_socket(8080);
    while (1) {
        int client_fd = accept(listen_fd, ...);
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程:处理请求
            close(listen_fd);  // 子进程不需要监听socket
            handle_request(client_fd);
            close(client_fd);
            exit(0);
        }
        close(client_fd);  // 父进程不需要client_fd
        // 不要忘了回收僵尸子进程!
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

性能分析——假设每秒10000个请求,每个请求10ms:

需要同时运行的进程数 = 10000 req/s × 0.01s = 100 个进程

单个进程的内存占用:
├── 代码段(nginx)     ~2MB
├── 数据段/堆/栈       ~5-10MB
├── 内核开销(页表等)  ~1-2MB
└── 合计               ~8-14MB

100 个进程:需要 ~1GB 内存
1
2
3
4
5
6
7
8
9

问题:

  • fork() + exec() 的开销(虽然没有exec,但fork本身也不轻)
  • 100个进程的CS频率很高(每次调度都要切换地址空间)
  • 子进程回收管理(僵尸进程风险)
  • 进程间如果共享数据(如缓存)需要额外的IPC机制
  • 无法利用IO等待时间:数据库返回 8ms 期间,进程整个被阻塞,白白占用内存而什么都不做

# 10.3 方案二多线程模型

设计:每个请求创建一个新线程处理。

// 多线程模型伪代码
void *handle_thread(void *arg) {
    int client_fd = *(int *)arg;
    free(arg);
    handle_request(client_fd);
    close(client_fd);
    return NULL;
}

void run_server() {
    int listen_fd = create_listen_socket(8080);
    while (1) {
        int *client_fd = malloc(sizeof(int));
        *client_fd = accept(listen_fd, ...);
        pthread_t tid;
        pthread_create(&tid, NULL, handle_thread, client_fd);
        pthread_detach(tid);  // 分离线程,自动回收
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

性能对比:

维度 多进程 多线程
创建开销 ~0.5-2ms ~10-50μs
每请求内存增量 8-14MB ~8MB(主要线程栈)
上下文切换 重(切地址空间) 轻(共享地址空间)
通信 IPC 共享内存

问题:

  • 仍然需要 100 个线程(线程数 = 并发请求数)
  • 100 个线程的切换开销累计仍然可观
  • 线程栈的固定开销:100 线程 × 8MB = 800MB!
  • 线程爆炸:如果请求延迟变大(数据库从 8ms变 80ms),并发数就需要 800 个线程

# 10.4 方案三线程池模型

设计:预先创建固定数量的工作线程(线程池),请求排队等待处理。

// 线程池模型伪代码
typedef struct {
    int client_fd;
} task_t;

// 任务队列(生产者-消费者模式)
task_queue_t queue;
pthread_t workers[THREAD_POOL_SIZE];

void *worker_loop(void *arg) {
    while (1) {
        task_t *task = dequeue(&queue);  // 阻塞等待任务
        handle_request(task->client_fd);
        close(task->client_fd);
        free(task);
    }
}

void run_server() {
    // 创建线程池(假设4核,创建8个线程)
    for (int i = 0; i < 8; i++) {
        pthread_create(&workers[i], NULL, worker_loop, NULL);
    }

    int listen_fd = create_listen_socket(8080);
    while (1) {
        int client_fd = accept(listen_fd, ...);
        task_t *task = malloc(sizeof(task_t));
        task->client_fd = client_fd;
        enqueue(&queue, task);  // 入队
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

为什么8个线程就够了?

分析:每个请求有 8ms IO等待 + 2ms 计算

如果 8 个线程都做计算(纯CPU):
  每秒处理 = 8 / 0.002s = 4000 个请求 ← 不够

实际上 IO 期间线程在阻塞,CPU空闲:
  1 个线程:处理一个请求的过程中,CPU利用率只有 2/10 = 20%
  8 个线程:8 × 20% = 160% → 2个核接近满负荷

要满足 10000 req/s:
  需要同时进行的计算量 = 10000 × 2ms = 20 秒-CPU
  需要 CPU 核心数 = 20 / 4核 ≈ 5 ← 8个线程足够!
1
2
3
4
5
6
7
8
9
10
11
12

优点:

  • 线程数固定,避免线程爆炸
  • 消除了频繁创建/销毁线程的开销
  • 内存占用可控(8线程 × 8MB = 64MB)

局限:

  • 如果所有线程都在等IO(比如数据库慢),新请求就得在队列里排队
  • 请求的处理逻辑如果不是"先读数据库再计算"这么简单,而是多次IO穿插,线程池模型就很难发挥全部效率

# 10.5 方案四协程模型

设计:用协程处理每个请求,IO操作自动挂起当前协程、切换到其他协程。

// Go协程模型 - 最简洁的版本
func handleRequest(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 4096)
    n, _ := conn.Read(buf)         // 读请求,IO自动挂起
    req := parseRequest(buf[:n])
    data := queryDB(req.userId)    // 查数据库,IO自动挂起
    resp := computeResponse(data)
    conn.Write(resp)               // 写响应,IO自动挂起
}

func main() {
    listener, _ := net.Listen("tcp", ":8080")
    for {
        conn, _ := listener.Accept()
        go handleRequest(conn)     // 每个请求一个goroutine
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

为什么协程模型最优雅?

对比:

线程池模型(8个线程):
  线程1: [请求A 计算] → [等待DB] → [请求A 继续] → [请求C 计算] → ...
  线程2: [请求B 计算] → [等待DB] → [请求B 继续] → ...
  → 业务逻辑被切成了不连续的片段,代码结构复杂
  → 每个"等待"都是线程阻塞,浪费线程资源

协程模型(10000个协程,复用8个线程):
  协程1: handleRequest() {
            读请求 → 查DB → 计算 → 返回  ← 完整的业务逻辑!
         }
  → 代码就像同步写的一样清晰
  → Go运行时在IO操作处自动挂起协程,切换到下一个可运行的协程
  → 线程被"多个协程分时复用",几乎不会空转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 10.6 四种方案横向对比

维度 多进程 多线程 线程池 协程
创建开销 ~1ms ~30μs 无(预创建) ~几十ns
并发单位数 100 100 8 10000
内存占用 ~1GB ~800MB ~64MB ~20MB(10000 × 2KB栈)
上下文切换频率 高(100进程) 高(100线程) 低(8线程) 极低(用户态切换)
CPU利用率 差(进程休眠时CPU空转) 一般 好 极好
代码可读性 每请求一个子进程 每请求一个线程 需要拆分逻辑 像同步代码一样写异步
适用场景 偶尔的并发、强隔离 适度并发 高并发 + CPU密集 超高并发 + IO密集
典型代表 Apache prefork 传统Java Servlet Nginx worker Go/Java Loom

选型决策树:

flowchart TD
    Q1{需要强隔离吗?<br/>一个任务崩溃会影响其他吗?} -->|是| MP[用多进程<br/>Chrome标签页<br/>Erlang Actor]
    Q1 -->|否| Q2{并发量多少?}
    Q2 -->|百级| MT[多线程/线程池<br/>传统后端服务]
    Q2 -->|千~万级| Q3{是IO密集还是CPU密集?}
    Q3 -->|IO密集| CO[协程模型<br/>Go/Node.js/Python asyncio]
    Q3 -->|CPU密集| TP[线程池/多进程<br/>CPU核数接近的线程数]
1
2
3
4
5
6
7

# 10.7 知识图谱回顾

用一张图把本章所有知识点串起来,回顾我们走过的路:

flowchart TB
    ROOT[进程与线程原理]

    ROOT --> A[进程<br/>资源分配的基本单位]
    ROOT --> B[线程<br/>CPU调度的基本单位]
    ROOT --> C[协程<br/>用户态的轻量级线程]

    A --> A1[状态模型<br/>5态 → 7态]
    A --> A2[PCB / task_struct<br/>操作系统管理进程的方式]
    A --> A3[fork + COW<br/>创建进程的唯一方式]
    A --> A4[地址空间<br/>虚拟内存隔离]

    B --> B1[实现模型<br/>ULT / KLT / 混合]
    B --> B2[上下文切换<br/>保存/恢复 → TLB刷新]
    B --> B3[线程池<br/>预创建,控制开销]

    C --> C1[有栈 vs 无栈]
    C --> C2[G-M-P 调度模型]
    C --> C3[比线程更轻<br/>百万级并发]

    A1 --> SW[切换代价对比<br/>线程~3μs vs 协程~50ns]
    B2 --> SW
    C2 --> SW

    SW --> FINAL{并发模型选型}
    FINAL -->|隔离优先| MP[多进程]
    FINAL -->|可控高并发| TP[线程池]
    FINAL -->|极致并发| CO[协程]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

最终的方法论沉淀——面对一个并发编程问题,你可以用这三个问题来引导选型:

  1. 这个任务需要和别的任务隔离吗?(会崩溃、会内存泄漏、有不同的安全等级)→ 是的话选多进程
  2. 并发量大概多大?(百级/千级/万级/百万级)→ 千级以上用协程、百级用线程池
  3. 计算和IO的比例?(IO密集还是CPU密集)→ IO密集用协程(释放线程给其他任务),CPU密集用固定大小线程池(核数相关)

把这三个问题问到位,你就从"会用 go func()"进化到了"理解并发模型本质"的工程师。这正是这一整篇文章希望传递给你的核心能力。

# 11.思考题与作业

# 11.1 基础思考题目

  1. 状态转换:一个进程在执行 read() 系统调用后,经历了什么状态变化?画出它的状态转换图。如果 read() 被 SIGALRM 信号中断(设置了 SA_RESTART 和不设置分别会怎样),状态又会怎么变?

  2. fork的执行次数:下面这段代码会输出多少个 - ?

    int main() {
        int i;
        for (i = 0; i < 2; i++) {
            fork();
            printf("-");
        }
        return 0;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    提示:注意 printf 的缓冲区——默认是行缓冲,这里输出没带 \n。

  3. 僵尸进程排查:线上服务器 ps aux | grep Z 发现大量 Z 状态进程,top 中 CPU 不高但 tasks 数量很大。请问:

    • 这些僵尸进程会导致系统不可用吗?
    • 为什么进程数多了系统会出问题?
    • 如何在代码层面避免僵尸进程?
  4. 进程 vs 线程 vs 协程:请用一句话说明三者的本质区别。并回答:一个进程最多能创建多少个线程?限制因素是什么?

  5. 上下文切换触发:列出至少 4 种会触发上下文切换的场景,并指出哪些是"主动"的,哪些是"被动"的。

# 11.2 进阶思考题目

  1. 1.1 节的复盘:回到开头 Go 服务崩溃的案例,如果你是小王,回答以下问题:

    • 为什么 Load Average 高达 96,但 CPU 使用率不高?
    • "goroutine 堆积导致上下文切换风暴"——这里指的是线程级的上下文切换还是协程级的?为什么协程的切换没有帮上忙?
    • 你会如何修复这个问题?(至少给出 3 种方案)
  2. COW 的极限:Copy-on-Write 可以大幅优化 fork 的性能,但它的效果在什么场景下会大打折扣?举例说明。

  3. goroutine 的调度时机:Go 的 goroutine 是协作式的,哪些操作会触发 goroutine 让出 CPU?如果一个 goroutine 执行纯死循环(没有任何函数调用),会发生什么?Go 是怎么解决这个问题的?

  4. 多线程 vs 多进程的缓存:为什么 Redis 选择单线程(主线程)+ 事件循环,而 Nginx 选择多进程?请从"内存访问"和"缓存效率"的角度分析单线程和多线程各自的优势。

  5. Python 的 GIL:Python(CPython)的 GIL 导致多线程不能在多核上并行。为什么 Python 选择了这种做法?如果要去掉 GIL,技术上会遇到哪些挑战?这和本章讲的"进程 vs 线程"有什么关系?

# 11.3 动手实践作业

作业一(必做):实测进程/线程/协程的创建和切换开销。

  • 分别测量创建10000个进程、10000个线程、10000个goroutine的总时间和内存增加量。
  • 分别测量在两个进程间、两个线程间(同一进程)、两个goroutine间的切换耗时。
  • 把你的实测结果填入表格,与本章给出的理论值对比。
操作 实测耗时 理论值 差异分析
fork 10000次 ~0.5-2ms/次
pthread_create 10000次 ~10-50μs/次
go func() 10000次 ~几十ns/次

作业二(选做):实现一个简单的线程池。

  • 用你熟悉的语言实现一个线程池,支持"提交任务 → 任务队列 → 工作线程消费"的基本模式。
  • 分别用"无线程池(每次新建线程)"和"线程池(固定8线程)"处理10000个任务(每个任务 sleep 1ms 模拟IO)。
  • 对比两种方式的耗时和CPU利用率,分析差异原因。

作业三(架构思考):对你当前负责的一个服务,画一张"并发模型图"。

  • 从"一个请求进来"画到"返回响应",中间涉及哪些进程?哪些线程?有没有协程?
  • 每一步标注:当前有多少个这样的执行单元?它们的数量是固定的还是动态变化的?
  • 如果你的服务突然增加 10 倍流量,瓶颈会在哪里?是进程数不够,线程不够,还是协程调度器顶不住?给出你的判断和理由。

作业四(源码阅读):阅读 Go 运行时的 G-M-P 模型源码骨架。

  • 去 $GOROOT/src/runtime/proc.go 找 schedule() 函数,理解Goroutine是如何被调度的。
  • 找 findrunnable() 函数,理解 Work Stealing 的实现。
  • 画出你理解的"一个goroutine从创建到被调度执行"的完整流程图。
#进程#线程#操作系统
上次更新: 2026/06/10, 09:51:58
README
OS处理器调度策略

← README OS处理器调度策略→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式