Skip to content

进程与线程:概念、状态、切换、fork 与协程 ​

进程与线程是操作系统面试的"根":内存、IO、并发、调度的问题都能挂到这条主干上。本文按考频编排,覆盖概念 → PCB / 状态机 → 上下文切换 → 线程模型 → fork/exec → 僵尸孤儿 → 协程的完整链条。

一、程序 / 进程 / 线程 / 协程:本质与关系 ​

先给结论:程序是静态的指令与数据;进程是程序的一次运行实例(资源分配单位);线程是进程内的执行流(CPU 调度单位);协程是用户态可挂起/恢复的轻量执行体(不占内核线程)。

Q1. 程序、进程、线程、协程分别是什么?它们的关系是什么? ​

答案
  • 程序(program):磁盘上的可执行文件,静态的机器指令与数据,本身"不活着"。
  • 进程(process):程序被加载进内存并开始执行的运行实例,是操作系统资源分配的基本单位。每个进程独享:虚拟地址空间(页表)、文件描述符表、信号处理表、进程控制块 PCB。
  • 线程(thread):进程内部的一条执行流,是 CPU 调度的基本单位。同进程的多个线程共享地址空间(代码段、数据段、堆、打开的文件),但各自拥有独立的栈、寄存器、程序计数器、线程本地存储(TLS)。
  • 协程(coroutine):运行在用户态、可被程序自己挂起/恢复的执行体。切换不经过内核,因此极轻。它不是线程:调度由用户态运行时决定,一个线程上可跑成千上万个协程。

Q2. 为什么引入线程?(并发、共享资源、开销) ​

答案

早期只有进程模型,一个进程只能串行执行;要并发就得开多个进程,而进程隔离太强带来三个问题:

  1. 通信成本高:地址空间隔离,数据交换必须走 IPC,无法直接读写同一块内存。
  2. 创建/切换开销大:创建要复制页表与地址空间;切换要换页表、失效 TLB。
  3. 资源共享不便:文件、缓存难以在多个执行流间高效共享。

引入线程的价值:

  • 并发:一个进程内多条执行流,可充分利用多核,且共享同一地址空间,通信近似"零成本"(直接读写变量,但需同步)。
  • 开销小:创建线程无需复制地址空间;同进程线程切换不换页表,比进程切换便宜。
  • 共享资源:线程天然共享进程的堆、全局变量、文件描述符。

二、PCB 与进程状态机 ​

Q3. PCB(进程控制块)里有什么? ​

答案

PCB 是内核用来描述与管理一个进程的数据结构(Linux 中是 task_struct),是"进程存在的唯一标志"。主要包含:

类别内容
标识PID、PPID、用户/组 ID
状态运行/就绪/阻塞等状态、优先级、调度信息
寄存器上下文PC、通用寄存器、栈指针(切换时保存/恢复)
内存管理页表基址、段表、地址空间信息
文件打开文件描述符表
信号信号处理函数表、待处理信号位图
统计CPU 时间、计时器、资源占用
链接指向父/子进程、同组进程的指针(链表)

内核把所有 PCB 组织为链表/哈希表/就绪队列,调度器从就绪队列中挑选 PCB 来运行。

Q4. 进程的状态机?各状态转换的触发条件是什么? ​

答案

五状态模型(加上挂起共七态):

text
   新建          调度器选中              时间片到 / 被抢占
 [新建] ---> [就绪] ----------------> [运行] ---------------> [就绪]
               ^                         |
               |  等待事件完成            | 等待资源 / 事件
               +-------------------------+---------> [阻塞]
                                          事件 / IO 完成
             [运行] --退出 / 异常--> [终止]

关键转换的触发条件:

转换触发条件
新建 → 就绪资源分配完成,进程创建完毕
就绪 → 运行调度器选中(分配 CPU)
运行 → 就绪时间片用完 / 被更高优先级抢占
运行 → 阻塞主动请求资源未满足(IO、锁、sleep)
阻塞 → 就绪等待的事件完成(IO 完成、信号到达)
运行 → 终止正常 exit / 异常 / 被信号杀死

挂起态:进程被换出到磁盘(swap),又分就绪挂起(在磁盘、可调入就绪队列)与阻塞挂起(在磁盘、仍在等事件);动机是内存不足时腾出空间。

易错点:① 阻塞 → 运行不合法,必须先回到就绪(哪怕 CPU 空闲,也要经调度器);② 就绪 → 阻塞也不合法(没在运行,何来等待)。

三、进程 vs 线程 全面对比 ​

Q5. 进程与线程的区别(全面版)? ​

答案
维度进程线程
定义资源分配基本单位CPU 调度基本单位
地址空间独立虚拟地址空间,各有页表共享进程地址空间
资源独立文件表、信号表等共享进程资源,仅独享栈/寄存器/TLS
创建开销大(复制页表、地址空间)小(共享地址空间)
切换开销大(切页表、失效 TLB)小(不切页表)
通信需 IPC(管道/共享内存/socket)直接读写共享变量(需同步)
独立性高,互不干扰低,一损俱损
崩溃影响仅本进程崩溃一个线程崩溃(如段错误)整个进程崩溃

