02 - 进程与线程¶
⏰ 建议学习时间:3 小时 🎯 难度等级:⭐⭐⭐⭐ 📋 前置知识:操作系统概述、内核态与用户态、系统调用机制
📋 本章目录¶
- 一、进程的概念
- 二、进程控制块 PCB
- 三、进程状态转换模型
- 四、进程创建 fork/exec
- 五、线程的概念与必要性
- 六、用户级线程 vs 内核级线程
- 七、线程模型
- 八、协程原理
- 九、进程间通信 IPC
- 十、练习题
- 十一、面试要点
一、进程的概念¶
1.1 为什么需要进程?¶
在早期的单道批处理系统中,一次只执行一个程序,概念很简单。但当我们引入多道程序设计后,内存中同时存在多个程序,每个程序有各自的执行状态、打开的文件、分配的内存等。为了管理这些并发执行的程序,操作系统需要一个抽象——这就是进程(Process)。
1.2 程序 vs 进程¶
| 比较维度 | 程序(Program) | 进程(Process) |
|---|---|---|
| 本质 | 静态的指令集合(存储在磁盘上) | 程序的一次动态执行实例 |
| 存储 | 磁盘上的可执行文件 | 内存中的运行实体 |
| 生命周期 | 永久存在(除非删除) | 临时存在(创建→运行→终止) |
| 组成 | 代码 + 数据 | 代码 + 数据 + PCB + 栈 + 堆 + 寄存器状态 |
| 数量关系 | 一个程序可以对应多个进程 | 每个进程对应一个程序的一次执行 |
1.3 进程的定义¶
进程是程序在某个数据集上的一次执行活动,是操作系统进行资源分配和调度的基本单位。
进程的核心特性:
- 动态性:进程是动态创建和消亡的,有生命周期
- 并发性:多个进程可以在单核 CPU 上交替执行(并发),在多核上同时执行(并行)
- 独立性:每个进程拥有独立的地址空间,一个进程不能直接访问另一个进程的内存
- 异步性:进程以不可预知的速度推进执行
- 结构性:每个进程由 程序段 + 数据段 + PCB 三部分组成
1.4 进程的地址空间¶
每个进程拥有独立的虚拟地址空间,典型的布局如下(以 Linux x86-64 为例):
高地址
┌─────────────────────┐ 0x7FFFFFFFFFFF
│ 内核空间 │ ← 用户不可直接访问
│ (Kernel Space) │
├─────────────────────┤ 0x7FFFFFFF0000 (约)
│ │
│ 栈(Stack) │ ← 向下增长
│ 局部变量、函数参数 │
│ ↓ │
│ ... │
│ ↑ │
│ 堆(Heap) │ ← 向上增长
│ malloc/new 分配 │
├─────────────────────┤
│ BSS 段 │ ← 未初始化的全局变量
├─────────────────────┤
│ 数据段(Data) │ ← 已初始化的全局变量
├─────────────────────┤
│ 代码段(Text) │ ← 程序指令(只读)
└─────────────────────┘ 0x000000400000
低地址
二、进程控制块 PCB¶
2.1 PCB 的定义¶
进程控制块(Process Control Block, PCB)是操作系统用来描述和管理进程的核心数据结构。每个进程有且仅有一个 PCB,它是进程存在的唯一标识。
🎯 面试要点:创建一个进程,本质上就是创建一个 PCB;销毁一个进程,本质上就是销毁其 PCB。
2.2 PCB 包含的信息¶
┌─────────────────────────────────────────────┐
│ 进程控制块(PCB) │
├─────────────────────────────────────────────┤
│ 1. 进程标识信息 │
│ ├── PID(进程 ID) │
│ ├── PPID(父进程 ID) │
│ └── UID(用户 ID) │
├─────────────────────────────────────────────┤
│ 2. 处理器状态信息(CPU 上下文) │
│ ├── 程序计数器(PC) │
│ ├── 通用寄存器(rax, rbx, ...) │
│ ├── 栈指针(SP) │
│ ├── 状态寄存器 / 标志位(PSW/EFLAGS) │
│ └── 浮点寄存器 │
├─────────────────────────────────────────────┤
│ 3. 进程调度信息 │
│ ├── 进程状态(就绪/运行/阻塞...) │
│ ├── 优先级 │
│ ├── 调度策略 │
│ └── 已运行时间 / 等待时间 │
├─────────────────────────────────────────────┤
│ 4. 进程控制信息 │
│ ├── 内存管理信息(页表基址、段表) │
│ ├── 打开的文件列表(文件描述符表) │
│ ├── I/O 状态(分配的 I/O 设备) │
│ ├── 信号处理信息 │
│ └── 进程间通信信息 │
└─────────────────────────────────────────────┘
2.3 Linux 中的 PCB:task_struct¶
在 Linux 内核中,PCB 的实现是 task_struct 结构体(定义在 include/linux/sched.h),它非常庞大(数千字节):
// Linux 内核中 task_struct 的关键字段(简化版)
struct task_struct { // struct结构体:自定义复合数据类型
// ---- 进程状态 ----
volatile long state; // 进程状态
// ---- 调度信息 ----
int prio; // 动态优先级
int static_prio; // 静态优先级(由nice值决定)
unsigned int policy; // 调度策略(SCHED_NORMAL/SCHED_FIFO/...)
struct sched_entity se; // CFS 调度实体(含 vruntime)
// ---- 进程标识 ----
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID(= 主线程的 PID)
// ---- 进程关系 ----
struct task_struct *parent; // 父进程
struct list_head children; // 子进程列表
struct list_head sibling; // 兄弟进程列表
// ---- 内存管理 ----
struct mm_struct *mm; // 内存描述符(页表、VMA等)
// ---- 文件系统 ----
struct files_struct *files; // 打开的文件表
struct fs_struct *fs; // 文件系统信息(当前目录等)
// ---- 信号处理 ----
struct signal_struct *signal; // 信号处理信息
// ---- CPU 上下文 ----
struct thread_struct thread; // CPU 寄存器状态(上下文切换时保存)
// ... 还有很多其他字段
};
2.4 PCB 的组织方式¶
操作系统需要高效地管理大量 PCB,常见的组织方式:
1. 线性表(数组)
┌────┬────┬────┬────┬────┐
│PCB1│PCB2│PCB3│PCB4│... │
└────┴────┴────┴────┴────┘
优点:简单 缺点:查找 O(n)
2. 链表(按状态分组)
就绪队列:PCB3 → PCB7 → PCB12 → NULL
阻塞队列:PCB2 → PCB5 → NULL
优点:状态切换时高效(O(1) 插入/删除)
3. 索引方式(索引表指向各状态的PCB)
就绪索引表 ──→ [PCB3, PCB7, PCB12]
阻塞索引表 ──→ [PCB2, PCB5]
Linux 的实现:Linux 使用双向链表管理所有进程,同时维护按状态组织的就绪队列和等待队列。CFS 调度器使用红黑树组织就绪进程。
三、进程状态转换模型¶
3.1 五状态模型¶
进程从创建到终止,经历以下五种状态:
┌──────── 时间片用完 ────────┐
│ │
▼ │
┌──────────┐ 调度选中 ┌──────────┐
┌─────┐ 创建│ │──────────►│ │ 运行完成 ┌──────┐
│ 新建 │────►│ 就绪 │ │ 运行 │────────►│ 终止 │
│ New │ │ Ready │◄──────────│ Running │ │ Exit │
└─────┘ └──────────┘ 被抢占 └──────────┘ └──────┘
▲ │
│ │ 等待I/O
│ I/O完成 │ 或事件
│ 或事件到达 │
│ ▼
│ ┌──────────┐
└──────────────│ 阻塞 │
│ Blocked │
└──────────┘
3.2 各状态详解¶
| 状态 | 英文 | 含义 | 触发事件 |
|---|---|---|---|
| 新建 | New/Created | 进程正在被创建,分配 PCB 和资源 | fork() 系统调用 |
| 就绪 | Ready | 进程已准备好,等待 CPU | 被调度器放入就绪队列 |
| 运行 | Running | 进程正在 CPU 上执行 | 被调度器选中 |
| 阻塞 | Blocked/Waiting | 进程等待某个事件(I/O、信号等) | 发起 I/O 请求、wait() 、sleep() |
| 终止 | Terminated/Exit | 进程执行完毕或被强制终止 | exit()、异常终止、被 kill |
3.3 状态转换的详细说明¶
① 新建 → 就绪: - 操作系统完成 PCB 创建、内存分配等初始化工作后,将进程放入就绪队列 - 在 Linux 中,fork() 返回后子进程即处于就绪状态
② 就绪 → 运行: - 调度器从就绪队列中选择一个进程,分配 CPU - 这是调度算法(FCFS、SJF、RR、CFS 等)的核心决策点
③ 运行 → 就绪: - 时间片用完:RR 调度中,进程用完了分配的时间片 - 被抢占:更高优先级的进程就绪,抢占当前进程的 CPU - 注意:进程没有完成也没有阻塞,只是暂时让出 CPU
④ 运行 → 阻塞: - 进程主动发起 I/O 请求(如 read()、write()) - 进程调用 wait() 等待子进程 - 进程调用 sleep() - 进程等待锁或信号量 - 这是进程主动行为,进入阻塞后释放 CPU
⑤ 阻塞 → 就绪: - I/O 操作完成(硬件中断通知 CPU) - 等待的事件发生(子进程终止、信号到达) - 这是被动事件,由中断处理程序或其他进程触发 - 注意:阻塞的进程不能直接变为运行状态,必须先变为就绪,再由调度器决定是否运行
⑥ 运行 → 终止: - 进程正常执行 exit() 退出 - 进程被其他进程 kill - 进程发生不可恢复的异常(如段错误 SIGSEGV)
3.4 七状态模型(含挂起状态)¶
当系统内存不足时,操作系统可能将某些进程从内存中换出到磁盘,这就是挂起(Suspend)状态:
┌───────────┐
│ 就绪挂起 │ ←─── 内存不足时,就绪进程被换出
│ Ready/Susp│
└─────┬─────┘
│ 换入内存
▼
┌─────┐ ┌──────────┐ ┌──────────┐ ┌──────┐
│新建 │────►│ 就绪 │────►│ 运行 │────►│ 终止 │
└─────┘ └──────────┘ └──────────┘ └──────┘
▲ │
│ ▼
│ ┌──────────┐
└──────────│ 阻塞 │
└────┬─────┘
│ 内存不足时被换出
▼
┌───────────┐
│ 阻塞挂起 │
│Block/Susp │ ─── 事件完成后变为就绪挂起
└───────────┘
四、进程创建 fork/exec¶
4.1 fork() 系统调用¶
fork() 是 Unix/Linux 中创建新进程的核心系统调用。
核心特性:调用一次,返回两次
fork() 的工作原理:
fork() 之前:
┌──────────────┐
│ 父进程 │
│ 代码 + 数据 │
│ PCB │
│ PID = 100 │
└──────────────┘
fork() 之后:
┌──────────────┐ ┌──────────────┐
│ 父进程 │ │ 子进程 │
│ 代码 + 数据 │ │ 代码 + 数据 │ ← 复制父进程的地址空间
│ PCB │ │ 新的 PCB │ ← 新分配的 PCB
│ PID = 100 │ │ PID = 101 │ ← 新的 PID
│ fork返回101 │ │ fork返回0 │ ← 返回值不同!
└──────────────┘ └──────────────┘
fork() 的细节:
-
Copy-on-Write(写时复制):现代操作系统并不立即复制父进程的整个地址空间,而是让父子进程共享同一份物理内存(页面标记为只读)。只有当其中一个进程试图修改某个页面时,才会真正复制该页面。这大大减少了 fork() 的开销。
-
子进程继承的内容:
- 代码段、数据段、堆、栈的副本
- 打开的文件描述符(共享文件表项)
- 环境变量
- 信号处理方式
- 当前工作目录
-
用户 ID 和组 ID
-
子进程不继承的内容:
- PID(子进程获得新的 PID)
- 父进程的锁(子进程不持有父进程获得的锁)
- 挂起的信号(子进程的挂起信号集被清空)
4.2 exec() 系统调用¶
exec() 用新程序替换当前进程的地址空间。fork() 创建新进程后,子进程通常会调用 exec() 来运行不同的程序。
// exec 家族函数
execl("/bin/ls", "ls", "-l", NULL); // 指定路径 + 参数列表
execlp("ls", "ls", "-l", NULL); // 在 PATH 中搜索 + 参数列表
execv("/bin/ls", argv); // 指定路径 + 参数数组
execvp("ls", argv); // 在 PATH 中搜索 + 参数数组
execve("/bin/ls", argv, envp); // 最底层的系统调用
exec() 的工作原理:
exec() 之前: exec() 之后:
┌──────────────┐ ┌──────────────┐
│ 子进程 (fork │ │ 子进程 (新程序│
│ 出来的) │ ──→ │ 替换了) │
│ │ │ │
│ 旧代码 │ │ 新代码(ls) │ ← 代码段被替换
│ 旧数据 │ │ 新数据 │ ← 数据段被替换
│ PID = 101 │ │ PID = 101 │ ← PID 不变!
│ 旧栈 │ │ 新栈 │
└──────────────┘ └──────────────┘
注意:exec 成功后不会返回(因为旧程序已经被替换了)
如果 exec 返回了,一定是出错了
4.3 fork + exec:Shell 执行命令的过程¶
当你在 Shell 中输入 ls -l 时,发生了什么?
Shell 进程 (PID=100)
│
├── 1. 读取用户输入: "ls -l"
├── 2. 解析命令
├── 3. fork() 创建子进程 (PID=101)
│ │
│ ├── [父进程 PID=100]
│ │ └── wait() 等待子进程结束
│ │
│ └── [子进程 PID=101]
│ ├── 4. exec("/bin/ls", "ls", "-l", NULL)
│ │ 地址空间被 ls 程序替换
│ ├── 5. ls 程序执行,输出文件列表
│ └── 6. exit(0) 退出
│
├── 7. wait() 返回,子进程已结束
└── 8. Shell 继续等待下一个命令
4.4 Python 中的进程创建¶
"""进程创建与管理示例"""
import os
import sys
import multiprocessing
import time
# ============ 方法一:使用 os.fork()(仅 Unix)============
def demo_fork():
"""展示 fork 的基本用法"""
print(f"[fork前] PID={os.getpid()}")
pid = os.fork()
if pid == 0:
# 子进程
print(f"[子进程] PID={os.getpid()}, PPID={os.getppid()}")
time.sleep(1)
print("[子进程] 即将退出")
os._exit(0)
else:
# 父进程
print(f"[父进程] PID={os.getpid()}, 子进程PID={pid}")
os.wait() # 等待子进程结束
print("[父进程] 子进程已结束")
# ============ 方法二:使用 multiprocessing(跨平台)============
def worker(name, duration):
"""工作进程函数"""
print(f"[Worker-{name}] 开始工作, PID={os.getpid()}")
time.sleep(duration)
print(f"[Worker-{name}] 工作完成")
def demo_multiprocessing():
"""使用 multiprocessing 模块创建进程"""
print(f"[主进程] PID={os.getpid()}")
# 创建多个子进程
processes = []
for i in range(3):
p = multiprocessing.Process(target=worker, args=(f"P{i}", i + 1))
processes.append(p)
p.start() # 启动进程
print(f"[主进程] 启动了子进程 PID={p.pid}")
# 等待所有子进程完成
for p in processes:
p.join()
print(f"[主进程] 子进程 PID={p.pid} 已结束")
print("[主进程] 所有子进程已完成")
if __name__ == "__main__":
demo_multiprocessing()
五、线程的概念与必要性¶
5.1 为什么需要线程?¶
考虑一个 Web 服务器需要同时处理多个客户端请求:
方案一:每个请求一个进程(多进程模型)
┌──────┐ ┌──────┐ ┌──────┐
│进程1 │ │进程2 │ │进程3 │ ← 每个进程独立的地址空间
│处理 │ │处理 │ │处理 │ 创建开销大(~10ms)
│请求1 │ │请求2 │ │请求3 │ 进程间通信复杂
└──────┘ └──────┘ └──────┘ 上下文切换慢
方案二:一个进程内多个线程(多线程模型)
┌──────────────────────────┐
│ 一个进程 │ ← 共享地址空间
│ ┌──────┐┌──────┐┌──────┐ │
│ │线程1 ││线程2 ││线程3 │ │ 创建开销小(~1ms)
│ │处理 ││处理 ││处理 │ │ 通过共享内存直接通信
│ │请求1 ││请求2 ││请求3 │ │ 上下文切换快
│ └──────┘└──────┘└──────┘ │
└──────────────────────────┘
5.2 线程的定义¶
线程是 CPU 调度和执行的基本单位,是进程内部的一个执行流。
一个进程至少包含一个线程(主线程),可以包含多个线程。同一进程的多个线程共享进程的资源(地址空间、文件描述符等),但各自拥有独立的执行上下文。
5.3 线程共享与独有¶
进程资源(线程间共享):
┌──────────────────────────────┐
│ 代码段(Text) │ ← 所有线程执行相同的程序
│ 数据段(Data + BSS) │ ← 全局变量被所有线程共享
│ 堆(Heap) │ ← malloc 分配的内存被共享
│ 打开的文件描述符表 │ ← 同一个 fd 表
│ 信号处理函数 │
│ 进程 ID、用户 ID │
│ 内存映射(mmap区域) │
└──────────────────────────────┘
每个线程独有:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 线程1 │ │ 线程2 │ │ 线程3 │
│ │ │ │ │ │
│ 线程 ID │ │ 线程 ID │ │ 线程 ID │
│ 栈(Stack)│ │ 栈 │ │ 栈 │ ← 每个线程有独立的栈
│ PC(程序计│ │ PC │ │ PC │ ← 独立的执行位置
│ 数器) │ │ │ │ │
│ 寄存器 │ │ 寄存器 │ │ 寄存器 │ ← 独立的寄存器上下文
│ 错误号 │ │ 错误号 │ │ 错误号 │ ← errno
│ 信号掩码 │ │ 信号掩码 │ │ 信号掩码 │
│ 优先级 │ │ 优先级 │ │ 优先级 │
└──────────┘ └──────────┘ └──────────┘
5.4 进程 vs 线程对比(面试高频)¶
| 比较维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立的虚拟地址空间 | 共享所属进程的地址空间 |
| 创建开销 | 大(需要分配独立地址空间) | 小(只需要栈和寄存器) |
| 切换开销 | 大(需要切换页表、刷新 TLB) | 小(共享地址空间,无需切换页表) |
| 通信方式 | IPC(管道/共享内存/Socket等) | 直接读写共享内存(需同步) |
| 安全性 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
| 资源 | 拥有独立资源 | 共享进程资源,独有栈和寄存器 |
| 并发性 | 进程间并发 | 线程间并发(更轻量) |
六、用户级线程 vs 内核级线程¶
6.1 用户级线程(User-Level Thread, ULT)¶
定义:线程的创建、调度、同步完全在用户空间完成,内核对线程的存在一无所知。
实现方式:通过用户空间的线程库(如早期的 POSIX 线程库、Green Threads)实现。
用户空间
┌──────────────────────────────┐
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ ULT1│ │ ULT2│ │ ULT3│ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ └────┬───┘──────┘ │
│ 线程库(Thread Library) │ ← 在用户空间管理线程
├──────────────────────────────┤
│ 内核 │
│ 只看到一个进程 │ ← 内核不知道有多个线程
└──────────────────────────────┘
6.2 内核级线程(Kernel-Level Thread, KLT)¶
定义:线程的创建、调度、同步由内核完成,内核维护所有线程的 TCB。
用户空间
┌──────────────────────────────┐
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ KLT1│ │ KLT2│ │ KLT3│ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
├─────┼───────┼───────┼───────┤
│ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │TCB1 │ │TCB2 │ │TCB3 │ │ ← 内核管理每个线程
│ └─────┘ └─────┘ └─────┘ │
│ 内核 │
└──────────────────────────────┘
6.3 对比表¶
| 特性 | 用户级线程(ULT) | 内核级线程(KLT) |
|---|---|---|
| 线程管理 | 用户空间的线程库 | 内核 |
| 创建/切换速度 | 快(不需要系统调用) | 慢(需要系统调用) |
| 系统调用阻塞 | 一个线程阻塞→整个进程阻塞 | 一个线程阻塞不影响其他线程 |
| 多核利用 | ❌ 不能利用多核(内核只看到一个进程) | ✅ 可以利用多核并行 |
| 调度粒度 | 进程级(内核调度进程,线程库调度线程) | 线程级(内核直接调度线程) |
| 可移植性 | 好(不依赖内核线程支持) | 差(依赖操作系统) |
| 代表 | Green Threads(早期 Java)、GNU Portable Threads | Linux NPTL、Windows 线程 |
6.4 用户级线程的致命缺陷¶
用户级线程最大的问题是阻塞传播:
场景:进程 P 有 3 个用户级线程 T1, T2, T3
T1 执行 read() 系统调用,等待磁盘 I/O
│
▼
内核不知道进程内有多个线程
内核认为整个进程 P 在等待 I/O
内核将进程 P 设为阻塞状态
│
▼
结果:T2 和 T3 也被阻塞了!(即使它们不需要 I/O)
这就是为什么现代操作系统大多采用内核级线程或混合方案。
七、线程模型¶
7.1 一对一模型(1:1)¶
每个用户线程对应一个内核线程。
代表:Linux(NPTL)、Windows
优点: - 一个线程阻塞不影响其他线程 - 可以充分利用多核 CPU
缺点: - 每个用户线程需要一个内核线程,创建数量受限 - 线程创建和切换需要系统调用,开销较大
7.2 多对一模型(N:1)¶
多个用户线程映射到一个内核线程。
代表:Green Threads(早期 Java)、GNU Portable Threads
优点:线程管理完全在用户空间,开销小 缺点: - 一个线程阻塞→全部阻塞 - 不能利用多核
7.3 多对多模型(M:N)¶
M 个用户线程映射到 N 个内核线程(M ≥ N)。
代表:Go 语言的 goroutine(GMP 模型)、Solaris 的 LWP
优点: - 兼顾创建效率和并行能力 - 一个用户线程阻塞时,其内核线程可以调度其他用户线程 - 用户线程数量不受内核线程限制
缺点: - 实现复杂,调试困难
7.4 三种模型对比¶
| 模型 | 并行能力 | 阻塞处理 | 创建开销 | 复杂度 | 代表 |
|---|---|---|---|---|---|
| 1:1 | ✅ 充分 | ✅ 独立 | 较大 | 简单 | Linux NPTL |
| N:1 | ❌ 无 | ❌ 全部阻塞 | 极小 | 简单 | Green Threads |
| M:N | ✅ 充分 | ✅ 独立 | 小 | 复杂 | Go goroutine |
八、协程原理¶
8.1 什么是协程?¶
协程(Coroutine)是一种用户态的轻量级线程,它的调度完全由程序自身控制(协作式调度),不需要内核参与。
线程 vs 协程:
线程(Thread):
├── 内核调度(抢占式)
├── 上下文切换需要内核参与
├── 切换开销:~1-10 微秒
├── 栈大小:固定 8MB(Linux 默认)
├── 数量限制:通常数千个
└── 创建开销:较大
协程(Coroutine):
├── 用户态调度(协作式)
├── 上下文切换在用户态完成
├── 切换开销:~0.1-1 微秒
├── 栈大小:可动态调整(如 Go goroutine 初始 2KB)
├── 数量限制:可以创建数十万甚至百万个
└── 创建开销:极小
8.2 协程的工作原理¶
协程的核心思想是协作式让出:一个协程主动让出 CPU(yield),然后另一个协程继续执行。
协程 A 和 协程 B 的执行流程:
协程 A:
def coroutine_a():
print("A: step 1")
yield # ← 主动让出,切换到 B
print("A: step 2")
yield # ← 主动让出,切换到 B
print("A: step 3")
协程 B:
def coroutine_b():
print("B: step 1")
yield # ← 主动让出,切换到 A
print("B: step 2")
yield # ← 主动让出,切换到 A
print("B: step 3")
执行结果:
A: step 1 → B: step 1 → A: step 2 → B: step 2 → A: step 3 → B: step 3
8.3 Python 协程示例¶
"""Python 协程(asyncio)示例"""
import asyncio
import time
async def fetch_data(name, delay): # async def定义异步函数;用await调用
"""模拟异步数据获取"""
print(f"[{name}] 开始获取数据...")
await asyncio.sleep(delay) # 模拟 I/O 等待(非阻塞)
print(f"[{name}] 数据获取完成(耗时 {delay}s)")
return f"{name}的数据"
async def main():
start = time.time()
# 并发执行 3 个协程
results = await asyncio.gather( # await等待异步操作完成
fetch_data("任务A", 2),
fetch_data("任务B", 1),
fetch_data("任务C", 3),
)
elapsed = time.time() - start
print(f"\n所有任务完成!总耗时: {elapsed:.1f}s") # 约 3s,不是 6s
print(f"结果: {results}")
asyncio.run(main()) # asyncio.run()启动异步事件循环
8.4 Go Goroutine(M:N 模型的典范)¶
Go 语言的 goroutine 是 M:N 模型的最佳实践,其 GMP 模型:
G = Goroutine(协程)
M = Machine(操作系统线程,即内核线程)
P = Processor(逻辑处理器,通常 = CPU 核心数)
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ G1 │ │ G2 │ │ G3 │ │ G4 │ │ G5 │ ← 可以有成千上万个 G
└─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐
│ P1 │ │ P2 │ ← P 的数量 = GOMAXPROCS(默认 = CPU核心数)
│ 本地队列 │ │ 本地队列 │
└─────┬────┘ └─────┬────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ M1 │ │ M2 │ ← 操作系统线程
│ (OS线程) │ │ (OS线程) │
└──────────┘ └──────────┘
│ │
▼ ▼
CPU Core 1 CPU Core 2
特点:
- G 极轻量(初始栈 2KB,可动态增长)
- 当一个 G 阻塞时,M 会释放 P,P 可以切换到其他 M 继续执行其他 G
- Work Stealing:空闲的 P 会从其他 P 的队列中偷取 G
8.5 线程 vs 协程 vs 进程总结¶
| 特性 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度方式 | 内核调度 | 内核调度 | 用户态调度 |
| 调度策略 | 抢占式 | 抢占式 | 协作式 |
| 地址空间 | 独立 | 共享 | 共享 |
| 切换开销 | 最大 | 中等 | 最小 |
| 创建开销 | 最大 | 中等 | 最小 |
| 并行能力 | ✅ 多核 | ✅ 多核 | 取决于模型 |
| 栈大小 | 独立大栈 | 固定(~8MB) | 动态(~2KB起) |
| 数量限制 | 数百-数千 | 数千-数万 | 数十万-数百万 |
| 安全隔离 | 强 | 弱 | 弱 |
九、进程间通信 IPC¶
9.1 为什么需要 IPC?¶
进程拥有独立的地址空间,一个进程不能直接访问另一个进程的数据。但实际应用中,进程之间经常需要交换数据或协调工作,这就需要进程间通信(Inter-Process Communication, IPC)机制。
9.2 六种 IPC 机制¶
IPC 机制
├── 管道(Pipe)—— 最简单,亲缘进程间单向通信
├── 命名管道(Named Pipe / FIFO)—— 无亲缘关系进程间通信
├── 消息队列(Message Queue)—— 格式化消息,异步通信
├── 信号量(Semaphore)—— 同步工具,不传数据
├── 共享内存(Shared Memory)—— 最快,直接共享内存区域
└── Socket —— 最通用,支持不同主机间通信
9.3 管道(Pipe)¶
特点: - 半双工(数据只能单向流动) - 只用于有亲缘关系的进程(父子进程) - 基于文件描述符,写端 fd[1],读端 fd[0] - 有固定容量(Linux 默认 64KB),满了就阻塞写入
"""管道通信示例(Python,Unix系统)"""
import os
# 创建管道
read_fd, write_fd = os.pipe() # 返回 (读端fd, 写端fd)
pid = os.fork()
if pid == 0:
# 子进程:作为写端
os.close(read_fd) # 关闭读端
message = "Hello from child process!"
os.write(write_fd, message.encode())
os.close(write_fd)
os._exit(0)
else:
# 父进程:作为读端
os.close(write_fd) # 关闭写端
data = os.read(read_fd, 1024)
print(f"父进程收到: {data.decode()}")
os.close(read_fd)
os.wait()
9.4 命名管道(Named Pipe / FIFO)¶
特点: - 与管道类似,但作为文件系统中的特殊文件存在 - 无亲缘关系的进程也可以通信 - 通过路径名访问
"""命名管道示例"""
import os
import sys
FIFO_PATH = "/tmp/my_fifo"
# 创建命名管道
if not os.path.exists(FIFO_PATH):
os.mkfifo(FIFO_PATH)
# 写端(在一个终端运行)
def writer():
with open(FIFO_PATH, 'w') as fifo: # with自动管理资源,确保文件正确关闭
fifo.write("Hello from writer process!\n")
print("写入完成")
# 读端(在另一个终端运行)
def reader():
with open(FIFO_PATH, 'r') as fifo:
data = fifo.read()
print(f"读取到: {data}")
9.5 消息队列(Message Queue)¶
特点: - 消息以结构化格式存储在内核中 - 支持按消息类型选择性读取 - 生命周期独立于进程(进程退出后消息队列仍在) - 有大小限制
"""消息队列示例(使用 multiprocessing.Queue)"""
from multiprocessing import Process, Queue
def producer(queue):
"""生产者:向队列中放入消息"""
for i in range(5):
msg = f"消息-{i}"
queue.put(msg)
print(f"[生产者] 发送: {msg}")
def consumer(queue):
"""消费者:从队列中读取消息"""
while True:
msg = queue.get()
if msg == "STOP":
break
print(f"[消费者] 收到: {msg}")
if __name__ == "__main__":
q = Queue()
p1 = Process(target=producer, args=(q,))
p2 = Process(target=consumer, args=(q,))
p1.start()
p2.start()
p1.join()
q.put("STOP") # 发送停止信号
p2.join()
print("通信完成")
9.6 信号量(Semaphore)¶
特点: - 主要用于同步,不传输数据 - 一个整数计数器 + P/V 操作 - P 操作(wait):减 1,若 < 0 则阻塞 - V 操作(signal):加 1,若 ≤ 0 则唤醒一个等待进程
"""信号量示例"""
from multiprocessing import Process, Semaphore
import time
def worker(sem, worker_id):
"""使用信号量控制并发数量"""
sem.acquire() # P 操作
print(f"[Worker-{worker_id}] 进入临界区")
time.sleep(2) # 模拟工作
print(f"[Worker-{worker_id}] 离开临界区")
sem.release() # V 操作
if __name__ == "__main__":
sem = Semaphore(2) # 最多允许 2 个进程同时进入
processes = []
for i in range(5):
p = Process(target=worker, args=(sem, i))
processes.append(p)
p.start()
for p in processes:
p.join()
9.7 共享内存(Shared Memory)¶
特点: - 最快的 IPC 方式(不需要内核中转,直接读写共享区域) - 多个进程将同一段物理内存映射到各自的地址空间 - 需要配合同步机制(如信号量)使用,否则会产生竞态条件
"""共享内存示例"""
from multiprocessing import Process, Value, Array
import time
def writer(shared_val, shared_arr):
"""写入共享内存"""
shared_val.value = 42
for i in range(len(shared_arr)):
shared_arr[i] = i * 10
print(f"[写进程] 写入: value={shared_val.value}, array={list(shared_arr)}")
def reader(shared_val, shared_arr):
"""读取共享内存"""
time.sleep(0.1) # 等待写入完成
print(f"[读进程] 读取: value={shared_val.value}, array={list(shared_arr)}")
if __name__ == "__main__":
# 共享整数
val = Value('i', 0) # 'i' = int 类型
# 共享数组
arr = Array('i', 5) # 5 个 int 元素
p1 = Process(target=writer, args=(val, arr))
p2 = Process(target=reader, args=(val, arr))
p1.start()
p2.start()
p1.join()
p2.join()
9.8 Socket¶
特点: - 最通用的 IPC 方式,不仅支持同一主机,还支持不同主机间的通信 - 基于网络协议(TCP/UDP) - 也可以用 Unix Domain Socket 进行本机高效通信 - 是分布式系统通信的基础
"""Socket IPC 示例(TCP, 同一主机)"""
import socket
import threading # 线程池/多线程:并发执行任务
def server():
"""服务端"""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('127.0.0.1', 9999))
srv.listen(1)
print("[服务端] 等待连接...")
conn, addr = srv.accept()
print(f"[服务端] 客户端已连接: {addr}")
data = conn.recv(1024)
print(f"[服务端] 收到: {data.decode()}")
conn.send("Hello from server!".encode())
conn.close()
srv.close()
def client():
"""客户端"""
import time
time.sleep(0.5) # 等待服务端启动
cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli.connect(('127.0.0.1', 9999))
cli.send("Hello from client!".encode())
data = cli.recv(1024)
print(f"[客户端] 收到: {data.decode()}")
cli.close()
# 启动服务端和客户端
t1 = threading.Thread(target=server)
t2 = threading.Thread(target=client)
t1.start()
t2.start()
t1.join()
t2.join()
print("Socket 通信完成")
9.9 六种 IPC 方式对比¶
| IPC 方式 | 传输方向 | 是否传数据 | 速度 | 可跨主机 | 适用场景 |
|---|---|---|---|---|---|
| 管道 | 单向 | ✅ | 中 | ❌ | 父子进程简单通信 |
| 命名管道 | 单向 | ✅ | 中 | ❌ | 无亲缘进程通信 |
| 消息队列 | 双向 | ✅ | 中 | ❌ | 格式化消息传递 |
| 信号量 | - | ❌(仅同步) | 快 | ❌ | 进程同步/互斥 |
| 共享内存 | 双向 | ✅ | 最快 | ❌ | 大量数据高速传输 |
| Socket | 双向 | ✅ | 较慢 | ✅ | 网络通信、分布式系统 |
十、练习题¶
选择题¶
1. 进程和程序的根本区别是: - A. 存储位置不同 - B. 进程是动态的,程序是静态的 - C. 进程可以并发执行 - D. 进程在内存中,程序在磁盘上
答案:B。最根本的区别是动态性 vs 静态性。A 和 D 是表面现象,C 是特性之一但不是根本区别。
2. 下列哪个不是线程独有的(即线程间共享的)? - A. 线程 ID - B. 栈 - C. 堆区 - D. 程序计数器
答案:C。堆区是进程资源,被所有线程共享。线程各自独有 ID、栈、PC 和寄存器。
3. 哪种线程模型既能利用多核 CPU 又能高效创建大量线程? - A. 1:1 模型 - B. N:1 模型 - C. M:N 模型 - D. 以上都不能
答案:C。M:N 模型兼顾了多核并行能力和用户态线程的轻量创建。
4. 以下哪种 IPC 方式速度最快? - A. 管道 - B. Socket - C. 共享内存 - D. 消息队列
答案:C。共享内存不需要内核中转,进程直接读写共享区域,是最快的 IPC 方式。
5. 关于 fork() 的描述,正确的是: - A. fork() 调用一次,返回一次 - B. 子进程是父进程的完全独立副本,不共享任何内容 - C. 现代 Linux 使用 Copy-on-Write 优化 fork - D. 子进程的 PID 一定是父进程 PID + 1
答案:C。A 错:调用一次返回两次。B 错:子进程会继承父进程的文件描述符表(共享打开文件)。D 错:PID 由内核分配,不一定连续。
简答题¶
1. 简述进程创建的完整过程(以 fork 为例)。
参考答案:
- 分配 PCB:为新进程分配 task_struct 结构
- 分配 PID:分配一个唯一的进程标识符
- 复制地址空间:使用 Copy-on-Write 技术,初始共享父进程页面
- 复制 PCB 内容:复制父进程的 PCB 信息(状态、优先级、打开文件等)
- 设置返回值:在父进程 PCB 中设置返回值为子进程 PID,在子进程 PCB 中设置返回值为 0
- 加入就绪队列:将新进程加入就绪队列
2. 解释 Go 语言 goroutine 的 GMP 模型。
参考答案:
Go 的 GMP 模型包含三个核心组件:
- G(Goroutine):Go 语言中的协程,轻量级执行单元,初始栈大小仅 2KB
- M(Machine):操作系统线程(内核线程),是实际的执行者
- P(Processor):逻辑处理器,管理 G 到 M 的映射,数量默认等于 CPU 核心数
工作原理:每个 P 维护一个本地 G 队列。P 绑定到一个 M 上,M 从 P 的队列中取出 G 执行。当一个 G 阻塞(如系统调用)时,M 会释放 P,P 可以被其他 M 获取继续执行其他 G。空闲的 P 还会通过 Work Stealing 从其他 P 的队列中偷取 G,实现负载均衡。
这是典型的 M:N 模型,兼顾了并行能力(利用多核)和轻量级(可创建百万级 goroutine)。
十一、面试要点¶
🔥 高频面试题¶
Q1:进程和线程的区别?(必考题)¶
标准回答(5 点对比):
- 资源分配:进程是资源分配的基本单位,拥有独立的地址空间;线程是 CPU 调度的基本单位,共享进程的地址空间
- 开销:进程创建/切换开销大(需要分配/切换地址空间),线程开销小(只需要栈和寄存器)
- 通信:进程间通信需要 IPC 机制(管道、共享内存等),线程通过共享内存直接通信
- 安全性:进程间互相隔离,一个崩溃不影响另一个;线程共享地址空间,一个崩溃可能导致整个进程崩溃
- 独有 vs 共享:线程独有栈、PC、寄存器;共享代码段、数据段、堆、文件描述符
加分点:举 Chrome 浏览器的例子——每个标签页是独立进程(安全隔离),标签页内部用多线程(渲染线程、JS 线程、网络线程)提高效率。
Q2:进程间通信有哪些方式?各有什么特点?¶
标准回答:六种方式(管道、命名管道、消息队列、信号量、共享内存、Socket),然后对比速度、方向、是否可跨主机等特性。重点强调共享内存最快(直接读写,无内核中转),Socket 最通用(支持跨主机)。
Q3:什么是协程?和线程有什么区别?¶
标准回答: - 协程是用户态的轻量级线程,调度完全在用户空间完成 - 调度方式不同:线程是抢占式(内核调度),协程是协作式(主动 yield) - 切换开销不同:线程切换需要内核参与(10μs),协程切换在用户态完成(0.1μs) - 数量不同:线程通常数千个,协程可以数十万甚至百万个 - 代表:Python asyncio、Go goroutine、Lua coroutine - Go goroutine 基于 GMP 模型是 M:N 线程模型的优秀实现
Q4:fork() 之后,父子进程的关系是什么?¶
标准回答: 1. 子进程是父进程的副本(通过 Copy-on-Write 优化) 2. 共享:代码段、打开的文件描述符(共享文件表项) 3. 不共享:PID、内存空间(写时复制后独立)、挂起信号 4. fork 返回值:父进程中返回子进程 PID,子进程中返回 0 5. 子进程可以通过 exec 加载新程序
Q5:用户级线程和内核级线程的区别?¶
核心回答: - 用户级线程在用户空间管理,内核不知道其存在;内核级线程由内核管理 - 用户级线程创建/切换快但一个阻塞全部阻塞,不能利用多核 - 内核级线程一个阻塞不影响其他,可以利用多核,但切换需要系统调用 - 现代 Linux 用 1:1 模型(NPTL),Go 用 M:N 模型(GMP)
📌 下一章:03-CPU调度算法 — 深入学习 7 种调度算法与 Linux CFS 调度器