第三章:幕后黑手 - 进程与线程

在上一章中,我们从架构层面理解了 EwokOS 的整体设计。现在,让我们深入系统的"心脏"——进程管理。这是操作系统最核心、最迷人的部分。如果没有进程管理,再强大的硬件也只能像一个傻瓜一样,一次只做一件事。

3.1 什么是进程?

如果 CPU 是一个做菜的厨师,进程 (Process) 就是一道正在做的菜。

  • 代码:菜谱。
  • 数据:食材。
  • 堆栈:切菜板和锅。
  • PC 指针:厨师读到了菜谱的哪一行。

在 EwokOS 中,进程是资源分配的最小单位。每个进程都有自己独立的厨房(内存空间),互不干扰。

进程的诞生:Unix 的革命性创新

进程的概念看似简单,但它的发明却是计算机历史上的一次重大突破。

在 Unix 诞生之前(1960 年代),大多数操作系统使用的是作业 (Job) 的概念。一个作业从开始到结束,独占整个计算机。想要同时运行多个程序?你需要等第一个跑完,或者买更多计算机。

没有进程的世界:回到 1960 年代

让我们想象一下,如果现代计算机仍然使用作业(Job)而不是进程(Process),会是什么样子:

作业 (Job) 的特点

  • 批处理模式:你提交一个作业(比如编译程序),然后去喝咖啡。等待几分钟或几小时后,回来取结果。
  • 独占式执行:作业运行时,整个计算机的所有资源(CPU、内存、I/O)都属于它。就像你包场了整个电影院,但只有你一个人在看。
  • 无交互性:作业提交后就不能修改或控制了。如果程序有 bug 进入死循环,你只能重启整台机器。
  • 低效率:如果作业在等待磁盘 I/O(可能需要几十毫秒),CPU 就会闲置,完全浪费。

进程 (Process) 的革命性改变

  • 并发执行:多个程序"同时"运行(通过时间片轮转)。你可以一边编译代码,一边听音乐,一边下载文件。
  • 虚拟化:每个进程都以为自己独占了整个计算机,但实际上它们共享硬件资源。
  • 交互性:你可以随时切换窗口、暂停程序、发送信号(如 Ctrl+C)。
  • 高效利用:当进程 A 在等待 I/O 时,CPU 可以切换去运行进程 B。资源利用率从 20% 提升到 80% 以上。

Jobs 和 Process 的核心区别

特性 Job(作业) Process(进程)
调度粒度 作业级别(整个程序) 时间片级别(毫秒级)
内存管理 物理内存直接分配 虚拟内存,隔离保护
交互性 无(批处理) 高(实时响应)
并发性 串行执行 并发/并行执行
状态管理 简单(运行/完成) 复杂(多种状态转换)
资源利用率 低(20-30%) 高(70-90%)

1969 年,Ken Thompson 和 Dennis Ritchie 在开发 Unix 时,创造性地引入了进程抽象。他们的核心洞察是:让每个程序都以为自己独占了整个计算机,但实际上操作系统在幕后快速切换。这就像魔术师的手法——观众看到的是连续的动作,实际上是一系列快速的离散操作。

这个设计如此成功,以至于 50 多年后的今天,几乎所有操作系统(包括 Windows、Linux、macOS、iOS、Android)都沿用了这个抽象。EwokOS 也不例外。

3.2 进程的档案袋:proc_t

内核怎么管理这么多进程呢?它给每个进程都建了一个档案袋,叫做 proc_t。 你可以在 kernel/kernel/include/kernel/proc.h 里找到它的定义。

typedef struct st_proc {
    procinfo_t        info;           // 1. 基本信息
    proc_space_t*     space;          // 2. 领地(内存空间)
    context_t         ctx;            // 3. 案发现场(上下文)
    int64_t           sleep_counter;  // 4. 睡眠时间
    // ... 其他字段
} proc_t;

让我们打开这个档案袋看看,并详细理解每个字段为什么存在、何时被使用:

1. info (基本信息) - 进程的身份证

