💻CS 操作系统
进程与线程
难度:⭐ | 高频指数:🔥🔥🔥
面试回答
常见问法
- 进程和线程有什么区别?
- 线程之间共享什么,不共享什么?
- 什么是协程?和线程有什么区别?
- 上下文切换的代价是什么?
- fork 做了什么?
- 什么是线程安全?
回答
进程是操作系统资源分配的基本单位,线程是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享进程的地址空间和资源。
核心区别:
| 对比项 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立地址空间、文件描述符、信号处理 | 共享进程地址空间 |
| 调度 | 操作系统调度单位(重量级) | CPU 调度单位(轻量级) |
| 创建开销 | 大(需要复制页表等) | 小(共享大部分资源) |
| 通信 | 需要 IPC 机制 | 直接读写共享内存 |
| 隔离性 | 强(一个崩溃不影响其他) | 弱(一个线程崩溃整个进程挂) |
线程共享与不共享:
- 共享:代码段、数据段、堆、文件描述符、信号处理方式、进程 ID
- 不共享:栈、寄存器、线程 ID、errno、信号掩码
协程是用户态的轻量级线程,由程序自己调度(而非操作系统),切换不需要陷入内核,开销极小。典型实现如 Go 的 goroutine、C++20 的 coroutine。
追问
1. 用户态线程 vs 内核态线程?
- 用户态线程:由用户空间的线程库管理,内核不感知。优点是切换快,缺点是一个线程阻塞会导致整个进程阻塞。
- 内核态线程:由操作系统内核管理和调度。优点是真正并行,缺点是切换需要陷入内核。
- 现代 Linux 的 pthread 是 1:1 模型(一个用户线程对应一个内核线程)。
2. 上下文切换的代价?
- 保存/恢复寄存器、程序计数器、栈指针
- 刷新 TLB(进程切换时)
- CPU 缓存失效(cache miss 增加)
- 进程切换比线程切换代价更大(因为要切换页表、刷新 TLB)
3. fork 做了什么?
- 创建子进程,复制父进程的地址空间(实际用 Copy-on-Write)
- 子进程获得父进程的文件描述符副本
- fork 返回两次:父进程返回子进程 PID,子进程返回 0
- COW 机制:fork 时不真正复制物理页,只有写入时才复制
4. 什么是线程安全?
一个函数或对象在多线程环境下被并发调用时,不需要额外同步就能保证正确性,就是线程安全的。实现方式包括:互斥锁、原子操作、无共享状态、线程局部存储。
原理展开
1. 进程的内存布局
高地址
┌──────────────┐
│ 内核空间 │ 用户不可访问
├──────────────┤
│ 栈 │ 局部变量、函数调用帧(向下增长)
│ ↓ │
│ │
│ ↑ │
│ 堆 │ 动态分配(向上增长)
├──────────────┤
│ BSS 段 │ 未初始化全局/静态变量
├──────────────┤
│ 数据段 │ 已初始化全局/静态变量
├──────────────┤
│ 代码段 │ 可执行指令(只读)
└──────────────┘
低地址
每个线程有自己独立的栈,但共享堆和全局数据。
2. 线程的实现模型
三种映射模型:
- 1:1 模型(Linux pthread):每个用户线程对应一个内核线程。调度由内核完成,能利用多核。
- N:1 模型:多个用户线程映射到一个内核线程。切换快但不能真正并行。
- M:N 模型(Go goroutine):M 个用户线程映射到 N 个内核线程。兼顾轻量和并行。
3. 协程的本质
线程切换:用户态 → 内核态 → 调度 → 内核态 → 用户态(微秒级)
协程切换:保存寄存器 → 跳转到另一个协程(纳秒级)
协程的核心特点:
- 协作式调度(主动让出,而非被抢占)
- 不需要内核参与
- 一个线程上可以跑成千上万个协程
- 适合 IO 密集型场景(等待时切换到其他协程)
C++20 协程是无栈协程(stackless),通过编译器变换实现状态机。
4. 上下文切换详细流程
线程切换(同进程内):
- 保存当前线程的寄存器到 TCB(Thread Control Block)
- 加载目标线程的寄存器
- 切换栈指针
- 不需要切换页表(同一地址空间)
进程切换:
- 保存当前进程的寄存器到 PCB(Process Control Block)
- 切换页表基址寄存器(CR3)
- 刷新 TLB
- 加载目标进程的寄存器
- CPU 缓存大概率失效
代价量化:
- 线程切换:约 1-10 微秒
- 进程切换:约 10-100 微秒(主要是 TLB 和缓存失效)
- 协程切换:约 10-100 纳秒
5. fork 与 Copy-on-Write
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("I am child, PID=%d\n", getpid());
} else if (pid > 0) {
// 父进程
printf("I am parent, child PID=%d\n", pid);
} else {
// fork 失败
perror("fork");
}
COW 机制:
- fork 后父子进程共享相同的物理页,页表项标记为只读
- 任何一方尝试写入时触发缺页中断
- 内核复制该页,修改页表,然后允许写入
- 好处:如果子进程马上 exec,就不需要复制任何页
6. 多线程 vs 多进程选型
| 场景 | 推荐 | 原因 |
|---|---|---|
| 计算密集、需要共享大量数据 | 多线程 | 共享内存,通信方便 |
| 需要强隔离(如浏览器标签页) | 多进程 | 一个崩溃不影响其他 |
| IO 密集、高并发连接 | 多线程 + IO 多路复用 | 线程开销小 |
| 需要利用多核 | 多线程或多进程都可 | 取决于隔离需求 |
易错点
- 说”线程有自己的地址空间”——错,线程共享进程的地址空间,只有栈是独立的。
- 混淆”并发”和”并行”——并发是逻辑上同时,并行是物理上同时(多核)。
- 认为协程能利用多核——单个线程上的协程不能并行,需要配合多线程。
- 说 fork 会复制所有内存——实际是 COW,只在写入时才复制。
- 忘记线程切换也有开销——虽然比进程切换小,但频繁切换仍然影响性能。
- 说”线程安全就是加锁”——加锁只是手段之一,无共享状态、原子操作也能实现线程安全。
记忆技巧
- 进程 vs 线程一句话:进程是资源容器,线程是执行单元
- 共享口诀:堆代码数据文件共享,栈寄存器线程ID独立
- 三种线程模型:1:1(Linux)、N:1(绿色线程)、M:N(Go)
- 切换代价排序:协程 < 线程 < 进程
- fork 记忆:返回两次、COW、子进程返回 0
面试速答版
进程是资源分配的基本单位,线程是 CPU 调度的基本单位。线程共享进程的地址空间(堆、代码段、数据段、文件描述符),不共享栈和寄存器。进程切换代价大(要切页表、刷 TLB),线程切换代价小(同一地址空间)。协程是用户态的轻量级线程,切换不需要内核参与,开销极小,适合 IO 密集场景。fork 通过 COW 创建子进程,不会立即复制物理内存。线程安全是指多线程并发访问时不需要额外同步就能保证正确性。
Related · 操作系统