跳转至

02 - 进程与线程

进程与线程关系示意图

建议学习时间:3 小时 🎯 难度等级:⭐⭐⭐⭐ 📋 前置知识:操作系统概述、内核态与用户态、系统调用机制


📋 本章目录


一、进程的概念

1.1 为什么需要进程?

在早期的单道批处理系统中,一次只执行一个程序,概念很简单。但当我们引入多道程序设计后,内存中同时存在多个程序,每个程序有各自的执行状态、打开的文件、分配的内存等。为了管理这些并发执行的程序,操作系统需要一个抽象——这就是进程(Process)

1.2 程序 vs 进程

比较维度 程序(Program) 进程(Process)
本质 静态的指令集合(存储在磁盘上) 程序的一次动态执行实例
存储 磁盘上的可执行文件 内存中的运行实体
生命周期 永久存在(除非删除) 临时存在(创建→运行→终止)
组成 代码 + 数据 代码 + 数据 + PCB + 栈 + 堆 + 寄存器状态
数量关系 一个程序可以对应多个进程 每个进程对应一个程序的一次执行
Text Only
示例:打开 3 个 Chrome 浏览器窗口
→ 程序只有 1 个:chrome.exe
→ 进程有 3 个(甚至更多,Chrome 多进程架构每个标签都是独立进程)

1.3 进程的定义

进程是程序在某个数据集上的一次执行活动,是操作系统进行资源分配和调度的基本单位。

进程的核心特性

  1. 动态性:进程是动态创建和消亡的,有生命周期
  2. 并发性:多个进程可以在单核 CPU 上交替执行(并发),在多核上同时执行(并行)
  3. 独立性:每个进程拥有独立的地址空间,一个进程不能直接访问另一个进程的内存
  4. 异步性:进程以不可预知的速度推进执行
  5. 结构性:每个进程由 程序段 + 数据段 + PCB 三部分组成

1.4 进程的地址空间

每个进程拥有独立的虚拟地址空间,典型的布局如下(以 Linux x86-64 为例):

Text Only
高地址
┌─────────────────────┐ 0x7FFFFFFFFFFF
│    内核空间           │ ← 用户不可直接访问
│   (Kernel Space)     │
├─────────────────────┤ 0x7FFFFFFF0000 (约)
│                     │
│    栈(Stack)       │ ← 向下增长
│    局部变量、函数参数  │
│         ↓            │
│         ...          │
│         ↑            │
│    堆(Heap)        │ ← 向上增长
│    malloc/new 分配    │
├─────────────────────┤
│    BSS 段            │ ← 未初始化的全局变量
├─────────────────────┤
│    数据段(Data)     │ ← 已初始化的全局变量
├─────────────────────┤
│    代码段(Text)     │ ← 程序指令(只读)
└─────────────────────┘ 0x000000400000
低地址

二、进程控制块 PCB

2.1 PCB 的定义

进程控制块(Process Control Block, PCB)是操作系统用来描述和管理进程的核心数据结构。每个进程有且仅有一个 PCB,它是进程存在的唯一标识。

🎯 面试要点:创建一个进程,本质上就是创建一个 PCB;销毁一个进程,本质上就是销毁其 PCB。

2.2 PCB 包含的信息

Text Only
┌─────────────────────────────────────────────┐
│              进程控制块(PCB)                 │
├─────────────────────────────────────────────┤
│ 1. 进程标识信息                               │
│    ├── PID(进程 ID)                        │
│    ├── PPID(父进程 ID)                     │
│    └── UID(用户 ID)                        │
├─────────────────────────────────────────────┤
│ 2. 处理器状态信息(CPU 上下文)               │
│    ├── 程序计数器(PC)                      │
│    ├── 通用寄存器(rax, rbx, ...)           │
│    ├── 栈指针(SP)                          │
│    ├── 状态寄存器 / 标志位(PSW/EFLAGS)     │
│    └── 浮点寄存器                            │
├─────────────────────────────────────────────┤
│ 3. 进程调度信息                               │
│    ├── 进程状态(就绪/运行/阻塞...)          │
│    ├── 优先级                                │
│    ├── 调度策略                               │
│    └── 已运行时间 / 等待时间                  │
├─────────────────────────────────────────────┤
│ 4. 进程控制信息                               │
│    ├── 内存管理信息(页表基址、段表)          │
│    ├── 打开的文件列表(文件描述符表)          │
│    ├── I/O 状态(分配的 I/O 设备)            │
│    ├── 信号处理信息                           │
│    └── 进程间通信信息                         │
└─────────────────────────────────────────────┘

2.3 Linux 中的 PCB:task_struct

在 Linux 内核中,PCB 的实现是 task_struct 结构体(定义在 include/linux/sched.h),它非常庞大(数千字节):

C
// Linux 内核中 task_struct 的关键字段(简化版)
struct task_struct {  // struct结构体:自定义复合数据类型
    // ---- 进程状态 ----
    volatile long state;           // 进程状态

    // ---- 调度信息 ----
    int prio;                      // 动态优先级
    int static_prio;               // 静态优先级(由nice值决定)
    unsigned int policy;           // 调度策略(SCHED_NORMAL/SCHED_FIFO/...)
    struct sched_entity se;        // CFS 调度实体(含 vruntime)

