跳转至

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 调度器