进程线程
一、进程与线程的本质区别
进程是资源分配的基本单位,相当于一个独立运行的程序,有独立的内存空间。
线程是 CPU 调度的基本单位,是进程内的执行单元,共享进程的内存空间。
主要区别:
- 资源开销:进程开销大(需要独立内存),线程开销小(共享内存)
- 通信方式:进程间通信复杂(需要 IPC 机制),线程间通信简单(直接访问共享变量)
- 稳定性:进程间互不影响,一个线程崩溃可能导致整个进程崩溃
- 创建销毁:进程慢,线程快
进程适用场景:
- 需要高稳定性的场景
- 需要安全隔离的场景
- 独立应用之间的协作
线程适用场景:
- 需要频繁数据交互的场景
- 计算密集型任务的并行处理
- IO 密集型任务
- 需要快速响应的场景
二、线程的实现方式
用户级线程(ULT)
线程管理由用户空间的线程库完成,内核不知道线程的存在,只看到进程。
优点:
- 切换快,无需陷入内核
- 开销小,创建、销毁、调度都在用户空间
- 灵活,可以自定义调度算法
- 跨平台,不依赖内核支持
缺点:
- 无法利用多核,多个线程只能在一个 CPU 上跑
- 阻塞问题,一个线程阻塞,整个进程都阻塞
- 无时间片,内核不感知线程,无法给每个线程分配 CPU 时间
内核级线程(KLT)
线程管理由操作系统内核完成,内核为每个线程维护 TCB(线程控制块)。
优点:
- 支持多核,内核可以把不同的线程调度到不同的 CPU
- 不会阻塞整个进程,一个线程阻塞,其他线程继续运行
- 公平调度,内核给每个线程分配时间片
缺点:
- 切换慢,需要用户态和内核态切换,开销大
- 资源消耗大,每个线程都需要内核资源
- 数量限制,内核线程数量受系统限制
三、进程的状态转换
三种基本状态
- 就绪态(Ready):进程已获得除 CPU 外的所有资源,等待调度
- 运行态(Running):进程正在 CPU 上执行
- 阻塞态(Blocked):进程等待某个事件(如 IO 完成),暂时无法运行
状态转换触发条件
- 创建 → 就绪:进程创建完成,分配了 PCB 和必要资源。比如
fork()后,子进程进入就绪队列 - 就绪 → 运行:调度器选中该进程,分配 CPU
- 运行 → 就绪:时间片用完或更高优先级进程抢占
- 运行 → 阻塞:等待 IO、申请资源失败、
sleep()等。这是主动行为,进程自己请求阻塞 - 阻塞 → 就绪:IO 完成、等待的事件发生。不能直接到运行态,需要先进入就绪队列排队
- 运行 → 终止:
exit()正常结束,或异常终止(段错误),或被kill
五态模型
在三种基本状态基础上新增:
- 新建态(New):进程刚创建,还未进入就绪队列,正在申请资源
- 终止态(Terminated):进程结束,等待系统收回资源,也叫僵尸态(Zombie)
七态模型(带挂起)
考虑内存交换:
- 就绪挂起(Ready Suspended):进程在外存中等待被调入内存
- 阻塞挂起(Blocked Suspended):进程在外存中等待事件
触发条件:内存不足时换出到磁盘(挂起),内存充足或优先级提高时换回内存(激活)。
注意事项
- 阻塞态不可能直接到运行态,必须先到就绪态
- 就绪态不可能直接到阻塞态,只有运行的进程才能请求 IO 或资源
- 状态转换是原子操作,由操作系统内核完成,不会被打断
实际例子:下载线程的生命周期
- 新建 → 就绪(下载线程创建)
- 就绪 → 运行(获得 CPU)
- 运行 → 阻塞(发起网络 IO,等待数据)
- 阻塞 → 就绪(网络数据到达,网卡中断)
- 就绪 → 运行(再次获得 CPU)
- 运行 → 就绪(时间片到,让出 CPU)
- …循环直到下载完成
- 运行 → 终止(下载完成,线程结束)
四、上下文切换的开销
直接开销
- 保存和恢复 CPU 寄存器:通用寄存器、程序计数器、栈指针、状态寄存器
- 切换内核栈:每个进程有独立的内核栈,需要切换栈指针
- 切换页表:更新 CR3 寄存器,不同进程有不同的虚拟地址空间
- 内核态和用户态切换:涉及特权级切换
间接开销
- TLB 失效:TLB 是页表缓存,切换进程后内容失效(除非支持 ASID/PCID),新进程需要重新填充
- CPU 缓存失效:L1/L2/L3 缓存中旧进程的数据失效,新进程需要从内存加载
- 流水线清空:CPU 指令流水线重新填充,分支预测失控
- 调度器开销:选择下一个进程、更新调度队列和统计信息
优化策略
减少切换次数:
- 增加时间片
- 使用 CPU 亲和性
使用线程代替进程:
- 同一进程的线程共享地址空间
- 切换不需要切换页表
- TLB 和缓存可以部分保留
使用协程:
- 用户态调度,不陷入内核
- 只保留栈指针和程序计数器
- 不涉及页表、TLB 切换
其他优化:
- IO 多路复用,一个线程处理多个连接
- 线程池,复用线程,避免频繁创建和销毁
- 硬件层面:PCID(x86)/ ASID(ARM),减少 TLB 刷新
实际案例:
- Nginx:
epoll+ 少量 worker 进程,单 worker 处理数千连接 - Redis:单线程 + IO 多路复用,完全避免上下文切换
- 数据库连接池:复用连接,减少线程创建
五、多线程 vs 多进程
| 维度 | 多线程 | 多进程 |
|---|---|---|
| 内存 | 共享内存空间 | 独立内存空间 |
| 通信 | 直接访问共享变量 | 需要 IPC |
| 开销 | 小(创建/切换快) | 大(创建/切换慢) |
| 稳定性 | 一个线程崩溃全崩 | 进程间互不影响 |
| 多核利用 | 支持 | 支持 |
| 编程难度 | 复杂(需要同步) | 相对简单(天然隔离) |
选择依据:
- 频繁共享数据 → 多线程
- 高可靠性要求 → 多进程
- 资源有限、大量并发 → 多线程
- 权限隔离、安全敏感 → 多进程
- 大量 IO 等待 → 多线程
- 计算密集、避免 GIL → 多进程
六、进程间通信(IPC)
共享内存(Shared Memory)
操作系统分配一块物理内存,映射到多个进程的虚拟地址空间,进程直接读写。
特点:无数据拷贝,效率最快。但需要配合信号量/互斥锁解决同步问题,编程复杂度高。
管道(Pipe)
内核中创建一个缓冲区,一个进程写入,另一个进程读取,单向传输,半双工。
- 匿名管道:只能用于父子进程
- 命名管道(FIFO):可用于无亲缘关系的进程
消息队列(Message Queue)
内核维护一个消息链表,进程可以按消息类型读取,不必按 FIFO 顺序。
特点:支持随机读取,消息有固定格式和大小限制,进程结束后消息仍然存在。
套接字(Socket)
通过网络协议栈通信,可以跨主机(TCP/UDP)。本地 Unix Domain Socket 性能更高。
特点:支持跨网络、双向通信,使用灵活但开销大。
信号量(Semaphore)
一个计数器,用于控制资源访问。P 操作(Wait)计数减 1,为 0 则阻塞;V 操作(Signal)计数加 1,唤醒等待进程。
主要用于同步,不直接传递数据。
信号(Signal)
操作系统向进程发送软件中断,进程收到后执行预定义的处理函数。
特点:只是通知,用于异步通知,传递信息量少。
七、死锁
四个必要条件(同时满足才会死锁)
- 互斥条件:资源不能被多个进程同时使用
- 持有并等待:进程已持有至少一个资源,还等待获取其他资源
- 不可剥夺:资源不能被强制抢占,只能由持有者主动释放
- 循环等待:P1 等 P2 的资源,P2 等 P3 的资源…Pn 等 P1 的资源
预防死锁(破坏任一条件)
- 破坏互斥:让资源可以共享
- 破坏持有并等待:进程启动时申请所有需要的资源
- 破坏不可剥夺:申请新资源失败时,主动释放已持有的资源
- 破坏循环等待:给资源编号,进程必须按递增顺序申请(最常用)
避免死锁:银行家算法
核心思想:分配资源前,先判断分配后系统是否处于安全状态。
步骤:
- 进程申请资源时,先试探性分配
- 检查是否存在一个安全序列(所有进程都能顺序执行完)
- 安全则真正分配,不安全则拒绝并让进程等待
死锁检测与恢复
- 定期运行死锁检查算法
- 恢复:终止一个或多个死锁进程,或强制剥夺某些进程资源
八、孤儿进程和僵尸进程
孤儿进程:父进程先于子进程结束,子进程变成孤儿进程。几乎没有危害,系统会自动托付给 init 进程。
僵尸进程:子进程已经结束,但父进程没有调用 wait()/waitpid() 回收,进程已死但 PCB 还在。
| 特性 | 孤儿进程 | 僵尸进程 |
|---|---|---|
| 定义 | 父进程先死 | 子进程先死,父进程不回收 |
| 是否运行 | 可能还在运行 | 已经结束 |
| 危害 | 无害 | 占用进程表 |
| 处理 | 系统自动处理 | 需要父进程回收 |
僵尸进程预防:
- 父进程主动调用
wait()/waitpid()回收 - 信号处理(推荐):捕获
SIGCHLD信号,在 handler 中回收 - 两次
fork()(守护进程经典做法)
僵尸进程清理:杀死父进程(僵尸会被 init 回收),或重启系统。
九、协程 vs 线程
| 维度 | 协程 | 线程 |
|---|---|---|
| 调度者 | 用户态程序自己 | 内核调度 |
| 切换位置 | 用户态,不陷入内核 | 内核态,需要系统调用 |
| 切换时机 | 程序主动让出 CPU | 被动(时间片到/阻塞) |
| 并发性 | 单线程内并发(伪并行) | 真正的多核并行 |
| 栈大小 | 几 KB | MB 级别 |
| 切换开销 | 纳秒级 | 微秒级 |
本质区别:
- 线程是抢占式调度,协程是协作式调度
- 线程切换比较重,协程切换极轻
- 线程是真并行,协程是伪并发
十、fork() 的工作原理
fork() 是 Unix/Linux 创建新进程的系统调用。
核心特性:调用一次,返回两次(父进程和子进程各返回一次),子进程是父进程的副本。
返回值含义:
pid_t pid = fork();
if (pid < 0) {
// fork 失败
} else if (pid == 0) {
// 当前是子进程
} else {
// 当前是父进程,pid 是子进程的 PID
}- 子进程返回 0:子进程没有子进程,返回 0 作为标识
- 父进程返回子进程 PID:父进程需要知道子进程的 PID 来管理它
- 失败返回 -1:遵循 Unix 错误处理惯例
十一、进程调度算法
| 算法 | 抢占 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| FCFS(先来先服务) | 否 | 简单公平 | 护航效应 | 批处理 |
| SJF(短作业优先) | 否/是 | 等待时间最短 | 饥饿、难预测 | 已知长度 |
| RR(时间片轮转) | 是 | 响应快、公平 | 切换开销 | 交互式 |
| 优先级调度 | 否/是 | 灵活 | 饥饿 | 实时系统 |
| 多级反馈队列 | 是 | 综合最优 | 复杂 | 通用 OS |