系统(一):进程、线程、协程与并发
系统(一):进程、线程、协程与并发
导语:操作系统的面试主线永远是「谁在跑、怎么切换、怎么不出乱子」。本篇从进程与线程的本质区别讲起,拆开用户态/内核态的切换代价,梳理进程的五态生命周期,重点补齐僵尸进程与孤儿进程、死锁这两道高频题,再讲清 IPC 与线程同步的机制对比,最后落到协程/虚拟线程与线程实现模型,共 11 题。
一、进程与线程的本质
1. 进程与线程的区别是什么?Linux 里它们是怎么实现的?
答:
| 维度 | 进程(Process) | 线程(Thread) |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 地址空间 | 独立的虚拟地址空间 | 同一进程内共享地址空间 |
| 共享内容 | 不共享(需 IPC) | 共享:堆、全局/静态变量、代码段、打开的文件描述符、信号处理 |
| 独享内容 | 全部 | 栈、寄存器、程序计数器(PC)、线程本地存储 |
| 切换开销 | 大(要切换页表、可能刷新 TLB) | 小(同一进程内只换寄存器与栈,页表不变) |
| 通信方式 | IPC(管道、共享内存、消息队列…) | 直接读写共享内存(需同步) |
| 健壮性 | 一个进程崩溃不影响其他进程 | 一个线程崩溃(如段错误)会导致整个进程退出 |
| 创建开销 | 大(fork 需复制/COW 地址空间) | 小(clone 共享大部分资源) |
Linux 的实现(面试加分点:Linux 没有"真正的线程"):
两个高频追问:
- 「为什么线程切换比进程切换快?」 → ① 同一进程的线程共享页表,不需要切换 CR3、也不需要刷新 TLB(这是主要开销);② 共享文件表与信号处理,切换内容更少。但要注意:同一进程内线程切换仍需进入内核态(除非是用户态协程);
- 「进程能共享内存吗?」 → 能,通过 共享内存(
shmget/mmap(MAP_SHARED)) 映射同一块物理页,这是最快的 IPC(但需要自己加同步)。
2. 用户态与内核态是什么?什么时候会切换?代价有多大?
答:
CPU 通过特权级(x86 的 Ring 0~3)区分两种运行模式:
触发「用户态 → 内核态」的三种方式(必背):
| 方式 | 触发源 | 例子 |
|---|---|---|
| ① 系统调用(System Call) | 用户程序主动请求 | read/write/open/fork/execve/mmap/sendfile |
| ② 硬件中断(Interrupt) | 外部设备异步触发 | 网卡收包、磁盘 IO 完成、时钟 tick、键盘输入 |
| ③ 异常(Exception) | 当前指令执行出错 | 缺页异常(Page Fault,最常见)、除零、非法指令、断点 |
严格说,"软中断"(如
int 0x80/syscall指令)是系统调用的进入方式,而"缺页异常"属于异常。面试答"系统调用 / 中断 / 异常"三类最稳。
切换的代价(面试常问"为什么系统调用慢"):
因此产生的性能优化原则(把这三点讲出来就很有说服力):
| 优化原则 | 手段 |
|---|---|
| 减少系统调用次数 | 批量读写(writev/readv、一次读大块)、epoll 一次等待多个事件、缓冲区聚合(stdio 缓冲、BufferedWriter) |
| 减少拷贝次数 | 零拷贝(sendfile/mmap)、vmsplice、避免中间缓冲 |
| 避免不必要的进程/线程切换 | 用 IO 多路复用或协程替代"每连接一线程";减少锁竞争(避免大量线程因抢锁被唤醒/睡下) |
有一个反直觉的现代补充:JDK 21 的虚拟线程让"阻塞式写法"重新变得高效——但它的代价是阻塞在
synchronized里会 pin 住载体线程;以及 vDSO 让部分系统调用(如gettimeofday)在用户态直接完成,不需要真正陷入内核。
二、进程生命周期与僵尸/孤儿进程
3. 进程有哪几种状态?状态如何变迁?PCB 是什么?
答:
关键规则:
- 运行 → 阻塞是"主动"的(进程自己发起 IO,主动让出 CPU);
- 阻塞 → 就绪是"被动"的:阻塞的进程不能自己唤醒自己,必须由其他进程/内核在事件完成时代它唤醒(这是常考的一句话);
- 运行 → 就绪:时间片耗尽或被更高优先级任务抢占(被动);
- 阻塞和挂起不同:挂起(Suspended)指进程被换出到磁盘/交换区,这里不展开。
PCB(进程控制块):内核描述进程的数据结构(Linux 里是 task_struct),包含:
| 内容 | 说明 |
|---|---|
| 标识 | PID、PPID、TID、用户/组 ID |
| 状态 | 当前状态、退出码 |
| 寄存器现场 | 通用寄存器、PC、SP(切换时必须保存/恢复的就是它) |
| 内存管理信息 | 页表基址(CR3)、内存映射区间 |
| 资源信息 | 打开文件表、信号处理表、IO 统计 |
| 调度信息 | 优先级、时间片、调度类、vruntime |
一句话:"进程切换"本质就是"保存当前 task_struct 的现场 + 恢复下一个 task_struct 的现场"。
4. 进程的创建、终止、阻塞与唤醒分别做了什么?
答:
| 动作 | 步骤 |
|---|---|
| 创建 | ① 分配唯一 PID;② 申请空白 PCB(失败则创建失败);③ 分配资源(内存不足会进入等待);④ 初始化 PCB;⑤ 插入就绪队列 |
| 终止 | ① 找到 PCB;② 若在运行则立即终止并让出 CPU;③ 回收其资源;④ 从队列删除;⑤ 保留退出状态通知父进程(这一步关系到僵尸进程,见第 5 题) |
| 阻塞 | ① 找到 PCB;② 保护现场;③ 状态置为阻塞;④ 插入对应事件的等待队列 |
| 唤醒 | ① 从等待队列找到 PCB;② 移出并置为就绪;③ 插入就绪队列 |
创建的两个关键机制:
| 机制 | 说明 |
|---|---|
fork() + 写时复制(COW) | fork 不立即复制父进程的物理内存,而是共享页表、标记只读;任何一方写入时才复制该页。这让 fork 变得非常快(这是 Unix 设计的精髓) |
fork() vs exec() vs clone() | fork 复制出新进程;exec 替换当前进程的映像(不创建新进程,PID 不变);clone 控制共享哪些资源(线程实现) |
⚠️ 必须纠正的一处流行错误(原文档表述有误):
错误说法:「父进程终止时其所有子进程也会被终止,Linux 下孤儿进程由 1 号进程接管」——这两句自相矛盾。
正确说法(Linux/Unix):
- 父进程终止后,子进程不会被终止,它变成孤儿进程(Orphan),被 PID 1(
init/systemd)收养(ppid变为 1),之后正常继续运行,退出时由 init 负责wait回收;- "父进程死则子进程一起死"是 Windows 的 Job Object 行为,或用 Linux 的
PR_SET_PDEATHSIG(prctl) 显式设置的,不是 Linux 默认行为;- 真正的默认行为是反之:子进程死了父进程没
wait→ 子进程变成僵尸进程(见第 5 题)。这两道题经常被一起考,必须分清:「父死子未死 = 孤儿;子死父未收 = 僵尸」。
5. 僵尸进程与孤儿进程是什么?有什么危害?怎么处理?
答: 这是操作系统面试的高频必考题("进程管理"里热度最高的细分点之一)。
僵尸进程(Zombie)——"子进程死了,父进程没收尸"
| 问题 | 答案 |
|---|---|
| 为什么会有僵尸? | 内核必须保留退出状态(退出码、CPU 时间)供父进程查询,所以"进程结束"与"资源回收"是两步;父进程不 wait 就卡在中间 |
| 危害 | 单个僵尸几乎不占内存,但每个僵尸都占一个 PID 与 task_struct → 大量堆积会耗尽 PID(fork 失败:Resource temporarily unavailable) |
| 怎么产生大量僵尸 | 服务端不注意 wait:多进程模型的 worker 退出、脚本里 Popen 不 wait、父进程忙于业务不处理 SIGCHLD |
| 怎么处理 | ① 父进程正确 wait/waitpid(阻塞或 WNOHANG 轮询);② 注册 SIGCHLD 信号处理器,在处理器里循环 waitpid(-1, ..., WNOHANG);③ 两次 fork(父 → 子 → 孙,子退出,孙变孤儿由 init 收养),父进程无需 wait;④ 父进程挂了(僵尸会被 init 接管并回收,问题自动消失) |
| 能 kill 掉僵尸吗? | kill -9 无效(它已经死了,没有可杀的执行体)。只能杀掉它的父进程,让 init 接管后回收 |
孤儿进程(Orphan)——"父进程先走一步"
两者对比(一张表记住):
| 维度 | 僵尸进程(Zombie) | 孤儿进程(Orphan) |
|---|---|---|
| 谁死了 | 子进程死了,父进程还活着 | 父进程死了,子进程还活着 |
| 状态 | Z(zombie) | 正常(R/S/D) |
| ppid | 不变(仍指向父进程) | 变为 1(被 init 收养) |
| 危害 | 占 PID,堆积会耗尽 PID | 无危害(正常现象) |
| 处理 | 父进程 wait;或杀掉父进程 | 无需处理 |
| 记忆口诀 | "子死父不收 → 僵尸" | "父死子未死 → 孤儿" |
排查命令:
ps -ef | grep -w Z # 列出僵尸进程(STAT 列显示 Z / Z+)
ps -o pid,ppid,stat,comm -p <zombie_pid> # 找到它的父进程(ppid)
ps -eo stat | grep -c Z # 统计僵尸数量
cat /proc/<pid>/status # State: Z (zombie)三、通信、同步与死锁
6. 进程间通信(IPC)有哪些方式?效率如何对比?
答:
| 方式 | 原理 | 方向 | 效率 | 适用 |
|---|---|---|---|---|
| 管道 Pipe | 内核缓冲区(环形队列),半双工 | 单向 | 中(需要 2 次拷贝:写方→内核→读方) | 父子/亲缘进程,shell 的 | |
| 命名管道 FIFO | 有文件名的管道 | 单向 | 中 | 无亲缘关系的进程 |
| 消息队列 MQ | 内核维护的消息链表,有边界、可按类型取 | 双向 | 中 | 异步、需要消息边界 |
| 共享内存 SHM | 多个进程映射同一块物理页 | 双向 | 最快(零拷贝,不经过内核中转) | 大数据量高频传输;必须自己配信号量/互斥做同步 |
| 信号 Signal | 异步通知,不传数据(只传信号编号) | 单向 | — | 事件通知(kill、SIGCHLD、SIGTERM) |
| 信号量 Semaphore | 计数器,做 PV 操作 | — | — | 同步(不传数据);也常用于跨进程限流 |
| Socket | 网络协议栈 | 双向 | 中(有协议栈开销) | 最通用:可跨机、可跨语言、支持多种协议(TCP/UDP/Unix Domain Socket) |
| Unix Domain Socket(UDS) | 同机的 socket,不走网络协议栈 | 双向 | 比 TCP 环回快很多 | 同机进程通信的现代首选(Docker、Nginx、gRPC 本机通信都用它) |
| mmap(文件映射) | 映射同一文件的同一区域 | 双向 | 快 | 大文件共享、持久化共享内存 |
效率排序(面试可给结论):
三个高频追问:
- 「为什么共享内存最快?」 → 它不做数据拷贝:多个进程的页表指向同一块物理页,读写就是普通内存访问。代价是必须自己解决同步(用信号量/互斥锁);
- 「管道为什么是半双工的?能不能双向?」 → 单个管道只有一个缓冲方向;要双向就建两个管道(或用 socketpair);
- 「为什么 Docker/K8s 用 Unix Domain Socket?」 → 同机、无需网络协议栈(比 TCP 环回快)、可用文件权限做访问控制(比开放 TCP 端口更安全)。
7. 线程同步有哪些机制?各适用什么场景?
答:
| 机制 | 原理 | 适用 | 注意 |
|---|---|---|---|
| 互斥锁(Mutex) | 同一时刻只允许一个线程进入临界区,抢不到会睡眠 | 临界区较长、竞争激烈 | 有上下文切换开销;注意死锁与优先级反转 |
| 自旋锁(Spinlock) | 抢不到就忙等(忙轮询),不睡眠 | 临界区极短(几行代码)、多核 | 在单核上无意义(自旋会白占 CPU);临界区不能睡眠 |
| 读写锁(RWLock) | 读共享、写独占 | 读多写少 | 写者可能饥饿;读锁不可重入嵌套(易死锁) |
| 条件变量(Condition Variable) | 等待某个条件成立时被唤醒(必须配互斥锁) | 生产者-消费者、等待队列 | 必须用 while 循环判断条件(防虚假唤醒),不能用 if |
| 信号量(Semaphore) | 计数器 + PV 操作 | 控制并发数量(限流)、资源池 | 计数信号量 ≠ 互斥锁(后者是二元信号量) |
| 屏障(Barrier) | 等齐 N 个线程再一起继续 | 分阶段并行计算 | 少用 |
| 原子变量 / CAS | 用 CPU 原子指令(cmpxchg)无锁更新 | 计数器、标志位、无锁队列 | ABA 问题、只能保护单个变量、自旋开销 |
三个高频追问:
- 「自旋锁和互斥锁怎么选?」 → 临界区比"一次上下文切换"还短(约几微秒)时用自旋;否则用互斥锁(自旋会浪费 CPU)。临界区里绝不能有阻塞操作(自旋锁会被持有一整个睡眠期,灾难性);
- 「条件变量为什么必须在
while里判断?」 → ① 防虚假唤醒(spurious wakeup);② 被唤醒时条件可能已被其他线程改变。标准写法:
pthread_mutex_lock(&m);
while (!condition) { // ★ while 而不是 if
pthread_cond_wait(&cv, &m); // 原子地释放锁并睡眠,被唤醒时重新持锁
}
/* 处理临界区 */
pthread_mutex_unlock(&m);- 「CAS 有什么问题?」 → ABA 问题(值从 A 改回 A,CAS 认为没变过)→ 用版本号/时间戳(
AtomicStampedReference);以及自旋失败率高时开销大(高竞争下性能可能不如锁)。
8. 死锁的四个必要条件是什么?如何预防、避免、检测与解除?
答: 操作系统面试必考,原文档只有一句话,这里展开成完整答案。
死锁:一组进程互相持有对方需要的资源,且都在等待对方的资源,导致永久阻塞。
四个必要条件(必须同时满足才可能死锁,"破坏任一即可防死锁"):
| 条件 | 含义 | 破坏手段 |
|---|---|---|
| ① 互斥(Mutual Exclusion) | 资源同一时刻只能被一个进程占用 | 让资源可共享(如只读数据用无锁结构读写)——但互斥锁本身很难破 |
| ② 占有且等待(Hold and Wait) | 持有资源的同时去申请其他资源 | 一次性申请全部资源(申请不到就释放已持有的) |
| ③ 不可剥夺(No Preemption) | 资源不能被强行夺走,只能自愿释放 | 允许抢占(申请不到时释放自己已占用的) |
| ④ 循环等待(Circular Wait) | 存在一个"进程 → 资源 → 进程"的环形等待链 | 按固定顺序加锁(最实用的工程手段) |
四大处理策略:
| 策略 | 做法 | 优缺点 |
|---|---|---|
| 预防(Prevention) | 破坏上述四个条件之一 | 简单但牺牲并发(如"一次性申请全部资源"会降低资源利用率) |
| 避免(Avoidance) | 运行时动态判断"这次分配会不会导致死锁"再决定是否分配(银行家算法) | 理论完美,实际很少用(需要预知最大需求、计算开销大) |
| 检测 + 解除(Detection & Recovery) | 允许死锁发生,定期检测资源分配图是否有环,发现后解除(剥夺资源、回滚进程、杀死进程) | 开销大、可能丢工作;DB 的死锁检测就走这条路 |
| 鸵鸟策略(Ignore) | 假设死锁极少发生,不管它(重启了事) | Unix/Linux 通用操作系统的实际选择 |
工程上最有效的四条实战手段(面试重点):
排查死锁的实操(加分点):
# Java:直接生成线程转储,JVM 会**自动检测并打印**死锁
jstack <pid> | grep -A 30 "Found one Java-level deadlock"
# 输出示例:
# Found one Java-level deadlock:
# "Thread-1": waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
# which is held by "Thread-0"
# "Thread-0": waiting to lock monitor ... which is held by "Thread-1"
# C/C++:用 gdb 查看所有线程栈,找互相等待的 pthread_mutex_lock
gdb -p <pid> -ex "thread apply all bt" -ex detach
# 通用:看进程是否长期处于 D 状态(不可中断睡眠)——常伴随 IO/锁等待
ps -eo pid,stat,wchan:30,comm | grep '^ *[0-9]* D'9. 协程是什么?与线程有什么区别?虚拟线程解决了什么问题?
答:
| 维度 | 线程 | 协程(Coroutine) |
|---|---|---|
| 调度者 | 内核(抢占式,时间片轮转) | 用户态运行时(协作式,主动让出) |
| 切换开销 | 需陷入内核、保存寄存器上下文(微秒级) | 纯用户态,只保存少量寄存器(纳秒级) |
| 内存占用 | 每线程栈 1MB 起(默认),1 万线程 = 10GB | 每协程栈 KB 级(初始 2KB 且可增长) |
| 并发规模 | 千~万级 | 十万~百万级 |
| 并行能力 | 可跑在多核 | 单个线程内的协程无法并行(需多线程 × 多协程) |
| 阻塞影响 | 只阻塞自己 | 一个协程阻塞整个线程(若用了阻塞系统调用) |
"协作式"的含义与代价(面试要点):
Java 虚拟线程(Project Loom,JDK 21 正式 GA)解决了什么:
虚拟线程的四个实战坑(体现"用过"而非"背过"):
| 坑 | 说明 | 解法 |
|---|---|---|
synchronized 会 pin 住载体线程 | 在 synchronized 块内阻塞时,虚拟线程无法卸载(JDK 21) | 改用 ReentrantLock(JDK 24 起 synchronized 也已支持卸载) |
| 不要用固定大小线程池装虚拟线程 | 池化会让虚拟线程退化成平台线程 | 用 Executors.newVirtualThreadPerTaskExecutor()(每任务一个) |
ThreadLocal 慎用 | 百万虚拟线程 × 每份 ThreadLocal 副本 = 内存爆炸 | 改用 ScopedValue(JDK 21 预览) |
| CPU 密集型任务无收益 | 虚拟线程只解决"等待",不提升算力 | CPU 密集仍用平台线程池(大小 ≈ 核数) |
对照记忆:Go 的 goroutine 是运行时(GMP 模型)调度的协程,且自带网络 IO 的非阻塞化(netpoller);Java 虚拟线程由 JVM 调度,把阻塞点交给 Loom 的调度器处理。共同本质:把"每连接一线程"的内存与切换成本降到极低。
10. 线程有哪三种实现模型?上下文切换的具体开销来自哪里?
答:
三种实现模型:
| 模型 | 用户线程 : 内核线程 | 优点 | 缺点 | 例子 |
|---|---|---|---|---|
| 多对一(用户级) | N : 1 | 切换极快(不陷入内核)、可创建海量 | 一个线程阻塞整个进程;无法利用多核 | 早期 Java 绿线程、协程 |
| 一对一(内核级) | 1 : 1 | 阻塞不影响他人、可并行多核 | 创建/切换开销大、数量受限 | Linux 的 pthread、Java 平台线程 |
| 多对多(混合) | M : N | 兼顾轻量与并行、阻塞可被运行时处理 | 实现复杂 | Go 的 GMP、Java 虚拟线程(M 虚拟线程 : N 载体线程) |
Linux 用的是"一对一"(
pthread→clone→ 一个 LWP),所以线程数量大时开销明显——这正是协程/虚拟线程要解决的问题。
上下文切换(Context Switch)的开销来源(逐条说清):
因此:"减少上下文切换"是高并发系统的核心优化目标:
| 手段 | 效果 |
|---|---|
| IO 多路复用替代"每连接一线程" | 用少量线程管海量连接,切换次数骤降 |
| 协程/虚拟线程 | 切换在用户态完成,成本降低 1~2 个数量级 |
| 无锁/减小锁粒度 | 减少线程因抢锁而睡眠/唤醒(锁竞争是隐形的切换大户) |
绑定 CPU(taskset/CPU affinity) | 减少跨核迁移导致的缓存失效 |
| 批量处理 | 用更少的系统调用与唤醒完成更多工作 |
查看切换情况(实操):
vmstat 1 # cs 列 = 每秒上下文切换次数
pidstat -w 1 # 每进程的 cswch(自愿,等 IO/锁)与 nvcswch(非自愿,被抢占)
cat /proc/<pid>/status | grep -i ctxt # 该进程累计切换次数面试延伸:「为什么
nvcswch(非自愿切换)高说明 CPU 竞争激烈?」→ 因为非自愿切换 = 时间片用完被强制换出,说明线程数远超 CPU 核数,此时应减少线程数或优化任务粒度(而不是继续加线程)。
四、面试总结
11. 综合:如何为一个高并发服务选择并发模型?
答: 把本篇与《网络(四)》串起来的一道综合题,答题框架:
一条永恒的原则:
"并发模型的选择,本质是在'开发复杂度、资源开销、可观测性'三者间取舍。"
用线程最省心、用协程最省资源、用 Netty 最省内存但最难调试——没有一个模型在所有维度都占优。
下一篇:《系统(二)》进入内存管理——虚拟内存与分页机制、TLB 与多级页表、缺页中断的三类、页面置换算法(FIFO/OPT/LRU/Clock)与 Belady 异常、堆栈与
malloc的brk/mmap分配、内存碎片、泄漏与 OOM 排查、页缓存与write是否落盘、mmap的应用。