四、上下文切换 ​

Q6. 上下文切换具体切换了什么?开销来自哪里? ​

答案

切换的内容分两部分:

  1. 硬件上下文(寄存器级):程序计数器 PC、通用寄存器、栈指针 SP、状态寄存器。它们被保存到被换出任务的 PCB / 内核栈中,再从中恢复下一个的。
  2. 内存上下文(仅进程切换):页表基址寄存器(x86 的 CR3)、TLB 内容、缓存。线程切换因共享地址空间,不需要切页表。

开销来源:

  • 进出内核态:切换必须在内核态完成(保存/恢复寄存器、运行调度器)。
  • TLB 失效:进程切换后旧 TLB 条目失效,后续访存要重新走页表(可用 ASID / PCID 缓解)。
  • Cache 污染:新任务的工作集不同,L1/L2 命中率骤降——这是最隐蔽的大头。
  • 调度器开销:选择下一个任务、更新统计。

Q7. 进程切换、线程切换、协程切换的差别? ​

答案
类型是否进内核是否换页表大致量级说明
进程切换是是(换 CR3、失效 TLB)微秒级(最贵)还含文件表等上下文
线程切换(同进程)是否亚微秒级共享地址空间
协程切换否(纯用户态)否纳秒级(最便宜)只保存少量寄存器/栈状态

五、线程实现模型 ​

Q8. 用户级线程、内核级线程、混合模型? ​

答案

按"谁管理线程"分类,也常按映射关系记作 N:1、1:1、M:N:

  • 用户级线程(N:1):线程库在用户态管理,内核只看到一个进程。优点:切换极快(无系统调用)、可移植。缺点:一个线程阻塞(如 IO)会阻塞整个进程;无法多核并行(内核只认一个调度实体)。
  • 内核级线程(1:1):每个用户线程对应一个内核调度实体(Linux 采用)。优点:可多核并行,单线程阻塞不影响其他。缺点:切换需进内核,开销较大。
  • 混合模型(M:N):M 个用户线程映射到 N 个内核线程(Go 的 GMP、部分绿色线程)。兼顾轻量与并行,但实现复杂。

Linux 的选择:1:1,通过 clone() 创建;内核里每个线程就是一个 task_struct,一视同仁参与调度(Linux 不严格区分"进程/线程",只看共享了多少资源)。

Q9. Linux 的 clone 与 task_struct 是什么关系? ​

答案

fork、vfork、clone 底层都落到内核的同一套创建流程,区别在于共享哪些资源:

  • fork():基本空 flags,全复制,得到独立进程。
  • vfork():共享地址空间(父进程阻塞,直到子进程 exec/exit)。
  • clone(flags, child_stack, ...):用 flags 精细控制共享 CLONE_VM(地址空间)、CLONE_FILES(文件表)、CLONE_THREAD(同线程组)等。pthread_create 底层就是 clone,带上 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD 等标志,于是新任务与调用者共享地址空间——这就是"线程"。

内核里每个任务都是 task_struct:

  • 线程 = 共享地址空间(同一个 mm_struct)、共享文件表的 task_struct;它们同属一个线程组(tgid 相同,首线程的 pid 即进程 pid)。
  • 进程 = 拥有自己 mm_struct 的 task_struct。

所以 Linux 把"线程"与"进程"在核心统一为 task,只是 clone 时共享程度不同。

六、fork / exec / wait 家族 ​

Q10. fork() 做了什么?返回值为什么这样设计? ​

答案

fork 创建一个几乎完全相同的子进程:复制父进程的 PCB、页表(实际用 COW)、文件描述符表等。

返回值(一次调用,两处返回):

  • 父进程中返回子进程 PID(> 0);
  • 子进程中返回 0;
  • 失败返回 -1(并设置 errno)。

设计动机:这样 if (pid == 0) { ... } else { ... } 就能用一段代码区分两条执行流;父进程拿到子 PID 可用于 wait、kill。

cpp
pid_t pid = fork();
if (pid < 0) { perror("fork"); }
else if (pid == 0) { /* 子进程 */ }
else { /* 父进程:pid 是子进程 PID */ }

易错点:① fork 后父子谁先运行不确定,取决于调度器;② 父子共享文件偏移量(复制的是同一文件表项,open 得到的偏移共享)。

Q11. 什么是写时复制(Copy-On-Write, COW)? ​

答案

fork 若真把整个地址空间物理复制一遍,开销巨大(多数子进程马上 exec 换镜像,复制等于白做)。因此采用 COW:

  1. fork 时父子共享同一批物理页,页表项都标记为只读(写保护)。
  2. 任一方尝试写某页 → 触发缺页异常 → 内核此时才复制该页(分配新物理页、拷贝内容、改回可写),并让父子页表分别指向各自副本。
  3. 只读页(如代码段)永远共享,不发生复制。

好处:fork 变"廉价"(只复制页表,不复制物理页),省内存。代价:首次写会触发缺页,且页表复制 + 引用计数仍有开销。

