💻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. 上下文切换详细流程

线程切换(同进程内):

  1. 保存当前线程的寄存器到 TCB(Thread Control Block)
  2. 加载目标线程的寄存器
  3. 切换栈指针
  4. 不需要切换页表(同一地址空间)

进程切换:

  1. 保存当前进程的寄存器到 PCB(Process Control Block)
  2. 切换页表基址寄存器(CR3)
  3. 刷新 TLB
  4. 加载目标进程的寄存器
  5. 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 · 操作系统