进程与线程
进程:资源分配的基本单位
进程(Process)是程序的一次运行实例。程序是静态的代码文件,进程是动态的执行过程。
打一个比喻:程序就像一份菜谱(存储在磁盘上),进程就是按照菜谱做菜的过程(运行在内存中)。同一份菜谱可以同时被多个厨师使用,就像同一个程序可以启动多个进程。
进程的组成
┌─────────────────────────────┐
│ 进程 │
│ ┌───────────────────────┐ │
│ │ 代码段 (Text) │ │ ← 可执行指令(只读、共享)
│ ├───────────────────────┤ │
│ │ 数据段 (Data + BSS) │ │ ← 全局变量、静态变量
│ ├───────────────────────┤ │
│ │ 堆 (Heap) ↓ │ │ ← 动态分配(malloc/new)
│ │ │ │
│ │ 空闲空间 │ │
│ │ │ │
│ │ 栈 (Stack) ↑ │ │ ← 函数调用栈(局部变量、返回地址)
│ ├───────────────────────┤ │
│ │ 内核栈 │ │ ← 系统调用时使用
│ └───────────────────────┘ │
│ │
│ PCB (进程控制块) │ ← 进程元数据
│ - PID、状态、优先级 │
│ - 程序计数器 (PC) │
│ - 寄存器集合 │
│ - 内存映射信息 │
│ - 打开的文件描述符表 │
│ - 信号处理信息 │
└─────────────────────────────┘
进程的生命周期
fork()
┌──────┐ ┌────────┐ 被调度 ┌────────┐
│ 创建 │───▶│ 就绪 │────────────▶│ 运行 │
└──────┘ └────────┘ └────────┘
▲ │ │
│ │ │
I/O 完成 时间片到│ │ 等待 I/O
或事件到达 被抢占 │ │ 或事件
│ │ │
│ ┌─────┘ │
│ ▼ ▼
┌────────┐ 回到就绪 ┌────────┐
│ 阻塞 │◀──────────────│ 阻塞 │
└────────┘ └────────┘
│
exit() │
▼
┌────────┐
│ 终止 │
└────────┘
Linux 中的进程状态:
| 状态 | 标识 | 说明 |
|---|---|---|
| 运行/就绪 | R (Running) |
正在 CPU 上执行或在就绪队列等待调度 |
| 可中断睡眠 | S (Sleeping) |
等待事件完成(如 I/O),可被信号唤醒 |
| 不可中断睡眠 | D (Disk sleep) |
等待 I/O(通常是磁盘),不可被信号打断 |
| 停止 | T (Stopped) |
收到 SIGSTOP 信号或被调试器暂停 |
| 僵尸 | Z (Zombie) |
已终止但父进程尚未回收其退出状态 |
僵尸进程与孤儿进程
僵尸进程:子进程已经退出,但父进程没有调用 wait() 回收其退出状态,导致子进程的 PCB 仍占据进程表中的一个位置。大量僵尸进程会耗尽 PID 资源。
孤儿进程:父进程先于子进程退出,子进程被 init 进程(PID=1)收养。孤儿进程通常无害,因为 init 进程会正确回收它们。
线程:CPU 调度的基本单位
线程(Thread)是进程内部的一条执行流。一个进程可以包含多个线程,它们共享进程的地址空间和资源(代码段、数据段、堆、打开的文件),但各自拥有独立的栈和寄存器。
┌──────────────── 进程 ────────────────┐
│ │
│ ┌──────────────────────────────┐ │ ← 共享区域
│ │ 代码段 │ 数据段 │ 堆 │ 文件表 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │ ← 各线程私有
│ │ 线程 1 │ │ 线程 2 │ │ 线程 3 │ │
│ │ ┌────┐ │ │ ┌────┐ │ │ ┌────┐ │ │
│ │ │ 栈 │ │ │ │ 栈 │ │ │ │ 栈 │ │ │
│ │ ├────┤ │ │ ├────┤ │ │ ├────┤ │ │
│ │ │寄存器│ │ │ │寄存器│ │ │ │寄存器│ │ │
│ │ ├────┤ │ │ ├────┤ │ │ ├────┤ │ │
│ │ │ PC │ │ │ │ PC │ │ │ │ PC │ │ │
│ │ └────┘ │ │ └────┘ │ │ └────┘ │ │
│ └────────┘ └────────┘ └────────┘ │
└──────────────────────────────────────┘
进程 vs 线程
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立的虚拟地址空间 | 共享进程的地址空间 |
| 通信方式 | IPC(管道、共享内存等) | 直接读写共享变量 |
| 创建开销 | 大(需复制地址空间) | 小(共享大部分资源) |
| 切换开销 | 大(需切换页表、刷新 TLB) | 小(不需切换页表) |
| 崩溃影响 | 不影响其他进程 | 一个线程崩溃导致整个进程终止 |
| 并发安全 | 天然隔离 | 需要同步机制(锁) |
Linux 中的线程实现
Linux 没有专门的「线程」概念。在 Linux 内核看来,线程就是共享了地址空间的进程。pthread_create() 底层调用的是 clone() 系统调用,通过标志位控制哪些资源共享:
fork() → clone(不共享任何资源) → 新进程
pthread_create() → clone(共享地址空间、文件表等) → 新线程
上下文切换
当 CPU 从一个进程/线程切换到另一个时,需要保存当前的执行状态(称为「上下文」),并恢复目标的状态。
进程上下文切换
当前进程 A 目标进程 B
│ │
│ 1. 保存 A 的寄存器到 A 的 PCB │
│ 2. 保存 A 的栈指针 │
│ 3. 切换页表 (CR3 寄存器) │
│ 4. 刷新 TLB(地址转换缓存) │
│ 5. 恢复 B 的寄存器从 B 的 PCB │
│ 6. 恢复 B 的栈指针 │
│ │
暂停 ─────────────────────────────────▶ 恢复运行
线程上下文切换
同进程内的线程切换不需要切换页表和刷新 TLB(因为共享地址空间),所以比进程切换快得多。
| 切换类型 | 需要保存/恢复 | 大致开销 |
|---|---|---|
| 进程切换 | 寄存器 + 页表 + TLB 刷新 + 缓存失效 | 数千 CPU 周期 |
| 线程切换(同进程) | 寄存器 + 栈指针 | 数百 CPU 周期 |
协程(简介)
协程(Coroutine)是用户态的轻量级线程,由程序自身调度而非操作系统调度。
| 对比 | 线程 | 协程 |
|---|---|---|
| 调度者 | 操作系统内核 | 用户态代码 |
| 切换开销 | 需要进入内核态 | 仅保存/恢复少量寄存器 |
| 并发模型 | 抢占式(OS 随时可能切换) | 协作式(主动让出 CPU) |
| 栈大小 | 通常 1~8MB | 通常 2~8KB |
| 代表 | POSIX threads | Go goroutine、Kotlin 协程 |
生产高频题
进程和线程的区别?
进程是资源分配的基本单位,拥有独立的地址空间;线程是 CPU 调度的基本单位,同一进程的线程共享地址空间。线程创建和切换的开销比进程小得多,但一个线程崩溃会导致整个进程终止。
为什么需要多线程?
(1) 提高并发性——多个线程可以并行处理请求;(2) 共享资源——同一进程内的线程可以直接访问共享数据,比进程间通信更高效;(3) 响应性——UI 线程和工作线程分离,避免界面卡顿。
什么是僵尸进程?怎么避免?
子进程退出后,如果父进程没有调用 wait() 回收,子进程就变成僵尸进程。避免方法:(1) 父进程调用 wait()/waitpid() 回收;(2) 注册 SIGCHLD 信号处理函数;(3) 使用 fork() 两次让 init 进程收养。