Q12. vfork 与 fork 的区别?exec 家族做什么? ​

答案

vfork vs fork:

forkvfork
地址空间COW,各有一份页表完全共享
父进程继续执行阻塞,直到子进程 exec/exit
使用场景通用仅"fork + exec"组合的优化

exec 家族(execl / execv / execvp / execve…):用新程序替换当前进程的镜像(代码段、数据段、堆栈),PID 不变,文件描述符默认保留(除设了 FD_CLOEXEC 的)。fork + exec 是 shell 启动新程序的经典组合。

wait / waitpid:父进程用来回收子进程、获取退出状态,同时防止僵尸。

Q13. fork 与多线程一起用有什么坑? ​

答案

经典陷阱:多线程进程 fork 后,子进程里只有一个线程存活(只有调用 fork 的那个线程被复制),但继承的锁/状态可能仍处于"被其他线程持有"的样子:

  • 若 fork 时其他线程正持有 malloc / 日志等内部锁,子进程里这把锁永远处于"已锁"状态 → 子进程一旦申请就死锁。
  • 信号处理、未刷新的 stdio 缓冲区也会带来困扰(缓冲区在父子各有一份,可能重复输出)。

规避:

  • fork 后只调用 async-signal-safe 的函数(如 execve、_exit),尽快 exec 换镜像;
  • 或用 pthread_atfork 注册 fork 前加锁 / 后解锁的处理器;
  • 能用 posix_spawn 就不用 fork。

七、僵尸进程与孤儿进程 ​

Q14. 什么是僵尸进程和孤儿进程?危害与如何避免? ​

答案
  • 僵尸进程(zombie):子进程已终止,但父进程尚未 wait 回收退出状态,于是子进程的 PCB(含 pid、退出码)残留内核,状态为 Z。危害:占用 PID 与内核表项,大量僵尸会耗尽 PID。已死,无法被 kill,只能靠父进程回收。
  • 孤儿进程(orphan):父进程先退出,子进程被 init/systemd(pid 1) 收养(重新指定父进程),由它负责 wait。孤儿进程基本无害。
bash
ps aux | grep 'Z'      # 查看僵尸进程

避免僵尸:

  1. 父进程调用 wait() / waitpid() 及时回收;
  2. SIGCHLD 处理函数里循环 waitpid(-1, ..., WNOHANG);
  3. 直接 signal(SIGCHLD, SIG_IGN)(Linux 上会让系统自动回收子进程);
  4. 双 fork:父 fork 出子,子再 fork 出孙后立即退出 → 孙成为孤儿被 init 收养,由 init 负责回收,父只需回收子。

八、其他高频追问 ​

Q15. 一个线程崩溃会导致整个进程崩溃吗? ​

答案

会。同进程线程共享地址空间,一个线程若发生段错误(访问非法地址)、除零、未捕获信号(如 SIGSEGV、SIGABRT),默认动作是终止整个进程,所有线程一起死。原因:内存是共享的,破坏后其他线程也无法安全运行。

工程缓解:① 把关键任务隔离到独立进程(这正是"多进程比多线程可靠"的理由);② 捕获信号优雅退出(但 SIGSEGV 后状态已不可信,通常只适合记日志/重启);③ C++ 里避免 UB。

Q16. 一个进程最多能创建多少线程?受什么限制? ​

答案

没有固定数字,受多重因素约束:

限制因素说明
虚拟内存 / 栈大小每线程默认栈约 8 MB(ulimit -s)。32 位下约 3 GB 地址空间,理论上限约几百个;64 位空间大、栈通常按需提交,限制更松。可用 pthread_attr_setstacksize 调小。
内核线程数上限/proc/sys/kernel/threads-max、pid_max(/proc/sys/kernel/pid_max)。
ulimit -u单用户可创建的最大进程/线程数(容器里还有 pids.max)。
内核内存每线程的内核栈、task_struct 都占内核内存。

Q17. 协程:有栈 vs 无栈?与线程的区别?C++20 协程属于哪种? ​

答案

协程 vs 线程:

维度线程协程
调度内核(抢占式)用户态运行时(协作式)
切换开销需进内核,亚微秒纯用户态,纳秒
数量有限(受内存 / ulimit)可极多(成千上万)
并行能多核并行单线程内并发不并行
阻塞只影响本线程一个协程不让出会阻塞所在线程

有栈 vs 无栈:

  • 有栈协程(stackful):每个协程有独立栈,可在任意函数深处挂起(如 Go goroutine、Boost.Context)。实现重,但用起来像普通函数。
  • 无栈协程(stackless):不保存独立栈,只保存状态机的续体/帧,只能在协程函数直接调用的挂起点挂起。C++20 协程就是无栈协程:编译器把协程体改写成状态机,局部变量存到堆上的协程帧。

C++20 要点:co_await / co_return / co_yield;编译器生成 promise_type、协程帧、三个挂起点(initial_suspend、await_suspend、final_suspend)。详见 C++20 协程专题。

相关 ​

糌粑的面经笔记 · VitePress 版