这是进程最基本的身份信息,内核通过这些字段来识别和管理进程。

  • PID (Process ID - 进程标识符)

    • 用途:全局唯一的进程标识符,从 0 开始递增分配。
    • 使用场景
      • 当你在 Shell 中执行 kill 1234 时,内核通过 PID 1234 找到对应的进程。
      • 父进程通过 fork() 返回的 PID 来追踪子进程。
      • IPC 通信时,进程需要指定目标进程的 PID。
    • 如果没有 PID:内核无法区分不同的进程。想象医院里所有病人都没有病历号,医生如何找到正确的病人?
  • State (进程状态)

    • 用途:标识进程当前所处的生命周期阶段。
    • 可能的值
      • CREATED:刚被创建,尚未初始化完成
      • READY:已准备好,等待调度器分配 CPU
      • RUNNING:正在某个 CPU 核心上执行
      • BLOCKED:等待 I/O 操作完成(如读取磁盘)
      • WAITING:等待某个事件(如等待子进程退出)
      • SLEEPING:主动休眠(如调用了 sleep(100)
      • ZOMBIE:已执行完毕,但父进程尚未回收其资源
    • 使用场景
      • 调度器只选择 READY 状态的进程来运行。
      • 当磁盘驱动完成读取后,会将进程从 BLOCKED 改为 READY
      • ps 命令显示进程列表时,就是读取这个状态字段。
    • 如果没有 State:调度器可能会试图运行一个正在等待 I/O 的进程,导致系统空转浪费 CPU。
  • PPID (Parent PID - 父进程 ID)

    • 用途:记录谁创建了这个进程。
    • 使用场景
      • 当子进程退出时,内核需要通过 PPID 找到父进程,发送 SIGCHLD 信号。
      • 构建进程树:pstree 命令就是通过 PPID 关系来构建的。
      • 孤儿进程处理:如果父进程先退出,内核会将子进程的 PPID 改为 1(init 进程)。
    • 如果没有 PPID:内核无法知道子进程退出时应该通知谁,也无法正确处理僵尸进程。
  • Priority (优先级)

    • 用途:决定进程调度的优先顺序。数字越小,优先级越高。
    • 使用场景
      • 系统关键服务(如网络守护进程)设置高优先级(如 5),确保及时响应。
      • 后台任务(如日志归档)设置低优先级(如 20),避免影响用户体验。
      • 实时任务(如音频播放)需要最高优先级,避免卡顿。
    • 如果没有 Priority:所有进程平等对待,导致重要任务被延迟。想象急诊病人和普通挂号者排同一个队。
  • Core ID (CPU 核心 ID)

    • 用途:在多核系统中,标识进程当前运行在(或上次运行在)哪个 CPU 核心上。
    • 使用场景
      • CPU 亲和性 (Affinity):将进程绑定到特定核心,提高 CPU 缓存命中率。
      • 负载均衡:避免所有进程都挤在核心 0 上,而核心 1-3 闲置。
      • 中断路由:硬件中断可以被路由到进程所在的核心,减少核间通信。
    • 如果没有 Core ID:调度器可能频繁地在不同核心间迁移进程,导致缓存失效,性能下降 30-50%。

2. space (领地) - 进程的内存空间

这是进程最重要的资源之一,定义了进程可以访问的内存范围。

  • 页目录指针 (page_dir_entry_t* vm)

    • 用途:指向进程的页表,记录虚拟地址到物理地址的映射关系。
    • 使用场景
      • 进程访问内存时,MMU(内存管理单元)通过这个页表进行地址转换。
      • 上下文切换时,内核会切换 CPU 的页表寄存器(ARM 中的 TTBR0)指向新进程的页目录。
      • 实现内存隔离:进程 A 无法访问进程 B 的内存,因为它们使用不同的页表。
    • 如果没有页表:所有进程共享同一个地址空间,任何进程都可以读写其他进程的内存,完全没有安全性和稳定性。
  • 堆 (heap) 和栈 (stack) 范围

    • 用途:记录堆和栈的起始地址和大小。
    • 使用场景
      • 当进程调用 malloc() 时,内核需要知道堆的当前边界,决定是否需要扩展(通过 brk() 系统调用)。
      • 当函数调用导致栈溢出时,内核检查栈指针是否超出栈范围,触发段错误 (Segmentation Fault)。
      • 栈向下增长、堆向上增长,内核需要确保它们不会相撞。
    • 如果没有这些范围记录:进程可以无限制地使用内存,导致内存耗尽;也无法检测栈溢出等常见错误。
  • COW (Copy-On-Write) 标记

    • 用途:标识哪些内存页面是写时复制的共享页面。
    • 使用场景
      • fork() 创建子进程时,父子进程共享相同的物理内存页面,并标记为 COW。
      • 当任何一方尝试写入这些页面时,触发缺页异常,内核复制页面,实现真正的独立。
      • 大大提高 fork() 的效率:如果没有 COW,创建一个 100MB 的进程副本需要复制 100MB 内存。
    • 如果没有 COWfork() 性能将降低 10-100 倍,导致进程创建极其缓慢。

3. ctx (案发现场) - CPU 上下文

这是进程最核心的状态信息,记录了 CPU 执行到哪一步。

  • pc (Program Counter - 程序计数器)

    • 用途:指向下一条要执行的指令地址。
    • 使用场景
      • 上下文切换时,内核保存当前 PC 值,下次恢复时从此处继续执行。
      • 异常处理时,内核需要知道异常发生在哪条指令。
      • 调试器设置断点时,会修改 PC 值或在 PC 指向的位置插入断点指令。
    • 如果没有 PC:进程被切换后无法恢复,每次都要从头开始运行。就像读书时不做书签,每次打开都从第 1 页开始。
  • sp (Stack Pointer - 栈指针)

    • 用途:指向当前栈顶的位置。
    • 使用场景
      • 函数调用时,参数和返回地址压入栈,SP 向下移动。
      • 函数返回时,SP 恢复到调用前的位置。
      • 上下文切换时,必须保存 SP,否则栈数据会混乱。
    • 如果没有 SP:函数调用将完全无法工作,程序无法执行任何复杂逻辑。
  • lr (Link Register - 链接寄存器)

    • 用途:在 ARM 架构中,保存函数返回地址。
    • 使用场景
      • 调用函数时,硬件自动将返回地址存入 LR。
      • 函数返回时,直接跳转到 LR 指向的地址。
      • 嵌套函数调用时,LR 被保存到栈中。
    • 如果没有 LR:需要用额外的指令和内存访问来管理返回地址,性能降低 10-20%。
  • r0-r12 (通用寄存器)

    • 用途:保存运算数据、函数参数、局部变量等。
    • 使用场景
      • 进程正在计算 a + b * c,中间结果存储在这些寄存器中。
      • 如果此时发生中断或上下文切换,必须保存这些值,否则计算结果会丢失。
    • 如果没有保存寄存器:进程恢复后会使用错误的数据,导致计算结果完全错误。
  • cpsr (Current Program Status Register - 当前程序状态寄存器)

    • 用途:保存 CPU 的状态标志(如零标志、负标志、进位标志)和处理器模式。
    • 使用场景
      • 条件分支指令(如 beq, bne)依赖于 CPSR 中的标志位。
      • 异常处理时,需要保存和恢复 CPSR 以确保正确的处理器模式。
    • 如果没有 CPSR:条件判断将失效,程序逻辑完全混乱。

4. sleep_counter (睡眠计数器) - 定时器

  • 用途:实现进程的定时休眠功能。
  • 使用场景
    • 进程调用 sleep(5) 时,内核将 sleep_counter 设置为 5000(假设时钟频率为 1000Hz)。
    • 每次时钟中断(通常每 10ms 一次),内核遍历所有睡眠进程,将计数器减 10。
    • 当计数器归零时,将进程状态从 SLEEPING 改为 READY,加入调度队列。
  • 如果没有 sleep_counter:进程无法精确休眠,只能通过忙等待(busy-waiting)实现延迟,白白浪费 CPU 资源。一个简单的 sleep(1) 会让 CPU 使用率达到 100%。

这个 proc_t 结构体看似简单,但每个字段都是经过精心设计的。它们共同构成了操作系统管理进程的完整基础设施。如果缺少任何一个字段,都会导致严重的功能缺失或性能问题。

3.2.1 进程状态转换图

       fork()
   NEW -------> READY --------+
                  ^            |
                  |  schedule()|
              wakeup()         v
   WAIT/BLOCK <--------- RUNNING -----> ZOMBIE ----> (销毁)
      ^                     |              ^
      |  sleep()/           |              |
      +-- ipc_call()        +-- exit() ----+

进程的生命周期就像一场精心编排的舞台剧,每个状态都有其特定的意义和转换条件。让我们详细剖析每个状态转换的时机和原因。

状态详解

NEW → READY(出生)

  • 触发条件:父进程调用 fork() 或内核启动 init 进程时。
  • 发生了什么
    1. 内核分配一个新的 proc_t 结构。
    2. 复制父进程的地址空间(通过 COW 机制,不会立即复制物理内存)。
    3. 初始化进程的上下文(寄存器、栈指针等)。
    4. 分配 PID,将进程加入就绪队列。
  • 实际例子
    • 你在 Shell 中输入 ls,Shell 进程调用 fork() 创建一个子进程。
    • 子进程立即进入 READY 状态,等待调度器分配 CPU。

READY → RUNNING(被选中)

  • 触发条件:调度器 schedule() 选中该进程。
  • 发生了什么
    1. 调度器从就绪队列中选择优先级最高的进程。
    2. 执行上下文切换(Context Switch):
      • 保存当前进程的 CPU 寄存器到其 proc_t->ctx
      • 加载新进程的 proc_t->ctx 到 CPU 寄存器。
      • 切换页表(修改 MMU 的页表基址寄存器)。
    3. CPU 开始执行新进程的代码。
  • 实际例子
    • 时钟中断(通常每 10ms)触发调度器运行。
    • 调度器发现 ls 进程优先级高且处于 READY 状态,将其切换为 RUNNING。

RUNNING → READY(被抢占)

  • 触发条件:时间片用完,或更高优先级的进程就绪。
  • 发生了什么
    1. 时钟中断或其他中断发生。
    2. 内核调用 schedule(),发现当前进程时间片已用完。
    3. 将当前进程状态改为 READY,重新加入就绪队列。
    4. 选择下一个进程切换执行。
  • 实际例子
    • ls 进程运行了 10ms(一个时间片),但还没执行完。
    • 时钟中断发生,调度器将 ls 改为 READY,切换到另一个进程(如 vim)。
    • 几毫秒后,ls 再次被调度,继续执行。

RUNNING → BLOCKED/WAITING(主动等待)

  • 触发条件:进程需要等待某个事件(I/O、IPC、子进程退出等)。
  • 发生了什么
    1. 进程执行系统调用,如:
      • read(fd, buf, size) - 等待磁盘数据
      • wait(&status) - 等待子进程退出
      • ipc_call() - 等待 IPC 响应
    2. 内核将进程状态改为 BLOCKED 或 WAITING。
    3. 将进程从就绪队列移除,放入等待队列。
    4. 立即调用 schedule() 切换到其他进程。
  • 实际例子
    • cat big_file.txt 进程调用 read() 读取磁盘文件。
    • 磁盘读取需要几毫秒,进程进入 BLOCKED 状态。
    • CPU 不会空等,而是切换去运行其他进程。

BLOCKED/WAITING → READY(被唤醒)

  • 触发条件:等待的事件完成(I/O 完成、IPC 响应、子进程退出等)。
  • 发生了什么
    1. 事件发生(如磁盘驱动完成数据传输)。
    2. 中断处理程序或其他进程调用 wakeup(pid)
    3. 内核将进程状态从 BLOCKED 改为 READY。
    4. 将进程重新加入就绪队列,等待调度。
  • 实际例子
    • 磁盘驱动完成了 cat 进程的读取请求。
    • 驱动程序调用 wakeup(cat_pid)
    • cat 进程状态变为 READY,等待下次被调度。

RUNNING → SLEEPING(定时休眠)

  • 触发条件:进程调用 sleep(n)usleep(n)
  • 发生了什么
    1. 进程执行 sleep(5) 系统调用。
    2. 内核将进程的 sleep_counter 设置为 5000(假设时钟频率 1000Hz)。
    3. 将进程状态改为 SLEEPING,从就绪队列移除。
    4. 每次时钟中断,内核递减 sleep_counter
    5. 当计数器归零时,自动触发 SLEEPING → READY 转换。
  • 实际例子
    • 一个守护进程每 5 秒检查一次邮件:while(1) { check_mail(); sleep(5); }
    • 调用 sleep(5) 后,进程让出 CPU,不浪费资源。

RUNNING → ZOMBIE(进程已死,灵魂未散)

  • 触发条件:进程执行完毕(main 返回或调用 exit()),但父进程尚未回收。
  • 发生了什么
    1. 进程执行 exit(0)main 函数返回。
    2. 内核释放进程的大部分资源(内存、文件描述符等)。
    3. 但保留 proc_t 结构(包含退出状态码)。
    4. 将进程状态改为 ZOMBIE。
    5. 向父进程发送 SIGCHLD 信号。
  • 为什么需要 ZOMBIE 状态
    • 父进程需要通过 wait() 获取子进程的退出状态码。
    • 如果立即销毁 proc_t,父进程就无法知道子进程是成功(返回 0)还是失败(返回非 0)。
    • ZOMBIE 就像墓碑,记录着进程的"遗言"(退出状态)。
  • 实际例子
    • Shell 执行 ls 命令,fork() 创建子进程。
    • ls 子进程执行完毕,调用 exit(0)
    • ls 进入 ZOMBIE 状态,等待 Shell 调用 wait() 回收。
    • Shell 调用 wait(&status) 后,获取退出状态,lsproc_t 才被完全销毁。
  • 僵尸进程的危害
    • 如果父进程忘记调用 wait(),ZOMBIE 进程会一直存在。
    • 每个 ZOMBIE 占用一个 PID 和一个 proc_t 结构(几百字节)。
    • 如果大量僵尸进程积累,可能耗尽 PID 空间(通常上限是 32768)。

ZOMBIE → 销毁(魂飞魄散)

  • 触发条件:父进程调用 wait()waitpid()
  • 发生了什么
    1. 父进程调用 wait(&status) 阻塞等待子进程退出。
    2. 内核找到 ZOMBIE 状态的子进程。
    3. 将退出状态码复制给父进程的 status 变量。
    4. 完全销毁子进程的 proc_t 结构。
    5. 回收 PID,供后续进程使用。
  • 实际例子
    • Shell 执行完 ls 命令后,立即调用 wait() 回收子进程。
    • 这就是为什么 Shell 命令执行后,不会留下僵尸进程。

特殊情况:孤儿进程

  • 什么是孤儿进程?父进程先于子进程退出,子进程成为孤儿。
  • 系统如何处理
    1. 内核检测到父进程退出时,会将其所有子进程的 PPID 改为 1(init 进程)。
    2. init 进程会定期调用 wait() 回收所有孤儿进程。
    3. 这确保了系统中不会有永久的僵尸进程。

状态转换的性能影响

  • 频繁的 READY ↔ RUNNING 切换(上下文切换):

    • 每次切换需要保存/加载寄存器、切换页表、刷新 TLB(Translation Lookaside Buffer)。
    • 单次切换开销约 1-10 微秒,但如果每秒切换上千次,累积开销可达 10-20% CPU 时间。
  • RUNNING → BLOCKED 转换的优势

    • 避免了 CPU 空转等待 I/O。
    • 如果没有这个状态,进程只能忙等待(busy-waiting),浪费 CPU 资源。
  • ZOMBIE 状态的必要性

    • 虽然占用少量内存,但提供了父子进程间的同步机制。
    • 如果没有 ZOMBIE,父进程无法获知子进程的退出状态,很多程序逻辑会失效。

理解这些状态转换,是掌握操作系统进程管理的关键。每个状态都不是凭空设计的,而是为了解决实际问题而引入的。

3.3 调度:谁是下一个幸运儿?

想象一下你在医院急诊室。有人只是擦伤了膝盖,有人心脏病突发,有人骨折,还有人只是来体检。如果按先来先服务的原则,心脏病患者可能要等体检的人做完检查才能得到救治——这显然是荒谬的。

调度器 (Scheduler) 就是操作系统的"急诊分诊护士",它的职责是:在众多等待 CPU 的进程中,决定下一个谁应该获得 CPU 资源

为什么需要调度器?

如果没有调度器会怎样?让我们看一个具体场景:

场景:你的电脑同时运行三个程序

  1. 浏览器:正在播放在线视频(需要每 16ms 解码一帧,否则会卡顿)
  2. 编译器:正在编译一个大型项目(需要 CPU 密集计算 10 秒)
  3. 备份程序:正在后台备份文件(I/O 密集,经常等待磁盘)

没有调度器的世界

  • 浏览器先抢到 CPU,播放 1 秒视频后,编译器才能运行。
  • 编译器开始运行,持续占用 CPU 10 秒,浏览器视频完全卡死。
  • 用户体验极差,系统看起来"死机"了。

有调度器的世界

  • 调度器每 10ms 轮换一次:浏览器 → 编译器 → 备份 → 浏览器 → ...
  • 浏览器获得足够频繁的 CPU 时间,视频流畅播放。
  • 编译器虽然被频繁打断,但总体上仍在推进。
  • 备份程序在等待磁盘 I/O 时,CPU 被分配给其他进程,没有浪费。

调度器的核心作用就是:在保证系统响应性的同时,最大化 CPU 利用率

调度器具体调度什么?

调度器管理的是 CPU 时间 这一稀缺资源。具体来说:

  1. 时间片分配:将 CPU 时间切成小块(通常 10ms),分配给不同进程。
  2. 优先级管理:重要的进程(如 GUI、音频播放)获得更多、更频繁的时间片。
  3. 资源均衡:避免某些进程饿死(永远得不到 CPU)。
  4. 核心分配(多核系统):决定进程运行在哪个 CPU 核心上。

EwokOS 的调度实现

EwokOS 使用的是优先级调度 (Priority Scheduling) 算法。其工作流程如下:

  1. 就绪队列 (Ready Queue):所有处于 READY 状态的进程都在这里排队,按优先级排序。
  2. 时钟滴答 (Tick):硬件时钟每隔一段时间(如 10ms)触发一次中断。
  3. 调度决策:中断处理程序调用调度器,从就绪队列选择优先级最高的进程。
  4. 上下文切换 (Context Switch):保存当前进程状态,加载新进程状态,切换执行。

关键代码片段 (kernel/kernel/src/sched.c):

void schedule(context_t* ctx) {
    // 1. 保存当前进程的上下文
    if(_current_proc != NULL)
        proc_save(ctx);

    // 2. 从就绪队列选择下一个进程
    _current_proc = proc_get_next_ready();

    // 3. 如果没有就绪进程,运行空闲进程
    if(_current_proc == NULL)
        _current_proc = _idle_proc;

    // 4. 加载新进程的上下文
    proc_load(ctx);
}

空闲进程 (Idle Process)

  • 当所有进程都在等待 I/O 时,CPU 运行 idle 进程。
  • Idle 进程的任务很简单:执行低功耗指令(如 ARM 的 WFI - Wait For Interrupt)。
  • 这可以节省电量,延长电池寿命(对嵌入式设备很重要)。

调度算法的演变:从简单到复杂

操作系统调度算法经历了几十年的演化,让我们来看这段有趣的历史:

1. 先来先服务 (FCFS, First-Come-First-Served) - 1950年代

  • 原理:谁先到谁先执行,直到执行完毕或主动放弃 CPU。
  • 优点:实现简单,公平(按到达顺序)。
  • 缺点
    • 护航效应 (Convoy Effect):一个长作业会阻塞后面所有短作业。
    • 平均等待时间长,用户体验差。
  • 实际例子
    • 进程 A 需要运行 1 小时(编译内核)。
    • 进程 B 只需要 1 秒(打开记事本)。
    • 如果 A 先到达,B 要等 1 小时。用户会认为系统"死机"了。

2. 时间片轮转 (Round Robin, RR) - 1960年代

  • 原理:每个进程分配固定时间片(如 10ms),时间到了就轮换到下一个。
  • 历史意义:这是 分时系统 (Time-Sharing) 的核心算法,由麻省理工学院在 1960 年代发明。
  • 优点
    • 所有进程都能获得响应,避免长时间等待。
    • 实现简单,容易理解。
  • 缺点
    • 所有进程平等对待,无法区分优先级。
    • 时间片太短导致频繁切换(开销大),太长导致响应慢。
  • 实际例子
    • Unix 早期版本(1970 年代)使用这个算法。
    • 时间片通常设为 100ms,兼顾响应性和效率。

3. 优先级调度 (Priority Scheduling) - 1970年代

  • 原理:每个进程有优先级(数字越小越高),高优先级优先执行。
  • EwokOS 使用这个算法:简单高效,适合嵌入式系统。
  • 优点
    • 可以保证重要进程(如实时任务)优先执行。
    • 灵活性高,可以根据需求调整优先级。
  • 缺点
    • 饿死 (Starvation):低优先级进程可能永远得不到执行。
    • 需要合理设计优先级,避免系统失衡。
  • 解决方案
    • 优先级老化 (Aging):进程等待时间越长,优先级逐渐提升。
    • 防止低优先级进程饿死。
  • 实际例子
    • 音频播放器优先级设为 5(高):确保不卡顿。
    • 后台备份优先级设为 20(低):不影响前台任务。
    • 普通应用优先级设为 10(中):平衡性能。

4. 多级反馈队列 (Multi-Level Feedback Queue, MLFQ) - 1980年代

  • 原理:维护多个优先级队列,进程根据行为动态调整优先级。
  • 行为
    • 新进程从最高优先级队列开始。
    • 如果进程用完时间片,降级到下一个队列。
    • 如果进程频繁 I/O(交互式),保持高优先级。
  • 优点
    • 自动识别 I/O 密集型(交互式)和 CPU 密集型进程。
    • 无需手动设置优先级。
  • 实际例子
    • Windows NT(1993 年)和 macOS 使用这个算法。

5. 完全公平调度器 (CFS, Completely Fair Scheduler) - 2007年

  • 原理:Linux 2.6.23 引入,使用红黑树跟踪每个进程的"虚拟运行时间"。
  • 核心思想
    • 每个进程都应该获得 1/N 的 CPU 时间(N 是进程数)。
    • 跟踪每个进程的 vruntime(虚拟运行时间)。
    • 总是选择 vruntime 最小的进程运行,确保公平。
  • 优点
    • 极其公平,避免饿死。
    • 自动适应不同负载。
  • 缺点
    • 实现复杂,需要红黑树数据结构。
    • 不适合硬实时系统(无法保证响应时间上限)。
  • 实际例子
    • Linux 桌面和服务器系统的标准调度器。
    • Android 系统也使用 CFS。

EwokOS 为什么选择优先级调度?

  • 简单性:代码量少,易于理解和调试。
  • 可预测性:高优先级进程总是优先执行,适合实时应用。
  • 效率:选择下一个进程的时间复杂度 O(1),而 CFS 是 O(log N)。
  • 适用性:嵌入式系统和微内核架构更重视确定性,而不是绝对公平。

多核调度 (SMP Scheduling):让多个厨师协同工作

在单核时代,调度器的任务相对简单:管理一个 CPU 和一个就绪队列。但现代 CPU 通常有 2、4、8 甚至更多核心。多核 (Multi-Core) 是指一个 CPU 芯片上集成了多个独立的处理核心,每个核心都可以同时执行不同的进程。

举个例子:

  • 单核 CPU:就像一个厨师,必须按顺序做菜——炒完菜 A 才能炒菜 B。
  • 4 核 CPU:就像 4 个厨师,可以同时炒 4 道菜,效率提升 4 倍(理想情况)。
  • 超线程 (Hyper-Threading):一个物理核心模拟成 2 个逻辑核心,利用 CPU 的空闲执行单元。

多核调度的挑战

挑战 1:负载均衡

  • 问题:如何避免某些核心忙碌,而其他核心空闲?
  • 例子
    • 4 个进程全部运行在核心 0 上,而核心 1-3 空闲。
    • 导致系统性能只发挥了 25%。
  • 后果:CPU 利用率低,用户体验差(部分核心过载,部分核心空闲)。

挑战 2:缓存一致性

  • 问题:进程在不同核心间迁移会导致缓存失效。
  • 背景:每个 CPU 核心都有独立的 L1/L2 缓存(几百 KB),用于加速内存访问。
  • 例子
    • 进程 A 在核心 0 上运行,L1 缓存已经加载了代码和数据。
    • 调度器将 A 迁移到核心 1,核心 1 的 L1 缓存是空的。
    • 需要重新从内存加载数据,性能损失 30-50%。
  • 解决方案:尽量让进程在同一核心运行(CPU 亲和性)。

挑战 3:同步和锁竞争

  • 问题:多个核心可能同时访问调度器数据结构(就绪队列)。
  • 例子
    • 核心 0 和核心 1 同时调用 schedule()
    • 如果没有锁保护,可能两个核心选择同一个进程,导致数据错误。
  • 解决方案:使用自旋锁 (Spinlock) 保护临界区。

EwokOS 的多核调度策略

1. 每核心就绪队列 (Per-Core Ready Queue)

  • 每个 CPU 核心维护自己独立的就绪队列。
  • 优点
    • 减少锁竞争:核心通常只访问自己的队列。
    • 提高缓存命中率:进程倾向于在同一核心运行。
  • 实现

    struct cpu_core {
        proc_t* ready_queue;      // 就绪队列
        proc_t* current_proc;     // 当前运行进程
        spinlock_t queue_lock;    // 队列锁
    };
    
    cpu_core cores[MAX_CPUS];
    

2. 负载均衡 (Load Balancing)

  • 被动均衡 - 工作窃取 (Work Stealing)
    • 空闲核心主动"偷"其他核心的进程。
    • 实现简单,开销低。
  • 主动均衡 - 定期迁移
    • 定期(如每 100ms)检查各核心负载。
    • 强制将任务从忙碌核心迁移到空闲核心。
  • 实现示例
    // 核心 1 空闲时,尝试从其他核心偷进程
    if (cores[1].ready_queue == NULL) {
        for (int i = 0; i < MAX_CPUS; i++) {
            if (i != 1 && cores[i].ready_queue != NULL) {
                proc_t* stolen = steal_process(&cores[i]);
                if (stolen != NULL) {
                    add_to_queue(&cores[1], stolen);
                    break;
                }
            }
        }
    }
    

3. CPU 亲和性 (CPU Affinity)

  • 概念:允许进程绑定到特定核心或核心集合。
  • 优点
    • 提高缓存命中率(进程总是在同一核心运行)。
    • 避免频繁迁移的开销。
  • 应用场景
    • 实时任务:绑定到特定核心,避免被其他进程干扰。
    • NUMA 系统:将进程绑定到内存最近的核心,减少访问延迟。
    • 性能关键应用:如数据库服务器、游戏服务器。
  • 实现
    // 设置进程只能在核心 0 和 1 上运行
    proc_set_affinity(pid, CPU_MASK(0) | CPU_MASK(1));
    

4. 临界区保护:自旋锁 (Spinlock)

  • 为什么需要锁:多个核心可能同时访问共享数据结构。
  • 自旋锁原理
    • 如果锁已被占用,核心会"自旋"(循环等待),而不是睡眠。
    • 适用于持锁时间很短的场景(如几微秒)。
  • 实现
    void schedule_on_core(int core_id) {
        spinlock_lock(&cores[core_id].queue_lock);
        // 访问就绪队列
        proc_t* next = get_next_ready(&cores[core_id]);
        spinlock_unlock(&cores[core_id].queue_lock);
        // 切换到新进程
        context_switch(next);
    }
    
  • 为什么不用睡眠锁(Mutex)
    • 睡眠锁需要调度器,但调度器本身就需要锁,会产生循环依赖。
    • 自旋锁的持锁时间极短(几微秒),自旋开销小于睡眠唤醒开销。

5. 核间中断 (IPI, Inter-Processor Interrupt)

  • 概念:一个核心可以向其他核心发送中断,通知它们执行特定操作。
  • 应用场景
    • 唤醒空闲核心:进程被唤醒后,通知目标核心重新调度。
    • TLB 刷新:修改页表后,通知其他核心刷新 TLB(Translation Lookaside Buffer)。
    • 停止核心:系统关机时,通知所有核心停止执行。
  • 实现

    // 核心 0 通知核心 1 重新调度
    send_ipi(1, IPI_RESCHEDULE);
    
    // 核心 1 收到 IPI 中断
    void ipi_handler() {
        if (ipi_type == IPI_RESCHEDULE) {
            schedule(get_context());
        }
    }
    

多核调度的实际例子

场景:4 核 CPU,运行 8 个进程(A-H)

时刻 核心 0 核心 1 核心 2 核心 3
T0 A B C D
T1 E F G H
T2 A B C (空闲)
  • T0:所有核心都在运行进程。
  • T1:调度器切换,每个核心运行新进程。
  • T2:进程 D 和 H 退出,核心 3 变为空闲。
  • 负载均衡
    • 核心 3 检测到自己空闲。
    • 尝试从其他核心"偷"进程:发现核心 0 的就绪队列中有进程 E 等待。
    • 将 E 迁移到核心 3 运行。

这就是多核调度的魅力:让多个 CPU 核心高效协作,最大化系统吞吐量和响应性。

3.4 上下文切换:魔术般的瞬间

上下文切换 (Context Switch) 是操作系统最迷人的魔术。想象一下:你正在看一本侦探小说,看到关键情节时电话响了。你接完电话回来,能够毫无障碍地继续阅读——这就是因为你的大脑记住了"上下文"(读到哪一页、主角是谁、案件进展如何)。

操作系统的上下文切换就是这样的魔术:让 CPU 可以在多个进程之间快速切换,而每个进程都感觉不到自己被打断过。

为什么需要上下文切换?

场景 1:充分利用 CPU

  • 进程 A 正在等待磁盘读取(需要 5ms)。
  • 如果 CPU 傻等着,这 5ms 就浪费了。
  • 通过上下文切换,CPU 可以在这 5ms 内运行进程 B,提高利用率。

场景 2:实现多任务

  • 用户同时运行浏览器、音乐播放器、编辑器。
  • 单核 CPU 通过快速切换(如每 10ms 一次),让用户感觉三个程序"同时"运行。

场景 3:响应紧急任务

  • 系统正在运行后台备份(低优先级)。
  • 用户点击鼠标(高优先级)。
  • 通过上下文切换,立即暂停备份,响应鼠标点击,保证系统交互性。

如果没有上下文切换

  • 系统只能串行执行进程,一个进程运行完才能运行下一个。
  • I/O 等待时 CPU 空闲,利用率从 80% 降到 20%。
  • 用户体验极差,感觉系统"卡顿"。

上下文切换的详细过程

当 CPU 从进程 A 切换到进程 B 时,发生了什么?

第 1 步:触发切换

上下文切换可以由多种事件触发:

  • 时钟中断:时间片用完(如 10ms)。
  • 系统调用:进程调用 sleep()read()wait() 等,主动让出 CPU。
  • 中断:硬件中断(如磁盘 I/O 完成),唤醒等待的进程。
  • 抢占:高优先级进程就绪,抢占当前进程。

第 2 步:保存现场(进程 A 的上下文)

内核必须保存进程 A 的所有 CPU 状态,确保下次恢复时能继续执行。

void proc_save(context_t* ctx) {
    if (_current_proc == NULL)
        return;

    // 保存 CPU 寄存器到进程的 ctx 结构
    memcpy(&_current_proc->ctx, ctx, sizeof(context_t));

    // ctx 包含:
    // - pc (Program Counter):下一条要执行的指令
    // - sp (Stack Pointer):栈顶位置
    // - r0-r12:通用寄存器
    // - cpsr:状态寄存器
    // - lr (Link Register):返回地址
}

需要保存的内容

  1. 程序计数器 (PC):记录进程执行到哪一条指令。
  2. 栈指针 (SP):记录栈顶位置,函数调用依赖它。
  3. 通用寄存器 (r0-r12):保存进程的计算数据。
  4. 状态寄存器 (CPSR):保存条件标志位(零标志、负标志等)。
  5. 链接寄存器 (LR):保存函数返回地址。

第 3 步:选择下一个进程

调度器从就绪队列中选择下一个要运行的进程:

proc_t* proc_get_next_ready() {
    // 遍历所有进程,找到优先级最高的 READY 进程
    proc_t* best = NULL;
    int best_priority = INT_MAX;

    for (int i = 0; i < MAX_PROCS; i++) {
        proc_t* p = &procs[i];
        if (p->info.state == READY && p->info.priority < best_priority) {
            best = p;
            best_priority = p->info.priority;
        }
    }

    return best;
}

第 4 步:恢复现场(进程 B 的上下文)

将进程 B 保存的寄存器值加载到 CPU:

void proc_load(context_t* ctx) {
    if (_current_proc == NULL)
        return;

    // 将进程 B 的 ctx 结构复制到 CPU 寄存器
    memcpy(ctx, &_current_proc->ctx, sizeof(context_t));

    // 切换页表:加载进程 B 的虚拟地址空间
    mmu_switch_page_dir(_current_proc->space->vm);
}

恢复的内容

  1. 加载寄存器:将保存的 PC、SP、r0-r12 等值恢复到 CPU。
  2. 切换页表:修改 MMU 的页表基址寄存器(ARM 中的 TTBR0),使 CPU 访问进程 B 的内存空间。
  3. 刷新 TLB:页表切换后,TLB(Translation Lookaside Buffer)缓存失效,需要刷新。

第 5 步:继续执行

CPU 从进程 B 的 PC 指向的位置继续执行。进程 B 感觉自己只是"眨了一下眼",完全不知道被暂停过。

上下文切换的开销

上下文切换看似简单,但实际开销不小:

直接开销

  1. 保存和加载寄存器:约 20-50 条指令,耗时 0.1-0.5 微秒。
  2. 切换页表:修改 MMU 寄存器,耗时 0.1 微秒。
  3. 刷新 TLB:清空地址转换缓存,耗时 1-5 微秒。
  4. 调度器逻辑:遍历就绪队列,选择进程,耗时 0.5-2 微秒。

总直接开销:约 2-10 微秒

间接开销(更严重)

  1. CPU 缓存失效
    • 进程 A 使用的数据在 L1/L2 缓存中。
    • 切换到进程 B 后,缓存被进程 B 的数据覆盖。
    • 进程 A 恢复运行时,需要重新从内存加载数据。
    • 缓存冷启动 (Cold Cache) 导致性能下降 20-50%。
  2. TLB 失效
    • TLB 缓存了虚拟地址到物理地址的映射。
    • 切换页表后,TLB 完全失效。
    • 后续内存访问需要重新查页表(慢 10-100 倍)。
  3. 流水线冲刷 (Pipeline Flush)
    • 现代 CPU 使用指令流水线(10-20 级)。
    • 上下文切换会导致流水线中的指令作废。
    • 需要重新填充流水线,损失几十个时钟周期。

总间接开销:可能导致后续数百到数千条指令执行变慢。

实际影响

  • 如果系统每秒切换 1000 次(每 1ms 一次),直接开销约占 1% CPU 时间。
  • 但加上间接开销(缓存失效等),总开销可达 10-20% CPU 时间
  • 这就是为什么减少上下文切换频率是性能优化的重要方向。

上下文切换的优化策略

1. 减少切换频率

  • 增加时间片:从 1ms 增加到 10ms,切换次数减少 10 倍。
  • 批量处理:等待多个事件再切换,而不是每个事件都切换。

2. CPU 亲和性 (Affinity)

  • 让进程总是在同一个 CPU 核心运行。
  • 减少缓存失效,提高性能 20-30%。

3. 轻量级进程切换

  • 同一进程的多个线程切换时,不需要切换页表(共享地址空间)。
  • 只需要保存和加载寄存器,开销降低 50%。

4. 懒惰 TLB 刷新 (Lazy TLB Flush)

  • 某些架构支持为每个进程分配 ASID (Address Space ID)。
  • 切换进程时不刷新 TLB,通过 ASID 区分不同进程的 TLB 项。

微内核 vs 宏内核:上下文切换的差异

在微内核和宏内核中,上下文切换的意义和频率有显著差异。

宏内核(如 Linux)

  • 特点:内核服务(文件系统、驱动)运行在内核态。
  • 系统调用开销
    • 用户进程调用 read() 时,只需要从用户态切换到内核态(特权级切换)。
    • 不需要进程切换,开销约 0.1-0.5 微秒。
  • 优点:系统调用快速,适合频繁 I/O 的应用。

微内核(如 EwokOS)

  • 特点:内核服务(VFS、驱动)运行为独立进程。
  • 系统调用开销
    • 用户进程调用 read() 时,需要通过 IPC 发送消息给 VFS 进程。
    • 涉及至少 2 次上下文切换:用户进程 → 内核 → VFS 进程。
    • 开销约 5-20 微秒。
  • 挑战:如何减少 IPC 和上下文切换的开销?
  • 优化方案
    • 零拷贝 IPC:通过共享内存传递数据,避免数据复制。
    • 批量 IPC:一次 IPC 处理多个请求。
    • 异步 IPC:发送消息后不等待响应,继续执行。

EwokOS 的设计权衡

  • 牺牲了部分性能(上下文切换频繁),但获得了:
    • 隔离性:VFS 崩溃不会导致内核崩溃。
    • 可扩展性:可以热插拔驱动和服务。
    • 安全性:服务之间无法直接访问对方内存。

上下文切换的实际测量

让我们看一个实际的测量例子(在 ARM Cortex-A 系列 CPU 上):

操作 耗时(微秒) 说明
保存寄存器 0.2 约 30 条指令
加载寄存器 0.2 约 30 条指令
切换页表 0.1 修改 TTBR0
刷新 TLB 1-5 取决于 TLB 大小
调度决策 0.5-2 遍历就绪队列
直接总开销 2-10 -
L1 缓存失效 10-50 后续几百次内存访问变慢
L2 缓存失效 50-200 大量数据需要从内存加载
间接总开销 60-250 影响后续执行

结论:上下文切换的真实开销远大于表面看起来的 2-10 微秒,可能达到数百微秒。

代码示例:完整的上下文切换

下面是 EwokOS 中上下文切换的简化代码:

// kernel/kernel/src/sched.c

void schedule(context_t* ctx) {
    // 1. 保存当前进程的上下文
    if (_current_proc != NULL) {
        memcpy(&_current_proc->ctx, ctx, sizeof(context_t));
        _current_proc->info.state = READY;  // 改为就绪状态
    }

    // 2. 选择下一个进程
    _current_proc = proc_get_next_ready();

    // 3. 如果没有就绪进程,运行空闲进程
    if (_current_proc == NULL) {
        _current_proc = _idle_proc;
    }

    // 4. 更新进程状态
    _current_proc->info.state = RUNNING;

    // 5. 加载新进程的上下文
    memcpy(ctx, &_current_proc->ctx, sizeof(context_t));

    // 6. 切换页表(切换虚拟地址空间)
    mmu_switch_page_dir(_current_proc->space->vm);

    // 7. 刷新 TLB
    __asm__ volatile("mcr p15, 0, %0, c8, c7, 0" : : "r"(0));
}

这个函数虽然只有几十行代码,但它是操作系统最核心的部分之一。每秒可能被调用数千次,是系统性能的关键瓶颈。

上下文切换:魔术背后的真相

上下文切换就像电影中的无缝剪辑——观众看到的是流畅的故事,但实际上导演在幕后做了大量工作(场景切换、演员调度、道具准备)。

操作系统通过上下文切换,让多个进程看起来"同时"运行。但这个魔术是有代价的——每次切换都消耗时间和资源。现代操作系统设计的一个重要目标,就是在提供良好多任务体验的同时,尽量减少上下文切换的开销。

3.5 线程:轻量级的进程

如果进程是"一道菜",线程 (Thread) 就是"这道菜里的不同工序"。洗菜、切菜、炒菜可以同时进行(如果有多核 CPU),它们共享同一个厨房(内存空间)和同一份食材(全局变量)。

但为什么我们需要线程?进程不是已经够用了吗?让我们来看看线程诞生的背景和它解决的问题。

为什么需要线程?进程的三大痛点

痛点 1:创建进程太慢、太重

场景:Web 服务器需要同时处理数千个客户端请求。

  • 方案 1:为每个请求 fork() 一个新进程。
  • 问题
    • fork() 需要复制进程的地址空间(即使使用 COW,仍需要复制页表)。
    • 每个进程占用 2-4 MB 内存(栈 + 堆 + 代码)。
    • 1000 个进程 = 2-4 GB 内存,小型服务器撑不住。
    • 创建一个进程需要 100-500 微秒,创建 1000 个进程 = 100-500 毫秒。

痛点 2:进程间通信(IPC)太复杂、太慢

场景:图形界面程序,需要一个线程负责 UI 渲染,另一个线程负责后台计算。

  • 方案 1:使用两个进程 + IPC(如管道、共享内存)。
  • 问题
    • 进程间通信需要通过内核,涉及上下文切换。
    • 共享数据需要显式复制或设置共享内存,复杂度高。
    • IPC 开销约 5-20 微秒,频繁通信会成为瓶颈。

痛点 3:进程切换开销太大

场景:需要在多个相关任务之间快速切换。

  • 方案 1:使用多个进程。
  • 问题
    • 每次进程切换需要切换页表,导致 TLB 和 CPU 缓存失效。
    • 上下文切换开销约 5-20 微秒(加上间接开销可达数百微秒)。
    • 频繁切换会严重影响性能。

线程的解决方案

线程的核心思想是:在同一个进程内,创建多个独立的执行流,共享同一个地址空间

线程的特点

  1. 共享内存:所有线程共享进程的代码段、数据段、堆内存。
  2. 独立栈:每个线程有自己独立的栈空间,用于保存局部变量和函数调用。
  3. 独立上下文:每个线程有自己的寄存器状态(PC、SP、r0-r12 等)。
  4. 共享资源:文件描述符、信号处理器等资源在线程间共享。

线程带来的好处

  • 创建快:创建线程只需分配栈空间和初始化上下文,耗时约 1-5 微秒(比进程快 100 倍)。
  • 通信简单:线程间直接读写共享内存,无需 IPC,零开销。
  • 切换快:线程切换不需要切换页表,只需保存/加载寄存器,开销降低 50-70%。

线程 vs 进程:详细对比

特性 进程 (Process) 线程 (Thread)
地址空间 独立(隔离) 共享(同一进程内)
创建开销 100-500 微秒 1-5 微秒
内存开销 2-4 MB 几十 KB(仅栈)
切换开销 5-20 微秒(需切换页表) 1-5 微秒(不切换页表)
通信方式 IPC(管道、共享内存、消息队列) 直接访问共享内存
通信开销 5-20 微秒 几乎为零
隔离性 强(崩溃不影响其他进程) 弱(一个线程崩溃可能导致整个进程崩溃)
调试难度 低(进程独立) 高(竞态条件、死锁)
适用场景 独立任务、需要隔离 协作任务、共享数据

线程的实现模型

操作系统实现线程有三种主要模型:

1. 用户级线程 (User-Level Threads)

  • 实现:线程由用户空间的库(如 Green Threads)管理,内核不知道线程的存在。
  • 调度:线程库实现调度器,在用户空间切换线程。
  • 优点
    • 创建和切换极快(无需系统调用)。
    • 可以在不支持线程的内核上运行。
  • 缺点
    • 无法利用多核(内核只看到一个进程)。
    • 一个线程阻塞(如 I/O),整个进程都阻塞。
  • 例子:早期的 Java Green Threads(Java 1.2 之前)。

2. 内核级线程 (Kernel-Level Threads)

  • 实现:线程由内核管理,每个线程是内核调度的基本单位。
  • 调度:内核调度器直接调度线程,可以利用多核。
  • 优点
    • 可以充分利用多核 CPU。
    • 一个线程阻塞不影响其他线程。
  • 缺点
    • 创建和切换需要系统调用,开销稍大。
    • 线程数量受内核限制。
  • 例子:Linux、Windows、macOS 的原生线程。

3. 混合模型 (Hybrid Model - M:N Threading)

  • 实现:M 个用户级线程映射到 N 个内核级线程(M > N)。
  • 调度:用户空间调度器 + 内核调度器两级调度。
  • 优点
    • 兼顾用户级线程的快速创建和内核级线程的多核支持。
    • 灵活性高。
  • 缺点
    • 实现复杂,调试困难。
    • 两级调度可能导致优先级倒置。
  • 例子:Solaris 操作系统、Go 语言的 goroutine(现代实现)。

EwokOS 中的线程实现

在 EwokOS 中,线程采用内核级线程模型,但实现非常简洁:

核心思想线程就是共享地址空间的进程

// 创建线程(简化版)
int thread_create(void (*entry)(void*), void* arg) {
    proc_t* thread = proc_alloc();

    // 1. 共享父进程的地址空间
    thread->space = _current_proc->space;
    space_add_ref(thread->space);  // 增加引用计数

    // 2. 分配独立的栈
    thread->ctx.sp = alloc_thread_stack();

    // 3. 设置入口点
    thread->ctx.pc = (uint32_t)entry;
    thread->ctx.r0 = (uint32_t)arg;  // 参数传递

    // 4. 其他设置(优先级、状态等)
    thread->info.priority = _current_proc->info.priority;
    thread->info.state = READY;

    // 5. 加入调度队列
    sched_add_ready(thread);

    return thread->info.pid;
}

关键点

  • 共享 space 指针:所有线程指向同一个 proc_space_t 结构,共享页表。
  • 独立栈:每个线程有独立的栈空间(通常 8-64 KB)。
  • 独立上下文:每个线程有独立的 ctx 结构,保存寄存器状态。
  • 引用计数:使用引用计数管理共享地址空间,最后一个线程退出时才释放。

线程的经典应用场景

1. Web 服务器

// 每个客户端连接由一个线程处理
void handle_client(void* socket_fd) {
    int fd = (int)socket_fd;
    while (1) {
        char buf[1024];
        int n = read(fd, buf, sizeof(buf));
        if (n <= 0) break;

        // 处理请求
        process_request(buf, n);

        // 发送响应
        write(fd, response, response_len);
    }
    close(fd);
}

// 主线程
void server_main() {
    int listen_fd = socket(...);
    bind(listen_fd, ...);
    listen(listen_fd, ...);

    while (1) {
        int client_fd = accept(listen_fd, ...);
        thread_create(handle_client, (void*)client_fd);
    }
}

2. GUI 应用程序

// UI 线程:负责渲染界面,必须快速响应
void ui_thread() {
    while (1) {
        Event event = get_next_event();
        handle_event(event);
        render_frame();
    }
}

// 工作线程:负责耗时的后台任务
void worker_thread() {
    while (1) {
        Task task = get_next_task();
        process_task(task);
        update_ui(task.result);  // 通过共享内存通知 UI
    }
}

3. 并行计算

// 图像处理:将图像分成 4 块,每块由一个线程处理
void process_image_chunk(void* arg) {
    ImageChunk* chunk = (ImageChunk*)arg;
    for (int y = chunk->start_y; y < chunk->end_y; y++) {
        for (int x = 0; x < chunk->width; x++) {
            chunk->pixels[y][x] = apply_filter(chunk->pixels[y][x]);
        }
    }
}

void process_image_parallel() {
    ImageChunk chunks[4];
    split_image(chunks);

    for (int i = 0; i < 4; i++) {
        thread_create(process_image_chunk, &chunks[i]);
    }

    wait_all_threads();
    merge_chunks(chunks);
}

线程的挑战:并发 Bug

线程带来性能提升的同时,也引入了新的复杂性:

1. 竞态条件 (Race Condition)

// 两个线程同时执行
int counter = 0;

void increment() {
    for (int i = 0; i < 1000000; i++) {
        counter++;  // 不是原子操作!
    }
}

// 预期结果:counter = 2000000
// 实际结果:counter = 1234567(小于预期)

原因counter++ 实际上是三条指令:

  1. 从内存读取 counter 到寄存器。
  2. 寄存器加 1。
  3. 写回内存。

如果两个线程交替执行,可能出现:

线程 A: 读取 counter = 0
线程 B: 读取 counter = 0
线程 A: 加 1 → 1
线程 B: 加 1 → 1
线程 A: 写回 counter = 1
线程 B: 写回 counter = 1
结果:counter = 1(应该是 2)

解决方案:使用互斥锁 (Mutex)。

2. 死锁 (Deadlock)

mutex_t lock_A, lock_B;

void thread_1() {
    lock(lock_A);
    sleep(1);  // 模拟延迟
    lock(lock_B);
    // 临界区
    unlock(lock_B);
    unlock(lock_A);
}

void thread_2() {
    lock(lock_B);
    sleep(1);
    lock(lock_A);
    // 临界区
    unlock(lock_A);
    unlock(lock_B);
}

// 结果:线程 1 持有 A 等待 B,线程 2 持有 B 等待 A,死锁!

解决方案

  • 总是以相同的顺序获取锁。
  • 使用超时机制。
  • 死锁检测算法。

3. 优先级反转 (Priority Inversion)

// 高优先级线程 H 等待低优先级线程 L 释放锁
// 但中优先级线程 M 抢占了 L,导致 H 被 M 间接阻塞

著名案例:1997 年火星探路者号因优先级反转导致系统重启。

解决方案:优先级继承协议(持锁的低优先级线程临时提升到等待者的优先级)。

微内核 vs 宏内核:线程的差异

宏内核(Linux)中的线程

  • 实现:线程通过 clone() 系统调用创建,本质上是共享资源的轻量级进程。
  • 调度:内核调度器统一调度进程和线程,无区别对待。
  • 特点
    • 线程创建开销约 5-10 微秒。
    • 线程切换开销约 1-3 微秒。
    • 线程数量可达数万(受内存限制)。

微内核(EwokOS)中的线程

  • 实现:线程是共享地址空间的进程,实现简洁。
  • 调度:与进程使用相同的调度器。
  • 设计考量
    • 服务进程通常单线程:VFS、驱动等系统服务通常不使用多线程,因为:
      • 微内核强调简单性和可靠性。
      • 多线程增加复杂性和 Bug 风险。
      • 多进程隔离更安全(一个服务崩溃不影响其他服务)。
    • 应用程序可以多线程:用户应用可以自由使用多线程提升性能。
  • 权衡
    • 牺牲了部分并发性(服务进程单线程),但换取了更高的稳定性和安全性。
    • 在需要并发的场景,可以使用异步 I/O 或事件驱动模型代替多线程。

线程的未来:协程和异步编程

随着编程语言的发展,出现了更轻量级的并发模型:

协程 (Coroutine)

  • 概念:用户空间的轻量级线程,可以主动让出 CPU(yield)。
  • 优点
    • 创建开销极低(几纳秒)。
    • 切换开销极低(几纳秒)。
    • 可以创建数百万个协程。
  • 例子:Go 的 goroutine、Python 的 async/await、Kotlin 的 coroutine。

异步 I/O + 事件循环

  • 概念:单线程 + 非阻塞 I/O + 事件驱动。
  • 优点
    • 避免线程同步问题。
    • 高并发(单线程处理数千连接)。
  • 例子:Node.js、Nginx、Redis。

这些现代并发模型在某些场景下比传统线程更高效,但它们的实现仍然依赖于操作系统的进程和线程机制。

总结:线程的定位

线程是进程和协程之间的中间层:

  • 比进程轻量,适合共享数据的并发任务。
  • 比协程重量,但由内核调度,可以利用多核。

在 EwokOS 这样的微内核中,线程的设计哲学是:简单、高效、够用。不追求最复杂的功能,而是提供清晰、可靠的抽象,为上层应用提供坚实的基础。

3.6 微内核 vs 宏内核:进程管理的设计差异

在深入理解了进程和线程的核心概念后,让我们从更高的视角审视:微内核(如 EwokOS)和宏内核(如 Linux)在进程管理上有哪些设计差异?为什么要这样设计?

架构差异:进程的角色定位

宏内核(Linux)的进程模型

在宏内核中,进程主要用于用户应用,而系统服务(文件系统、网络协议栈、设备驱动)都运行在内核态,以内核模块或内核线程的形式存在。

用户空间:
  [应用 A] [应用 B] [应用 C]
       |       |       |
    系统调用 (syscall)
       |       |       |
-------+-------+-------+-------- 特权级分界线
       v       v       v
内核空间:
  [VFS] [网络栈] [调度器] [内存管理] [驱动 1] [驱动 2]
  (都在同一个地址空间,可以直接函数调用)

特点

  • 进程 = 用户应用:系统服务不是进程。
  • 高性能:系统调用只需特权级切换(0.1-0.5 微秒),不需要进程切换。
  • 低隔离:驱动崩溃会导致整个内核崩溃(蓝屏/Kernel Panic)。

微内核(EwokOS)的进程模型

在微内核中,一切皆进程。不仅用户应用是进程,文件系统、网络协议栈、设备驱动也都是独立的进程,运行在用户空间。

用户空间:
  [应用 A] [应用 B] [VFS 进程] [网络进程] [驱动 1 进程] [驱动 2 进程]
       |       |         |          |            |             |
       +-------+---------+----------+------------+-------------+
                          |
                        IPC 消息
                          |
       -------------------v-------------------- 特权级分界线
                          |
内核空间(极简):
  [调度器] [IPC 机制] [内存管理] [中断处理]
  (只有最核心的功能)

特点

  • 进程 = 应用 + 服务:系统服务也是进程。
  • 强隔离:VFS 崩溃只影响文件操作,不会导致内核崩溃。
  • 高开销:系统调用需要 IPC + 进程切换(5-20 微秒),比宏内核慢 10-40 倍。

进程创建:fork() 的实现差异

宏内核(Linux)的 fork()

// Linux 的 fork() 实现(简化)
pid_t do_fork() {
    // 1. 分配新的 task_struct(进程描述符)
    task_struct* child = alloc_task_struct();

    // 2. 复制父进程的资源
    copy_mm(child, current);        // 内存空间(COW)
    copy_files(child, current);     // 文件描述符
    copy_signals(child, current);   // 信号处理
    copy_namespaces(child, current);// 命名空间(容器隔离)

    // 3. 分配 PID
    child->pid = alloc_pid();

    // 4. 加入调度队列
    wake_up_new_task(child);

    return child->pid;
}

开销:约 100-500 微秒(取决于进程大小)。

优化

  • 写时复制 (COW):不立即复制内存,延迟到真正写入时。
  • vfork():共享父进程内存,子进程立即 exec(),避免复制。
  • clone():允许选择性共享资源(内存、文件、信号等),用于创建线程。

微内核(EwokOS)的 fork()

// EwokOS 的 fork() 实现(简化)
int proc_fork() {
    // 1. 分配新的 proc_t
    proc_t* child = proc_alloc();

    // 2. 复制地址空间(COW)
    child->space = proc_space_clone(current->space);

    // 3. 复制上下文
    memcpy(&child->ctx, ¤t->ctx, sizeof(context_t));
    child->ctx.r0 = 0;  // fork() 在子进程中返回 0

    // 4. 分配 PID
    child->info.pid = proc_alloc_pid();
    child->info.ppid = current->info.pid;

    // 5. 加入就绪队列
    child->info.state = READY;
    sched_add_ready(child);

    return child->info.pid;  // 父进程中返回子进程 PID
}

开销:约 50-200 微秒(比 Linux 稍快,因为内核更简单)。

区别

  • EwokOS 的 fork() 更纯粹:只复制进程本身,不涉及命名空间、cgroup 等复杂特性。
  • Linux 的 fork() 更灵活:支持容器、资源限制等高级功能。

调度器:设计哲学的不同

宏内核(Linux)的 CFS 调度器

Linux 使用 完全公平调度器 (CFS, Completely Fair Scheduler),追求绝对的公平性。

核心思想

  • 每个进程都应该获得 1/N 的 CPU 时间(N 是进程数)。
  • 使用红黑树跟踪每个进程的"虚拟运行时间" (vruntime)。
  • 总是选择 vruntime 最小的进程运行。

复杂度

  • 插入/删除进程:O(log N)
  • 选择下一个进程:O(1)(红黑树最小值缓存)
  • 代码量:约 3000 行

优点:极其公平,适合通用桌面和服务器。 缺点:实现复杂,不适合硬实时系统。

微内核(EwokOS)的优先级调度器

EwokOS 使用 优先级调度 (Priority Scheduling),追求简单性和可预测性。

核心思想

  • 每个进程有固定优先级(0-31,数字越小优先级越高)。
  • 总是选择优先级最高的 READY 进程运行。
  • 使用简单的链表或数组实现就绪队列。

复杂度

  • 插入进程:O(1)
  • 选择下一个进程:O(N)(遍历查找最高优先级)
  • 代码量:约 200 行

优点:实现简单,可预测性强,适合嵌入式和实时系统。 缺点:低优先级进程可能饿死(需要优先级老化机制)。

为什么选择优先级调度?

  • 简单即美:微内核强调最小化内核复杂度。
  • 实时性优先:嵌入式系统通常需要确定性响应时间。
  • 易于理解:教学和学习更容易。

进程间通信(IPC):最大的差异

这是微内核和宏内核最根本的区别。

宏内核(Linux)的 IPC

在 Linux 中,IPC 主要用于用户进程之间的通信。内核内部直接函数调用。

IPC 机制

  • 管道 (Pipe):单向字节流,适合父子进程。
  • Socket:网络通信,也可用于本地 IPC(Unix Domain Socket)。
  • 共享内存:最快的 IPC,但需要额外的同步机制(信号量、互斥锁)。
  • 消息队列:异步消息传递,POSIX 或 System V 风格。
  • 信号 (Signal):轻量级异步通知。

特点

  • IPC 是可选的:内核不依赖 IPC。
  • 性能开销中等:共享内存几乎零开销,管道约 1-5 微秒。

微内核(EwokOS)的 IPC

在 EwokOS 中,IPC 是核心机制,整个系统架构都建立在 IPC 之上。

IPC 是必需的

  • 应用调用文件系统:需要 IPC。
  • 驱动返回数据:需要 IPC。
  • 服务之间协作:需要 IPC。

EwokOS 的 IPC 实现

  • 同步 IPCipc_call() - 发送消息并等待响应(类似函数调用)。
  • 异步 IPCipc_send() - 发送消息后立即返回,稍后接收响应。
  • 共享内存:零拷贝传递大数据块(如文件内容)。

性能挑战

  • 每次 IPC 涉及 2-4 次上下文切换:
    应用进程 → 内核 → 服务进程 → 内核 → 应用进程
    
  • 开销约 5-20 微秒(比 Linux 系统调用慢 10-40 倍)。

优化策略

  • 零拷贝 IPC:通过共享内存传递数据,避免复制。
  • 批量 IPC:一次消息携带多个请求。
  • 缓存:服务进程缓存常用数据(如元数据)。

为什么接受这个开销?

  • 隔离性:驱动崩溃不会影响内核。
  • 安全性:服务之间完全隔离,无法直接访问对方内存。
  • 可扩展性:可以热插拔服务和驱动。
  • 简洁性:内核只关注核心功能(调度、IPC、内存),其他功能外置。

进程生命周期管理:细节差异

僵尸进程处理

Linux

  • 父进程通过 wait() 回收子进程。
  • 如果父进程退出,孤儿进程会被 init (PID 1) 收养。
  • init 会定期调用 wait() 回收所有僵尸进程。

EwokOS

  • 类似机制,但更简单:

    // 父进程退出时
    void proc_exit(int exit_code) {
        // 1. 将所有子进程的 PPID 改为 1
        for (int i = 0; i < MAX_PROCS; i++) {
            if (procs[i].info.ppid == current->info.pid) {
                procs[i].info.ppid = 1;
            }
        }
    
        // 2. 标记为 ZOMBIE
        current->info.state = ZOMBIE;
        current->info.exit_code = exit_code;
    
        // 3. 通知父进程(发送 SIGCHLD)
        proc_signal(current->info.ppid, SIGCHLD);
    
        // 4. 调度其他进程
        schedule(get_context());
    }
    

进程优先级管理

Linux

  • 支持动态优先级调整(nice 值:-20 到 19)。
  • 支持实时优先级(SCHED_FIFO、SCHED_RR)。
  • 支持 cgroup 资源限制。

EwokOS

  • 只支持静态优先级(通常不动态调整)。
  • 实时任务通过高优先级实现。
  • 简单直接,易于预测。

多核支持:规模的差异

Linux 的多核调度

  • 支持数百个 CPU 核心(服务器级)。
  • 使用 Per-CPU 运行队列 + 全局负载均衡。
  • 支持 NUMA(非统一内存访问)优化。
  • 支持 CPU 热插拔。

EwokOS 的多核调度

  • 通常支持 2-8 个核心(嵌入式级)。
  • 使用 Per-CPU 就绪队列 + 简单的工作窃取。
  • 不考虑 NUMA(嵌入式系统通常是 UMA)。
  • 简化的核心管理。

为什么规模不同?

  • 目标平台:Linux 针对服务器和桌面,EwokOS 针对嵌入式。
  • 设计哲学:Linux 追求通用性,EwokOS 追求简洁性。

线程实现:一致的抽象,不同的优先级

Linux 的线程

  • 通过 pthread 库(基于 clone() 系统调用)。
  • 内核调度器统一调度进程和线程(NPTL - Native POSIX Thread Library)。
  • 广泛用于高并发应用(Web 服务器、数据库)。

EwokOS 的线程

  • 实现机制相同:共享地址空间的进程。
  • 但使用场景不同:
    • 系统服务通常单线程:VFS、驱动等服务避免使用多线程,降低复杂性。
    • 用户应用可以多线程:图形界面、游戏等应用自由使用。

为什么服务进程单线程?

  • 简单性:多线程增加同步复杂度,容易引入 Bug。
  • 隔离性:多进程隔离 > 多线程共享,更符合微内核哲学。
  • 稳定性:单线程服务更容易测试和验证。
  • 异步替代:使用事件驱动模型(如 epoll)代替多线程,单线程也能高并发。

设计权衡总结

维度 宏内核(Linux) 微内核(EwokOS)
进程定位 主要是用户应用 应用 + 系统服务
系统调用开销 0.1-0.5 微秒 5-20 微秒
隔离性 低(驱动在内核态) 高(驱动是独立进程)
调度算法 CFS(复杂、公平) 优先级(简单、可预测)
IPC 重要性 可选(用户进程间) 核心(整个系统依赖)
多核规模 数百核 2-8 核
线程使用 广泛(应用+内核) 选择性(主要是应用)
代码复杂度 高(数百万行) 低(数万行)
性能 高(系统调用快) 中(IPC 开销)
可靠性 中(驱动 Bug 影响内核) 高(服务隔离)
适用场景 通用(桌面、服务器) 嵌入式、实时、教学

结论:没有绝对的优劣,只有适合的选择

  • 宏内核(Linux):牺牲了部分隔离性和简洁性,换取更高的性能和灵活性。适合通用计算场景。
  • 微内核(EwokOS):牺牲了部分性能,换取更高的可靠性、安全性和简洁性。适合嵌入式、实时和教学场景。

两种设计都是工程权衡的结果,没有绝对的优劣。理解它们的差异,能帮助我们在不同场景下做出更明智的选择。

3.7 本章小结与展望

我们刚刚完成了一次深入操作系统心脏的旅程。让我们回顾一下学到的核心概念:

  • 进程抽象:Unix 在 1969 年发明的革命性概念,让多个程序可以"同时"运行在单个 CPU 上。
  • 进程状态:从 NEW 到 READY,再到 RUNNING、WAIT/BLOCK,最后到 ZOMBIE,每个进程都经历着生命周期。
  • 进程数据结构proc_t 中的每个字段都有其特定用途,缺一不可。
  • 调度算法:从最简单的 FCFS 到现代的 CFS,操作系统一直在追求更公平、更高效的 CPU 分配。
  • 多核调度:如何让多个 CPU 核心高效协作而不互相干扰,这是现代操作系统必须面对的难题。
  • 上下文切换:操作系统最核心的魔术,让多个进程看起来同时运行,但代价是 10-20% 的性能开销。
  • 线程:比进程更轻量,适合共享数据的并发任务,但引入了竞态条件、死锁等新挑战。
  • 微内核 vs 宏内核:两种不同的设计哲学,各有优劣,适用于不同场景。

但这里有一个关键问题我们还没有回答:在微内核架构中,所有的服务(VFS、驱动等)都是独立的进程,它们有自己独立的内存空间。那么,Shell 如何告诉 VFS"我要读取文件"?驱动如何把数据返回给应用程序?

在宏内核中,这很简单——大家都在同一个内存空间,直接调用函数就行了。但在微内核中,进程之间是完全隔离的,不能直接访问对方的内存。这就需要一个特殊的机制来传递消息和数据。

这就是下一章的主题——IPC(进程间通信)。我们将看到:

  • 微内核如何像一个高效的邮局,在进程之间传递消息。
  • 同步 IPC 和异步 IPC 的区别——打电话 vs 发邮件。
  • 共享内存如何实现零拷贝的高效数据传输。
  • IPC 如何成为微内核架构的"神经系统"。
  • 如何优化 IPC 性能,弥补与宏内核的差距。

进程管理解决了"谁来用 CPU"的问题,而 IPC 将解决"进程之间如何协作"的问题。让我们继续探索。

results matching ""

    No results matching ""