    // ---- 进程标识 ----
    pid_t pid;                     // 进程 ID
    pid_t tgid;                    // 线程组 ID(= 主线程的 PID)

    // ---- 进程关系 ----
    struct task_struct *parent;    // 父进程
    struct list_head children;     // 子进程列表
    struct list_head sibling;      // 兄弟进程列表

    // ---- 内存管理 ----
    struct mm_struct *mm;          // 内存描述符(页表、VMA等)

    // ---- 文件系统 ----
    struct files_struct *files;    // 打开的文件表
    struct fs_struct *fs;          // 文件系统信息(当前目录等)

    // ---- 信号处理 ----
    struct signal_struct *signal;  // 信号处理信息

    // ---- CPU 上下文 ----
    struct thread_struct thread;   // CPU 寄存器状态(上下文切换时保存)

    // ... 还有很多其他字段
};

2.4 PCB 的组织方式

操作系统需要高效地管理大量 PCB,常见的组织方式:

Text Only
1. 线性表(数组)
   ┌────┬────┬────┬────┬────┐
   │PCB1│PCB2│PCB3│PCB4│... │
   └────┴────┴────┴────┴────┘
   优点:简单  缺点:查找 O(n)

2. 链表(按状态分组)
   就绪队列:PCB3 → PCB7 → PCB12 → NULL
   阻塞队列:PCB2 → PCB5 → NULL
   优点:状态切换时高效(O(1) 插入/删除)

3. 索引方式(索引表指向各状态的PCB)
   就绪索引表 ──→ [PCB3, PCB7, PCB12]
   阻塞索引表 ──→ [PCB2, PCB5]

Linux 的实现:Linux 使用双向链表管理所有进程,同时维护按状态组织的就绪队列和等待队列。CFS 调度器使用红黑树组织就绪进程。


三、进程状态转换模型

3.1 五状态模型

进程从创建到终止,经历以下五种状态:

Text Only
                    ┌──────── 时间片用完 ────────┐
                    │                          │
                    ▼                          │
              ┌──────────┐   调度选中   ┌──────────┐
 ┌─────┐  创建│          │──────────►│          │  运行完成 ┌──────┐
 │ 新建 │────►│   就绪    │            │   运行    │────────►│ 终止 │
 │ New  │     │  Ready   │◄──────────│ Running  │         │ Exit │
 └─────┘     └──────────┘   被抢占   └──────────┘         └──────┘
                    ▲                     │
                    │                     │ 等待I/O
                    │    I/O完成          │ 或事件
                    │    或事件到达       │
                    │                     ▼
                    │              ┌──────────┐
                    └──────────────│   阻塞    │
                                  │ Blocked  │
                                  └──────────┘

3.2 各状态详解

状态 英文 含义 触发事件
新建 New/Created 进程正在被创建,分配 PCB 和资源 fork() 系统调用
就绪 Ready 进程已准备好,等待 CPU 被调度器放入就绪队列
运行 Running 进程正在 CPU 上执行 被调度器选中
阻塞 Blocked/Waiting 进程等待某个事件(I/O、信号等) 发起 I/O 请求、wait() 、sleep()
终止 Terminated/Exit 进程执行完毕或被强制终止 exit()、异常终止、被 kill

3.3 状态转换的详细说明

① 新建 → 就绪: - 操作系统完成 PCB 创建、内存分配等初始化工作后,将进程放入就绪队列 - 在 Linux 中,fork() 返回后子进程即处于就绪状态

② 就绪 → 运行: - 调度器从就绪队列中选择一个进程,分配 CPU - 这是调度算法(FCFS、SJF、RR、CFS 等)的核心决策点

③ 运行 → 就绪: - 时间片用完:RR 调度中,进程用完了分配的时间片 - 被抢占:更高优先级的进程就绪,抢占当前进程的 CPU - 注意:进程没有完成没有阻塞,只是暂时让出 CPU

④ 运行 → 阻塞: - 进程主动发起 I/O 请求(如 read()、write()) - 进程调用 wait() 等待子进程 - 进程调用 sleep() - 进程等待锁或信号量 - 这是进程主动行为,进入阻塞后释放 CPU

⑤ 阻塞 → 就绪: - I/O 操作完成(硬件中断通知 CPU) - 等待的事件发生(子进程终止、信号到达) - 这是被动事件,由中断处理程序或其他进程触发 - 注意:阻塞的进程不能直接变为运行状态,必须先变为就绪,再由调度器决定是否运行

⑥ 运行 → 终止: - 进程正常执行 exit() 退出 - 进程被其他进程 kill - 进程发生不可恢复的异常(如段错误 SIGSEGV)

3.4 七状态模型(含挂起状态)

当系统内存不足时,操作系统可能将某些进程从内存中换出到磁盘,这就是挂起(Suspend)状态:

Text Only
                 ┌───────────┐
                 │   就绪挂起  │ ←─── 内存不足时,就绪进程被换出
                 │ Ready/Susp│
                 └─────┬─────┘
                       │ 换入内存
  ┌─────┐     ┌──────────┐     ┌──────────┐     ┌──────┐
  │新建  │────►│   就绪    │────►│   运行    │────►│ 终止 │
  └─────┘     └──────────┘     └──────────┘     └──────┘
                    ▲                │
                    │                ▼
                    │          ┌──────────┐
                    └──────────│   阻塞    │
                               └────┬─────┘
                                    │ 内存不足时被换出
                             ┌───────────┐
                             │   阻塞挂起  │
                             │Block/Susp │ ─── 事件完成后变为就绪挂起
                             └───────────┘

