进程与线程
进程是资源分配单位,线程是调度单位——这是两者最本质的区别。进程拥有独立的地址空间(代码、数据、堆栈),线程是进程内的执行流,共享进程的地址空间。开一个进程的成本是"建一座城"(分配地址空间、装载代码),开一个线程的成本是"城里多住一个人"(只需一份栈和寄存器上下文),所以并发首选线程。
提示
用类比理解:进程是"公司",线程是"员工"。公司之间财务独立(资源隔离),员工共享公司场地(共享内存);公司倒闭员工全失业(进程终止所有线程退出),员工挂了一个不影响其他员工(进程崩溃隔离,线程崩溃拖垮整个进程)。
进程与线程
进程的构成
进程由 PCB(进程控制块)描述,它是操作系统感知进程存在的唯一凭证:
| PCB 内容 | 记录什么 | 例子 |
|---|---|---|
| 进程标识 | 唯一编号 | PID 1234 |
| 状态 | 当前处于哪个状态 | 就绪/运行/阻塞 |
| 寄存器上下文 | CPU 现场快照 | PC、栈指针、通用寄存器 |
| 内存信息 | 地址空间布局 | 页表指针、代码段/数据段范围 |
| 资源清单 | 占用的系统资源 | 打开的文件描述符、信号掩码 |
线程由 TCB 描述,只记录自己的执行上下文(寄存器、栈指针),共享进程的地址空间和资源清单。切换进程要换整个地址空间(页表切换开销大),切换线程只换寄存器和栈。
进程状态机
进程的一生在三个状态间流转:
只有就绪态能被调度器选中运行;运行态等待 I/O 时主动让出 CPU 进入阻塞;阻塞的进程事件到达后先回就绪再排队——它不会直接回到运行态,因为 CPU 可能正在被别人用。
创建与通信
进程用 fork 创建:fork 一次调用返回两次——父进程得到子进程 PID,子进程得到 0,靠返回值区分角色。创建后子进程是父进程的复制品,随后通常用 exec 替换成新程序(Linux 的 fork + exec 组合,见 Linux进程)。
进程间通信(IPC)解决"隔离的进程如何协作":
| 方式 | 机制 | 适用 |
|---|---|---|
| 管道 | 一端写一端读的内存缓冲 | 父子进程、命令行 | 串联 |
| 共享内存 | 映射同一块物理内存 | 大数据量、追求速度 |
| 消息队列 | 内核维护的消息链表 | 进程解耦、异步 |
| 信号 | 异步通知事件 | 进程退出通知、Ctrl+C |
线程共享什么、独有什么
| 维度 | 线程共享 | 线程独有 |
|---|---|---|
| 内存 | 代码、堆、全局变量 | 栈、寄存器、程序计数器 |
| 资源 | 打开的文件、信号处理 | 线程 ID、调度优先级 |
共享让线程间通信不需要内核参与(直接读写同一块内存,比 IPC 快一个数量级),也带来竞态条件问题——这就是同步机制存在的理由。
线程模型
用户态线程、内核态线程、混合模型是三种实现路径:
线程库完全在用户空间实现,内核只看到一个进程:
- 优点:创建切换不经过内核,极快;不依赖内核支持
- 缺点:一个线程阻塞(如读文件),整个进程都阻塞;多核用不上
线程由内核管理(Linux 的 pthread 即此模型):
- 优点:多核并行;一个线程阻塞不影响其他线程
- 缺点:创建切换要进内核,成本高(微秒级)
用户态线程映射到少量内核线程上(Go goroutine、Java 虚拟线程):
- 优点:结合两者——用户态切换快,内核线程负责真正的并行
- 缺点:实现复杂,需要调度器(M:N 映射)
调度算法
调度器决定就绪队列里谁先运行。以三个任务为例看策略差异:任务 A 运行 2ms、任务 B 运行 4ms、任务 C 运行 8ms(同时到达):
| 算法 | 调度结果 | 平均等待时间 |
|---|---|---|
| 先来先服务 | A→B→C | (0+2+6)/3 ≈ 2.7ms |
| 短作业优先 | A→B→C(按长度排) | 同上,最优 |
| 时间片轮转 | A→B→C 轮流 | 取决于时间片 |
短作业优先的平均等待最优,但长任务可能饿死;时间片轮转响应快,但时间片太小切换开销大、太大退化成先来先服务。现代操作系统普遍用多级反馈队列综合两者:
交互型任务(输入输出频繁)天然占用 CPU 时间短,在 Q1 就能完成,获得快速响应;CPU 密集型任务逐渐降级到低优先级队列——每个任务都有机会,但交互任务优先。
同步与互斥
竞态条件
多个线程共享数据时,读写交错会产生竞态条件:两个线程同时执行 count++,由于"读-改-写"不是原子的,实际执行可能是"线程1读到 5 → 线程2读到 5 → 线程1写 6 → 线程2写 6",最终只加了一次。并发编程的几乎所有 Bug 都源于"非原子操作被并发执行"。
同步手段
| 手段 | 原理 | 适用 |
|---|---|---|
| 互斥锁 | 同一时刻只有一个线程进入临界区 | 保护共享数据(Java并发编程 的 synchronized、Lock) |
| 信号量 | 计数器控制同时访问的数量 | 控制连接池等有限资源 |
| 条件变量 | 等待某个条件成立再继续 | 生产者-消费者模型 |
信号量的经典用法是控制资源数量,比如有界缓冲区的生产者-消费者:生产者执行 empty.acquire() 检查还有没有空位,消费者执行 full.acquire() 检查有没有数据,两个信号量一进一出完成节奏控制——一个空位计数器(初始为容量 N),一个数据计数器(初始为 0)。
// 伪码:生产者-消费者节奏控制
class BoundedBuffer {
Semaphore empty = new Semaphore(N); // 有 N 个空位
Semaphore full = new Semaphore(0); // 有 0 个数据
void put(Object x) {
empty.acquire(); // 没有空位就等待
buf.add(x);
full.release(); // 数据 +1
}
Object take() {
full.acquire(); // 没有数据就等待
Object x = buf.remove();
empty.release(); // 空位 +1
return x;
}
}死锁风险
多个线程互相持有对方等待的锁时形成死锁。四个必要条件缺一不可:互斥(资源不可共享)、持有并等待(拿着一个锁等另一个)、不可剥夺(锁只能自己释放)、循环等待(A 等 B、B 等 A)。打破任何一个条件即可预防,工程上常用两种手段:固定锁顺序(所有人按同一顺序加锁,消除循环等待)和超时放弃(获取锁设超时,超时回滚释放)。
上下文切换的成本
上下文切换不是免费的:每次切换要保存/恢复寄存器、更新 PCB/TCB、可能刷 TLB。线程切换约几微秒,进程切换更贵(换页表、刷缓存)。这个成本在极端场景下的表现:每秒 10 万次线程切换,仅切换开销就占掉 CPU 近一半时间。高并发服务(如 Netty)用少量线程 + 事件驱动避免频繁切换,正是这个原因。
协程:更轻的并发单位
协程(goroutine、虚拟线程)把切换从"内核调度"降级为"用户态调度":切换只换栈和指令指针,不经过内核,切换成本从微秒降到纳秒级。代价是协程无法利用多核并行,需要配合内核线程(M:N 映射)才能吃满 CPU——这是"混合模型"的现代形态。
进程与线程的机制向上延展到具体实现与语言层:Linux 的进程模型(fork、僵尸进程、信号)见 Linux进程;语言层的并发抽象(Java并发编程 的线程池、锁)都建立在本文的底层机制之上;在 操作系统 的知识地图里,本文是 CPU 资源管理的入口。