Lecture 2–3: 线程、进程与调度(Threads, Processes, and Dispatching)

目录 · ← l1 · l3 →

Lecture 2–3: 线程、进程与调度(Threads, Processes, and Dispatching)

概述

本讲介绍操作系统最核心的两个执行抽象:线程(thread,最小的执行单元)与进程(process,一个或多个线程及其执行状态)。随后深入调度机制(dispatching):OS 如何在有限的核(core)上运行远多于核数的线程,包括进程控制块(PCB)、线程状态机、上下文切换(context switch),以及陷阱(trap)与中断(interrupt)如何驱动调度器运行。

核心概念与系统机制图解

线程(Thread)

  • 定义:在一颗核上顺序执行的一段代码;它按顺序执行一串指令,因此”一次只发生一件事”,易于推理。
  • 直观解释:线程是”房子里的住户”。同一进程内的住户共享公共空间(全局变量、堆)和设施(打开的文件),但各自有自己的私人物品(寄存器、调用栈)。
  • 为什么需要多线程:利用多核并行;简化应用结构(如 Web 服务器为每个连接开一个线程)。
  • 执行状态(Execution State):一切能影响或被线程影响的东西——代码、数据、寄存器、调用栈、打开的文件、网络连接、时间等。

进程(Process)

  • 定义:一个或多个线程 + 它们的执行状态;是操作系统资源分配(内存、文件句柄等)的基本单位。
  • 直观解释:进程是一座”房子”,拥有自己的地址空间(房间布局)、财产(数据)和住户(线程)。房子之间相互隔离——一个房子的问题通常不影响另一个房子。

线程间共享 vs 私有状态(Lecture 2 课堂问题):

状态进程内线程间共享?
代码(Code)✅ 共享
变量(Variables,全局/堆)✅ 共享
寄存器(Registers)❌ 每个线程私有
调用栈(Call Stack)❌ 每个线程私有
打开的文件 / 网络连接✅ 共享
时钟(Time of day)✅ 共享

系统调用(System Call)

  • 定义:用户进程请求内核执行特权操作的机制;调用序列因 OS 而异,但本质都是”陷入内核”。
  • 机制:进程执行特殊指令(如 x86-64 的 syscall),CPU 切换到内核模式,跳到内核中预先注册的处理代码,执行完再返回用户模式。
用户进程                      OS 内核
┌─────────────┐             ┌──────────────┐
│ Code        │  系统调用     │ 特权代码      │
│ Stack       │ ──────────►  │ 文件/进程/内存 │
│ Heap        │ ◄──────────  │ 管理          │
└─────────────┘   返回       └──────────────┘

进程创建:fork / exec / wait(Linux)

  • fork():复制当前进程,产生一个几乎完全相同的子进程(子进程从 fork 返回处继续执行,返回 0;父进程得到子进程 PID)。
  • execvp():用新程序的代码和数据覆盖当前进程——不创建新进程,只是换内容。
  • waitpid():等待子进程结束,回收其退出状态。
  • 为什么”先复制再覆盖”:优点——在 exec 之前可以修改进程状态(改环境变量、重定向文件描述符,例如 ls > ls.out);缺点——大部分复制的工作被浪费。Windows 用 CreateProcess(10 个参数)一步到位,Linux 只需 fork(0 参数)+ exec(2 参数)。
fork() 前                   fork() 后                 exec("ls") 后
父进程                      父进程        子进程         父进程        子进程
┌────────┐               ┌────────┐  ┌────────┐    ┌────────┐  ┌────────┐
│ 代码    │               │ 代码    │  │ 代码    │    │ 代码    │  │ ls代码  │
│ 数据    │               │ 数据    │  │ 数据(副本)│   │ 数据    │  │ ls数据  │
│ 栈      │               │ 栈      │  │ 栈      │    │ 栈      │  │ ls栈   │
└────────┘               └────────┘  └────────┘    └────────┘  └────────┘
                              \____ 复制(Copy) ____/        \___ 覆盖 ____/

线程创建

  • 系统调用级:Linux clone、macOS thread_create_running、Windows NtCreateThreadEx;需要提供:起始程序计数器(要调用的函数)、线程栈区域与初始栈指针、参数传递方式。
  • 语言库包装:C++ std::thread t(func); t.join();、C pthread_create(...)、Go go func()、Python threading.Thread(...)