四、进程创建 fork/exec

4.1 fork() 系统调用

fork() 是 Unix/Linux 中创建新进程的核心系统调用。

核心特性:调用一次,返回两次

C
pid_t pid = fork();
// 这条语句返回两次:
// 1. 在父进程中返回子进程的 PID(> 0)
// 2. 在子进程中返回 0

fork() 的工作原理

Text Only
fork() 之前:
┌──────────────┐
│   父进程      │
│  代码 + 数据  │
│  PCB          │
│  PID = 100    │
└──────────────┘

fork() 之后:
┌──────────────┐     ┌──────────────┐
│   父进程      │     │   子进程      │
│  代码 + 数据  │     │  代码 + 数据  │ ← 复制父进程的地址空间
│  PCB          │     │  新的 PCB     │ ← 新分配的 PCB
│  PID = 100    │     │  PID = 101    │ ← 新的 PID
│  fork返回101  │     │  fork返回0    │ ← 返回值不同!
└──────────────┘     └──────────────┘

fork() 的细节

  1. Copy-on-Write(写时复制):现代操作系统并不立即复制父进程的整个地址空间,而是让父子进程共享同一份物理内存(页面标记为只读)。只有当其中一个进程试图修改某个页面时,才会真正复制该页面。这大大减少了 fork() 的开销。

  2. 子进程继承的内容

  3. 代码段、数据段、堆、栈的副本
  4. 打开的文件描述符(共享文件表项)
  5. 环境变量
  6. 信号处理方式
  7. 当前工作目录
  8. 用户 ID 和组 ID

  9. 子进程不继承的内容

  10. PID(子进程获得新的 PID)
  11. 父进程的锁(子进程不持有父进程获得的锁)
  12. 挂起的信号(子进程的挂起信号集被清空)

4.2 exec() 系统调用

exec() 用新程序替换当前进程的地址空间。fork() 创建新进程后,子进程通常会调用 exec() 来运行不同的程序。

C
// exec 家族函数
execl("/bin/ls", "ls", "-l", NULL);      // 指定路径 + 参数列表
execlp("ls", "ls", "-l", NULL);          // 在 PATH 中搜索 + 参数列表
execv("/bin/ls", argv);                   // 指定路径 + 参数数组
execvp("ls", argv);                       // 在 PATH 中搜索 + 参数数组
execve("/bin/ls", argv, envp);           // 最底层的系统调用

exec() 的工作原理

Text Only
exec() 之前:         exec() 之后:
┌──────────────┐     ┌──────────────┐
│ 子进程 (fork  │     │ 子进程 (新程序│
│  出来的)      │ ──→ │  替换了)     │
│              │     │              │
│ 旧代码       │     │ 新代码(ls)  │ ← 代码段被替换
│ 旧数据       │     │ 新数据        │ ← 数据段被替换
│ PID = 101    │     │ PID = 101    │ ← PID 不变!
│ 旧栈         │     │ 新栈          │
└──────────────┘     └──────────────┘

注意:exec 成功后不会返回(因为旧程序已经被替换了)
如果 exec 返回了,一定是出错了

4.3 fork + exec:Shell 执行命令的过程

当你在 Shell 中输入 ls -l 时,发生了什么?

Text Only
Shell 进程 (PID=100)
├── 1. 读取用户输入: "ls -l"
├── 2. 解析命令
├── 3. fork() 创建子进程 (PID=101)
│      │
│      ├── [父进程 PID=100]
│      │   └── wait() 等待子进程结束
│      │
│      └── [子进程 PID=101]
│          ├── 4. exec("/bin/ls", "ls", "-l", NULL)
│          │      地址空间被 ls 程序替换
│          ├── 5. ls 程序执行,输出文件列表
│          └── 6. exit(0) 退出
├── 7. wait() 返回,子进程已结束
└── 8. Shell 继续等待下一个命令

4.4 Python 中的进程创建

Python
"""进程创建与管理示例"""
import os
import sys
import multiprocessing
import time

# ============ 方法一:使用 os.fork()(仅 Unix)============
def demo_fork():
    """展示 fork 的基本用法"""
    print(f"[fork前] PID={os.getpid()}")

    pid = os.fork()

    if pid == 0:
        # 子进程
        print(f"[子进程] PID={os.getpid()}, PPID={os.getppid()}")
        time.sleep(1)
        print("[子进程] 即将退出")
        os._exit(0)
    else:
        # 父进程
        print(f"[父进程] PID={os.getpid()}, 子进程PID={pid}")
        os.wait()  # 等待子进程结束
        print("[父进程] 子进程已结束")

# ============ 方法二:使用 multiprocessing(跨平台)============
def worker(name, duration):
    """工作进程函数"""
    print(f"[Worker-{name}] 开始工作, PID={os.getpid()}")
    time.sleep(duration)
    print(f"[Worker-{name}] 工作完成")

