一、程序运行时发生了什么?

当一个程序运行时,它做的事情非常简单:不断执行指令
处理器每秒执行数百万甚至数十亿条指令,每次循环做三件事:

取指令 (Fetch)  →  解码 (Decode)  →  执行 (Execute)

用ASCII图示意:

内存 (Memory)
┌───────────────────┐
│  指令1: ADD R1,R2 │ ← 处理器取出指令
│  指令2: LOAD R3   │
│  指令3: JMP 0x100 │
│  ...              │
└───────────────────┘
          ↓
    处理器 (CPU)
    ┌──────────────┐
    │ 1. 取指令    │
    │ 2. 解码      │
    │ 3. 执行      │
    │ 4. 下一条 →  │
    └──────────────┘

这就是经典的冯·诺依曼计算模型(Von Neumann Model)——程序存在内存里,处理器顺序取出并执行。

二、什么是操作系统(OS)?

操作系统是一套软件,负责:

  • 让程序方便地运行(甚至同时运行多个程序)
  • 让程序之间共享内存
  • 让程序能访问各种硬件设备(硬盘、网卡等)
    OS 实现这些目标的核心手段叫做虚拟化(Virtualization)

虚拟化:把真实的物理资源(CPU、内存、磁盘)包装成更通用、更易用的"虚拟版本"提供给程序使用。
因此,操作系统有时也被称为虚拟机(Virtual Machine)
OS 还提供系统调用(System Call)接口,让应用程序告诉操作系统"我要做什么"(如运行程序、申请内存、读写文件)。这些接口也叫做标准库(Standard Library)
由于 OS 管理着 CPU、内存、磁盘等共享资源,它也被称为资源管理器(Resource Manager)

三、虚拟化CPU

3.1 示例代码解析

common.h

#ifndef __common_h__
#define __common_h__
#include <sys/time.h>
#include <sys/stat.h>
#include <assert.h>
double GetTime() {
    struct timeval t;
    int rc = gettimeofday(&t, NULL);
    assert(rc == 0);
    return (double) t.tv_sec + (double) t.tv_usec/1e6;
}
void Spin(int howlong) {
    double t = GetTime();
    while ((GetTime() - t) < (double) howlong)
	; // do nothing in loop
}
#endif // __common_h__
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"
int main(int argc, char *argv[])
{
    /* 检查命令行参数:必须传入一个字符串 */
    if (argc != 2) {
        fprintf(stderr, "usage: cpu <string>\n");
        exit(1);
    }
    char *str = argv[1];  /* 取第一个命令行参数 */
    while (1) {
        Spin(1);           /* 等待约1秒(忙等待,反复检查时间) */
        printf("%s\n", str); /* 打印传入的字符串 */
    }
    return 0;
}

这个程序每隔1秒打印一次传入的字符串,永远循环。

3.2 同时运行多个程序

prompt> ./cpu A & ./cpu B & ./cpu C & ./cpu D &

& 符号让程序在后台运行,所以四个程序几乎同时启动。
输出结果:

A
B
D
C
A
B
...

问题:计算机只有一个 CPU,为什么四个程序能"同时"运行?
答案:这是操作系统制造的幻觉
OS 让 CPU 在多个程序之间快速切换,速度快到人感觉它们在同时运行。这就叫虚拟化CPU
用图示意:

物理CPU(只有1个)
        ↓ OS 快速切换
┌──────────────────────────────────┐
│  时间片1  时间片2  时间片3  时间片4  │
│  程序A   程序B   程序C   程序D    │
└──────────────────────────────────┘
  从人的角度看:好像4个程序同时在跑

当多个程序都想运行时,OS 要决定谁先跑——这由 OS 的**调度策略(Policy)**来决定。

四、虚拟化内存

4.1 示例代码解析

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
int main(int argc, char *argv[])
{
    /* malloc 申请一块存放 int 的内存,返回指向该内存的指针 */
    int *p = malloc(sizeof(int));
    assert(p != NULL);  /* 确保申请成功,否则程序终止 */
    /* 打印进程ID(PID)和申请到的内存地址 */
    printf("(%d) address pointed to by p: %p\n", getpid(), p);
    *p = 0;  /* 把该内存初始化为0 */
    while (1) {
        Spin(1);      /* 等待1秒 */
        *p = *p + 1;  /* 内存中的值加1 */
        printf("(%d) p: %d\n", getpid(), *p);  /* 打印PID和当前值 */
    }
    return 0;
}

4.2 奇怪的现象

同时运行两个此程序:

prompt> ./mem & ./mem &
(24113) address pointed to by p: 0x200000
(24114) address pointed to by p: 0x200000
(24113) p: 1
(24114) p: 1
(24113) p: 2
(24114) p: 2
...

两个程序申请到了相同的地址 0x200000,但它们各自独立地计数,互不干扰!
这说明每个程序看到的地址其实是虚拟地址(Virtual Address),不是真实的物理内存地址。
OS 为每个进程维护一个独立的虚拟地址空间(Virtual Address Space),并负责把虚拟地址映射到真实的物理内存:

进程A(PID 24113)          进程B(PID 24114)
虚拟地址 0x200000            虚拟地址 0x200000
       ↓                            ↓
  OS 内存映射表                OS 内存映射表
       ↓                            ↓
物理地址 0x5A0000            物理地址 0x7B0000
(实际存放在不同位置)

这就是虚拟化内存:每个程序以为自己独占整个内存,但实际上物理内存是所有进程共享的,由 OS 统一管理。

五、并发(Concurrency)

5.1 示例代码解析

common_threads.h

#ifndef __common_threads_h__
#define __common_threads_h__
#include <pthread.h>
#include <assert.h>
#include <sched.h>
#ifdef __linux__
#include <semaphore.h>
#endif
#define Pthread_create(thread, attr, start_routine, arg) assert(pthread_create(thread, attr, start_routine, arg) == 0);
#define Pthread_join(thread, value_ptr)                  assert(pthread_join(thread, value_ptr) == 0);
#define Pthread_mutex_lock(m)                            assert(pthread_mutex_lock(m) == 0);
#define Pthread_mutex_unlock(m)                          assert(pthread_mutex_unlock(m) == 0);
#define Pthread_cond_signal(cond)                        assert(pthread_cond_signal(cond) == 0);
#define Pthread_cond_wait(cond, mutex)                   assert(pthread_cond_wait(cond, mutex) == 0);
#define Mutex_init(m)                                    assert(pthread_mutex_init(m, NULL) == 0);
#define Mutex_lock(m)                                    assert(pthread_mutex_lock(m) == 0);
#define Mutex_unlock(m)                                  assert(pthread_mutex_unlock(m) == 0);
#define Cond_init(cond)                                  assert(pthread_cond_init(cond, NULL) == 0);
#define Cond_signal(cond)                                assert(pthread_cond_signal(cond) == 0);
#define Cond_wait(cond, mutex)                           assert(pthread_cond_wait(cond, mutex) == 0);
#ifdef __linux__
#define Sem_init(sem, value)                             assert(sem_init(sem, 0, value) == 0);
#define Sem_wait(sem)                                    assert(sem_wait(sem) == 0);
#define Sem_post(sem)                                    assert(sem_post(sem) == 0);
#endif // __linux__
#endif // __common_threads_h__
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "common_threads.h"
volatile int counter = 0;  /* 共享计数器,volatile 防止编译器优化掉它 */
int loops;                 /* 每个线程循环的次数 */
/* 工作线程:循环 loops 次,每次给 counter 加1 */
void *worker(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        counter++;  /* 看似简单,实际分3步:取值、加1、写回 */
    }
    return NULL;
}
int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: threads <value>\n");
        exit(1);
    }
    loops = atoi(argv[1]);  /* 将命令行参数转为整数 */
    pthread_t p1, p2;       /* 两个线程的句柄 */
    printf("Initial value : %d\n", counter);
    /* 创建线程1和线程2,都执行 worker 函数 */
    Pthread_create(&p1, NULL, worker, NULL);
    Pthread_create(&p2, NULL, worker, NULL);
    /* 等待两个线程都执行完毕 */
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);
    printf("Final value : %d\n", counter);
    return 0;
}

5.2 奇怪的结果

当 loops=1000 时,结果正确:

Initial value : 0
Final value : 2000    ← 正确(1000+1000=2000)

当 loops=100000 时:

Initial value : 0
Final value : 143012  ← 错误!应该是200000

为什么会出错?
counter++ 在 CPU 层面其实分三步执行:
counter++ = { 步骤1:  R ← counter (从内存读入寄存器) 步骤2:  R ← R + 1 (寄存器加1) 步骤3: counter ← R (写回内存) \text{counter++} = \begin{cases} \text{步骤1: } R \leftarrow \text{counter} & \text{(从内存读入寄存器)} \\ \text{步骤2: } R \leftarrow R + 1 & \text{(寄存器加1)} \\ \text{步骤3: } \text{counter} \leftarrow R & \text{(写回内存)} \end{cases} counter++= 步骤1: Rcounter步骤2: RR+1步骤3: counterR(从内存读入寄存器)(寄存器加1)(写回内存)
当两个线程交叉执行时,可能发生如下情况:

线程1                      线程2
读 counter=5
                           读 counter=5   ← 也读到了5,不是6!
counter+1 = 6
写回 counter=6
                           counter+1 = 6
                           写回 counter=6 ← 本应是7,却写成了6!

结果:两次加法只使 counter 增加了1,而不是2。这种现象叫做竞态条件(Race Condition)
这就是并发问题的核心:多个线程同时访问共享数据,且操作不是原子的(Atomic,即不可分割的),就会导致数据错误。

六、持久化(Persistence)

内存(DRAM)是易失性存储,断电数据就丢失。我们需要把数据持久地保存到磁盘上。
OS 中负责管理磁盘上文件的软件叫文件系统(File System)

6.1 文件I/O示例代码

#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <fcntl.h>
#include <sys/types.h>
int main(int argc, char *argv[]) {
    /*
     * open() 系统调用:打开(或创建)文件
     * 参数:
     *   "/tmp/file"    → 文件路径
     *   O_WRONLY       → 只写模式
     *   O_CREAT        → 若文件不存在则创建
     *   O_TRUNC        → 若文件存在则清空内容
     *   S_IRWXU        → 文件权限:当前用户可读/写/执行
     * 返回值:文件描述符(fd),失败时返回-1
     */
    int fd = open("/tmp/file",
                  O_WRONLY | O_CREAT | O_TRUNC,
                  S_IRWXU);
    assert(fd > -1);  /* 确保文件打开成功 */
    /*
     * write() 系统调用:向文件写入数据
     * 参数:fd(文件描述符),数据指针,字节数
     * 返回值:实际写入的字节数
     */
    int rc = write(fd, "hello world\n", 13);
    assert(rc == 13);  /* 确保13个字节全部写入成功 */
    /*
     * close() 系统调用:关闭文件
     * 表示不再写入,OS可以将缓冲区的数据刷入磁盘
     */
    close(fd);
    return 0;
}

6.2 文件操作流程

应用程序调用 open() / write() / close()
         ↓
     系统调用接口
         ↓
     文件系统(OS)
         ↓  ┌──────────────────────────────────────┐
            │ 1. 决定数据存放在磁盘的哪个位置        │
            │ 2. 更新目录/索引等管理结构             │
            │ 3. 发出 I/O 请求给存储设备             │
            │ 4. 为防止崩溃,使用日志(Journaling)等  │
            └──────────────────────────────────────┘
         ↓
      硬盘/SSD

文件系统不像 CPU/内存那样为每个程序创建私有的"虚拟磁盘",而是让多个程序共享同一个文件空间(例如编译器读取源码文件,链接器再处理编译结果)。

七、操作系统的设计目标

OS 在设计时需要在多个目标之间做权衡:

目标 说明
抽象(Abstraction) 提供简单易用的接口,隐藏硬件复杂性
高性能(Performance) 虚拟化的开销要尽可能小
保护(Protection) 程序之间相互隔离,一个程序不能破坏另一个
可靠性(Reliability) OS 本身不能崩溃,否则所有程序都会受影响
节能(Energy-efficiency) 在移动设备上尤为重要
安全(Security) 防止恶意程序攻击系统

八、操作系统发展简史

1950s 早期操作系统 仅是常用函数库 批处理(Batch Processing) 1960s 系统调用出现 Atlas系统首创 用户态/内核态分离 1970s 多道程序时代 UNIX诞生(Bell Labs) Ken Thompson & Dennis Ritchie 1980s 个人电脑时代 DOS出现,但缺少保护 早期Mac OS不稳定 1991年 Linux诞生 Linus Torvalds 开源运动兴起 现代 macOS/Windows成熟 移动设备OS(Android/iOS) 云计算操作系统 操作系统发展史

关键概念演进

系统调用(System Call)的诞生
早期 OS 只是一个库,程序直接调用函数访问磁盘等资源,没有任何保护。后来发现这样不安全(任何程序都能读所有文件),于是发明了系统调用:

用户程序
   |
   | 普通函数调用(user mode,权限受限)
   |
   ↓  执行 trap 指令 → 硬件提升权限
操作系统内核(kernel mode,全权限)
   |
   | 执行请求(如读磁盘)
   |
   ↓  return-from-trap 指令 → 恢复用户态
用户程序继续运行

多道程序(Multiprogramming)

  • 背景:I/O 设备很慢,程序等待 I/O 时 CPU 空闲,浪费资源
  • 解决方案:把多个程序都加载到内存,一个等 I/O 时就切换到另一个运行
  • 挑战:需要内存保护(程序不能互相读写内存),需要处理并发问题

九、三大主题总结

操作系统 OS

虚拟化 Virtualization

并发 Concurrency

持久化 Persistence

虚拟化CPU
让多个程序'同时'运行

虚拟化内存
每个进程独立的地址空间

多线程
共享内存中的数据

竞态条件
需要同步原语解决

文件系统
管理磁盘上的数据]