核(Core)与多核演化

  • 早期一台机器一个 CPU(单核);把多个处理器放进一颗芯片 → 多核(multicore);一颗核同时跑两个线程 → 同时多线程 SMT(Intel 超线程)
  • 今天的核数:手机约 8 核,笔记本 12–16,台式机 16–24,服务器 128–256 核(256–512 硬件线程)。

进程控制块(PCB, Process Control Block)

  • 定义:内核为每个进程维护的数据结构,包含:
    • 每个线程保存的执行状态(保存的寄存器等);
    • 调度信息;
    • 进程所用内存的信息;
    • 打开文件的信息;
    • 记账及其他杂项信息。

线程状态机

             ┌──────────┐
             │  Create  │
             └────┬─────┘
                  ▼
   Load      ┌──────────┐   Blocks    ┌──────────┐
 ┌──────────►│  Ready   │───────────►│  Blocked │
 │           └────┬─────┘            └────┬─────┘
 │                │ Unload               │ Unblocks
 │                ▼                      │
 │           ┌──────────┐                │
 └───────────│ Running  │◄───────────────┘
             └──────────┘
                  │ Exit
                  ▼
              (Terminated)
  • Ready:就绪,等待被调度到核上;Running:正在核上执行;Blocked:阻塞(等待 I/O、锁、事件)。

调度器(Dispatcher)与上下文切换

  • 单核调度器主循环: ``` while true:
    1. 让某个线程运行一段时间
    2. 保存它的执行状态
    3. 加载另一个线程的状态 end while ← 第2、3步合称”上下文切换” ```
  • 上下文切换机制图解(以线程 A 切换到 B 为例):
                硬件寄存器                进程A控制块              进程B控制块
                ┌──────────┐            ┌────────────┐          ┌────────────┐
                │ R0..RN   │            │ 保存的寄存器 │          │ 保存的寄存器 │
                │ SP       │──┐         │ (除SP外的   │          │            │
                │ PC       │  │         │  全部状态)  │          │            │
                └──────────┘  │         │ SP → A3栈   │          │ SP → B1栈   │
                              │         └────────────┘          └────────────┘
                              ▼
                      ┌─────────────┐      ┌─────────────┐
                      │ A3 线程栈    │      │ B1 线程栈    │
                      │ (A 已运行)   │      │ (B 已运行)   │
                      └─────────────┘      └─────────────┘
    步骤: 1) 调用 context_switch
       2) 把寄存器保存进 A 的 PCB(线程状态)
       3) 把 SP 切换为 B 的栈指针, 从 B 的 PCB 恢复寄存器
       4) 从 B 上次被中断/让出的地址继续执行
    
  • 是什么触发调度器运行?
    • 让进程自己回调 OS?→ 协作式多任务:坏进程会搞死机器。✗
    • 陷阱(trap):系统调用、非法指令、段错误、页错误等,自动跳入 OS;
    • 中断(interrupt):键盘按键、磁盘传输完成等外部事件;
    • 定时器(timer):如果进程没有主动让出 CPU,定时器周期性中断强制切换(抢占式调度的基础)。
  • 线程是怎么”出生”的:创建线程时,内核把 PCB 线程状态和栈初始化为”看起来刚从第一行指令上下文切换而来”的样子。

代码示例与系统调用解说

示例 A:fork + exec + wait 创建并运行新程序

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();                 // 1. 创建子进程(复制当前进程)

    if (pid < 0) {                      // fork 失败
        perror("fork failed");
        return 1;
    }

    if (pid == 0) {
        // 子进程代码: 用 ls 程序覆盖自己
        char* argv[] = {"ls", "-l", nullptr};
        execvp("ls", argv);             // 2. 覆盖当前进程的代码和数据
        perror("exec failed");          // 只有 exec 失败才会执行到这里
        return 1;
    } else {
        // 父进程代码: 等待子进程结束
        int status;
        waitpid(pid, &status, 0);       // 3. 阻塞直到子进程退出
        printf("Child (pid %d) finished\n", pid);
    }
    return 0;
}

【代码做了什么?】

  1. fork() 调用一次、返回两次:父进程得到子进程 PID,子进程得到 0。
  2. 子进程调用 execvp("ls", ...) 把自己替换成 ls 程序;exec 成功则不返回。
  3. 父进程 waitpid 阻塞等待,子进程结束后回收其退出状态。