def demo_multiprocessing():
    """使用 multiprocessing 模块创建进程"""
    print(f"[主进程] PID={os.getpid()}")

    # 创建多个子进程
    processes = []
    for i in range(3):
        p = multiprocessing.Process(target=worker, args=(f"P{i}", i + 1))
        processes.append(p)
        p.start()  # 启动进程
        print(f"[主进程] 启动了子进程 PID={p.pid}")

    # 等待所有子进程完成
    for p in processes:
        p.join()
        print(f"[主进程] 子进程 PID={p.pid} 已结束")

    print("[主进程] 所有子进程已完成")

if __name__ == "__main__":
    demo_multiprocessing()

五、线程的概念与必要性

5.1 为什么需要线程?

考虑一个 Web 服务器需要同时处理多个客户端请求:

Text Only
方案一:每个请求一个进程(多进程模型)
┌──────┐ ┌──────┐ ┌──────┐
│进程1  │ │进程2  │ │进程3  │  ← 每个进程独立的地址空间
│处理   │ │处理   │ │处理   │     创建开销大(~10ms)
│请求1  │ │请求2  │ │请求3  │     进程间通信复杂
└──────┘ └──────┘ └──────┘     上下文切换慢

方案二:一个进程内多个线程(多线程模型)
┌──────────────────────────┐
│         一个进程           │  ← 共享地址空间
│ ┌──────┐┌──────┐┌──────┐ │
│ │线程1  ││线程2  ││线程3  │ │     创建开销小(~1ms)
│ │处理   ││处理   ││处理   │ │     通过共享内存直接通信
│ │请求1  ││请求2  ││请求3  │ │     上下文切换快
│ └──────┘└──────┘└──────┘ │
└──────────────────────────┘

5.2 线程的定义

线程是 CPU 调度和执行的基本单位,是进程内部的一个执行流。

一个进程至少包含一个线程(主线程),可以包含多个线程。同一进程的多个线程共享进程的资源(地址空间、文件描述符等),但各自拥有独立的执行上下文

5.3 线程共享与独有

Text Only
进程资源(线程间共享):
┌──────────────────────────────┐
│  代码段(Text)               │ ← 所有线程执行相同的程序
│  数据段(Data + BSS)         │ ← 全局变量被所有线程共享
│  堆(Heap)                  │ ← malloc 分配的内存被共享
│  打开的文件描述符表           │ ← 同一个 fd 表
│  信号处理函数                 │
│  进程 ID、用户 ID            │
│  内存映射(mmap区域)         │
└──────────────────────────────┘

每个线程独有:
┌──────────┐ ┌──────────┐ ┌──────────┐
│  线程1    │ │  线程2    │ │  线程3    │
│          │ │          │ │          │
│  线程 ID  │ │  线程 ID  │ │  线程 ID  │
│  栈(Stack)│ │  栈       │ │  栈       │ ← 每个线程有独立的栈
│  PC(程序计│ │  PC       │ │  PC       │ ← 独立的执行位置
│   数器)   │ │          │ │          │
│  寄存器   │ │  寄存器   │ │  寄存器   │ ← 独立的寄存器上下文
│  错误号   │ │  错误号   │ │  错误号   │ ← errno
│  信号掩码  │ │  信号掩码  │ │  信号掩码  │
│  优先级   │ │  优先级   │ │  优先级   │
└──────────┘ └──────────┘ └──────────┘

5.4 进程 vs 线程对比(面试高频)

比较维度 进程 线程
定义 资源分配的基本单位 CPU 调度的基本单位
地址空间 独立的虚拟地址空间 共享所属进程的地址空间
创建开销 大(需要分配独立地址空间) 小(只需要栈和寄存器)
切换开销 大(需要切换页表、刷新 TLB) 小(共享地址空间,无需切换页表)
通信方式 IPC(管道/共享内存/Socket等) 直接读写共享内存(需同步)
安全性 一个进程崩溃不影响其他进程 一个线程崩溃可能导致整个进程崩溃
资源 拥有独立资源 共享进程资源,独有栈和寄存器
并发性 进程间并发 线程间并发(更轻量)

六、用户级线程 vs 内核级线程

6.1 用户级线程(User-Level Thread, ULT)

定义:线程的创建、调度、同步完全在用户空间完成,内核对线程的存在一无所知

实现方式:通过用户空间的线程库(如早期的 POSIX 线程库、Green Threads)实现。

Text Only
用户空间
┌──────────────────────────────┐
│  ┌─────┐ ┌─────┐ ┌─────┐   │
│  │ ULT1│ │ ULT2│ │ ULT3│   │
│  └──┬──┘ └──┬──┘ └──┬──┘   │
│     └────┬───┘──────┘       │
│     线程库(Thread Library)  │  ← 在用户空间管理线程
├──────────────────────────────┤
│           内核               │
│  只看到一个进程               │  ← 内核不知道有多个线程
└──────────────────────────────┘

6.2 内核级线程(Kernel-Level Thread, KLT)

定义:线程的创建、调度、同步由内核完成,内核维护所有线程的 TCB。