日志/写时复制
防止崩溃导致数据损坏

十、完整可运行的C语言演示程序

以下是一个展示并发问题的完整C程序,无需任何非标准头文件:

/*
 * 演示并发竞态条件的完整程序
 * 编译:gcc -o concurrency_demo concurrency_demo.c -lpthread
 * 运行:./concurrency_demo 100000
 */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
/* 共享计数器,volatile 告诉编译器不要优化这个变量的读写 */
volatile int counter = 0;
int loops;  /* 每个线程循环的次数 */
/* 线程执行的函数:循环累加计数器 */
void *worker(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        /*
         * counter++ 编译后实际是三条指令:
         *   MOV R, [counter]   ; 从内存读到寄存器
         *   ADD R, 1           ; 寄存器加1
         *   MOV [counter], R   ; 写回内存
         * 两个线程可能在这三步中间被打断,导致数据丢失
         */
        counter++;
    }
    return NULL;
}
int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <循环次数>\n", argv[0]);
        fprintf(stderr, "示例: %s 100000\n", argv[0]);
        return 1;
    }
    loops = atoi(argv[1]);
    pthread_t thread1, thread2;  /* 两个线程的标识符 */
    printf("初始计数器值: %d\n", counter);
    printf("期望最终值: %d\n", loops * 2);
    /* 创建两个线程,都执行 worker 函数 */
    if (pthread_create(&thread1, NULL, worker, NULL) != 0) {
        fprintf(stderr, "创建线程1失败\n");
        return 1;
    }
    if (pthread_create(&thread2, NULL, worker, NULL) != 0) {
        fprintf(stderr, "创建线程2失败\n");
        return 1;
    }
    /* 等待两个线程执行完毕 */
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    printf("实际最终值: %d\n", counter);
    if (counter == loops * 2) {
        printf("结果:正确(这次运气好,没出现竞态条件)\n");
    } else {
        printf("结果:错误!丢失了 %d 次加法操作\n", loops * 2 - counter);
        printf("原因:并发竞态条件(Race Condition)\n");
    }
    return 0;
}

编译并运行

gcc -o concurrency_demo concurrency_demo.c -lpthread
./concurrency_demo 1000      # 小数字,通常正确
./concurrency_demo 100000    # 大数字,大概率出错

十一、关键术语速查


英文术语 中文 简单解释
Operating System 操作系统 管理硬件资源、为应用提供服务的软件
Virtualization 虚拟化 把一个物理资源变成多个易用的虚拟资源
System Call 系统调用 应用程序请求OS服务的接口
Process 进程 运行中的程序实例
Thread 线程 进程内的执行单元,共享同一内存空间
Virtual Address Space 虚拟地址空间 每个进程看到的独立内存视图
Concurrency 并发 多个任务同时推进,可能产生竞态问题
Race Condition 竞态条件 多线程不加同步访问共享数据导致的错误
Atomic Operation 原子操作 不可被中断的操作,要么全做要么不做
File System 文件系统 OS中管理磁盘上文件的子系统
Persistence 持久化 数据在断电后仍然保存
Kernel Mode 内核态 CPU特权模式,OS在此模式下运行
User Mode 用户态 受限模式,普通应用程序在此运行
Multiprogramming 多道程序 多个程序同时驻留内存并轮流使用CPU
Batch Processing 批处理 早期计算模式,一批作业顺序执行

进程抽象 — 详细中文解析

原书:《Operating Systems: Three Easy Pieces》(OSTEP) 第4章

一、引子:用桃子理解虚拟化

书中用了一个生动的比喻:

假设只有一个桃子(物理桃子),但有很多人想吃。我们给每个人都"变出"一个虚拟的桃子,让他们觉得自己拥有一个真实的桃子——但实际上大家在轮流共享那一个。
关键:大多数时候,吃桃子的人在打盹或做别的事,所以可以把桃子悄悄传给下一个人,再传给下一个……
对应到计算机


桃子的比喻 计算机中的实体
物理桃子(只有1个) 物理 CPU(只有1个或少数几个)
虚拟桃子(每人一个) 虚拟 CPU(每个程序以为自己独占)
轮流吃桃 时间片轮转(Time Sharing)

二、什么是进程(Process)?

进程是操作系统提供的最基本抽象之一。

进程 = 运行中的程序
注意区分:

程序(Program)                进程(Process)
─────────────────────          ─────────────────────
存放在磁盘上的文件              正在内存中运行的实体
一堆静态的指令和数据            有生命力,占用CPU和内存
就像一本放在书架上的书          就像一个人正在阅读这本书

一台典型的计算机可能同时运行数十甚至数百个进程(浏览器、音乐播放器、邮件客户端……),这让用户感觉 CPU 好像是"无限供应"的。
核心问题:物理 CPU 只有几个,OS 如何制造出"无数 CPU"的幻觉?
答案时分复用(Time Sharing)——快速地轮流运行各个进程,速度快到人感觉不出切换。
代价是:如果进程很多,每个进程分到的 CPU 时间就少,运行速度会变慢。

三、机制(Mechanism)与策略(Policy)

OS 实现虚拟化 CPU 需要两层:

┌─────────────────────────────────────────┐
│              高层:策略 (Policy)         │
│  回答"which"问题:现在应该运行哪个进程?  │
│  例:调度策略(优先级、公平性、吞吐量…)  │
├─────────────────────────────────────────┤
│              低层:机制 (Mechanism)      │
│  回答"how"问题:如何切换进程?           │
│  例:上下文切换(Context Switch)        │
└─────────────────────────────────────────┘

分离的好处:改变调度策略(Policy)时,不需要重写底层切换机制(Mechanism)。这是软件设计中模块化原则的体现。
类比:

  • 机制 = 汽车的变速箱(如何换挡)
  • 策略 = 司机决定什么时候换挡

四、进程的机器状态(Machine State)

一个进程在运行时,需要记录哪些信息?

4.1 内存(Address Space)

进程能访问的内存区域称为地址空间(Address Space),包含:

高地址
┌─────────────────┐
│     栈 (Stack)  │  ← 函数调用、局部变量、返回地址
│        ↓        │     向低地址增长
│                 │
│        ↑        │
│    堆 (Heap)    │  ← malloc() 动态分配的内存,向高地址增长
├─────────────────┤
│  静态数据区      │  ← 全局变量、静态变量
├─────────────────┤
│   代码段 (Text) │  ← 程序指令本身
低地址
└─────────────────┘

4.2 寄存器(Registers)

CPU 内部的临时存储单元,执行指令时频繁读写。
几个特别重要的寄存器:

寄存器名 作用
PC(程序计数器 / 指令指针) 指向下一条要执行的指令的地址
栈指针(Stack Pointer) 指向当前栈顶位置
帧指针(Frame Pointer) 帮助管理函数调用栈帧
通用寄存器(EAX/EBX…) 存放运算的中间结果

4.3 I/O 信息

进程当前打开了哪些文件——操作系统用**文件描述符(File Descriptor)**来追踪。

五、进程 API

任何 OS 都必须提供以下基本接口来操作进程:

API 操作 说明
Create(创建) 在 shell 中输入命令或双击图标时,OS 创建新进程
Destroy(销毁) 强制终止不听话的进程(如卡死的程序)
Wait(等待) 等待某个进程执行完毕
Miscellaneous(其他控制) 挂起(暂停)、恢复进程运行
Status(状态查询) 查询进程已运行多长时间、当前状态等

六、进程创建的详细过程

OS 如何把磁盘上的程序文件变成一个运行中的进程?

磁盘上的可执行文件

OS读取代码和静态数据
加载到内存地址空间

OS为栈分配内存
并初始化 argc/argv

OS为堆分配初始内存

OS做I/O初始化
打开stdin/stdout/stderr

OS跳转到 main 函数
将CPU控制权交给进程

进程开始运行

各步骤详解

步骤1:加载代码和静态数据

  • 早期OS:饥饿加载(Eager Loading)——一次性把所有内容读入内存
  • 现代OS:懒惰加载(Lazy Loading)——只在真正需要时才把对应代码/数据页读入内存(依赖分页/交换机制)
    步骤2:初始化栈
    C 程序的栈用于:局部变量、函数参数传递、保存返回地址。
    OS 会把 argc(参数个数)和 argv(参数数组)填入栈中,这样 main() 函数一开始就能读到命令行参数。
    步骤3:分配堆
    堆用于程序运行时动态申请的内存(malloc())。堆初始很小,随着程序调用 malloc() 而增长,OS 按需分配更多物理内存。
    步骤4:I/O 初始化
    UNIX 中,每个新进程默认获得三个已打开的文件描述符:
文件描述符 0  →  标准输入  (stdin)   ← 键盘输入
文件描述符 1  →  标准输出  (stdout)  → 屏幕输出
文件描述符 2  →  标准错误  (stderr)  → 屏幕(错误信息)

步骤5:跳转到 main()
OS 通过特殊机制将 CPU 执行权移交给新进程的 main() 函数入口,进程正式开始运行。

七、进程的三种状态

进程在其生命周期中可以处于三种基本状态:

状态 说明
Running(运行) 正在 CPU 上执行指令
Ready(就绪) 准备好了,但 OS 暂时没有把 CPU 给它
Blocked(阻塞) 在等待某个事件(如磁盘I/O完成),无法运行

状态转换图

创建进程

OS 调度(Scheduled)

OS 取消调度(Descheduled)

发起I/O请求

I/O完成

进程退出

Ready

Running

Blocked

用 ASCII 再直观描述:

            OS调度
   就绪  ──────────→  运行
   Ready  ←──────────  Running
            OS取消调度
               │              ↑
   发起I/O     │              │ I/O完成
               ↓              │
            阻塞  ────────────┘
            Blocked

八、进程状态追踪示例

示例一:只有CPU操作,无I/O

两个进程 P0 和 P1,都只做 CPU 计算:

时刻   P0状态      P1状态      说明
─────────────────────────────────────
  1    Running     Ready
  2    Running     Ready
  3    Running     Ready
  4    Running     Ready       P0 运行完毕
  5    ------      Running
  6    ------      Running
  7    ------      Running
  8    ------      Running     P1 运行完毕

P0 先独占 CPU 跑完,P1 再跑——串行执行,CPU 利用率 100%(但没有并发)。

示例二:有I/O操作

P0 运行一段时间后发起 I/O 请求,P1 趁机使用 CPU:

时刻   P0状态      P1状态      说明
─────────────────────────────────────
  1    Running     Ready
  2    Running     Ready
  3    Running     Ready       P0 发起 I/O
  4    Blocked     Running     P0 等I/O,P1 上CPU
  5    Blocked     Running
  6    Blocked     Running     I/O 完成,P0 变就绪
  7    Ready       Running
  8    Ready       Running     P1 运行完毕
  9    Running     ------
 10    Running     ------      P0 运行完毕

关键洞察:当 P0 在等 I/O 时(CPU 对它没用),OS 把 CPU 给 P1 用,这样 CPU 几乎没有空闲——资源利用率更高
这正是多道程序(Multiprogramming)的核心价值。

九、OS 追踪进程的数据结构

OS 本身也是一个程序,它用数据结构来记录所有进程的信息。

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

每个进程对应一个 PCB,也叫进程描述符
下面是 xv6(MIT 教学用 OS)的进程结构体,加上中文注释:

/* xv6 保存和恢复进程时需要的寄存器集合
 * 进程切换时,把这些寄存器的值保存到这里
 * 恢复时再把这里的值写回真实的寄存器
 */