【系统机制透视】

  • fork() 在内核中做了什么:分配新的 PCB 和地址空间结构,把父进程的页表/内存复制(现代实现是写时复制 COW:父子共享物理页并标记只读,任一方向写入时内核才复制该页)给子进程,把子进程加入就绪队列。
  • exec 做了什么:释放/替换进程的代码段、数据段,载入新可执行文件,重置栈与堆,保留文件描述符与环境变量(这正是”先 fork 再 exec”的意义——可以在 exec 前重定向 I/O)。
  • 隔离性:父进程和子进程的地址空间彼此独立,任何一方的修改不会影响另一方。

示例 B:std::thread 创建线程

#include <iostream>
#include <thread>

void worker(int id) {
    // 这段代码将与主线程并发执行
    std::cout << "Thread " << id << " running\n";
}

int main() {
    std::thread t(worker, 42);   // 创建一个新线程, 从 worker(42) 开始
    std::cout << "Main thread continues\n";
    t.join();                    // 等待线程 t 结束(否则进程退出时会崩溃/未定义行为)
    return 0;
}

【代码做了什么?】 std::thread 构造器在库内部调用线程创建系统调用(Linux 上是 clone),为新线程分配独立栈并从 worker 函数开始执行;join() 阻塞主线程直到工作线程完成。

【系统机制透视】

  • 与 fork 的本质区别std::thread 不复制地址空间——新线程与已有线程共享进程的全部代码、全局变量、堆和打开文件,只有寄存器和栈是私有的。这就是”轻量级”的含义。
  • 新线程的”出生”:内核创建一个线程控制块(TCB),把它的寄存器现场初始化成”刚从函数入口被调度进来”的样子(返回地址指向线程结束后的清理代码,栈指针指向新分配的栈顶)。
  • 代价:由于共享地址空间,线程间必须用同步机制(后续讲座的锁/条件变量)避免数据竞争。

关键要点

  1. 线程是最小的执行单元;进程 = 线程 + 执行状态,是资源分配与隔离的基本单位。
  2. 上下文切换 = 保存当前线程状态 + 恢复目标线程状态;由陷阱(trap)或中断(interrupt)触发,定时器是实现抢占式调度的关键硬件。
  3. fork/exec/wait 是 Unix 进程管理三件套;fork 复制进程、exec 换程序、wait 回收。
  4. 线程切换比进程切换便宜:同一进程内线程共享页表,切换时无需刷新 TLB;进程切换必须换页表。
  5. 调度器(dispatcher)是机制(怎么切),调度策略(scheduler)是政策(切给谁)——Lecture 8 详述。

常见陷阱与注意事项

  • 僵尸进程(Zombie):子进程退出后其 PCB 仍存在,直到父进程 wait() 回收;父进程不 wait 会造成内核资源泄漏。
  • 孤儿进程(Orphan):父进程先退出,子进程被 init(PID 1)收养并负责回收。
  • 忘写 t.join() / t.detach()std::thread 析构时线程仍可 join 会调用 std::terminate
  • 线程安全:多线程共享全局变量而无同步 → 数据竞争,结果不确定。
  • exec 失败检查exec 成功后不返回,必须检查失败情况(否则子进程会”悄悄”继续执行父进程代码)。

思考题

  1. 问题:为什么线程上下文切换通常比进程上下文切换开销小?
    • 答案:同一进程内的线程共享地址空间和页表,切换时不需要更换页表、不必刷新 TLB(缓存失效是进程切换的主要开销之一);进程切换必须载入新页表并使 TLB 全部失效,还要承受缓存冷启动。
  2. 问题fork() 之后父进程修改一个全局变量,子进程能看到吗?
    • 答案:看不到。fork() 创建的是独立副本(写时复制技术下,写入才复制物理页),父进程的修改只影响自己的副本,子进程的页不受影响。
  3. 问题:为什么现代 OS 用定时器中断而不是”请求进程主动让出 CPU”来驱动调度器?
    • 答案:协作式(cooperative)方案下,一个拒绝让出 CPU 的(恶意或出错的)进程会独占机器、使系统无法响应;定时器中断提供了抢占(preemption)能力,保证每个线程最终都能获得 CPU(公平性与可用性)。