Text Only
用户空间
┌──────────────────────────────┐
│  ┌─────┐ ┌─────┐ ┌─────┐   │
│  │ KLT1│ │ KLT2│ │ KLT3│   │
│  └──┬──┘ └──┬──┘ └──┬──┘   │
├─────┼───────┼───────┼───────┤
│     ▼       ▼       ▼       │
│  ┌─────┐ ┌─────┐ ┌─────┐   │
│  │TCB1 │ │TCB2 │ │TCB3 │   │  ← 内核管理每个线程
│  └─────┘ └─────┘ └─────┘   │
│           内核               │
└──────────────────────────────┘

6.3 对比表

特性 用户级线程(ULT) 内核级线程(KLT)
线程管理 用户空间的线程库 内核
创建/切换速度 快(不需要系统调用) 慢(需要系统调用)
系统调用阻塞 一个线程阻塞→整个进程阻塞 一个线程阻塞不影响其他线程
多核利用 ❌ 不能利用多核(内核只看到一个进程) ✅ 可以利用多核并行
调度粒度 进程级(内核调度进程,线程库调度线程) 线程级(内核直接调度线程)
可移植性 好(不依赖内核线程支持) 差(依赖操作系统)
代表 Green Threads(早期 Java)、GNU Portable Threads Linux NPTL、Windows 线程

6.4 用户级线程的致命缺陷

用户级线程最大的问题是阻塞传播

Text Only
场景:进程 P 有 3 个用户级线程 T1, T2, T3

T1 执行 read() 系统调用,等待磁盘 I/O
内核不知道进程内有多个线程
内核认为整个进程 P 在等待 I/O
内核将进程 P 设为阻塞状态
结果:T2 和 T3 也被阻塞了!(即使它们不需要 I/O)

这就是为什么现代操作系统大多采用内核级线程混合方案


七、线程模型

7.1 一对一模型(1:1)

每个用户线程对应一个内核线程。

Text Only
用户空间:   UT1    UT2    UT3
             │      │      │
             ▼      ▼      ▼
内核空间:   KT1    KT2    KT3

代表:Linux(NPTL)、Windows

优点: - 一个线程阻塞不影响其他线程 - 可以充分利用多核 CPU

缺点: - 每个用户线程需要一个内核线程,创建数量受限 - 线程创建和切换需要系统调用,开销较大

7.2 多对一模型(N:1)

多个用户线程映射到一个内核线程。

Text Only
用户空间:   UT1  UT2  UT3  UT4
             │    │    │    │
             └──┬─┴──┬─┘────┘
                ▼    ▼
内核空间:      KT1

代表:Green Threads(早期 Java)、GNU Portable Threads

优点:线程管理完全在用户空间,开销小 缺点: - 一个线程阻塞→全部阻塞 - 不能利用多核

7.3 多对多模型(M:N)

M 个用户线程映射到 N 个内核线程(M ≥ N)。

Text Only
用户空间:   UT1  UT2  UT3  UT4  UT5
             │    │    │    │    │
             └──┬─┘──┬─┘──┬─┘───┘
                ▼    ▼    ▼
内核空间:      KT1  KT2  KT3

代表:Go 语言的 goroutine(GMP 模型)、Solaris 的 LWP

优点: - 兼顾创建效率和并行能力 - 一个用户线程阻塞时,其内核线程可以调度其他用户线程 - 用户线程数量不受内核线程限制

缺点: - 实现复杂,调试困难

7.4 三种模型对比

模型 并行能力 阻塞处理 创建开销 复杂度 代表
1:1 ✅ 充分 ✅ 独立 较大 简单 Linux NPTL
N:1 ❌ 无 ❌ 全部阻塞 极小 简单 Green Threads
M:N ✅ 充分 ✅ 独立 复杂 Go goroutine

八、协程原理

8.1 什么是协程?

协程(Coroutine)是一种用户态的轻量级线程,它的调度完全由程序自身控制(协作式调度),不需要内核参与。

Text Only
线程 vs 协程:

线程(Thread):
├── 内核调度(抢占式)
├── 上下文切换需要内核参与
├── 切换开销:~1-10 微秒
├── 栈大小:固定 8MB(Linux 默认)
├── 数量限制:通常数千个
└── 创建开销:较大

协程(Coroutine):
├── 用户态调度(协作式)
├── 上下文切换在用户态完成
├── 切换开销:~0.1-1 微秒
├── 栈大小:可动态调整(如 Go goroutine 初始 2KB)
├── 数量限制:可以创建数十万甚至百万个
└── 创建开销:极小

8.2 协程的工作原理

协程的核心思想是协作式让出:一个协程主动让出 CPU(yield),然后另一个协程继续执行。

Text Only
协程 A 和 协程 B 的执行流程:

协程 A:
def coroutine_a():
    print("A: step 1")
    yield              # ← 主动让出,切换到 B
    print("A: step 2")
    yield              # ← 主动让出,切换到 B
    print("A: step 3")

协程 B:
def coroutine_b():
    print("B: step 1")
    yield              # ← 主动让出,切换到 A
    print("B: step 2")
    yield              # ← 主动让出,切换到 A
    print("B: step 3")

执行结果:
A: step 1 → B: step 1 → A: step 2 → B: step 2 → A: step 3 → B: step 3

8.3 Python 协程示例

Python
"""Python 协程(asyncio)示例"""
import asyncio
import time

