开篇导读
进程和线程是操作系统中最核心的两个概念,也是后端开发、系统编程、嵌入式开发以及各类大厂面试中必考的高频知识点。很多同学在学习时容易把二者混为一谈,或者在面对追问「进程和线程到底有什么区别」「线程切换为什么比进程切换快」「多线程一定比单线程快吗」这类问题时答得不够深入。
本文用八股文的方式,系统梳理进程和线程相关的全部核心考点,涵盖基础概念、生命周期、PCB、线程模型、进程间通信、CPU 调度、同步互斥、信号量、管程、经典同步问题、死锁、线程池、协程以及高频面试题。全文力求覆盖全面、逻辑清晰,适合面试前系统复习,也适合作为操作系统课程的辅助笔记。
建议阅读顺序:先理解进程和线程的底层定义,再掌握状态转换和调度,最后攻克同步互斥与死锁这两个公认的难点。文中所有代码示例都尽量保持简洁,方便在面试现场手写。
1. 进程的基本概念
进程是操作系统进行资源分配和调度的基本单位。简单来说,进程就是正在运行中的程序。程序是静态的指令集合,存放在磁盘上;进程是程序的一次动态执行过程,它拥有独立的地址空间、内存、文件描述符等资源。
一个进程通常包含以下组成部分:
- 代码段(Text Segment):存放程序的机器指令,通常是只读的。
- 数据段(Data Segment):存放已初始化的全局变量和静态变量。
- BSS 段:存放未初始化的全局变量和静态变量,程序加载时会被清零。
- 堆(Heap):动态内存分配区域,由程序员通过 malloc、new 等主动管理。
- 栈(Stack):存放函数调用栈帧、局部变量、返回值地址等,由编译器自动管理。
进程具有三个核心特征:动态性、并发性和独立性。动态性指进程由创建而生、由调度而执行、由撤销而消亡;并发性指多个进程可以在宏观上同时推进;独立性指每个进程拥有独立的资源空间,互不干扰。
面试中常问的一个问题是:「程序和进程的区别是什么?」标准回答是:程序是静态的、存储在磁盘上的指令集合,本身没有生命周期;进程是程序在内存中的动态执行实体,拥有独立的资源和控制块,具有生命周期。一个程序可以对应多个进程,例如同时打开多个浏览器窗口,执行的是同一套程序,但它们是不同的进程。
2. 进程的状态与生命周期
进程从创建到消亡,会经历多种状态。经典的进程状态模型包括三态模型、五态模型和七态模型。
2.1 三态模型
三态模型是最基础的状态划分,包含以下三种状态:
- 就绪态(Ready):进程已经获得除 CPU 之外的所有必要资源,只要获得 CPU 就可以立即运行。
- 运行态(Running):进程正在占用 CPU 执行指令。
- 阻塞态(Blocked / Waiting):进程正在等待某个事件发生,例如等待 I/O 完成、等待信号量、等待用户输入,此时即使把 CPU 分配给它也无法继续执行。
三种状态之间的转换关系如下:就绪态进程被调度器选中后进入运行态;运行态进程时间片用完或被更高优先级进程抢占时回到就绪态;运行态进程发起 I/O 请求或等待某个事件时进入阻塞态;阻塞态进程等待的事件发生后回到就绪态。
2.2 五态模型
五态模型在三态模型的基础上增加了两个状态:
- 创建态(New):进程正在被创建,操作系统为其分配 PCB、初始化资源,但尚未进入就绪队列。
- 终止态(Terminated):进程执行完毕或被强制终止,操作系统正在回收其资源,尚未完全消亡。
2.3 七态模型
七态模型进一步引入了挂起操作,区分了「就绪挂起」和「阻塞挂起」两种状态。挂起通常是因为内存紧张,操作系统把某些进程从内存中调出到外存,以腾出内存空间。挂起状态的进程暂时不参与 CPU 调度,需要被激活后才能重新进入就绪态。
面试中常问:「进程从运行态能直接变为阻塞态吗?能直接变为就绪态吗?」答案是:运行态可以主动发起系统调用后进入阻塞态;运行态可以因为时间片用完或被抢占而进入就绪态。但阻塞态不能直接进入运行态,必须先回到就绪态,因为阻塞进程虽然等到了事件,但仍需要排队等待 CPU。
3. 进程控制块 PCB
进程控制块(Process Control Block,PCB)是操作系统用于描述和管理进程的数据结构,是进程存在的唯一标志。操作系统通过 PCB 感知进程的存在,并通过 PCB 中的信息对进程进行调度、管理和控制。
一个典型的 PCB 包含以下四类信息:
- 进程标识符信息:进程 ID(PID)、父进程 ID(PPID)、用户标识符(UID)、进程组 ID 等。
- 处理机状态信息:程序计数器(PC)、通用寄存器、栈指针、程序状态字(PSW)等,这些信息在上下文切换时需要被保存和恢复。
- 进程调度信息:进程状态、优先级、阻塞原因、时间片大小、调度队列指针等。
- 进程控制信息:程序和数据的地址、同步与通信机制、资源清单、链接指针、打开的文件列表等。
PCB 的组织方式主要有线性方式、链接方式(链表)和索引方式。现役操作系统通常使用链表把 PCB 组织成不同的队列,例如就绪队列、阻塞队列和运行队列。Linux 内核用 task_struct 结构体来表示 PCB,每个进程或线程在内核中都对应一个 task_struct。
面试高频追问:「为什么说 PCB 是进程存在的唯一标志?」因为操作系统对进程的一切管理,本质上都是通过读取和修改 PCB 来实现的。当 PCB 被创建时,进程诞生;当 PCB 被销毁时,进程消亡。即使进程的代码和数据还在内存中,只要 PCB 不存在,操作系统就认为该进程已经不存在了。
4. 进程的创建、终止与阻塞唤醒
4.1 进程的创建
进程的创建通常由三种事件触发:系统初始化时创建第一个进程(如 init 进程或 systemd);运行中的进程通过系统调用创建子进程;用户主动启动一个应用程序。
进程创建流程大致如下:
- 申请一个空白的 PCB,并为新进程分配唯一的 PID。
- 为新进程分配必要的资源,如内存空间、栈空间。
- 初始化 PCB,包括设置进程状态为创建态、填充标识信息、设置优先级。
- 将新进程插入就绪队列,等待调度执行。
4.2 fork 与 exec
在类 Unix 系统中,创建进程主要依靠 fork 系统调用。fork 会创建一个与父进程几乎完全相同的子进程,子进程拥有父进程代码段、数据段、堆和栈的副本。fork 的返回值非常特殊:在父进程中返回子进程的 PID,在子进程中返回 0。因此可以通过判断返回值让父子进程执行不同逻辑。
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid < 0) { printf("fork failed\n"); } else if (pid == 0) { printf("child process, pid = %d\n", getpid()); } else { printf("parent process, child pid = %d\n", pid); } return 0; }exec 系列系统调用则用于替换当前进程的映像,即用一个新的程序覆盖当前进程的代码段、数据段、堆和栈,但 PID 保持不变。典型的用法是「先 fork 再 exec」:父进程 fork 出一个子进程,子进程调用 exec 执行新程序,父进程则继续自己的逻辑。这种组合方式也是 shell 执行命令的底层原理。
现代 Linux 的 fork 使用写时复制(Copy On Write,COW)技术优化。fork 时并不真正复制父进程的内存,而是让父子进程共享同一份物理内存,并把这些内存页标记为只读。当任一进程试图修改某个页面时,才触发缺页异常,内核为该页复制一份副本。这样既保证了父子进程的独立性,又大幅减少了 fork 的开销。
4.3 进程的终止
进程终止的常见原因包括:正常执行完毕;调用 exit 主动退出;被信号杀死;发生致命错误(如段错误、除零错误);被其他进程调用 kill 终止。进程终止后,操作系统会回收其占用的资源、关闭打开的文件、释放内存,并最终删除 PCB。
这里有一个重要的知识点是僵尸进程和孤儿进程。僵尸进程是指子进程已经结束,但父进程没有调用 wait 或 waitpid 回收它的状态,导致子进程的 PCB 仍然残留在系统中。僵尸进程本身不占用内存和 CPU,但会占用 PID 资源,大量僵尸进程可能导致系统无法创建新进程。孤儿进程是指父进程先于子进程结束,此时子进程会被 init 或 systemd 进程收养,由它负责后续的回收工作,因此孤儿进程一般不会造成资源泄漏。
4.4 进程的阻塞与唤醒
进程的阻塞是进程自身的主动行为。当进程发起 I/O 请求、等待某个事件或者请求的资源暂时不可用时,它主动调用阻塞原语,把自己的状态从运行态改为阻塞态,并把 PCB 插入对应的阻塞队列,然后触发一次调度让出 CPU。进程的唤醒则由其他进程或系统事件触发,唤醒原语会把目标进程从阻塞队列中移出,将其状态改为就绪态并插入就绪队列。
5. 线程的基本概念
线程是操作系统进行调度的最小单位,也被称为轻量级进程(Lightweight Process,LWP)。一个进程可以包含多个线程,这些线程共享进程的地址空间和大部分资源,但每个线程拥有独立的程序计数器、寄存器和栈。
引入线程的主要原因是为了提高系统的并发性和响应能力。传统进程模型下,进程既是资源分配单位,又是调度执行单位,创建、销毁和切换进程的开销都比较大。如果把进程比作一个车间,线程就是车间里的工人,多个工人可以在同一个车间里共享工具和材料,并发地完成不同任务。
线程和进程一样,也有就绪、运行、阻塞等基本状态,也有自己的控制块 TCB(Thread Control Block),用于保存线程标识、程序计数器、寄存器状态、栈指针和线程优先级等信息。
在多线程程序中,多个线程共享进程的堆、全局变量、静态变量、打开的文件和地址空间,但每个线程的栈是独立的。这意味着线程之间的通信非常方便,直接读写共享变量即可,但也带来了数据竞争和同步问题,必须借助锁、信号量等机制来保证正确性。
6. 进程与线程的核心区别
「进程和线程的区别」是八股文中的经典题目,几乎是应届生面试的必考题。建议从以下几个维度全面作答:
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 资源分配的基本单位,拥有独立地址空间和系统资源 | 不拥有系统资源,共享所属进程的资源 |
| 调度 | 传统上是调度的基本单位 | 现代操作系统中是 CPU 调度的基本单位 |
| 地址空间 | 每个进程拥有独立的虚拟地址空间 | 同一进程的多个线程共享地址空间 |
| 创建和销毁开销 | 开销大,需要分配独立资源 | 开销小,只需分配栈和少量私有数据 |
| 切换开销 | 需要切换页表、刷新 TLB,开销大 | 同进程内切换不需要切换地址空间,开销小 |
| 通信方式 | 需要借助 IPC 机制,如管道、消息队列、共享内存 | 直接读写共享变量即可,但需要同步机制 |
| 健壮性 | 一个进程崩溃不会影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
| 独立性 | 独立性强,互不干扰 | 独立性弱,相互影响 |
除了表格中的要点,还应该回答为什么线程的创建、销毁和切换开销更小。原因在于:创建线程不需要重新分配整个地址空间,只需分配栈和少量控制结构;同进程线程切换时,页表基址寄存器不需要改变,TLB 缓存依然有效,而进程切换则需要切换页表并导致大量 TLB 失效;线程间通信直接访问共享内存,不需要经过内核的系统调用。
面试官还可能追问:「进程切换一定比线程切换慢吗?」这里需要注意,如果线程属于同一个进程,切换时不需要切换地址空间,确实更快;但如果讨论的是内核级线程,线程切换本质上是内核调度切换 task_struct,开销仍然存在,只是少了地址空间切换这一部分。
7. 用户级线程与内核级线程
根据线程的实现位置,线程可以分为用户级线程(User-Level Threads,ULT)和内核级线程(Kernel-Level Threads,KLT)。这两种实现方式各有优劣,是八股文的重要考点。
7.1 用户级线程
用户级线程完全在用户空间实现,由用户态的线程库(如早期的 POSIX Pthreads 用户态实现)负责创建、调度和管理。内核完全感知不到用户级线程的存在,它只看到一个普通的进程。
用户级线程的优点包括:
- 线程切换不需要陷入内核,开销极小。
- 调度算法可以由应用自定义,灵活性强。
- 不依赖操作系统内核,可以在不支持线程的操作系统上实现。
用户级线程的致命缺点包括:
- 如果某个线程发起阻塞式系统调用,整个进程都会被阻塞,因为内核不知道其他线程的存在,无法调度执行它们。
- 多个用户级线程无法真正并行运行在多核处理器上,因为内核只管理和调度进程。
- 线程切换时需要手动保存和恢复上下文,实现复杂。
7.2 内核级线程
内核级线程由操作系统内核直接创建、调度和管理。内核为每个线程维护独立的 TCB,线程切换由内核完成。Windows 的线程和现代 Linux 的线程都属于内核级线程。
内核级线程的优点包括:
- 多核处理器上可以实现真正的并行执行。
- 一个线程阻塞不影响同进程的其他线程。
- 内核可以直接管理线程的调度和优先级。
内核级线程的缺点是:线程的创建、销毁和切换都需要进入内核态,系统调用开销较大。
7.3 用户级线程与内核级线程对比
| 对比维度 | 用户级线程 | 内核级线程 |
|---|---|---|
| 实现位置 | 用户空间线程库 | 操作系统内核 |
| 内核感知 | 内核不可见 | 内核可见且可管理 |
| 切换开销 | 小,无需陷入内核 | 大,需要内核参与 |
| 阻塞系统调用 | 会阻塞整个进程 | 只阻塞当前线程 |
| 多核支持 | 不能真正并行 | 可以真正并行 |
| 调度粒度 | 应用自定义 | 内核统一调度 |
8. 多线程模型
在实际系统中,用户级线程和内核级线程通常组合使用,形成不同的多线程模型。主要有一对一模型、多对一模型和多对多模型三种。
8.1 多对一模型
多个用户级线程映射到一个内核级线程。线程管理在用户空间完成,效率高,但一个线程阻塞会导致整个进程阻塞,且无法利用多核并行。早期的 Green Threads 就采用了这种模型。
8.2 一对一模型
每个用户级线程都映射到一个内核级线程。这种模型真正实现了并行,一个线程阻塞不影响其他线程,但每创建一个用户线程都需要创建对应的内核线程,开销较大。Linux 的 pthread、Windows 线程都属于这种模型。
8.3 多对多模型
多个用户级线程以多路复用的方式映射到较少数量的内核级线程。这种模型兼顾了并发能力和系统开销,既允许应用创建大量用户线程,又不会给内核造成过大压力。同时,当一个用户线程阻塞时,其他用户线程可以被调度到其他内核线程上执行。不过多对多模型实现复杂度较高,目前主流商用系统的应用相对较少。
Java 的线程模型在不同平台和版本上有所差异。在 Linux 平台上,HotSpot JVM 的 Java 线程通常一对一映射到内核线程,即通过 pthread 库创建。也正因为如此,Java 线程的创建和切换开销都相对较高,高并发场景下后来发展出了线程池、虚拟线程(协程)等优化方案。
9. 进程间通信 IPC
由于每个进程拥有独立的地址空间,进程之间不能直接访问对方的数据,因此需要借助操作系统提供的进程间通信(Inter-Process Communication,IPC)机制来交换数据。这是面试中极其重要的考点,务必掌握每种方式的原理、优缺点和适用场景。
9.1 管道(Pipe)
管道是一种半双工的通信方式,数据只能单向流动。管道本质上是一个内核缓冲区,通信双方分别从管道的读端和写端进行操作。管道分为匿名管道和命名管道。
匿名管道通常用于父子进程或兄弟进程之间的通信,因为它没有名字,只能通过继承文件描述符的方式传递。命令ps aux | grep java中的竖线就是匿名管道的典型应用。命名管道(FIFO)在文件系统中有对应的路径名,任意两个不相关的进程也可以通过它通信。
管道的缺点在于它是半双工的,需要双向通信时就得创建两条管道;同时管道内的数据是无格式的字节流,接收方需要自行解析消息边界。
9.2 消息队列(Message Queue)
消息队列是存放在内核中的消息链表,每个消息都有固定的格式,包括类型和数据。发送进程把消息放入队列,接收进程从队列中取走消息。消息队列克服了管道只能传输无格式字节流的问题,支持消息的边界和类型区分,同时消息队列可以双向通信且不要求通信双方同时在线。
消息队列的缺点是消息有大小限制,且消息从用户态拷贝到内核态存在拷贝开销。与管道类似,消息队列的容量也受内核限制,不适合传输大数据量。
9.3 共享内存(Shared Memory)
共享内存是最快的 IPC 方式,没有之一。它允许多个进程把同一块物理内存映射到各自的虚拟地址空间中,之后这些进程就可以像访问普通内存一样直接读写共享数据,不需要经过内核中转。
共享内存的缺点是它本身不提供任何同步机制,多个进程同时读写同一块内存会产生数据竞争,因此通常需要配合信号量或互斥锁使用。
9.4 信号量(Semaphore)
信号量本质上是一个计数器,用于解决进程间的同步和互斥问题。它既可以作为同步工具,也可以作为通信工具。信号量的操作包含 P 操作(等待)和 V 操作(信号),P 操作会尝试把信号量减一,如果结果小于零则阻塞当前进程;V 操作会把信号量加一,并唤醒一个等待的进程。关于信号量的详细原理,后文会有专门章节展开。
9.5 信号(Signal)
信号是一种异步通信机制,用于通知目标进程发生了某个事件。例如,用户按下 Ctrl+C 会向进程发送 SIGINT 信号,Kill 命令可以向进程发送 SIGKILL 或 SIGTERM 信号,段错误会触发 SIGSEGV 信号。进程可以注册信号处理函数来响应信号,也可以选择忽略某些信号。信号承载的信息量很小,主要用于事件通知,不适合传输大量数据。
9.6 套接字(Socket)
套接字是网络通信的基石,也是进程间通信的一种重要方式。与上述几种本机 IPC 不同,套接字不仅能用于本机进程之间的通信,还能用于不同主机之间的网络通信。套接字基于 TCP 或 UDP 协议,通信双方通过 IP 地址和端口号进行标识。
9.7 IPC 方式对比总结
| 通信方式 | 数据量 | 是否需内核中转 | 是否支持同步 | 典型场景 |
|---|---|---|---|---|
| 管道 | 小 | 是,数据拷贝两次 | 自带流式同步 | 父子进程、命令管道 |
| 消息队列 | 中小 | 是,数据拷贝两次 | 自带消息边界 | 需要消息类型区分的场景 |
| 共享内存 | 大 | 否,直接映射 | 需额外同步机制 | 高频大数据量交互 |
| 信号量 | 极小 | 是 | 本身即同步工具 | 资源计数、进程互斥 |
| 信号 | 极小 | 是 | 异步通知 | 异常通知、外部控制 |
| 套接字 | 大 | 是 | 依赖协议 | 网络通信、分布式系统 |
10. 上下文切换
上下文切换(Context Switch)是指 CPU 从一个进程或线程切换到另一个进程或线程执行的过程。切换前,操作系统必须保存当前任务的执行现场,也就是上下文,包括程序计数器、寄存器内容、栈指针等;切换后,再恢复下一个任务的现场,使其从上次中断的位置继续执行。
进程上下文切换的完整流程大致如下:
- 保存当前进程的 CPU 寄存器状态到其 PCB 或内核栈中。
- 更新当前进程的 PCB,将其状态从运行态改为就绪态或阻塞态,并移动到相应队列。
- 从就绪队列中选出下一个要运行的进程,更新其 PCB 状态为运行态。
- 切换到新进程的地址空间,更新页表基址寄存器并刷新 TLB。
- 恢复新进程保存的 CPU 寄存器状态,跳转到其 PC 指向的位置继续执行。
上下文切换本身是纯开销,切换期间 CPU 不执行用户程序,操作系统还要消耗时间保存和恢复现场。因此,频繁的上下文切换会严重拖累系统性能。
面试中常问:「什么情况下会触发上下文切换?」主要包括:时间片耗尽、当前进程主动阻塞(如 I/O 等待、sleep)、更高优先级的进程就绪被抢占、系统调用结束需要重新调度、发生硬件中断且中断处理改变了调度状态等。
另一个高频追问是:「系统调用会发生上下文切换吗?」需要区分两种切换。系统调用会触发用户态到内核态的切换,这称为模式切换(Mode Switch),不是上下文切换(Context Switch)。模式切换不需要保存和恢复完整的进程现场,也不改变进程。但在某些情况下,系统调用返回前如果需要重新调度(例如当前进程的时间片已用完),则会发生真正的进程上下文切换。所以准确的说法是:系统调用一定会发生用户态与内核态的切换,但不一定发生进程上下文切换。
11. CPU 调度算法
CPU 调度是操作系统的核心功能之一。当多个进程或线程同时就绪时,调度器必须决定把 CPU 分配给谁。调度算法的评价指标通常包括:CPU 利用率、系统吞吐量、周转时间、等待时间、响应时间以及公平性。
11.1 先来先服务(FCFS)
先来先服务按照进程到达就绪队列的先后顺序进行调度。它的优点是实现简单、公平;缺点是短进程可能排在长进程后面长时间等待,产生「护航效应」,且对交互式系统不友好。
11.2 短作业优先(SJF)
短作业优先选择预计运行时间最短的进程先执行。SJF 能获得最小的平均等待时间,但需要预知进程的运行时间,这在现实中很难做到。更严重的问题是长作业可能被无限期推迟,产生饥饿现象。SJF 分为非抢占式和抢占式,抢占式 SJF 也叫最短剩余时间优先(SRTF)。
11.3 优先级调度
优先级调度为每个进程分配一个优先级,每次选择优先级最高的进程执行。优先级可以是静态的,也可以是动态调整的。低优先级进程可能长期得不到 CPU,产生饥饿。解决饥饿的常见方法是老化(Aging),即随着等待时间增加逐步提高进程的优先级。
11.4 时间片轮转(RR)
时间片轮转专为分时系统设计。所有就绪进程排成一个队列,调度器每次把队首进程取出执行一个时间片,时间片用完后就把它放到队尾,然后调度下一个进程。RR 保证了响应时间,适合交互式系统,但进程的切换开销与时间片大小相关:时间片太小会导致频繁切换,执行效率下降;时间片太大则会退化为 FCFS。
11.5 多级反馈队列(MLFQ)
多级反馈队列是实际操作系统中最常用的综合调度算法,它结合了优先级、时间片轮转和老化等多种思路。系统维护多个不同优先级的就绪队列,优先级越高的队列时间片越短。新进程先进入最高优先级队列,如果在一个时间片内执行不完,就降级到下一级队列;CPU 优先调度高优先级队列,只有高优先级队列为空时才调度低优先级队列。此外,为了防止低优先级队列饥饿,系统会定期把所有进程重新提升到最高优先级队列。Linux 的 CFS 调度器虽然不是严格意义上的 MLFQ,但在设计思路上也有类似的多队列思想。
11.6 Linux 常用调度器
Linux 历史上使用过 O(1) 调度器,后来被完全公平调度器(Completely Fair Scheduler,CFS)取代。CFS 的核心思想不是固定优先级,而是尽量保证每个进程获得公平的 CPU 时间。CFS 使用红黑树组织就绪进程,以虚拟运行时间(vruntime)为键值,每次选择 vruntime 最小的进程运行。vruntime 增长越慢的进程,越容易获得 CPU,从而实现了基于权重的公平分配。
12. 进程同步与互斥
在多进程或多线程环境中,多个执行流可能同时访问共享资源。如果对这些共享资源的访问不加控制,就会产生数据不一致、逻辑错乱等严重问题。这就是并发编程中的同步与互斥问题。
这里先明确几个核心概念:
- 临界资源:一次只允许一个进程访问的共享资源,如共享变量、打印机、共享文件。
- 临界区(Critical Section):进程中访问临界资源的那段代码。
- 互斥(Mutual Exclusion):保证同一时刻只有一个进程进入临界区访问临界资源。
- 同步(Synchronization):多个进程之间按照某种先后顺序协调执行,例如生产者必须先生产,消费者才能消费。
临界区问题的解法必须满足四个条件:
- 互斥:同一时刻最多有一个进程在临界区内。
- 前进(Progress):如果没有进程在临界区,且存在想进入临界区的进程,则必须能选出一个进程让它进入,不能无限拖延。
- 有限等待(Bounded Waiting):一个进程从提出进入请求到获准进入的时间不能无限长,必须存在上界,防止饥饿。
- 让权等待:进程如果不能进入临界区,应该立即释放 CPU,不能忙等(忙等只浪费 CPU 且不推进系统状态,但纯软件方案往往无法做到这一点)。
软件方法最经典的是 Peterson 算法,它通过两个共享标志位和一个 turn 变量来解决两个进程的互斥问题。硬件方法则包括中断屏蔽、TestAndSet 指令和 Swap 指令。现代操作系统通常不直接使用这些底层方法,而是在其基础上构建信号量、管程等高级同步原语。
13. 信号量机制
信号量(Semaphore)由荷兰计算机科学家 Dijkstra 提出,是一种功能强大的同步工具,既能解决互斥问题,也能解决同步先后顺序问题。信号量本质上是一个受保护的整数变量,其值只能通过 P 操作和 V 操作来改变。
- P 操作(原语 wait,也叫 down 或 acquire):把信号量的值减一。如果减一后的值小于零,则当前进程阻塞,进入该信号量的等待队列。
- V 操作(原语 signal,也叫 up 或 release):把信号量的值加一。如果加一后的值小于等于零,说明有进程正在等待该信号量,则唤醒等待队列中的一个进程。
信号量按照用途可以分为两类:
- 互斥信号量:初值为 1,用于实现进程间的互斥访问。P 操作相当于加锁,V 操作相当于解锁。
- 同步信号量:初值为 0 或某个正整数,用于控制进程之间的执行顺序。例如生产者生产出数据后执行 V 操作,消费者的 P 操作就能通过,从而保证消费者不会在数据生产出来之前执行。
下面给出一个用互斥信号量保护临界区的伪代码示例:
semaphore mutex = 1; void access_critical_resource() { P(mutex); // 申请进入临界区 // 临界区:访问共享资源 V(mutex); // 释放临界区 }信号量的一个重要特点是:P 操作和 V 操作都必须是原子操作,不能被中断打断。在实现上,单核系统可以通过关中断来保证原子性,多核系统则需要借助硬件提供的原子指令(如 CAS、TestAndSet)或自旋锁来保证。
信号量的缺点是使用不当容易出错。例如忘记执行 V 操作会导致死锁,P 操作位置放错会导致死锁或逻辑错误,程序员必须自行保证 P 和 V 的成对出现和正确顺序。为了降低使用难度,后来发展出了管程机制。
14. 管程
管程(Monitor)是一种更高级的同步机制,由 Hoare 和 Hansen 提出。管程把共享资源以及对该资源的所有操作封装在一个模块内部,模块外的进程只能通过管程提供的接口来访问共享资源,而且管程保证任何时刻只有一个进程能在管程内执行。这样就避免了程序员手动放置 P、V 操作的复杂性,从机制上降低了出错概率。
管程由四部分组成:
- 共享变量:管程内部保护的临界资源。
- 条件变量(Condition Variable):用于实现进程在特定条件下的等待和唤醒。
- 入口队列:等待进入管程的进程队列。
- 条件等待队列:因条件不满足而阻塞的进程队列。
条件变量的两个关键操作是 wait 和 signal。当一个进程在管程内执行时,如果发现某个条件不满足,就执行条件变量的 wait 操作,释放管程的控制权并进入该条件变量的等待队列;当另一个进程修改条件后,执行 signal 操作唤醒等待队列中的一个进程。
关于 signal 之后的管程控制权归属,有两种经典语义:Hoare 语义要求 signal 后立即把控制权交给被唤醒的进程,signal 的调用者需要额外等待;Hansen 语义则要求 signal 调用者继续执行直到退出管程,被唤醒的进程之后才能继续。Java 的 synchronized 关键字和 wait、notify、notifyAll 方法在语义上更接近 Hansen 管程。notify 只唤醒一个线程且不释放锁;notifyAll 会唤醒所有等待线程,让它们重新竞争锁,因此 Java 中更推荐使用 notifyAll 以避免信号丢失问题。
15. 经典同步问题
经典同步问题是八股文面试的重灾区,尤其是生产者消费者问题,几乎人手必会。下面逐一介绍。
15.1 生产者消费者问题
问题描述:一组生产者进程不断生产产品放入缓冲区,一组消费者进程不断从缓冲区取出产品。缓冲区大小为 n,当缓冲区满时生产者必须等待,缓冲区空时消费者必须等待。同时,多个生产者和多个消费者对缓冲区的访问必须互斥。
解决该问题需要三个信号量:互斥信号量 mutex 初值为 1;表示空缓冲区数量的 empty 信号量初值为 n;表示满缓冲区数量的 full 信号量初值为 0。
semaphore mutex = 1; semaphore empty = n; semaphore full = 0; void producer() { while (1) { produce_item(); P(empty); // 先申请空位 P(mutex); // 再进入互斥区 put_item(); V(mutex); // 先退出互斥区 V(full); // 再增加满位计数 } } void consumer() { while (1) { P(full); // 先申请满位 P(mutex); // 再进入互斥区 take_item(); V(mutex); // 先退出互斥区 V(empty); // 再增加空位计数 consume_item(); } }这里有一个非常经典的追问:「P 操作的顺序能不能反过来,先 P(mutex) 再 P(empty)?」答案是不能。如果生产者先获取互斥锁,再申请空位,当缓冲区满时,生产者会拿着 mutex 阻塞在 empty 上,消费者又因为拿不到 mutex 无法进入缓冲区消费,系统进入死锁。所以正确的顺序是先申请资源信号量,再申请互斥信号量;释放时则先释放互斥信号量,再释放资源信号量。
15.2 读者写者问题
问题描述:多个读者可以同时读共享数据,但写者与写者之间、写者与读者之间必须互斥。根据对读者和写者优先级的处理,分为读者优先、写者优先和公平竞争三种变体。
读者优先的经典解法使用一个 readcount 变量记录当前读者数量,并用 mutex 保护 readcount,用 rw 信号量保护数据本身。第一个读者进入时对 rw 执行 P 操作,最后一个读者离开时对 rw 执行 V 操作,从而保证写者只在没有读者时才写。这种方案的缺点是如果读者源源不断,写者就会饥饿,因此叫读者优先。
semaphore mutex = 1; semaphore rw = 1; int readcount = 0; void reader() { P(mutex); readcount++; if (readcount == 1) { P(rw); // 第一个读者锁住数据 } V(mutex); read_data(); P(mutex); readcount--; if (readcount == 0) { V(rw); // 最后一个读者释放数据 } V(mutex); } void writer() { P(rw); write_data(); V(rw); }写者优先的解法通常引入写者计数器和额外的阻断信号量,让后来的读者在已有写者等待时不能进入,从而避免写者饥饿。公平竞争的解法则统一排队,让读者和写者按到达顺序获得访问权,可以用读写锁或条件变量配合 FIFO 队列实现。
15.3 哲学家进餐问题
问题描述:五位哲学家围坐在圆桌旁,每两位哲学家之间放着一根筷子。哲学家需要同时拿到左右两根筷子才能进餐,进餐结束后放下筷子。如果每个哲学家都先拿起左边的筷子,再等待右边的筷子,就会形成循环等待,导致死锁。
常见的解法有三种:
- 限制同时进餐人数:最多允许四位哲学家同时拿筷子,保证至少有一位哲学家能拿到两根筷子完成进餐。
- 奇偶编号策略:奇数号哲学家先拿左边再拿右边,偶数号哲学家先拿右边再拿左边,打破循环等待。
- 同时拿起两根筷子:只有当左右两根筷子都空闲时才一起拿起,否则一根都不拿,通过互斥实现原子获取。
15.4 吸烟者问题
吸烟者问题是生产者消费者问题的变体,常被用来考察信号量的熟练程度。桌上有三个吸烟者,他们分别拥有烟草、纸和火柴三种材料中的一种。供应者每次随机放两种材料到桌上,拥有剩下一种材料的吸烟者才能拿材料卷烟。解题关键在于用三个同步信号量分别对应三种组合,供应者根据放下的材料组合执行对应的 V 操作,唤醒对应吸烟者。
16. 死锁
死锁(Deadlock)是并发编程中最严重的错误之一。死锁发生时,两个或多个进程互相等待对方释放资源,导致所有相关进程都无法继续推进,且永远无法自行解除。
16.1 死锁产生的四个必要条件
面试必背的四条:
- 互斥条件:资源一次只能被一个进程占用。如果资源可以被共享,就不会发生死锁。
- 请求与保持条件:进程已经占有了至少一个资源,又提出了新的资源请求,而该资源被其他进程占用,此时请求进程被阻塞,但又不释放已占有的资源。
- 不可剥夺条件:进程已获得的资源在使用完之前不能被其他进程强行夺走,只能由占有者主动释放。
- 循环等待条件:存在一个进程等待环路,P0 等 P1 手里的资源,P1 等 P2 手里的资源,最终 Pn 又等 P0 手里的资源。
只有四个条件同时满足才会发生死锁。因此,只要破坏其中任意一个条件,就能预防死锁。这一点是回答「如何预防死锁」的核心逻辑。
16.2 死锁的处理策略
处理死锁有四种基本策略:预防、避免、检测与恢复、鸵鸟策略(忽略)。
预防(Prevention):通过破坏四个必要条件之一来杜绝死锁。破坏互斥条件通常不现实,因为很多资源本质上就是互斥的;破坏请求与保持条件可以要求进程一次性申请所有资源,或者申请新资源前先释放已有资源;破坏不可剥夺条件可以允许系统强制回收资源;破坏循环等待条件可以给所有资源编号,要求进程按编号递增的顺序申请资源。
避免(Avoidance):在分配资源之前先判断这次分配是否会导致系统进入不安全状态。最著名的算法是银行家算法。银行家算法要求进程事先声明最大资源需求,系统维护可用资源、已分配资源和剩余需求三个矩阵。每次分配前,系统先模拟分配,然后执行安全性检查:如果存在一个安全序列,可以让所有进程按顺序执行完毕,则这次分配是安全的,否则拒绝分配。银行家算法的复杂度较高,现实中应用有限。
检测与恢复(Detection and Recovery):允许死锁发生,系统定期通过资源分配图检测是否存在环路,一旦发现死锁,就通过撤销进程、回滚进程或强制剥夺资源等方式恢复。资源分配图检测法的思路是:如果图中不存在环,则一定没有死锁;如果存在环,且每种资源只有一个实例,则一定发生死锁;如果每种资源有多个实例,则存在环是死锁的必要不充分条件。
鸵鸟策略:操作系统假装死锁不会发生,不做任何处理。很多通用操作系统采用这种策略,因为死锁发生的概率低,而预防和检测的开销又比较大,系统重启和个人重启进程通常是更经济的恢复方式。
16.3 死锁与饥饿的区别
死锁是多个进程互相等待形成闭环,谁也动不了,是「僵持」状态;饥饿是一个进程长期得不到所需资源或 CPU,是「单个进程被冷落」的状态。死锁一定涉及多个进程,饥饿可以只有一个进程;死锁中的进程处于阻塞状态,饥饿中的进程可能一直在就绪队列里反复错过调度;解除死锁通常需要外部干预,而饥饿可以通过老化等调度策略缓解。活锁则是另一种情形,进程虽然没有阻塞,但一直重复无意义的动作,始终无法推进,本质上也是一种资源分配问题。
17. 线程池
线程池(Thread Pool)是实际工程中最重要的线程管理手段。由于线程的创建和销毁开销较大,如果每个任务都创建一个新线程,在高并发场景下系统会频繁分配和回收线程资源,性能急剧下降。线程池通过预先创建一批工作线程并复用它们,把任务提交与任务执行解耦,从而显著降低系统开销。
线程池的核心参数通常包括:核心线程数、最大线程数、空闲线程存活时间、任务队列和拒绝策略。以 Java 的 ThreadPoolExecutor 为例,它的工作流程如下:
- 提交任务后,如果当前线程数小于核心线程数,则创建新线程执行任务。
- 如果线程数已达到核心线程数,任务被放入阻塞队列等待。
- 如果队列已满但线程数小于最大线程数,则创建非核心线程执行任务。
- 如果线程数达到最大线程数且队列已满,则触发拒绝策略。
常见的拒绝策略有四种:AbortPolicy 直接抛异常;CallerRunsPolicy 由提交任务的线程自己执行;DiscardPolicy 静默丢弃任务;DiscardOldestPolicy 丢弃队首最老的任务然后重试提交。
线程池大小的设置没有固定公式,需要根据任务类型调整。CPU 密集型任务通常设置线程数为 CPU 核数加一,避免过多线程造成频繁切换;I/O 密集型任务因为线程大部分时间在等待 I/O,可以设置更多线程,常用估算公式为:线程数等于 CPU 核数乘以(1 加平均等待时间除以平均计算时间),再结合压测结果微调。
18. 协程
协程(Coroutine)是近年来高并发领域的热点,Java 的虚拟线程(Virtual Thread)、Go 的 goroutine、Python 的 asyncio、Kotlin 的协程都是它的具体实现。协程可以理解为用户态的轻量级线程,它由程序自身调度,而不是由操作系统内核调度。
协程与线程相比有几个显著优势:
- 创建开销极小:一个协程通常只占几 KB 的栈空间,普通线程则要分配几 MB 栈。
- 切换开销极小:协程切换在用户态完成,不需要陷入内核,也不需要切换地址空间。
- 数量优势:单台机器上可以轻松创建几十万甚至上百万个协程,而线程数量通常受限于内存和内核调度能力。
协程的核心机制是在 I/O 等待时主动让出执行权并保存当前上下文,等 I/O 就绪后再恢复执行。这种「协作式」调度与线程的「抢占式」调度有本质区别:协程必须主动让出,否则它会一直占用执行权;线程则可以由操作系统强制剥夺 CPU。这也意味着协程更适合 I/O 密集型的并发场景,对于 CPU 密集型的计算任务,协程并不能真正并行加速,还需要配合线程池使用。
面试中常问「协程和线程的区别」,建议从调度者(用户态调度与内核态调度)、资源占用(小栈与大栈)、切换开销(无需内核陷入与需要内核陷入)、任务抢占(协作式与抢占式)、适用场景(高并发 I/O 与通用并行计算)等维度展开。
19. 高频面试题汇总
这一节把前面所有知识点浓缩成高频面试问答,方便快速背诵。
19.1 进程和线程的区别是什么?
从资源分配、调度单位、地址空间、切换开销、通信方式、健壮性六个方面回答,详见第 6 节表格。
19.2 为什么线程切换比进程切换快?
因为同进程线程共享地址空间,切换时不需要更换页表基址寄存器,TLB 缓存依然有效;而进程切换必须切换地址空间,导致大量 TLB 条目失效,内存访问效率骤降。同时线程切换需要保存和恢复的上下文也相对更少。
19.3 进程有哪些状态?它们之间如何转换?
至少回答三态模型:就绪、运行、阻塞。就绪经调度进入运行,运行因时间片耗尽回到就绪,运行因等待事件进入阻塞,阻塞因事件完成回到就绪。再补充五态模型中的创建态和终止态。
19.4 什么是僵尸进程?什么是孤儿进程?
子进程结束后父进程未调用 wait 回收,留下僵尸进程,占用 PID 资源;父进程先结束,子进程成为孤儿进程,被 init 或 systemd 收养并负责回收。
19.5 进程间有哪些通信方式?
管道、消息队列、共享内存、信号量、信号、套接字。补充说明各自特点和共享内存最快的原因。
19.6 什么是死锁?产生死锁的四个必要条件是什么?
死锁是多进程互相等待对方资源导致的僵持状态。四必要条件是互斥、请求与保持、不可剥夺、循环等待。四者缺一不可。
19.7 如何预防和避免死锁?
预防是破坏四个必要条件之一,如一次性申请全部资源、资源有序分配、允许剥夺等。避免是动态判断分配安全性,典型算法是银行家算法。
19.8 什么是临界区?进入临界区需要满足什么条件?
临界区是访问临界资源的代码段。需要满足互斥、前进、有限等待,并尽量做到让权等待。
19.9 信号量和管程有什么区别?
信号量是低层原语,P 和 V 操作需要程序员手动分布,容易出错;管程是高层抽象,把共享数据和对数据的操作封装在一起,由编译器或运行时自动保证互斥,降低了使用难度。Java 的 synchronized 就是管程的典型实现。
19.10 什么是上下文切换?什么场景会触发?
上下文切换是 CPU 从一个任务切换到另一个任务时保存和恢复现场的过程,是纯开销。触发场景包括时间片耗尽、主动阻塞、被高优先级进程抢占、调度点等。注意区分用户态内核态切换与进程上下文切换。
19.11 多线程一定比单线程快吗?
不一定。多线程带来并行收益的同时也引入了线程创建、切换、同步和缓存一致性的开销。对于 CPU 密集型的计算任务,在核数足够的情况下多线程确实可能加速;对于 I/O 密集型任务,多线程可以通过并发等待显著提高吞吐;但如果线程数过多、任务粒度过小、锁竞争严重或存在缓存伪共享,多线程反而可能比单线程慢。
19.12 什么是伪共享?如何避免?
伪共享(False Sharing)是 CPU 多级缓存机制下出现的性能问题。CPU 缓存以缓存行为单位加载数据,通常为 64 字节。如果两个线程频繁修改位于同一缓存行的不同变量,即使它们互不相关,也会导致缓存行在 CPU 核心之间反复失效和同步,严重拖慢性能。避免伪共享的方法包括:把频繁修改的独立变量分散到不同缓存行(填充 padding)、使用内存对齐、Java 中可以使用 @Contended 注解(JEP 142)等。
19.13 什么是锁?乐观锁和悲观锁有什么区别?
悲观锁假定冲突一定会发生,每次访问共享数据前都先加锁,典型实现是 synchronized 和数据库的行锁。乐观锁假定冲突概率低,先直接操作,提交时再检查是否有冲突,典型实现是 CAS 和数据库版本号机制。悲观锁适合写多读少、冲突激烈的场景,乐观锁适合读多写少、冲突较少的场景。
19.14 什么是 CAS?它有什么问题?
CAS(Compare And Swap)是乐观锁的基础原子操作,包含三个操作数:内存地址、期望值和更新值。只有当内存地址当前值等于期望值时,才把它更新为更新值,否则不做任何修改。CAS 的问题包括:ABA 问题(值从 A 变 B 又变回 A,CAS 无法察觉中间变化,可用版本号或时间戳解决);自旋开销高,竞争激烈时 CPU 空转;只能保证单个变量的原子性,不能保证代码块的原子性。
20. 总结
进程和线程是操作系统的基石,也是所有并发编程知识的源头。回顾全文,掌握下面的主线就能应对绝大多数八股文面试:
- 进程是资源分配的基本单位,线程是 CPU 调度的基本单位,二者在资源、地址空间、切换开销、通信和健壮性上有本质区别。
- 进程和线程都有就绪、运行、阻塞等状态,进程的生命周期由 PCB 承载,PCB 是进程存在的唯一标志。
- 进程间通信有管道、消息队列、共享内存、信号量、信号和套接字六大方式,共享内存最快但需要额外同步。
- 上下文切换是影响性能的重要开销,要区分模式切换和上下文切换。
- CPU 调度算法的核心追求是吞吐、响应和公平之间的平衡,实际系统常用多级反馈队列或 CFS 之类的综合方案。
- 同步互斥解决的是共享资源的正确访问问题,信号量和管程是两大核心工具,生产者消费者、读者写者和哲学家进餐是三个经典问题。
- 死锁产生的四个必要条件缺一不可,处理策略包括预防、避免、检测与恢复以及鸵鸟策略。
- 线程池和协程是工程中控制并发成本的利器,分别从复用线程和用户态轻量调度两个方向进行优化。
建议在理解这些概念之后,用自己熟悉的语言把生产者消费者、死锁检测、线程池和简单的协程调度各实现一遍,再把每一节的面试题用自己的话复述出来。只有把原理转化为能写、能讲、能调的知识,才能在面试中从容应对追问。祝大家面试顺利,早日拿下心仪的 offer。