struct context {
    int eip;  /* 指令指针:下一条要执行的指令地址 */
    int esp;  /* 栈指针:当前栈顶位置 */
    int ebx;  /* 通用寄存器 B */
    int ecx;  /* 通用寄存器 C(常用于循环计数) */
    int edx;  /* 通用寄存器 D */
    int esi;  /* 源索引寄存器(字符串操作) */
    int edi;  /* 目的索引寄存器(字符串操作) */
    int ebp;  /* 基址指针:当前函数栈帧的底部 */
};
/* 进程的所有可能状态 */
enum proc_state {
    UNUSED,    /* 该 PCB 槽位空闲,还未分配给任何进程 */
    EMBRYO,    /* 进程正在被创建("胚胎"状态) */
    SLEEPING,  /* 进程在睡眠/等待(即阻塞状态) */
    RUNNABLE,  /* 进程就绪,等待 OS 调度(即就绪状态) */
    RUNNING,   /* 进程正在 CPU 上运行 */
    ZOMBIE     /* 进程已退出,但父进程还没回收它(僵尸状态) */
};
/* xv6 中描述每个进程的完整结构体 */
struct proc {
    char *mem;                  /* 进程内存的起始地址(在物理内存中) */
    uint sz;                    /* 进程占用内存的大小(字节数) */
    char *kstack;               /* 该进程的内核栈底部地址
                                 * 每个进程有独立的内核栈,
                                 * 用于在内核态处理系统调用/中断 */
    enum proc_state state;      /* 进程当前状态(见上面的枚举) */
    int pid;                    /* 进程ID:每个进程唯一的整数标识符 */
    struct proc *parent;        /* 指向父进程的指针 */
    void *chan;                 /* 若非零,表示进程在等待这个"频道"上的事件 */
    int killed;                 /* 若非零,表示该进程已被标记为"待杀" */
    struct file *ofile[NOFILE]; /* 进程打开的文件表(文件描述符数组) */
    struct inode *cwd;          /* 当前工作目录(Current Working Directory) */
    struct context context;     /* 切换到此进程时需要恢复的寄存器状态
                                 * 这是实现上下文切换的核心数据 */
    struct trapframe *tf;       /* 陷阱帧:保存进入内核时的 CPU 状态
                                 * 用于系统调用和中断处理返回 */
};

进程列表

OS 维护一张进程列表(Process List),里面是所有进程的 PCB:

进程列表 (Process List)
┌──────────────────────────────────────────┐
│ PCB[0]: pid=1,  state=RUNNING, ...       │  ← shell
│ PCB[1]: pid=42, state=RUNNABLE, ...      │  ← 浏览器
│ PCB[2]: pid=57, state=SLEEPING, ...      │  ← 音乐播放器(等I/O)
│ PCB[3]: pid=63, state=ZOMBIE,  ...       │  ← 已退出但未被回收
│ ...                                      │
└──────────────────────────────────────────┘

十、僵尸进程(Zombie Process)

进程退出后,并不会立刻消失,而是进入 ZOMBIE(僵尸) 状态。
原因:父进程可能需要查看子进程的退出码,判断它是否成功完成了任务。

  • UNIX 惯例:返回 0 表示成功,非零表示出错
  • 父进程调用 wait() 来回收子进程,OS 此后才真正清理 PCB
子进程退出
    ↓
进入 ZOMBIE 状态(释放内存,但保留退出码)
    ↓
父进程调用 wait(),读取退出码
    ↓
OS 彻底删除 PCB,进程消亡

如果父进程不调用 wait(),僵尸进程会一直占着 PCB 槽——资源泄漏。

十一、完整可运行的C语言演示程序

程序1:观察进程创建与状态

/*
 * 演示进程创建(fork)和等待(wait)
 * 编译:gcc -o process_demo process_demo.c
 * 运行:./process_demo
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>   /* fork(), getpid(), getppid() */
#include <sys/wait.h> /* wait() */
int main(void)
{
    printf("=== 进程演示程序 ===\n");
    printf("父进程 PID = %d\n\n", getpid());
    /*
     * fork() 系统调用:创建一个子进程
     * 返回值:
     *   在父进程中返回子进程的 PID(正整数)
     *   在子进程中返回 0
     *   出错时返回 -1
     */
    pid_t child_pid = fork();
    if (child_pid < 0) {
        /* fork 失败 */
        fprintf(stderr, "fork 失败!\n");
        return 1;
    }
    else if (child_pid == 0) {
        /*
         * 子进程执行这里
         * 子进程是父进程的一份完整拷贝,从 fork() 之后继续执行
         */
        printf("[子进程] PID = %d, 父进程 PID = %d\n",
               getpid(), getppid());
        printf("[子进程] 我在工作,稍后退出...\n");
        sleep(2); /* 模拟做了一些工作 */
        printf("[子进程] 工作完成,退出码 = 0\n");
        exit(0); /* 子进程退出,进入 ZOMBIE 状态,等父进程回收 */
    }
    else {
        /*
         * 父进程执行这里
         * child_pid 是刚创建的子进程的 PID
         */
        printf("[父进程] 我创建了子进程,子进程 PID = %d\n", child_pid);
        printf("[父进程] 等待子进程完成...\n");
        /*
         * wait() 系统调用:阻塞父进程,直到子进程退出
         * status 会保存子进程的退出信息
         */
        int status;
        pid_t finished_pid = wait(&status);
        /*
         * WIFEXITED(status)  检查子进程是否正常退出
         * WEXITSTATUS(status) 获取子进程的退出码
         */
        if (WIFEXITED(status)) {
            printf("[父进程] 子进程 %d 已退出,退出码 = %d\n",
                   finished_pid, WEXITSTATUS(status));
        }
        printf("[父进程] 清理完毕,我也退出了。\n");
    }
    return 0;
}

https://godbolt.org/z/Wdx6Y98vP
预期输出

=== 进程演示程序 ===
父进程 PID = 12345
[父进程] 我创建了子进程,子进程 PID = 12346
[父进程] 等待子进程完成...
[子进程] PID = 12346, 父进程 PID = 12345
[子进程] 我在工作,稍后退出...
[子进程] 工作完成,退出码 = 0
[父进程] 子进程 12346 已退出,退出码 = 0
[父进程] 清理完毕,我也退出了。

程序2:模拟进程状态机

/*
 * 用文本模拟进程状态转换:Running → Blocked → Ready → Running
 * 编译:gcc -o state_demo state_demo.c
 * 运行:./state_demo
 */
#include <stdio.h>
#include <unistd.h>  /* sleep() */
/* 定义进程状态枚举(模拟 OS 内部的 proc_state) */
typedef enum {
    READY,
    RUNNING,
    BLOCKED
} ProcessState;
/* 将状态枚举转换为可读字符串 */
const char* state_name(ProcessState s) {
    switch (s) {
        case READY:   return "就绪(READY)  ";
        case RUNNING: return "运行(RUNNING)";
        case BLOCKED: return "阻塞(BLOCKED)";
        default:      return "未知";
    }
}
/* 打印当前两个进程的状态 */
void print_states(int time, ProcessState p0, ProcessState p1, const char *note) {
    printf("时刻%2d  P0: %s  |  P1: %s   %s\n",
           time, state_name(p0), state_name(p1), note);
}
int main(void)
{
    printf("=== 进程状态转换模拟(含I/O)===\n");
    printf("时刻    进程0状态          进程1状态          说明\n");
    printf("------------------------------------------------------\n");
    /* 模拟图4.4的场景:P0运行后发起I/O,P1趁机运行 */
    print_states(1,  RUNNING, READY,   "P0 运行中");
    print_states(2,  RUNNING, READY,   "P0 运行中");
    print_states(3,  RUNNING, READY,   "P0 发起I/O请求...");
    sleep(1);
    print_states(4,  BLOCKED, RUNNING, "P0 等I/O,P1 被调度上CPU");
    print_states(5,  BLOCKED, RUNNING, "P0 仍在等,P1 运行中");
    print_states(6,  BLOCKED, RUNNING, "I/O完成!P0变就绪");
    sleep(1);
    print_states(7,  READY,   RUNNING, "P0 就绪,P1 还在运行");
    print_states(8,  READY,   RUNNING, "P1 运行完毕");
    print_states(9,  RUNNING, BLOCKED, "P0 重新获得CPU"); /* BLOCKED此处表示已完成 */
    print_states(10, RUNNING, BLOCKED, "P0 运行完毕");
    printf("------------------------------------------------------\n");
    printf("结论:通过在P0等待I/O时运行P1,CPU几乎没有空闲!\n");
    return 0;
}

https://godbolt.org/z/snare5Y7a

十二、关键概念汇总

进程 Process

机器状态 Machine State

进程API

进程状态

数据结构

内存/地址空间
代码/堆/栈/静态数据

寄存器
PC / SP / 通用寄存器

I/O信息
打开的文件列表

创建 fork/exec

销毁 kill

等待 wait

状态查询

运行 Running

就绪 Ready

阻塞 Blocked

僵尸 Zombie

进程控制块 PCB

进程列表 Process List

上下文 Context
保存寄存器用于切换

十三、时分复用 vs 空间分复用