async def fetch_data(name, delay):  # async def定义异步函数;用await调用
    """模拟异步数据获取"""
    print(f"[{name}] 开始获取数据...")
    await asyncio.sleep(delay)  # 模拟 I/O 等待(非阻塞)
    print(f"[{name}] 数据获取完成(耗时 {delay}s)")
    return f"{name}的数据"

async def main():
    start = time.time()

    # 并发执行 3 个协程
    results = await asyncio.gather(  # await等待异步操作完成
        fetch_data("任务A", 2),
        fetch_data("任务B", 1),
        fetch_data("任务C", 3),
    )

    elapsed = time.time() - start
    print(f"\n所有任务完成!总耗时: {elapsed:.1f}s")  # 约 3s,不是 6s
    print(f"结果: {results}")

asyncio.run(main())  # asyncio.run()启动异步事件循环

8.4 Go Goroutine(M:N 模型的典范)

Go 语言的 goroutine 是 M:N 模型的最佳实践,其 GMP 模型

Text Only
G = Goroutine(协程)
M = Machine(操作系统线程,即内核线程)
P = Processor(逻辑处理器,通常 = CPU 核心数)

┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ G1 │ │ G2 │ │ G3 │ │ G4 │ │ G5 │  ← 可以有成千上万个 G
└─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘
  │      │      │      │      │
  ▼      ▼      ▼      ▼      ▼
┌──────────┐   ┌──────────┐
│    P1     │   │    P2     │  ← P 的数量 = GOMAXPROCS(默认 = CPU核心数)
│ 本地队列  │   │ 本地队列  │
└─────┬────┘   └─────┬────┘
      │              │
      ▼              ▼
┌──────────┐   ┌──────────┐
│    M1     │   │    M2     │  ← 操作系统线程
│ (OS线程)  │   │ (OS线程)  │
└──────────┘   └──────────┘
      │              │
      ▼              ▼
   CPU Core 1     CPU Core 2

特点:
- G 极轻量(初始栈 2KB,可动态增长)
- 当一个 G 阻塞时,M 会释放 P,P 可以切换到其他 M 继续执行其他 G
- Work Stealing:空闲的 P 会从其他 P 的队列中偷取 G

8.5 线程 vs 协程 vs 进程总结

特性 进程 线程 协程
调度方式 内核调度 内核调度 用户态调度
调度策略 抢占式 抢占式 协作式
地址空间 独立 共享 共享
切换开销 最大 中等 最小
创建开销 最大 中等 最小
并行能力 ✅ 多核 ✅ 多核 取决于模型
栈大小 独立大栈 固定(~8MB) 动态(~2KB起)
数量限制 数百-数千 数千-数万 数十万-数百万
安全隔离

九、进程间通信 IPC

9.1 为什么需要 IPC?

进程拥有独立的地址空间,一个进程不能直接访问另一个进程的数据。但实际应用中,进程之间经常需要交换数据或协调工作,这就需要进程间通信(Inter-Process Communication, IPC)机制。

9.2 六种 IPC 机制

Text Only
IPC 机制
├── 管道(Pipe)—— 最简单,亲缘进程间单向通信
├── 命名管道(Named Pipe / FIFO)—— 无亲缘关系进程间通信
├── 消息队列(Message Queue)—— 格式化消息,异步通信
├── 信号量(Semaphore)—— 同步工具,不传数据
├── 共享内存(Shared Memory)—— 最快,直接共享内存区域
└── Socket —— 最通用,支持不同主机间通信

9.3 管道(Pipe)

特点: - 半双工(数据只能单向流动) - 只用于有亲缘关系的进程(父子进程) - 基于文件描述符,写端 fd[1],读端 fd[0] - 有固定容量(Linux 默认 64KB),满了就阻塞写入

Python
"""管道通信示例(Python,Unix系统)"""
import os

# 创建管道
read_fd, write_fd = os.pipe()  # 返回 (读端fd, 写端fd)

pid = os.fork()

if pid == 0:
    # 子进程:作为写端
    os.close(read_fd)  # 关闭读端
    message = "Hello from child process!"
    os.write(write_fd, message.encode())
    os.close(write_fd)
    os._exit(0)
else:
    # 父进程:作为读端
    os.close(write_fd)  # 关闭写端
    data = os.read(read_fd, 1024)
    print(f"父进程收到: {data.decode()}")
    os.close(read_fd)
    os.wait()

9.4 命名管道(Named Pipe / FIFO)

特点: - 与管道类似,但作为文件系统中的特殊文件存在 - 无亲缘关系的进程也可以通信 - 通过路径名访问

Python
"""命名管道示例"""
import os
import sys

FIFO_PATH = "/tmp/my_fifo"

# 创建命名管道
if not os.path.exists(FIFO_PATH):
    os.mkfifo(FIFO_PATH)

# 写端(在一个终端运行)
def writer():
    with open(FIFO_PATH, 'w') as fifo:  # with自动管理资源,确保文件正确关闭
        fifo.write("Hello from writer process!\n")
    print("写入完成")

# 读端(在另一个终端运行)
def reader():
    with open(FIFO_PATH, 'r') as fifo:
        data = fifo.read()
    print(f"读取到: {data}")

9.5 消息队列(Message Queue)

