进程与线程的区别(图解)
进程和线程是操作系统的核心概念,面试必问,理解它们对写好并发程序至关重要。
一、基本概念
进程(Process)
程序运行的实例,是资源分配的最小单位。
$ ps aux | grep nginx
root 1234 nginx: master process ← 一个进程
www 1235 nginx: worker process ← 另一个进程
线程(Thread)
进程内的执行单元,是 CPU 调度的最小单位。
一个进程可以包含多个线程,它们共享进程的资源。
┌─────────────────────────────────────┐
│ 进程 (Process) │
│ ┌─────────────────────────────┐ │
│ │ 代码段 / 数据段 │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 堆 (Heap) │ │
│ └─────────────────────────────┘ │
│ ┌─────────┬─────────┬─────────┐ │
│ │ 线程 1 │ 线程 2 │ 线程 3 │ │
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │
│ │ │ 栈 │ │ │ 栈 │ │ │ 栈 │ │ │
│ │ ├─────┤ │ ├─────┤ │ ├─────┤ │ │
│ │ │ PC │ │ │ PC │ │ │ PC │ │ │
│ │ ├─────┤ │ ├─────┤ │ ├─────┤ │ │
│ │ │寄存器│ │ │寄存器│ │ │寄存器│ │ │
│ │ └─────┘ │ └─────┘ │ └─────┘ │ │
│ └─────────┴─────────┴─────────┘ │
└─────────────────────────────────────┘
二、核心区别对比
| 维度 | 进程 | 线程 |
| ---- | ---- | ---- |
| 资源 | 独立地址空间 | 共享进程资源 |
| 开销 | 创建/销毁开销大 | 创建/销毁开销小 |
| 切换 | 上下文切换慢 | 上下文切换快 |
| 通信 | IPC(管道、消息队列等) | 直接读写共享内存 |
| 安全性 | 一个进程崩溃不影响其他 | 一个线程崩溃整个进程崩溃 |
| 数量 | 通常几十到几百 | 可以成百上千 |
三、上下文切换
CPU 在多个进程/线程之间快速切换,给人「同时运行」的感觉。
进程切换
需要切换:
- 虚拟地址空间(页表)
- 内核栈
- 寄存器、PC
- TLB 失效
开销大:可能消耗几千个时钟周期。
线程切换
同一进程内的线程切换:
- 不需要切换地址空间
- 只切换寄存器、PC、栈
- TLB 不失效
开销小:通常几百个时钟周期。
四、进程间通信(IPC)
由于进程内存隔离,需要专门的 IPC 机制:
| 方式 | 特点 | 场景 |
| ---- | ---- | ---- |
| 管道 (Pipe) | 半双工、有亲缘关系 | 父子进程 |
| 命名管道 (FIFO) | 任意进程 | 简单通信 |
| 消息队列 | 内核维护 | 异步通信 |
| 共享内存 | 最快 | 大数据量 |
| 信号量 | 同步互斥 | 锁机制 |
| 信号 | 异步通知 | 中断 |
| Socket | 跨网络 | 网络通信 |
共享内存示例
// 创建共享内存
int shmid = shmget(KEY, SIZE, IPC_CREAT | 0666);
char *addr = shmat(shmid, NULL, 0);
// 多进程读写同一块内存
strcpy(addr, "hello from 小宇宙");
五、线程间通信与同步
线程共享内存,通信简单,但需要同步防止数据竞争:
// 线程不安全
class Counter {
int count = 0;
public void inc() {
count++; // 非原子操作
}
}
// 加锁后安全
class Counter {
int count = 0;
synchronized public void inc() {
count++;
}
}
同步方式
- 互斥锁(Mutex):保护临界区
- 读写锁:读多写少场景
- 条件变量:等待通知
- 信号量(Semaphore):控制并发数
- 自旋锁:短临界区
六、协程(Coroutine)
更轻量级的「用户态线程」。
| 维度 | 进程 | 线程 | 协程 |
| ---- | ---- | ---- | ---- |
| 调度 | 内核 | 内核 | 用户态 |
| 切换开销 | 大 | 中 | 小 |
| 数量 | 几十 | 几百/千 | 几万/十万 |
| 编程模型 | 阻塞 | 阻塞 | 同步非阻塞 |
Go 语言的 goroutine
// 启动一个 goroutine(协程)
go func() {
fmt.Println("小宇宙在 goroutine 中")
}()
Go 的 GMP 调度器让成百万 goroutine 高效运行。
七、实战:多线程问题
1. 竞态条件
// 1000 个线程各自对 count++,结果不是 1000
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
pool.submit(() -> counter.inc());
}
// count 可能是 9xx
2. 死锁
// 线程 1:持有 A,请求 B
// 线程 2:持有 B,请求 A
synchronized(lockA) {
synchronized(lockB) { ... }
}
死锁四个必要条件:
- 互斥
- 持有并等待
- 不可剥夺
- 循环等待
3. 内存可见性
// 没用 volatile,子线程可能永远读不到 stop 的新值
boolean stop = false;
while (!stop) {
// ...
}
八、面试高频题
Q1:线程和进程的区别?
一句话:进程是资源分配的最小单位,线程是 CPU 调度的最小单位。
详细:进程独立内存,线程共享进程内存;进程开销大,线程开销小。
Q2:多线程一定比单线程快吗?
不一定。
- IO 密集型:多线程快(IO 时 CPU 可以做别的)
- CPU 密集型:线程数 > CPU 核数反而更慢(切换开销)
Q3:协程的优势?
- 用户态调度,开销极小
- 单机可创建海量协程
- 同步编程模型,更易理解
Q4:如何避免死锁?
- 破坏四个必要条件之一
- 固定加锁顺序
- 使用 tryLock + 超时
- 死锁检测
九、小结
| 概念 | 一句话 |
| ---- | ---- |
| 进程 | 资源分配单位 |
| 线程 | CPU 调度单位 |
| 协程 | 用户态轻量级线程 |
| 上下文切换 | 进程切换 > 线程切换 |
| IPC | 进程间通信 |
| 同步 | 线程间协作 |
小宇宙提醒:并发编程的本质是协调多个执行流对共享资源的访问,掌握锁和通信是关键。