OS 共享资源有两种基本方式:
资源共享方式 = { 时分复用(Time Sharing) 资源轮流使用,如CPU 空间分复用(Space Sharing) 资源分块占用,如磁盘 \text{资源共享方式} = \begin{cases} \text{时分复用(Time Sharing)} & \text{资源轮流使用,如CPU} \\ \text{空间分复用(Space Sharing)} & \text{资源分块占用,如磁盘} \end{cases} 资源共享方式={时分复用(Time Sharing空间分复用(Space Sharing资源轮流使用,如CPU资源分块占用,如磁盘
时分复用:CPU 在时间维度上轮流分配给不同进程

  • 每个进程运行一小段时间,然后换下一个
  • 每个进程感觉自己独占了 CPU
    空间分复用:磁盘在空间维度上分配给不同文件
  • 文件A占用磁盘的第100200块,文件B占用第201300块
  • 一个块一旦分给某文件,就不能同时给另一个文件用

十四、关键术语速查


英文 中文 简单解释
Process 进程 运行中的程序,OS 调度的基本单位
Program Counter (PC) 程序计数器 指向下一条指令的寄存器
Address Space 地址空间 进程能访问的内存范围
Stack 存放局部变量和函数调用信息
Heap 动态分配的内存区域
Time Sharing 时分复用 轮流使用 CPU 制造并发幻觉
Context Switch 上下文切换 保存一个进程状态、恢复另一个进程状态
Mechanism 机制 如何做(底层实现)
Policy 策略 做什么(高层决策)
PCB 进程控制块 存放一个进程所有信息的数据结构
Process List 进程列表 OS 维护的所有 PCB 的集合
Zombie 僵尸状态 进程已退出但父进程未回收时的状态
Eager Loading 饥饿加载 一次性全部加载到内存
Lazy Loading 懒惰加载 按需加载,用到哪块才加载哪块
File Descriptor 文件描述符 OS 用整数代表打开的文件(0=stdin,1=stdout,2=stderr)

进程 API — 详细中文解析

原书:《Operating Systems: Three Easy Pieces》(OSTEP) 第5章

一、本章概述

UNIX 提供了一套优雅的进程创建接口,核心是三个系统调用:

系统调用 作用
fork() 创建一个新进程(子进程),是父进程的近似拷贝
exec() 让当前进程变身为另一个程序(替换自身)
wait() 父进程等待子进程执行完毕

这三个调用组合在一起,构成了 UNIX shell 的运行基础,也是实现管道、重定向等强大功能的关键。

二、fork() —— 最奇怪的系统调用

2.1 fork() 做了什么

fork() 创建一个新进程,新进程是调用者(父进程)的近似完整拷贝

  • 拥有自己独立的地址空间(代码、栈、堆各自一份)
  • 拥有自己的寄存器、程序计数器(PC)
  • 不从 main() 开始运行,而是从 fork() 返回处继续运行
    fork() 的返回值是区分父子进程的唯一依据:
    fork() 返回值 = { > 0 (父进程中)返回子进程的 PID = 0 (子进程中)返回 0 < 0 fork 失败 \text{fork() 返回值} = \begin{cases} > 0 & \text{(父进程中)返回子进程的 PID} \\ = 0 & \text{(子进程中)返回 0} \\ < 0 & \text{fork 失败} \end{cases} fork() 返回值= >0=0<0(父进程中)返回子进程的 PID(子进程中)返回 0fork 失败

2.2 完整注释代码(p1.c)

/*
 * 演示 fork() 的基本用法
 * 编译:gcc -o p1 p1.c
 * 运行:./p1
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>   /* fork(), getpid() */
int main(int argc, char *argv[])
{
    /* getpid() 返回当前进程的 PID(进程标识符)
     * 这行只会打印一次,因为 fork() 还没调用 */
    printf("hello (pid:%d)\n", (int) getpid());
    /*
     * fork() 系统调用:创建子进程
     *
     * 调用之后,系统中有两个进程(父和子),
     * 它们都从这里的下一行继续执行。
     * 区别只在于 rc 的值不同:
     *   父进程:rc = 子进程的PID(正整数)
     *   子进程:rc = 0
     */
    int rc = fork();
    if (rc < 0) {
        /* fork 失败,通常是系统资源不足 */
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    else if (rc == 0) {
        /*
         * 子进程执行这里
         * rc == 0 是子进程的标志
         * 子进程有自己独立的内存空间(从父进程拷贝而来)
         */
        printf("child (pid:%d)\n", (int) getpid());
    }
    else {
        /*
         * 父进程执行这里
         * rc 存放的是子进程的 PID
         * 父进程和子进程的执行顺序是不确定的(由 OS 调度器决定)
         */
        printf("parent of %d (pid:%d)\n", rc, (int) getpid());
    }
    return 0;
}

2.3 执行过程图解

程序开始
    │
    ▼
打印 "hello (pid:29146)"          ← 只打印一次
    │
    ▼
调用 fork()
    │
    ├─────────────────────────────┐
    │(父进程继续)                 │(子进程在此"诞生")
    ▼                             ▼
rc = 29147(子进程PID)          rc = 0
    │                             │
    ▼                             ▼
进入 else 分支                   进入 else if (rc==0) 分支
打印 "parent of 29147 ..."       打印 "child (pid:29147)"

关键点:输出顺序是不确定的(Non-deterministic)
父进程可能先打印,也可能子进程先打印,取决于 OS 调度器的决定。两种可能的输出:

可能输出1:              可能输出2:
hello (pid:29146)       hello (pid:29146)
parent of 29147 ...     child (pid:29147)
child (pid:29147)       parent of 29147 ...

三、wait() —— 让父进程等待子进程

3.1 wait() 的作用

wait() 让父进程阻塞自身,直到某个子进程退出才返回。返回值是已退出的子进程的 PID。
这样可以保证输出顺序确定:子进程一定先完成,父进程再继续。

3.2 完整注释代码(p2.c)

/*
 * 演示 fork() + wait() 的配合使用
 * 编译:gcc -o p2 p2.c
 * 运行:./p2
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>    /* fork(), getpid() */
#include <sys/wait.h>  /* wait() */
int main(int argc, char *argv[])
{
    printf("hello (pid:%d)\n", (int) getpid());
    int rc = fork();  /* 创建子进程 */
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    else if (rc == 0) {
        /* 子进程:直接打印然后退出 */
        printf("child (pid:%d)\n", (int) getpid());
        /* 子进程在这里隐式 return 0,退出 */
    }
    else {
        /*
         * 父进程:调用 wait() 等待子进程结束
         *
         * wait(NULL) 表示不关心子进程的退出状态
         * 返回值 rc_wait 是已退出的子进程的 PID
         *
         * 如果子进程先于父进程运行并退出,
         * 那父进程调用 wait() 时会立即返回。
         * 如果父进程先运行到 wait(),
         * 那父进程会在这里"睡觉",等子进程退出后再唤醒。
         */
        int rc_wait = wait(NULL);
        printf("parent of %d (rc_wait:%d) (pid:%d)\n",
               rc, rc_wait, (int) getpid());
    }
    return 0;
}

3.3 加了 wait() 后的执行流

两种场景,结果都一样:
场景A:父进程先被调度
  父进程运行 → 调用 wait() → 阻塞(进入睡眠)
                                     │
                              子进程被调度运行
                              打印 "child..."
                              子进程退出
                                     │
  父进程从 wait() 返回 ←────────────┘
  打印 "parent of..."
场景B:子进程先被调度
  子进程运行 → 打印 "child..." → 子进程退出
  父进程运行 → 调用 wait()(子进程已退出,立即返回)
  打印 "parent of..."
两种场景下,最终输出顺序都是:
  1. hello
  2. child
  3. parent of...

四、exec() —— 变身为另一个程序

4.1 exec() 做了什么

exec() 不是创建新进程,而是让当前进程"变身"成另一个程序

  • 从磁盘加载指定程序的代码和静态数据
  • 覆盖当前进程的代码段和数据段
  • 重新初始化堆和栈
  • 从新程序的 main() 开始执行
    成功的 exec() 调用永远不会返回,因为当前程序已经被替换掉了。
    exec() 效果 = 当前进程的外壳(PID不变) 内部完全替换为新程序 \text{exec() 效果} = \frac{\text{当前进程的外壳(PID不变)}}{\text{内部完全替换为新程序}} exec() 效果=内部完全替换为新程序当前进程的外壳(PID不变)

4.2 完整注释代码(p3.c)

/*
 * 演示 fork() + exec() + wait() 的经典组合
 * 子进程通过 execvp() 运行 wc(词数统计)程序
 *
 * 编译:gcc -o p3 p3.c
 * 运行:./p3
 * 注意:需要当前目录下有 p3.c 文件才能看到 wc 的输出
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>    /* fork(), execvp() */
#include <string.h>    /* strdup() */
#include <sys/wait.h>  /* wait() */
int main(int argc, char *argv[])
{
    printf("hello (pid:%d)\n", (int) getpid());
    int rc = fork();  /* 创建子进程 */
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    else if (rc == 0) {
        /* 子进程执行这里 */
        printf("child (pid:%d)\n", (int) getpid());
        /*
         * 准备传给 exec 的参数数组
         * myargs[0] = 要运行的程序名
         * myargs[1] = 程序的第一个参数
         * myargs[2] = NULL(必须以NULL结尾,表示参数列表结束)
         */
        char *myargs[3];
        myargs[0] = strdup("wc");    /* 程序名:wc(word count,统计行数/单词/字节) */
        myargs[1] = strdup("p3.c");  /* 参数:对 p3.c 文件进行统计 */
        myargs[2] = NULL;            /* 参数列表结束标志 */
        /*
         * execvp() 系统调用:
         *   第一个参数:程序名(会在 PATH 环境变量的路径中搜索)
         *   第二个参数:参数数组
         *
         * 调用成功后:
         *   - 当前进程的代码被 wc 程序的代码完全替换
         *   - 从 wc 的 main() 开始执行
         *   - 下面的 printf 永远不会被执行到!
         *
         * 调用失败后才会继续执行下面的代码(如程序不存在)
         */
        execvp(myargs[0], myargs);
        /* 如果能执行到这里,说明 execvp 失败了 */
        printf("this shouldn't print out\n");
    }
    else {
        /*
         * 父进程:等待子进程(实际上是 wc 程序)运行完毕
         * wait() 返回后,wc 已经执行完,输出已经打印到屏幕
         */
        int rc_wait = wait(NULL);
        printf("parent of %d (rc_wait:%d) (pid:%d)\n",
               rc, rc_wait, (int) getpid());
    }
    return 0;
}

4.3 fork + exec 的执行过程

父进程(p3,PID=29383)
    │
    ├── fork() ──────────────────────────────┐
    │                                        │
    │(父进程)                               │(子进程,PID=29384)
    │ 调用 wait(),阻塞等待                   │ 打印 "child (pid:29384)"
    │                                        │ 准备参数 ["wc", "p3.c", NULL]
    │                                        │ 调用 execvp("wc", myargs)
    │                                        │
    │                                        │ ↓ 子进程内部被完全替换
    │                                        │ wc 程序开始运行
    │                                        │ 输出:29  107  1030  p3.c
    │                                        │ wc 程序退出
    │                                        │
    │ wait() 返回,rc_wait=29384 ←───────────┘
    │ 打印 "parent of 29384..."
    │
   结束

五、为什么要把 fork() 和 exec() 分开?

这个设计看起来很奇怪——为什么不直接提供一个"创建新进程并运行新程序"的调用?
答案:分开设计让 shell 可以在 fork() 之后、exec() 之前对子进程的环境进行修改。

5.1 Shell 的工作原理

子进程

父进程

Shell 显示命令提示符

等待用户输入命令

用户输入命令,如: wc p3.c

Shell 调用 fork 创建子进程

当前是哪个进程?

可以在这里修改环境
如: 重定向/管道设置

调用 exec 运行用户指定的程序

用户程序运行并退出

调用 wait 等待子进程

子进程退出

父进程 wait 返回

5.2 输出重定向的实现

命令 wc p3.c > newfile.txt 是如何实现的?

普通情况(无重定向):
  程序输出 → 文件描述符1(stdout) → 屏幕
重定向后:
  程序输出 → 文件描述符1(stdout) → newfile.txt(文件)
实现方法:在 fork 之后、exec 之前,
  把文件描述符1关掉,改成指向 newfile.txt

5.3 重定向完整注释代码(p4.c)

/*
 * 演示输出重定向的实现原理
 * 相当于 shell 执行:wc p4.c > p4.output
 *
 * 编译:gcc -o p4 p4.c
 * 运行:./p4
 * 查看结果:cat p4.output
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>    /* fork(), close(), STDOUT_FILENO */
#include <string.h>    /* strdup() */
#include <fcntl.h>     /* open(), O_CREAT, O_WRONLY, O_TRUNC */
#include <sys/wait.h>  /* wait() */
int main(int argc, char *argv[])
{
    int rc = fork();  /* 创建子进程 */
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    else if (rc == 0) {
        /*
         * 子进程:在 exec 之前修改文件描述符,实现重定向
         *
         * UNIX 文件描述符规则:
         *   0 → stdin(标准输入)
         *   1 → stdout(标准输出)  ← 我们要改的
         *   2 → stderr(标准错误)
         *
         * UNIX 分配文件描述符时,总是从最小的可用数字开始。
         */
        /* 第一步:关闭标准输出(文件描述符1) */
        close(STDOUT_FILENO);  /* STDOUT_FILENO = 1 */
        /*
         * 第二步:打开目标文件
         * 此时文件描述符1已空出,open() 会把1分配给这个新文件
         *
         * O_CREAT  → 文件不存在则创建
         * O_WRONLY → 只写模式
         * O_TRUNC  → 若文件已存在则清空内容
         * S_IRWXU  → 文件权限(当前用户可读/写/执行)
         */
        open("./p4.output", O_CREAT | O_WRONLY | O_TRUNC, S_IRWXU);
        /*
         * 现在的文件描述符状态:
         *   0 → stdin(不变)
         *   1 → p4.output(原来是屏幕,现在是文件!)
         *   2 → stderr(不变)
         */
        /* 第三步:exec 运行 wc,它会向 stdout(现在是文件)输出 */
        char *myargs[3];
        myargs[0] = strdup("wc");
        myargs[1] = strdup("p4.c");
        myargs[2] = NULL;
        execvp(myargs[0], myargs);
        /* wc 的输出会自动写入 p4.output,而不是屏幕!
         * exec 调用保留了打开的文件描述符,所以重定向生效 */
    }
    else {
        /* 父进程:等待子进程(wc)执行完毕 */
        int rc_wait = wait(NULL);
        /* 父进程不知道输出去哪里,也不关心,它的 stdout 没有改变 */
        (void) rc_wait;  /* 避免编译器"未使用变量"警告 */
    }
    return 0;
}

5.4 重定向原理图解

调用 close(STDOUT_FILENO) 前:    调用 open() 后(STDOUT_FILENO 空出了):
文件描述符表                        文件描述符表
┌────┬──────────────┐             ┌────┬──────────────────┐
│ 0  │  键盘(stdin) │             │ 0  │  键盘(stdin)      │
│ 1  │  屏幕(stdout)│  ← 关闭    │ 1  │  p4.output(文件) │  ← 新分配
│ 2  │  屏幕(stderr)│             │ 2  │  屏幕(stderr)     │
└────┴──────────────┘             └────┴──────────────────┘
exec() 之后,wc 程序运行,向 fd=1 写输出 → 写进了文件,不是屏幕!

六、管道(Pipe)的工作原理

UNIX 管道(|)与重定向原理相似,只是用了 pipe() 系统调用。

命令:grep foo file.txt | wc -l
含义:grep 的输出 → 作为 wc 的输入
内核实现(简化):
┌──────────┐  fd[1]写入   ┌─────────────┐  fd[0]读取   ┌──────────┐
│  grep    │ ──────────→  │ 内核管道缓冲 │ ──────────→  │  wc      │
│ 进程     │              │ (环形队列)   │              │ 进程     │
└──────────┘              └─────────────┘              └──────────┘
grep 的 stdout 被重定向到 pipe 写端
wc 的 stdin  被重定向到 pipe 读端

七、进程控制与信号

除了 fork/exec/wait,UNIX 还提供了信号(Signal)机制让进程之间互发通知:

信号 快捷键 含义
SIGINT Ctrl+C 中断进程(通常是终止)
SIGTSTP Ctrl+Z 暂停进程(可用 fg 恢复)
SIGKILL kill -9 PID 强制杀死进程(不可忽略)
SIGTERM kill PID 请求进程自行终止

进程可以用 signal() 系统调用注册信号处理函数,捕获信号后执行自定义逻辑。

八、用户与权限


角色 权限
普通用户(User) 只能控制自己的进程(发信号、终止等)
超级用户(Root / Superuser) 可以控制系统中所有进程,执行关机等特权操作

“With great power comes great responsibility.”(权力越大,责任越大。)
日常工作尽量用普通账户,需要 root 时要谨慎。

九、常用进程管理工具


命令 作用
ps 查看当前运行的进程列表
top / htop 实时显示进程的 CPU、内存占用
kill <PID> 向指定进程发送 SIGTERM 信号(请求终止)
kill -9 <PID> 发送 SIGKILL,强制终止(无法被进程忽略)
killall <名称> 按程序名批量发送信号
fg 把后台暂停的进程恢复到前台运行

十、完整可运行演示程序

程序一:fork 基本演示

/*
 * fork 完整演示,包含父子进程的执行顺序不确定性
 * 编译:gcc -o fork_demo fork_demo.c
 * 运行:./fork_demo
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
    printf("=== fork 演示 ===\n");
    printf("[主进程] 开始,PID = %d\n", (int)getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork 失败!\n");
        return 1;
    }
    else if (rc == 0) {
        /* 子进程 */
        printf("[子进程] PID = %d, 父PID = %d\n",
               (int)getpid(), (int)getppid());
        printf("[子进程] 我是父进程的拷贝,但有独立内存\n");
    }
    else {
        /* 父进程 */
        printf("[父进程] PID = %d, 子进程PID = %d\n",
               (int)getpid(), rc);
        wait(NULL);  /* 等待子进程结束 */
        printf("[父进程] 子进程已结束\n");
    }
    return 0;
}

程序二:fork + exec + wait 完整演示(列出当前目录)

/*
 * 经典三件套:fork() + exec() + wait()
 * 子进程运行 /bin/ls 列出当前目录内容
 *
 * 编译:gcc -o forkexec_demo forkexec_demo.c
 * 运行:./forkexec_demo
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
    printf("=== fork + exec + wait 演示 ===\n");
    printf("[父进程] PID = %d,准备创建子进程运行 ls\n", (int)getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork 失败!\n");
        return 1;
    }
    else if (rc == 0) {
        /* 子进程:变身为 ls 命令 */
        printf("[子进程] PID = %d,即将执行 ls -l\n", (int)getpid());
        /*
         * execl() 的参数:
         *   第1个:程序的完整路径
         *   第2个起:程序名(argv[0])及各参数
         *   最后:NULL 表示参数列表结束
         */
        char *args[] = {"ls", "-l", NULL};
        execvp("ls", args);
        /* 若 execvp 成功,下面这行不会执行 */
        fprintf(stderr, "[子进程] execvp 失败!\n");
        exit(1);
    }
    else {
        /* 父进程:等待子进程(ls)执行完毕 */
        printf("[父进程] 等待子进程完成...\n");
        int status;
        int finished = wait(&status);
        if (WIFEXITED(status)) {
            printf("[父进程] 子进程 %d 结束,退出码 = %d\n",
                   finished, WEXITSTATUS(status));
        }
    }
    return 0;
}

程序三:输出重定向完整演示

/*
 * 演示在 fork 之后、exec 之前实现输出重定向
 * 将 ls 的输出写入文件而非屏幕
 *
 * 编译:gcc -o redirect_demo redirect_demo.c
 * 运行:./redirect_demo
 * 结果:cat ls_output.txt
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>     /* open(), O_CREAT 等 */
#include <sys/wait.h>
int main(void)
{
    printf("=== 输出重定向演示 ===\n");
    printf("[父进程] 将把 ls 的输出重定向到 ls_output.txt\n");
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork 失败!\n");
        return 1;
    }
    else if (rc == 0) {
        /* 子进程:实现重定向,然后 exec ls */
        /* 第1步:关闭标准输出(fd=1) */
        close(STDOUT_FILENO);
        /* 第2步:打开目标文件,它会自动占用 fd=1(最小空闲描述符)*/
        int fd = open("ls_output.txt",
                      O_CREAT | O_WRONLY | O_TRUNC,
                      0644);   /* 0644 = 文件权限:所有者读写,其他人只读 */
        if (fd < 0) {
            fprintf(stderr, "打开文件失败!\n");
            exit(1);
        }
        /* 验证:fd 应该等于 1(STDOUT_FILENO) */
        /* printf("fd = %d\n", fd); */  /* 此时 stdout 已是文件,不会打印到屏幕 */
        /* 第3步:exec ls,其输出自动写入文件 */
        char *args[] = {"ls", "-l", NULL};
        execvp("ls", args);
        /* 若执行到这里说明 exec 失败 */
        exit(1);
    }
    else {
        /* 父进程:等待子进程完成 */
        wait(NULL);
        printf("[父进程] ls 已执行完毕,结果在 ls_output.txt\n");
        printf("[父进程] 请运行 'cat ls_output.txt' 查看结果\n");
    }
    return 0;
}

十一、fork/exec/wait 关系总结

rc=0 子进程

rc>0 父进程

父进程调用 fork

创建子进程
子进程是父进程的拷贝

返回值判断

修改环境
如重定向/管道

调用 exec
变身为新程序

新程序运行

子进程退出

调用 wait
阻塞等待子进程

子进程进入 ZOMBIE

wait 返回
父进程回收子进程

父进程继续执行

十二、exec() 的六种变体(Linux)

Linux 提供了多种 exec 变体,满足不同场景:

函数名 参数传递方式 搜索 PATH 传递环境变量
execl() 可变参数列表 否(需完整路径) 继承父进程
execlp() 可变参数列表 继承父进程
execle() 可变参数列表 自定义
execv() 数组 继承父进程
execvp() 数组 继承父进程
execvpe() 数组 自定义

记忆规律:

  • l(list)→ 参数以可变参数列表传递,最后以 NULL 结尾
  • v(vector)→ 参数以字符串数组传递
  • p(path)→ 会在 PATH 环境变量中搜索程序
  • e(environment)→ 可以传入自定义环境变量数组

十三、关键术语速查


英文 中文 简单解释
fork() 复制进程 创建一个与当前进程几乎完全相同的子进程
exec() 替换进程映像 用新程序替换当前进程的代码和数据
wait() 等待子进程 阻塞父进程直到子进程退出
PID 进程标识符 每个进程唯一的整数编号
Non-determinism 不确定性 父子进程执行顺序由调度器决定,不可预知
File Descriptor 文件描述符 用整数表示打开的文件(0=stdin,1=stdout,2=stderr)
Redirection 重定向 把程序的输入/输出从默认设备改到文件
Pipe 管道 连接两个进程,一个的输出成为另一个的输入
Signal 信号 进程间发送异步通知的机制
Zombie 僵尸进程 已退出但未被父进程回收的进程
Superuser / Root 超级用户 拥有完全系统控制权的特殊账户

机制:受限直接执行 — 详细中文解析

原书:《Operating Systems: Three Easy Pieces》(OSTEP) 第6章

一、核心问题:虚拟化CPU的两大挑战

OS 要把一个物理 CPU 虚拟成"无数个",核心思路是时分复用——轮流运行各个进程。但这带来两个关键挑战:
两大挑战 = { 性能(Performance) 如何在不引入过多开销的前提下实现虚拟化? 控制(Control) 如何在高效运行进程的同时,保持对CPU的掌控权? \text{两大挑战} = \begin{cases} \text{性能(Performance)} & \text{如何在不引入过多开销的前提下实现虚拟化?} \\ \text{控制(Control)} & \text{如何在高效运行进程的同时,保持对CPU的掌控权?} \end{cases} 两大挑战={性能(Performance控制(Control如何在不引入过多开销的前提下实现虚拟化?如何在高效运行进程的同时,保持对CPU的掌控权?
控制权尤为重要:若 OS 失去控制,一个进程可以永远霸占 CPU,或者访问它不该看到的内存。

二、基本思路:直接执行(Direct Execution)

最朴素的想法:直接把程序放到 CPU 上跑

2.1 无限制的直接执行协议

OS                               程序
──────────────────────────────────────────
在进程列表中创建条目
为程序分配内存
把程序代码从磁盘加载到内存
用 argc/argv 设置好栈
清空寄存器
执行 call main()
                                 运行 main()
                                 ...执行各种指令...
                                 从 main() return
释放进程内存
从进程列表中移除

这样做速度快,但有两个致命问题:

  1. 程序可能做"坏事":如直接读写磁盘、占用所有内存——OS 如何阻止?
  2. OS 何时能重新拿回 CPU?:如果程序一直在跑,OS 根本没机会执行。

三、问题一:受限操作(Restricted Operations)

3.1 两种处理器模式

解决方案是引入两种CPU特权级别

内核态(Kernel Mode)                   用户态(User Mode)
──────────────────────────             ──────────────────────────
OS 在此模式运行                          普通程序在此模式运行
可以执行任何指令                          受限:不能直接访问I/O
可以访问所有硬件资源                       不能读写任意内存
可以修改特权寄存器                         不能执行特权指令

试图在用户态执行特权操作 → CPU 产生异常 → OS 通常终止该进程。

3.2 系统调用:用户程序请求内核服务的桥梁

当用户程序需要做"特权操作"时(如读磁盘、申请内存),它执行系统调用(System Call)

OS内核(内核态) 硬件 用户程序(用户态) OS内核(内核态) 硬件 用户程序(用户态) 执行 trap 指令 (内含系统调用号) 保存寄存器到内核栈 切换到内核态 跳转到陷阱处理程序 根据系统调用号 执行对应服务 执行 return-from-trap 从内核栈恢复寄存器 切换回用户态 从 trap 后继续执行

3.3 系统调用看起来像普通函数调用的原因

C 程序员调用 open()read() 时,和调用普通函数没有区别。这是因为:

用户代码调用 open()
      ↓
C 标准库中的 open() 函数
      ↓
  库函数内部:
    1. 按约定把参数放到寄存器/栈
    2. 把系统调用号写入特定位置(如寄存器 eax)
    3. 执行 trap 指令(手写汇编)
      ↓
硬件陷入内核

C 库帮你写好了这些汇编代码,用户不需要自己写汇编。

3.4 陷阱表(Trap Table):启动时预设"跳转目录"

OS 如何知道陷入内核后该执行哪段代码?
答案:在机器启动时(boot time),OS 在内核态初始化一张陷阱表(Trap Table),告诉硬件各种异常事件对应的处理函数地址:

陷阱表(Trap Table)
┌─────────────────┬──────────────────────────┐
│  事件类型        │  处理函数地址             │
├─────────────────┼──────────────────────────┤
│  系统调用        │  syscall_handler 地址     │
│  硬盘中断        │  disk_handler 地址        │
│  键盘中断        │  keyboard_handler 地址    │
│  非法指令        │  illegal_instr_handler    │
│  除以零          │  div_zero_handler         │
└─────────────────┴──────────────────────────┘

硬件记住这张表的位置,此后遇到对应事件直接跳过去。
用户程序无法修改陷阱表(修改陷阱表本身是特权指令),所以程序无法把 OS 引导到任意地址执行。

3.5 系统调用号:用数字代替地址

用户程序不能直接指定要跳到内核哪个地址(否则可以绕过权限检查),只能提供一个系统调用号
系统调用号 → 内核查表 对应的处理函数 \text{系统调用号} \xrightarrow{\text{内核查表}} \text{对应的处理函数} 系统调用号内核查表 对应的处理函数
例如(Linux x86-64 上):

调用号 对应系统调用
0 read()
1 write()
2 open()
60 exit()

这层间接性是一种保护:程序只能请求合法的服务编号,不能跳到任意位置。

3.6 受限直接执行完整协议(含系统调用)

OS(启动时,内核态)          硬件                    程序(用户态)
─────────────────────────────────────────────────────────────────────
初始化陷阱表
                              记住 syscall_handler 地址
─────────────────────────────────────────────────────────────────────
(运行进程时)
创建进程列表条目
分配内存
加载程序到内存
设置用户栈(argv)
填充内核栈(寄存器/PC)
执行 return-from-trap
                              恢复寄存器(从内核栈)
                              切换到用户态
                              跳转到 main()
                                                       运行 main()
                                                       ...
                                                       调用系统调用
                                                       执行 trap 进入OS
                              保存寄存器(到内核栈)
                              切换到内核态
                              跳转到陷阱处理程序
处理陷阱
执行系统调用的具体工作
执行 return-from-trap
                              恢复寄存器(从内核栈)
                              切换到用户态
                              跳转到 trap 之后的指令
                                                       ...
                                                       从 main() 返回
                                                       执行 trap(exit())
释放进程内存
从进程列表移除

四、问题二:进程间切换(Switching Between Processes)

4.1 核心矛盾

进程在跑 → OS 没在跑 → OS 没在跑就啥也干不了 → 怎么切换进程?
核心矛盾: 进程运行中 ⇒ OS 不在CPU上 ⇒ OS 无法主动切换 \text{核心矛盾:}\ \text{进程运行中} \Rightarrow \text{OS 不在CPU上} \Rightarrow \text{OS 无法主动切换} 核心矛盾: 进程运行中OS 不在CPUOS 无法主动切换

4.2 方案一:协作式(Cooperative)—— 靠进程自觉

思路:相信进程会"讲礼貌",定期主动放弃 CPU。
进程放弃 CPU 的两种方式:

  1. 调用系统调用(如 open()read()yield()),这会陷入 OS,OS 趁机切换
  2. 触发异常(如除以零、非法内存访问),OS 获得控制权
进程A运行
   │
   ├── 调用 read() → 陷入OS → OS可以切换到进程B
   │
   ├── 调用 yield() → 主动告诉OS"我先让出来"
   │
   └── 访问非法内存 → 产生异常 → OS介入(通常杀掉进程A)

缺陷:如果进程进入死循环,永远不调用系统调用怎么办?
→ 唯一办法:重启机器(真的,书里就这么说的)

4.3 方案二:非协作式(Non-Cooperative)—— 定时器中断

思路:硬件上设置一个定时器(Timer),每隔几毫秒强制产生一个中断,OS 趁机夺回 CPU。

时间轴:
  0ms     10ms    20ms    30ms    40ms
   |-------|-------|-------|-------|---
   进程A运行  定时器中断!  进程B运行  定时器中断!
              ↑                      ↑
              OS获得控制权            OS获得控制权
              决定切换到进程B          决定继续运行B或切换

定时器的启动本身也是特权操作(启动时由 OS 在内核态完成),普通进程无法关闭定时器。
中断发生时,硬件自动

  • 把当前进程的寄存器保存到该进程的内核栈
  • 切换到内核态
  • 跳转到 OS 预设的中断处理程序

五、上下文切换(Context Switch)

5.1 什么是上下文切换

当 OS 决定从进程 A 切换到进程 B 时,需要:

  1. 保存 A 的上下文(所有寄存器、PC、内核栈指针)→ 存入 A 的进程结构体
  2. 恢复 B 的上下文(从 B 的进程结构体读出)→ 写入真实寄存器
  3. 执行 return-from-trap,CPU 跳入 B 继续运行
    上下文就是一个进程运行到某个时刻的"完整快照"——寄存器里的所有值。

5.2 两种寄存器保存/恢复

切换过程中发生两次不同级别的保存/恢复:

第一次(硬件自动,定时器中断时):
  用户态寄存器(通用寄存器、PC、flags 等)
  保存到:进程A的内核栈
第二次(OS软件显式,决定切换时):
  内核态寄存器(调用 switch() 时的内核寄存器)
  保存到:进程A的进程结构体(proc_t)
  从进程B的进程结构体恢复到真实寄存器

5.3 完整切换时间线

OS(启动时)         硬件                    进程A(用户态)      进程B(用户态)
────────────────────────────────────────────────────────────────────────────
初始化陷阱表
                     记住 syscall_handler
                     记住 timer_handler
启动定时器
                     每隔Xms中断CPU
                                             进程A运行...
                     定时器中断!
                     保存A的寄存器→内核栈A
                     切换到内核态
                     跳转到中断处理程序
处理中断
调用 switch() 例程
  保存A的内核寄存器→proc_t(A)
  从proc_t(B)恢复B的内核寄存器
  切换到B的内核栈
执行 return-from-trap
                     从内核栈B恢复B的寄存器
                     切换到用户态
                     跳转到B的PC
                                                                  进程B运行...

5.4 xv6 上下文切换汇编代码注释版

/*
 * xv6 的 swtch() 函数(x86 汇编)
 * 功能:保存当前进程的内核寄存器,加载目标进程的内核寄存器
 * 原型:void swtch(struct context *old, struct context *new);
 *
 * 注意:这是内核级别的切换,不是用户级别的。
 *       此时用户寄存器已经由硬件保存到内核栈了。
 */
/*
.globl swtch
swtch:
    # ── 保存旧进程(当前进程A)的寄存器 ──
    movl 4(%esp), %eax   # 把第一个参数(old指针)放入eax
                         # 4(%esp) 是因为栈顶是返回地址,参数在其上方
    popl 0(%eax)         # 把栈顶的"旧IP(返回地址)"弹出,保存到old->eip
                         # 此时esp已经加4(弹出了返回地址)
    movl %esp, 4(%eax)   # 保存当前栈指针(esp)到 old->esp
    movl %ebx, 8(%eax)   # 保存 ebx 到 old->ebx
    movl %ecx, 12(%eax)  # 保存 ecx 到 old->ecx
    movl %edx, 16(%eax)  # 保存 edx 到 old->edx
    movl %esi, 20(%eax)  # 保存 esi 到 old->esi
    movl %edi, 24(%eax)  # 保存 edi 到 old->edi
    movl %ebp, 28(%eax)  # 保存基址指针到 old->ebp
    # ── 加载新进程(目标进程B)的寄存器 ──
    movl 4(%esp), %eax   # 重新读第一个参数位置
                         # (此时esp已变化,但new是第2个参数)
                         # 实际上这里应该读 new 指针
    movl 28(%eax), %ebp  # 恢复 ebp(最先恢复,后续可能用到)
    movl 24(%eax), %edi  # 恢复 edi
    movl 20(%eax), %esi  # 恢复 esi
    movl 16(%eax), %edx  # 恢复 edx
    movl 12(%eax), %ecx  # 恢复 ecx
    movl  8(%eax), %ebx  # 恢复 ebx
    movl  4(%eax), %esp  # ★ 关键:切换栈指针到进程B的内核栈!
                         #   从这一刻起,我们"活在"进程B的内核栈上了
    pushl 0(%eax)        # 把进程B的返回地址(eip)压入新栈
    ret                  # 弹出这个返回地址并跳转过去
                         # 效果:从进程B的内核上下文"返回",继续B的执行
*/

用 C 语言逻辑伪代码解释相同的操作:

/*
 * 上下文切换的C语言逻辑伪代码(仅用于理解,不可直接编译运行)
 * 真实实现必须用汇编,因为需要直接操作寄存器
 */
/* 上下文结构体:保存内核寄存器 */
struct context {
    int eip;  /* 指令指针(返回地址) */
    int esp;  /* 栈指针 */
    int ebx;
    int ecx;
    int edx;
    int esi;
    int edi;
    int ebp;
};
/*
 * 概念上的 switch() 做了什么:
 *
 * 1. 把当前CPU寄存器的值写入 old 结构体("拍照"保存进程A的状态)
 * 2. 把 new 结构体的值写入CPU寄存器("还原"进程B的状态)
 * 3. 切换内核栈指针(esp 指向进程B的内核栈)
 * 4. return(跳到进程B上次在内核中执行到的位置)
 *
 * 调用者看来:调用 switch(A的ctx, B的ctx) 时"在A的世界",
 *             函数返回时"突然在B的世界"——就像时间旅行!
 */

六、完整可运行C程序

程序一:测量系统调用开销

/*
 * 测量系统调用的时间开销
 * 方法:对一个极简系统调用(0字节read)循环一百万次,计算平均耗时
 *
 * 编译:gcc -O0 -o syscall_cost syscall_cost.c
 * 运行:./syscall_cost
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>    /* read(), close() */
#include <fcntl.h>     /* open() */
#include <sys/time.h>  /* gettimeofday() */
/*
 * 获取当前时间,单位:微秒(microsecond,1秒 = 1,000,000 微秒)
 * gettimeofday() 返回自1970年1月1日以来的秒数和微秒数
 */
static long long get_time_us(void)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (long long)tv.tv_sec * 1000000LL + tv.tv_usec;
}
int main(void)
{
    const int ITERATIONS = 1000000;  /* 循环100万次,减小误差 */
    int i;
    char buf[1];  /* 读取缓冲区(实际读0字节,但需要有效指针) */
    /* 打开 /dev/null:一个总是可读但读到EOF的特殊文件
     * 对它执行 read() 是最简单的系统调用(几乎不做任何工作)*/
    int fd = open("/dev/null", O_RDONLY);
    if (fd < 0) {
        fprintf(stderr, "无法打开 /dev/null\n");
        return 1;
    }
    printf("=== 系统调用开销测量 ===\n");
    printf("循环次数:%d\n", ITERATIONS);
    /* 记录开始时间 */
    long long start = get_time_us();
    /* 循环执行系统调用 */
    for (i = 0; i < ITERATIONS; i++) {
        /*
         * read(fd, buf, 0) 读取0个字节
         * 这个调用会:
         *   1. 用户态执行 trap 指令(陷入内核)
         *   2. 内核处理(几乎什么都不做)
         *   3. return-from-trap 回到用户态
         * 几乎所有时间都花在 用户态↔内核态 的切换上
         */
        read(fd, buf, 0);
    }
    /* 记录结束时间 */
    long long end = get_time_us();
    close(fd);
    long long total_us = end - start;
    double avg_ns = (double)total_us * 1000.0 / ITERATIONS;
    printf("总耗时:%lld 微秒\n", total_us);
    printf("单次系统调用平均耗时:%.2f 纳秒\n", avg_ns);
    printf("\n说明:现代系统单次系统调用约需 100~500 纳秒\n");
    printf("      (模态切换 + 内核栈操作 + 返回的总开销)\n");
    return 0;
}

程序二:演示用户态/内核态切换(通过信号模拟中断)

/*
 * 演示定时器中断的概念:
 * 用 SIGALRM 信号模拟定时器中断,展示OS如何周期性地"打断"进程
 *
 * 编译:gcc -o timer_demo timer_demo.c
 * 运行:./timer_demo
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>    /* signal(), SIGALRM */
#include <sys/time.h>  /* setitimer() */
/* 模拟"调度计数器",记录定时器中断触发次数 */
static volatile int interrupt_count = 0;
static volatile int current_process = 0;  /* 0=进程A, 1=进程B */
/*
 * 定时器中断处理函数(类比OS的定时器中断处理程序)
 * 真实OS中:硬件中断→陷入内核→OS决定是否切换进程
 * 这里用信号处理函数模拟这个过程
 */
void timer_interrupt_handler(int signum)
{
    interrupt_count++;
    printf("\n[定时器中断 #%d] OS获得控制权!", interrupt_count);
    /* 模拟调度决策:每次中断切换进程 */
    current_process = 1 - current_process;
    printf(" 切换到进程%c\n", current_process == 0 ? 'A' : 'B');
}
int main(void)
{
    printf("=== 定时器中断演示(模拟非协作式调度)===\n\n");
    /* 注册信号处理函数(类比OS启动时安装中断处理程序)*/
    signal(SIGALRM, timer_interrupt_handler);
    /* 设置定时器:每500毫秒触发一次SIGALRM(模拟定时器中断)*/
    struct itimerval timer;
    timer.it_value.tv_sec     = 0;
    timer.it_value.tv_usec    = 500000;  /* 首次触发:500ms后 */
    timer.it_interval.tv_sec  = 0;
    timer.it_interval.tv_usec = 500000;  /* 之后每500ms触发 */
    setitimer(ITIMER_REAL, &timer, NULL);
    printf("[OS] 已启动定时器,每500ms产生一次中断\n");
    printf("[OS] 初始运行:进程A\n\n");
    /* 模拟两个进程轮流"运行"(实际上还是同一个程序,只是打印不同内容)*/
    int step = 0;
    while (interrupt_count < 6) {  /* 运行到发生6次中断为止 */
        step++;
        if (current_process == 0) {
            printf("  [进程A] 正在执行第 %d 步...\n", step);
        } else {
            printf("  [进程B] 正在执行第 %d 步...\n", step);
        }
        /* 模拟进程做一些工作(sleep让输出慢一点,方便观察)*/
        usleep(200000);  /* 等待200ms */
    }
    printf("\n[OS] 演示结束,共发生 %d 次定时器中断\n", interrupt_count);
    return 0;
}

程序三:系统调用号直接调用演示(Linux x86-64)

/*
 * 演示系统调用号的概念:
 * 直接使用系统调用号调用 write(),绕过 C 库
 * 展示系统调用号→处理函数的映射机制
 *
 * 编译(仅限 Linux x86-64):gcc -o syscall_num syscall_num.c
 * 运行:./syscall_num
 */
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>  /* SYS_write, SYS_exit 等常量 */
int main(void)
{
    printf("=== 系统调用号演示 ===\n\n");
    printf("常用系统调用号(Linux x86-64):\n");
    printf("  SYS_read  = %d\n", SYS_read);   /* 通常是 0 */
    printf("  SYS_write = %d\n", SYS_write);  /* 通常是 1 */
    printf("  SYS_open  = %d\n", SYS_open);   /* 通常是 2 */
    printf("  SYS_close = %d\n", SYS_close);  /* 通常是 3 */
    printf("  SYS_exit  = %d\n", SYS_exit);   /* 通常是 60 */
    printf("\n直接用系统调用号调用 write()(不经过C库封装):\n");
    /*
     * syscall() 函数:直接触发系统调用
     * 第1个参数:系统调用号
     * 后续参数:传给系统调用的参数
     *
     * syscall(SYS_write, fd, buf, len)
     * 等价于 write(fd, buf, len)
     * 但前者直接执行 trap 指令,后者经过C库的包装层
     */
    const char *msg = "  [直接系统调用] 这是通过SYS_write号直接输出的!\n";
    long len = 0;
    while (msg[len]) len++;  /* 手动计算字符串长度,不用strlen */
    /* 直接调用内核:不经过任何C库函数 */
    syscall(SYS_write,
            STDOUT_FILENO,  /* fd=1,标准输出 */
            msg,            /* 数据缓冲区 */
            len);           /* 字节数 */
    printf("\n两种调用方式对比:\n");
    printf("  C库: printf/write → C库包装 → trap指令 → 内核\n");
    printf("  直接: syscall(号) → trap指令 → 内核\n");
    printf("  结果相同,C库方式更方便,直接方式更底层\n");
    return 0;
}

七、整体架构总结

进程调用系统调用

定时器中断到期

非法操作/异常

继续当前进程

切换到其他进程

机器启动

OS在内核态初始化

建立陷阱表
登记所有中断/异常处理地址

启动定时器
设置每Xms触发一次中断

以用户态运行进程

发生了什么?

执行trap指令

硬件产生中断

硬件产生异常陷阱

硬件保存用户寄存器到内核栈

切换到内核态
跳转到陷阱处理程序

OS决策

return-from-trap
恢复寄存器
切换回用户态

上下文切换
保存A的内核寄存器
恢复B的内核寄存器
切换到B的内核栈

return-from-trap
恢复B的用户寄存器
切换回用户态

八、协作式 vs 非协作式对比

协作式调度(Cooperative)
─────────────────────────
优点:简单,开销小
缺点:进程死循环 → OS 永远拿不回控制权 → 只能重启
非协作式调度(Non-Cooperative / Preemptive)
─────────────────────────────────────────────
优点:OS 始终能夺回控制权,任何情况都有效
缺点:需要硬件定时器支持,上下文切换有开销
现代OS:全部采用非协作式(抢占式)调度

九、关键性能数据

根据实测(lmbench 工具):

时期 处理器 系统调用耗时 上下文切换耗时
1996年 200 MHz P6 约 4 微秒 约 6 微秒
现代系统 2~3 GHz 约 0.2~0.5 微秒 约 1~3 微秒

开销来源:
系统调用总开销 = trap指令执行 ⏟ 模态切换 + 寄存器保存/恢复 ⏟ 内核栈操作 + 内核处理实际工作 ⏟ 可变 + return-from-trap ⏟ 模态切换回来 \text{系统调用总开销} = \underbrace{\text{trap指令执行}}_{\text{模态切换}} + \underbrace{\text{寄存器保存/恢复}}_{\text{内核栈操作}} + \underbrace{\text{内核处理实际工作}}_{\text{可变}} + \underbrace{\text{return-from-trap}}_{\text{模态切换回来}} 系统调用总开销=模态切换 trap指令执行+内核栈操作 寄存器保存/恢复+可变 内核处理实际工作+模态切换回来 return-from-trap

十、关键术语速查


英文 中文 简单解释
Limited Direct Execution 受限直接执行 程序直接在CPU上跑,但受限于用户态权限
User Mode 用户态 受限的CPU执行模式,普通程序运行其中
Kernel Mode 内核态 特权CPU执行模式,OS运行其中
Trap 陷阱/系统调用陷入 从用户态主动进入内核态的机制
Trap Table 陷阱表 启动时建立的事件→处理函数地址映射表
System Call Number 系统调用号 标识要请求哪个内核服务的整数
Return-from-trap 从陷阱返回 从内核态回到用户态的特殊指令
Timer Interrupt 定时器中断 硬件定期触发,强制打断当前进程
Context Switch 上下文切换 保存A的寄存器、恢复B的寄存器,切换执行进程
Cooperative Scheduling 协作式调度 进程自愿放弃CPU(靠系统调用或yield)
Preemptive Scheduling 抢占式调度 OS通过定时器强制夺回CPU
Kernel Stack 内核栈 每个进程在内核态使用的独立栈空间
Interrupt Handler 中断处理程序 OS中处理硬件中断的函数

第七章:调度算法入门详解

本文对应 OSTEP(操作系统:三个简单部分)第七章,力求用最易懂的语言讲清楚每个概念。

一、什么是调度?

操作系统里同时跑着很多进程,但 CPU 同一时刻只能运行一个。**调度(Scheduling)**就是操作系统决定"现在该轮到谁用 CPU"的策略。
调度的思想其实比计算机更古老——工厂流水线、超市收银台,都需要调度。

二、简化假设(Workload Assumptions)

为了一步步分析问题,教材先做了 5 个"理想化"假设:

编号 假设内容 现实中是否成立
1 每个任务运行时间相同
2 所有任务同时到达
3 任务一旦开始就跑完
4 任务只用 CPU,不做 I/O
5 调度器事先知道每个任务的运行时长

后面每介绍一个新算法,就会放开一条假设,让模型越来越贴近现实。

三、评价指标(Metrics)

3.1 周转时间(Turnaround Time)

衡量任务从到达到完成花了多久,是性能指标。
T t u r n a r o u n d = T c o m p l e t i o n − T a r r i v a l T_{turnaround} = T_{completion} - T_{arrival} Tturnaround=TcompletionTarrival
在"所有任务同时到达"的假设下, T a r r i v a l = 0 T_{arrival} = 0 Tarrival=0,所以:
T t u r n a r o u n d = T c o m p l e t i o n T_{turnaround} = T_{completion} Tturnaround=Tcompletion

3.2 响应时间(Response Time)

衡量任务从到达到第一次被执行要等多久,是交互体验指标。
T r e s p o n s e = T f i r s t r u n − T a r r i v a l T_{response} = T_{firstrun} - T_{arrival} Tresponse=TfirstrunTarrival
响应时间越短,用户感觉越流畅,就像打字后不会等很久才看到字出现。

3.3 两个指标的矛盾

性能(周转时间短)和公平(响应时间短)往往不能同时满足,这是系统设计中常见的权衡取舍(trade-off)。

四、先来先服务(FIFO / FCFS)

First In, First Out,排队谁先来谁先做,就像银行叫号。

4.1 正常情况示例

三个任务 A、B、C 同时到达,每个都需要 10 秒:

时间轴:
|<-- A (10s) -->|<-- B (10s) -->|<-- C (10s) -->|
0              10              20              30

平均周转时间:
T ˉ = 10 + 20 + 30 3 = 20  秒 \bar{T} = \frac{10 + 20 + 30}{3} = 20 \text{ 秒} Tˉ=310+20+30=20 

4.2 问题:护航效应(Convoy Effect)

放开假设 1(任务时间不再相等):A 需要 100 秒,B 和 C 只需 10 秒。

时间轴:
|<---------- A (100s) ---------->|<- B ->|<- C ->|
0                                100     110     120

平均周转时间:
T ˉ = 100 + 110 + 120 3 = 110  秒 \bar{T} = \frac{100 + 110 + 120}{3} = 110 \text{ 秒} Tˉ=3100+110+120=110 
B 和 C 明明只需 10 秒,却要苦等 A 跑完 100 秒。就像超市里你只买了一瓶水,前面的人推着三车货物——非常痛苦。

五、最短任务优先(SJF)

Shortest Job First:先做最短的任务,再做次短的,以此类推。

5.1 改进示例(同时到达)

同样是 A(100s)、B(10s)、C(10s),用 SJF:

时间轴:
|<- B ->|<- C ->|<---------- A (100s) ---------->|
0       10      20                               120

平均周转时间:
T ˉ = 10 + 20 + 120 3 = 50  秒 \bar{T} = \frac{10 + 20 + 120}{3} = 50 \text{ 秒} Tˉ=310+20+120=50 
比 FIFO 的 110 秒减少了一半以上!
SJF 结论:在所有任务同时到达的前提下,SJF 是最优的调度算法。

5.2 新问题:任务不同时到达

放开假设 2(任务可以在不同时刻到达):

  • A 在 t = 0 t=0 t=0 到达,需要 100 秒
  • B、C 在 t = 10 t=10 t=10 到达,各需要 10 秒
    SJF(非抢占)的结果:
时间轴:
[B,C 在此到达]
     |
|<---------- A (100s) ---------->|<- B ->|<- C ->|
0   10                          100     110     120

B 和 C 虽然很短,但 A 已经在跑了,SJF 不会打断它。
平均周转时间:
T ˉ = ( 100 − 0 ) + ( 110 − 10 ) + ( 120 − 10 ) 3 = 100 + 100 + 110 3 ≈ 103.3  秒 \bar{T} = \frac{(100-0) + (110-10) + (120-10)}{3} = \frac{100+100+110}{3} \approx 103.3 \text{ 秒} Tˉ=3(1000)+(11010)+(12010)=3100+100+110103.3 
护航效应又出现了!

六、最短剩余时间优先(STCF)

Shortest Time-to-Completion First,也叫 PSJF(抢占式 SJF)

6.1 核心思想

放开假设 3(任务可以被中断):当新任务到达时,比较"新任务的运行时间"和"当前任务的剩余时间",谁短就先跑谁。

6.2 示例

同样是 A(100s, t=0)、B(10s, t=10)、C(10s, t=10):

时间轴:
[B,C 在此到达,A 剩余 90s,B/C 各 10s,所以先跑 B 和 C]
     |
|<A: 10s>|<-B(10s)->|<-C(10s)->|<---------- A 剩余 90s ---------->|
0        10         20         30                                  120

平均周转时间:
T ˉ = ( 120 − 0 ) + ( 20 − 10 ) + ( 30 − 10 ) 3 = 120 + 10 + 20 3 = 50  秒 \bar{T} = \frac{(120-0) + (20-10) + (30-10)}{3} = \frac{120+10+20}{3} = 50 \text{ 秒} Tˉ=3(1200)+(2010)+(3010)=3120+10+20=50 
比 SJF 的 103.3 秒好得多!
STCF 结论:在任务可以随时到达的前提下,STCF 是最优的(周转时间最短)。

七、STCF 的缺陷:响应时间差

STCF 对周转时间很好,但对响应时间很糟糕。
例子:三个任务同时到达,每个跑 5 秒,SJF(即不抢占)下:

|<--- A(5s) --->|<--- B(5s) --->|<--- C(5s) --->|
0               5              10              15

响应时间:

  • A: 0 0 0 秒(立刻开始)
  • B: 5 5 5 秒(等 A 跑完)
  • C: 10 10 10 秒(等 A、B 都跑完)
    T ˉ r e s p o n s e = 0 + 5 + 10 3 = 5  秒 \bar{T}_{response} = \frac{0+5+10}{3} = 5 \text{ 秒} Tˉresponse=30+5+10=5 
    C 要等 10 秒才第一次被执行到,对于交互式程序(比如文字编辑器)这是无法接受的。

八、轮转调度(Round Robin,RR)

8.1 核心思想

不让任何任务独占 CPU,而是给每个任务一小段时间(叫做时间片 / time slice / quantum),时间到了就换下一个,循环往复。

8.2 示例对比

三个任务 A、B、C 同时到达,每个需要 5 秒,时间片 = 1 秒:

SJF(顺序执行):
|AAAAA|BBBBB|CCCCC|
0     5     10    15
RR(每 1 秒切换):
|A|B|C|A|B|C|A|B|C|A|B|C|A|B|C|
0 1 2 3 4 5 6 7 8 9 ...      15

响应时间对比:

算法 A 响应时间 B 响应时间 C 响应时间 平均
SJF 0 5 10 5 秒
RR 0 1 2 1 秒

RR 的平均响应时间大幅优于 SJF!

8.3 RR 的代价:周转时间差

用 RR 时(时间片=1秒):

  • A 在 t = 13 t=13 t=13 完成
  • B 在 t = 14 t=14 t=14 完成
  • C 在 t = 15 t=15 t=15 完成
    T ˉ t u r n a r o u n d = 13 + 14 + 15 3 = 14  秒 \bar{T}_{turnaround} = \frac{13+14+15}{3} = 14 \text{ 秒} Tˉturnaround=313+14+15=14 
    而 SJF 的平均周转时间只有 5 5 5 秒。RR 把每个任务都拖得很长——对周转时间来说几乎是最差的策略。

8.4 时间片长度的权衡

时间片太短:
  优点:响应时间好
  缺点:上下文切换太频繁,切换本身消耗大量 CPU
时间片太长:
  优点:切换开销小
  缺点:响应时间差,退化成 FIFO

摊销(Amortization)原则:让切换开销在足够多的计算中"均摊"掉。比如时间片 10ms,切换耗时 1ms,则 10% 时间浪费在切换上;若时间片改为 100ms,则切换开销降到 1% 以下。

注意:上下文切换的代价不仅是保存/恢复寄存器,还包括 CPU 缓存、TLB、分支预测器中的状态全部失效,这些隐性开销也很大。

九、算法总结对比

算法 核心思想 周转时间 响应时间 是否抢占 关键缺陷
FIFO 先来先服务 一般 护航效应
SJF 最短任务先做 好(同时到达时最优) 晚到的短任务也要等
STCF 剩余最短的先做 好(最优) 对交互不友好
RR 轮流分时间片 拖长了每个任务的完成时间
核心矛盾
  • 想让周转时间短 → 让短任务尽快做完(SJF/STCF),但长任务要等很久
  • 想让响应时间短 → 公平轮转(RR),但每个任务都被拖长了
    这是系统设计中不可避免的二律背反,鱼与熊掌不可兼得。

十、处理 I/O(放开假设 4)

真实程序会做磁盘读写、网络请求等 I/O 操作。I/O 期间进程不需要 CPU,只是等待。

10.1 朴素做法(浪费)

进程 A(每 10ms 做一次 I/O)、进程 B(纯 CPU 50ms):
时间轴(未优化):
CPU:  |A10ms|等I/O|A10ms|等I/O|...|B50ms|
Disk: |     |IO  |     |IO  |...|     |
CPU 在等 I/O 时完全空闲——浪费!

10.2 优化做法:重叠(Overlap)

把 A 的每个 CPU 小片(10ms)当作独立的"小任务",I/O 等待期间切换去跑 B:

时间轴(优化后):
CPU:  |A|B |A|B |A|B |A|B |A|B |
Disk: | |IO| |IO| |IO| |IO| |IO|

CPU 和磁盘同时工作,资源利用率大幅提升。
原则:尽可能让 CPU 和 I/O 设备同时忙碌(重叠操作),而不是串行等待。

十一、C 语言代码演示

下面用 C 语言模拟 FIFO、SJF、RR 三种算法,计算平均周转时间和响应时间。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 定义进程结构体 */
typedef struct {
    char name;          /* 进程名称,如 'A', 'B', 'C' */
    int arrival;        /* 到达时间 */
    int burst;          /* 需要的 CPU 时间(总量) */
    int remaining;      /* 剩余需要执行的时间 */
    int finish;         /* 完成时间 */
    int first_run;      /* 第一次被调度的时间 */
    int started;        /* 是否已经开始执行过(0=未开始,1=已开始) */
} Process;
/* 打印分隔线 */
void print_sep(void) {
    printf("----------------------------------------\n");
}
/* 计算并打印统计结果 */
void print_stats(Process procs[], int n) {
    double total_turnaround = 0, total_response = 0;
    printf("%-6s %-8s %-8s %-12s %-12s\n",
           "进程", "到达", "完成", "周转时间", "响应时间");
    print_sep();
    for (int i = 0; i < n; i++) {
        int turnaround = procs[i].finish - procs[i].arrival;
        int response   = procs[i].first_run - procs[i].arrival;
        total_turnaround += turnaround;
        total_response   += response;
        printf("  %c    %-8d %-8d %-12d %-12d\n",
               procs[i].name, procs[i].arrival,
               procs[i].finish, turnaround, response);
    }
    print_sep();
    printf("平均周转时间: %.2f 秒\n", total_turnaround / n);
    printf("平均响应时间: %.2f 秒\n", total_response   / n);
}
/* ============================================================
   FIFO 调度:按到达顺序执行,不抢占
   ============================================================ */
void fifo(Process procs[], int n) {
    printf("\n=== FIFO 调度 ===\n");
    int time = 0;
    for (int i = 0; i < n; i++) {
        /* 如果当前时间早于任务到达,先空等 */
        if (time < procs[i].arrival) {
            time = procs[i].arrival;
        }
        procs[i].first_run = time;         /* 第一次运行就是现在 */
        procs[i].started   = 1;
        time              += procs[i].burst; /* 跑完整个任务 */
        procs[i].finish    = time;
    }
    print_stats(procs, n);
}
/* ============================================================
   SJF 调度:在已到达的任务中选最短的,不抢占
   ============================================================ */
void sjf(Process procs[], int n) {
    printf("\n=== SJF 调度(非抢占) ===\n");
    int done[n];
    memset(done, 0, sizeof(done));  /* 0=未完成,1=已完成 */
    int time = 0, finished = 0;
    while (finished < n) {
        /* 找当前时刻已到达且未完成的最短任务 */
        int shortest = -1;
        for (int i = 0; i < n; i++) {
            if (!done[i] && procs[i].arrival <= time) {
                if (shortest == -1 ||
                    procs[i].burst < procs[shortest].burst) {
                    shortest = i;
                }
            }
        }
        if (shortest == -1) {
            /* 没有可运行的任务,时间推进到下一个任务到达 */
            int next_arrival = 1e9;
            for (int i = 0; i < n; i++) {
                if (!done[i] && procs[i].arrival < next_arrival) {
                    next_arrival = procs[i].arrival;
                }
            }
            time = next_arrival;
            continue;
        }
        procs[shortest].first_run = time;
        time                     += procs[shortest].burst;
        procs[shortest].finish    = time;
        done[shortest]            = 1;
        finished++;
    }
    print_stats(procs, n);
}
/* ============================================================
   RR 调度:轮转,每次最多运行 quantum 秒
   ============================================================ */
void round_robin(Process procs[], int n, int quantum) {
    printf("\n=== RR 调度(时间片 = %d 秒) ===\n", quantum);
    /* 重置剩余时间和标记 */
    for (int i = 0; i < n; i++) {
        procs[i].remaining = procs[i].burst;
        procs[i].started   = 0;
        procs[i].first_run = -1;
        procs[i].finish    = 0;
    }
    int time = 0, finished = 0;
    int queue[1000];     /* 就绪队列(存进程下标) */
    int head = 0, tail = 0;
    int in_queue[n];
    memset(in_queue, 0, sizeof(in_queue));
    /* 先把 t=0 时已到达的进程加入队列 */
    for (int i = 0; i < n; i++) {
        if (procs[i].arrival == 0) {
            queue[tail++] = i;
            in_queue[i]   = 1;
        }
    }
    while (finished < n) {
        if (head == tail) {
            /* 队列空,推进时间到下一个到达 */
            int next_arrival = 1e9;
            for (int i = 0; i < n; i++) {
                if (procs[i].remaining > 0 &&
                    procs[i].arrival   < next_arrival) {
                    next_arrival = procs[i].arrival;
                }
            }
            time = next_arrival;
            /* 把此时到达的进程加入队列 */
            for (int i = 0; i < n; i++) {
                if (!in_queue[i] && procs[i].remaining > 0 &&
                    procs[i].arrival <= time) {
                    queue[tail++] = i;
                    in_queue[i]   = 1;
                }
            }
            continue;
        }
        int cur = queue[head++];          /* 取出队首进程 */
        in_queue[cur] = 0;
        /* 记录第一次运行时间 */
        if (procs[cur].first_run == -1) {
            procs[cur].first_run = time;
        }
        /* 本轮实际运行时长 */
        int run = (procs[cur].remaining < quantum)
                ? procs[cur].remaining
                : quantum;
        /* 在这段时间内,把新到达的进程加入队列 */
        for (int t = time + 1; t <= time + run; t++) {
            for (int i = 0; i < n; i++) {
                if (!in_queue[i] && procs[i].remaining > 0 &&
                    procs[i].arrival == t && i != cur) {
                    queue[tail++] = i;
                    in_queue[i]   = 1;
                }
            }
        }
        time                  += run;
        procs[cur].remaining  -= run;
        if (procs[cur].remaining == 0) {
            /* 任务完成 */
            procs[cur].finish = time;
            finished++;
        } else {
            /* 还没跑完,重新加入队尾 */
            queue[tail++] = cur;
            in_queue[cur] = 1;
        }
    }
    print_stats(procs, n);
}
/* ============================================================
   主函数:演示三种算法
   ============================================================ */
int main(void) {
    /* 场景一:三个任务同时到达,长度不同 */
    printf("########################################\n");
    printf("场景一:三个任务同时到达(A=100s, B=10s, C=10s)\n");
    printf("########################################\n");
    Process p1[3] = {
        {'A', 0, 100, 100, 0, 0, 0},
        {'B', 0,  10,  10, 0, 0, 0},
        {'C', 0,  10,  10, 0, 0, 0},
    };
    Process p1_copy1[3], p1_copy2[3], p1_copy3[3];
    memcpy(p1_copy1, p1, sizeof(p1));
    memcpy(p1_copy2, p1, sizeof(p1));
    memcpy(p1_copy3, p1, sizeof(p1));
    fifo(p1_copy1, 3);
    sjf(p1_copy2, 3);
    round_robin(p1_copy3, 3, 1);
    /* 场景二:任务不同时到达 */
    printf("\n########################################\n");
    printf("场景二:A=100s(t=0),B=10s(t=10),C=10s(t=10)\n");
    printf("########################################\n");
    Process p2[3] = {
        {'A',  0, 100, 100, 0, 0, 0},
        {'B', 10,  10,  10, 0, 0, 0},
        {'C', 10,  10,  10, 0, 0, 0},
    };
    Process p2_sjf[3], p2_rr[3];
    memcpy(p2_sjf, p2, sizeof(p2));
    memcpy(p2_rr,  p2, sizeof(p2));
    sjf(p2_sjf, 3);       /* SJF 非抢占,结果差 */
    round_robin(p2_rr, 3, 1);
    return 0;
}

https://godbolt.org/z/EqfxMEEbz
编译与运行

gcc -o scheduler scheduler.c
./scheduler

十二、各算法流程图(Mermaid)

FIFO 流程

开始

按到达顺序排列任务

队列是否为空?

取出队首任务

运行至完成

记录完成时间

结束

SJF 流程

开始

还有未完成任务?

找出已到达且运行时间
最短的任务

运行至完成
中途不打断

记录完成时间

结束

STCF 流程

开始

还有未完成任务?

找出剩余时间最短的任务

运行一小段时间

有新任务到达
且剩余更短?

抢占:切换到新任务

当前任务完成?

记录完成时间

结束

RR 流程

开始

将到达的任务加入就绪队列

队列是否为空?

取出队首任务

运行一个时间片
quantum 秒

任务完成了?

记录完成时间

检查新到达任务

将任务放回队尾

结束

十三、直观的ASCII演示

场景:A=100s(t=0), B=10s(t=10), C=10s(t=10)

FIFO / SJF 非抢占(结果相同,因为 A 先到):

时间:  0         10        20        100       110       120
       |---------|---------|---------|---------|---------|
CPU:   |<========A=100s=============================>|B|C|
                 ^
                 B、C 此时到达,但 A 还在跑,只能等

STCF(抢占式):

时间:  0    10   20   30                        120
       |----|----|----|-------------------------|
CPU:   |<A:10>|<B>|<C>|<======A 剩余 90s======>|
                 ^
                 B、C 剩余 10s < A 剩余 90s,抢占!

RR(时间片=1s,简化示意):

时间:  0  1  2  3  4  5  6  7  8  9  10 11 12 ...
CPU:   |A |A |A |A |A |A |A |A |A |A |B |A |C |A...
                                      ^
                                      B、C 到达后加入轮转

十四、核心知识点速记

FIFO  → 简单,有护航效应
SJF   → 最优(同时到达),但不抢占
STCF  → 最优(任何时刻),可抢占
RR    → 响应时间最好,周转时间最差
调度的本质矛盾:
  周转时间好 ←→ 响应时间好
  (短任务优先)←→ (公平轮转)
I/O 优化:把 I/O 等待时间用来跑其他任务(重叠)
未解之谜(下一章):
  现实中调度器不知道任务有多长——如何办?
  → 多级反馈队列(MLFQ):用历史行为预测未来

十五、课后练习解析

Q1: 三个长度均为 200 的任务,用 SJF 和 FIFO。
由于长度相同,SJF 和 FIFO 结果一样:
T ˉ t u r n a r o u n d = 200 + 400 + 600 3 = 400  秒 \bar{T}_{turnaround} = \frac{200+400+600}{3} = 400 \text{ 秒} Tˉturnaround=3200+400+600=400 
T ˉ r e s p o n s e = 0 + 200 + 400 3 = 200  秒 \bar{T}_{response} = \frac{0+200+400}{3} = 200 \text{ 秒} Tˉresponse=30+200+400=200 
Q4: 什么工作负载下 SJF 和 FIFO 给出相同周转时间?
当所有任务运行时间相同,或者任务已经按从短到长顺序排列时,SJF 和 FIFO 的结果完全一致。
Q6: SJF 下响应时间随任务长度增加会怎样?
随着任务长度增大,排在后面的任务等待时间变长,响应时间线性增长:若有 N N N 个长度为 L L L 的任务,第 k k k 个任务的响应时间为 ( k − 1 ) × L (k-1) \times L (k1)×L
Q7: RR 下最坏情况响应时间公式?
设有 N N N 个任务,时间片为 q q q,则最坏情况(最后一个任务)的响应时间为:
T r e s p o n s e w o r s t = ( N − 1 ) × q T_{response}^{worst} = (N-1) \times q Tresponseworst=(N1)×q

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