特点: - 消息以结构化格式存储在内核中 - 支持按消息类型选择性读取 - 生命周期独立于进程(进程退出后消息队列仍在) - 有大小限制

Python
"""消息队列示例(使用 multiprocessing.Queue)"""
from multiprocessing import Process, Queue

def producer(queue):
    """生产者:向队列中放入消息"""
    for i in range(5):
        msg = f"消息-{i}"
        queue.put(msg)
        print(f"[生产者] 发送: {msg}")

def consumer(queue):
    """消费者:从队列中读取消息"""
    while True:
        msg = queue.get()
        if msg == "STOP":
            break
        print(f"[消费者] 收到: {msg}")

if __name__ == "__main__":
    q = Queue()
    p1 = Process(target=producer, args=(q,))
    p2 = Process(target=consumer, args=(q,))

    p1.start()
    p2.start()

    p1.join()
    q.put("STOP")  # 发送停止信号
    p2.join()
    print("通信完成")

9.6 信号量(Semaphore)

特点: - 主要用于同步,不传输数据 - 一个整数计数器 + P/V 操作 - P 操作(wait):减 1,若 < 0 则阻塞 - V 操作(signal):加 1,若 ≤ 0 则唤醒一个等待进程

Python
"""信号量示例"""
from multiprocessing import Process, Semaphore
import time

def worker(sem, worker_id):
    """使用信号量控制并发数量"""
    sem.acquire()  # P 操作
    print(f"[Worker-{worker_id}] 进入临界区")
    time.sleep(2)  # 模拟工作
    print(f"[Worker-{worker_id}] 离开临界区")
    sem.release()  # V 操作

if __name__ == "__main__":
    sem = Semaphore(2)  # 最多允许 2 个进程同时进入
    processes = []
    for i in range(5):
        p = Process(target=worker, args=(sem, i))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()

9.7 共享内存(Shared Memory)

特点: - 最快的 IPC 方式(不需要内核中转,直接读写共享区域) - 多个进程将同一段物理内存映射到各自的地址空间 - 需要配合同步机制(如信号量)使用,否则会产生竞态条件

Python
"""共享内存示例"""
from multiprocessing import Process, Value, Array
import time

def writer(shared_val, shared_arr):
    """写入共享内存"""
    shared_val.value = 42
    for i in range(len(shared_arr)):
        shared_arr[i] = i * 10
    print(f"[写进程] 写入: value={shared_val.value}, array={list(shared_arr)}")

def reader(shared_val, shared_arr):
    """读取共享内存"""
    time.sleep(0.1)  # 等待写入完成
    print(f"[读进程] 读取: value={shared_val.value}, array={list(shared_arr)}")

if __name__ == "__main__":
    # 共享整数
    val = Value('i', 0)  # 'i' = int 类型
    # 共享数组
    arr = Array('i', 5)  # 5 个 int 元素

    p1 = Process(target=writer, args=(val, arr))
    p2 = Process(target=reader, args=(val, arr))

    p1.start()
    p2.start()
    p1.join()
    p2.join()

9.8 Socket

特点: - 最通用的 IPC 方式,不仅支持同一主机,还支持不同主机间的通信 - 基于网络协议(TCP/UDP) - 也可以用 Unix Domain Socket 进行本机高效通信 - 是分布式系统通信的基础

Python
"""Socket IPC 示例(TCP, 同一主机)"""
import socket
import threading  # 线程池/多线程:并发执行任务

def server():
    """服务端"""
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(('127.0.0.1', 9999))
    srv.listen(1)
    print("[服务端] 等待连接...")

    conn, addr = srv.accept()
    print(f"[服务端] 客户端已连接: {addr}")

    data = conn.recv(1024)
    print(f"[服务端] 收到: {data.decode()}")

    conn.send("Hello from server!".encode())
    conn.close()
    srv.close()

def client():
    """客户端"""
    import time
    time.sleep(0.5)  # 等待服务端启动

    cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    cli.connect(('127.0.0.1', 9999))

    cli.send("Hello from client!".encode())

    data = cli.recv(1024)
    print(f"[客户端] 收到: {data.decode()}")
    cli.close()

# 启动服务端和客户端
t1 = threading.Thread(target=server)
t2 = threading.Thread(target=client)
t1.start()
t2.start()
t1.join()
t2.join()
print("Socket 通信完成")

9.9 六种 IPC 方式对比

IPC 方式 传输方向 是否传数据 速度 可跨主机 适用场景
管道 单向 父子进程简单通信
命名管道 单向 无亲缘进程通信
消息队列 双向 格式化消息传递
信号量 - ❌(仅同步) 进程同步/互斥
共享内存 双向 最快 大量数据高速传输
Socket 双向 较慢 网络通信、分布式系统

十、练习题

选择题

1. 进程和程序的根本区别是: - A. 存储位置不同 - B. 进程是动态的,程序是静态的 - C. 进程可以并发执行 - D. 进程在内存中,程序在磁盘上

答案:B。最根本的区别是动态性 vs 静态性。A 和 D 是表面现象,C 是特性之一但不是根本区别。


2. 下列哪个不是线程独有的(即线程间共享的)? - A. 线程 ID - B. 栈 - C. 堆区 - D. 程序计数器

答案:C。堆区是进程资源,被所有线程共享。线程各自独有 ID、栈、PC 和寄存器。


3. 哪种线程模型既能利用多核 CPU 又能高效创建大量线程? - A. 1:1 模型 - B. N:1 模型 - C. M:N 模型 - D. 以上都不能

答案:C。M:N 模型兼顾了多核并行能力和用户态线程的轻量创建。


4. 以下哪种 IPC 方式速度最快? - A. 管道 - B. Socket - C. 共享内存 - D. 消息队列

答案:C。共享内存不需要内核中转,进程直接读写共享区域,是最快的 IPC 方式。


5. 关于 fork() 的描述,正确的是: - A. fork() 调用一次,返回一次 - B. 子进程是父进程的完全独立副本,不共享任何内容 - C. 现代 Linux 使用 Copy-on-Write 优化 fork - D. 子进程的 PID 一定是父进程 PID + 1

答案:C。A 错:调用一次返回两次。B 错:子进程会继承父进程的文件描述符表(共享打开文件)。D 错:PID 由内核分配,不一定连续。


简答题

1. 简述进程创建的完整过程(以 fork 为例)。

参考答案

  1. 分配 PCB:为新进程分配 task_struct 结构
  2. 分配 PID:分配一个唯一的进程标识符
  3. 复制地址空间:使用 Copy-on-Write 技术,初始共享父进程页面
  4. 复制 PCB 内容:复制父进程的 PCB 信息(状态、优先级、打开文件等)
  5. 设置返回值:在父进程 PCB 中设置返回值为子进程 PID,在子进程 PCB 中设置返回值为 0
  6. 加入就绪队列:将新进程加入就绪队列

2. 解释 Go 语言 goroutine 的 GMP 模型。

参考答案

Go 的 GMP 模型包含三个核心组件:

  • G(Goroutine):Go 语言中的协程,轻量级执行单元,初始栈大小仅 2KB
  • M(Machine):操作系统线程(内核线程),是实际的执行者
  • P(Processor):逻辑处理器,管理 G 到 M 的映射,数量默认等于 CPU 核心数

工作原理:每个 P 维护一个本地 G 队列。P 绑定到一个 M 上,M 从 P 的队列中取出 G 执行。当一个 G 阻塞(如系统调用)时,M 会释放 P,P 可以被其他 M 获取继续执行其他 G。空闲的 P 还会通过 Work Stealing 从其他 P 的队列中偷取 G,实现负载均衡。

这是典型的 M:N 模型,兼顾了并行能力(利用多核)和轻量级(可创建百万级 goroutine)。


十一、面试要点

🔥 高频面试题

Q1:进程和线程的区别?(必考题)

标准回答(5 点对比)

  1. 资源分配:进程是资源分配的基本单位,拥有独立的地址空间;线程是 CPU 调度的基本单位,共享进程的地址空间
  2. 开销:进程创建/切换开销大(需要分配/切换地址空间),线程开销小(只需要栈和寄存器)
  3. 通信:进程间通信需要 IPC 机制(管道、共享内存等),线程通过共享内存直接通信
  4. 安全性:进程间互相隔离,一个崩溃不影响另一个;线程共享地址空间,一个崩溃可能导致整个进程崩溃
  5. 独有 vs 共享:线程独有栈、PC、寄存器;共享代码段、数据段、堆、文件描述符

加分点:举 Chrome 浏览器的例子——每个标签页是独立进程(安全隔离),标签页内部用多线程(渲染线程、JS 线程、网络线程)提高效率。

Q2:进程间通信有哪些方式?各有什么特点?

标准回答:六种方式(管道、命名管道、消息队列、信号量、共享内存、Socket),然后对比速度、方向、是否可跨主机等特性。重点强调共享内存最快(直接读写,无内核中转),Socket 最通用(支持跨主机)。

Q3:什么是协程?和线程有什么区别?

标准回答: - 协程是用户态的轻量级线程,调度完全在用户空间完成 - 调度方式不同:线程是抢占式(内核调度),协程是协作式(主动 yield) - 切换开销不同:线程切换需要内核参与(10μs),协程切换在用户态完成(0.1μs) - 数量不同:线程通常数千个,协程可以数十万甚至百万个 - 代表:Python asyncio、Go goroutine、Lua coroutine - Go goroutine 基于 GMP 模型是 M:N 线程模型的优秀实现

Q4:fork() 之后,父子进程的关系是什么?

标准回答: 1. 子进程是父进程的副本(通过 Copy-on-Write 优化) 2. 共享:代码段、打开的文件描述符(共享文件表项) 3. 不共享:PID、内存空间(写时复制后独立)、挂起信号 4. fork 返回值:父进程中返回子进程 PID,子进程中返回 0 5. 子进程可以通过 exec 加载新程序

Q5:用户级线程和内核级线程的区别?

核心回答: - 用户级线程在用户空间管理,内核不知道其存在;内核级线程由内核管理 - 用户级线程创建/切换快但一个阻塞全部阻塞,不能利用多核 - 内核级线程一个阻塞不影响其他,可以利用多核,但切换需要系统调用 - 现代 Linux 用 1:1 模型(NPTL),Go 用 M:N 模型(GMP)


📌 下一章03-CPU调度算法 — 深入学习 7 种调度算法与 Linux CFS 调度器