目录
进程与线程调度 虚拟内存 文件系统 IO 模型
01

进程与线程调度

背景 为什么需要调度?

早期计算机一次只能运行一个程序,CPU 利用率极低。当程序等待 I/O 时,CPU 空闲空转。多道程序设计的目标是让 CPU 在等待 I/O 时去执行其他程序,从而提高系统吞吐量。但多个程序同时存在,谁先运行?运行多久?这就诞生了调度

第一性原理: CPU 是稀缺资源,多个进程竞争使用。调度的本质是「资源分配策略」 —— 决定下一个该谁用 CPU、用多久。这个决策直接影响系统的响应速度、吞吐量和公平性。

原理 调度算法与上下文切换

调度器维护一组就绪队列,按照特定算法从队列中选择进程投入运行。核心机制包括:

  • 调度时机: 进程主动让出(系统调用、I/O)、时间片用完、中断发生(时钟中断、设备中断)
  • 上下文切换: 保存当前进程的寄存器、PC、栈指针,恢复下一个进程的状态 —— 这是调度的「代价」
  • 调度算法: 先来先服务 (FCFS)、短作业优先 (SJF)、时间片轮转 (RR)、多级反馈队列 (MFQ) 等
进程 A (P1) 进程 B (P2) 进程 C (P3) 时间片耗尽 → 切换 I/O 阻塞 → 切换 就绪队列 就绪队列 (PQ) P4, P5, ... 调度 t₀ tₙ ▲ 时间 → 多进程交替执行,CPU 在就绪队列中调度
图:多进程在 CPU 上交替执行,调度器决定下一个运行进程

多级反馈队列 (MFQ) 是现代调度器的基石:设置多个不同优先级的队列,高优先级队列时间片短,低优先级队列时间长。进程用完时间片降级,I/O 密集型进程留在高优先级,CPU 密集型进程逐渐降级。这种设计兼顾了响应时间吞吐量

▸ C 语言 · 模拟调度器核心逻辑
// 简化版调度器:时间片轮转 + 优先级队列 struct task { int pid; int priority; // 优先级 0~3 (0 最高) int time_slice; // 剩余时间片 struct task* next; }; void schedule(struct task* ready_queue) { struct task* current = ready_queue; if (current == NULL) return; // 选择优先级最高的就绪任务 struct task* best = current; while (current != NULL) { if (current->priority < best->priority) { best = current; } current = current->next; } // 运行该任务(模拟) best->time_slice--; if (best->time_slice <= 0) { // 时间片用完,降级或放回就绪队列 best->priority++; // 降级 best->time_slice = 10; // 重置时间片 } }

演进 从单任务到多核调度

  • 单道程序 → 多道批处理: 内存中同时存放多道作业,CPU 在作业间切换,但无交互性
  • 分时系统: 引入时间片轮转,每个用户获得一小段时间片,交互性诞生
  • 优先级调度: 区分高优先级(实时任务)和低优先级(后台任务)
  • 多核调度: 负载均衡、处理器亲和性、每个 CPU 独立运行队列
  • CFS (完全公平调度器): Linux 使用红黑树实现,按虚拟运行时间分配 CPU
"调度的核心矛盾是:响应时间 vs 吞吐量。交互式系统要快速响应,批处理系统要最大吞吐。"
—— 操作系统设计哲学

取舍 设计中的权衡

⏱ 响应时间 vs 吞吐量
短时间片 → 响应快但切换开销大;长时间片 → 吞吐高但交互迟钝。MFQ 通过多级队列妥协。
⚖️ 公平 vs 效率
严格公平(CFS)可能导致高优先级任务延迟;完全偏向优先级则可能饿死低优先级任务。
💾 切换代价 vs 粒度
上下文切换有开销(TLB 刷新、缓存失效)。太小的调度粒度会浪费 CPU 在切换上,通常 1~10ms 是合理范围。
02

虚拟内存

背景 物理内存不够用,程序怎么装?

早期程序直接使用物理地址,两个问题:地址冲突(无法同时运行两个大程序)和 内存碎片(分配和回收导致碎片)。更严重的是,物理内存容量有限,而程序越来越大(如大型游戏、数据库)。

第一性原理: 程序不需要一次性全部装入内存。只要当前用到的页面在内存中即可,其余的留在磁盘。虚拟内存提供「每个程序拥有独立连续地址空间」的假象,实际映射到离散的物理页。

原理 分页、页表、TLB、缺页中断

虚拟内存将程序地址空间划分为固定大小的页 (Page),物理内存划分为相同大小的页框 (Frame)。通过页表记录虚拟页到物理页框的映射关系。

虚拟地址空间 页 0 页 1 页 2 页 3 页 4 …… 页表 0 → 帧 2 1 → 帧 5 2 → 帧 0 3 → 帧 3 4 → 未映射 …… 物理内存 帧 0 (页 2) 帧 1 帧 2 (页 0) 帧 3 (页 3) 帧 4 …… 查页表 映射 虚拟页号 → 页表 → 物理页框
图:虚拟地址通过页表映射到物理地址,未映射的页触发缺页中断

TLB (Translation Lookaside Buffer) 是页表的硬件缓存,存储最近使用的地址映射。由于局部性原理,TLB 命中率可达 99% 以上,大大降低地址转换开销。缺页中断发生在访问未映射页面时,操作系统从磁盘加载页面到内存,更新页表,然后重新执行指令。

▸ C 语言 · 模拟缺页中断处理
// 缺页中断处理函数(简化) void page_fault_handler(uintptr_t fault_addr) { // 1. 获取缺页的虚拟地址 uintptr_t page_start = fault_addr & ~(0xFFF); // 对齐到页边界 int vpn = page_start / PAGE_SIZE; // 虚拟页号 // 2. 检查地址是否合法 if (!is_valid_address(vpn)) { kill_process(current_process); // 非法访问 → 段错误 return; } // 3. 从磁盘读取页面 int phys_frame = allocate_frame(); // 分配物理页框 read_from_swap(vpn, phys_frame); // 从交换分区读入 // 4. 更新页表 current_process->page_table[vpn] = phys_frame | PRESENT_BIT; }

演进 分段 → 分页 → 段页式

  • 分段: 按逻辑段(代码、数据、栈)分配,段大小可变,易产生外部碎片
  • 分页: 固定大小页,无外部碎片,但内部碎片(页内浪费)不可避免
  • 段页式: 先分段再分页,结合两者优点:段用于保护,页用于管理
  • 多级页表: 针对 64 位地址空间,页表本身过大,采用树状结构(如 x86-64 的 4 级页表)
  • 大页 (HugePages): 减少页表开销,提升 TLB 覆盖范围,适合大数据应用
"虚拟内存的本质是:用磁盘空间换内存空间,用时间换空间(缺页中断有开销)。但局部性原理让这个交换变得高效。"
—— 操作系统设计哲学

取舍 设计中的权衡

📦 页大小权衡
小页 (4KB) 内部碎片少但页表大;大页 (2MB) 页表小但内存浪费多。现代系统支持多种页大小混合。
⚡ TLB 命中 vs 缺页代价
TLB 未命中可接受,缺页中断代价极高(磁盘 I/O 毫秒级)。因此内存管理要尽量减少缺页,利用局部性。
🔄 换页策略
LRU (最近最少使用) 最接近最优但实现昂贵;Clock 算法是 LRU 的近似,性能好。Linux 使用 LRU 链表 + 二次机会法。
03

文件系统

背景 如何持久化存储数据?

内存数据在断电后丢失,需要磁盘(HDD/SSD)来持久化存储。但磁盘操作远慢于内存(纳秒 vs 毫秒),且按(通常 4KB)访问。文件系统解决的核心问题是:如何组织、命名、查找、管理磁盘上的数据块,并提供高效的访问接口。

第一性原理: 文件系统是把「字节流」映射到「磁盘块」的中间层。用户看到的是连续的文件,实际存储是离散的块。如何映射、如何索引、如何保证一致性是设计的三个核心问题。

原理 inode、目录、数据块、日志

文件系统将磁盘划分为超级块(元数据)、inode 区域(索引节点)、数据块区域。每个文件对应一个 inode,存储文件元数据(权限、大小、时间、数据块指针)。

inode 文件元数据 权限 · 大小 · 时间 数据块指针 [12] 间接块指针 二级间接指针 三级间接指针 数据块 (Data Blocks) 块 1 块 2 块 3 块 4 块 5 块 6 块 7 块 8 块 9 块 10 指针 inode 指向数据块,间接指针支持大文件
图:inode 包含数据块指针,直接指针和间接指针共同构成文件数据

目录本质是特殊的文件,存储文件名到 inode 的映射。日志 (Journaling) 在写数据前先记录操作意图,系统崩溃后可以恢复,保证元数据一致性。现代文件系统如 ext4、XFS 都支持日志。

▸ C 语言 · 文件系统读文件流程
// 读文件:路径解析 → 查 inode → 读数据块 int read_file(const char* path, char* buffer, size_t size) { // 1. 路径解析:从根目录开始查找 struct inode* ino = lookup_path(path); if (!ino) return -1; // 2. 检查权限 & 类型 if (!(ino->mode & REGULAR_FILE)) return -1; // 3. 计算需要读取的块 size_t offset = 0; int block_idx = 0; while (offset < size) { int phys_block = get_data_block(ino, block_idx); read_block(phys_block, buffer + offset); offset += BLOCK_SIZE; block_idx++; } return offset; }

演进 FAT → ext 系列 → ZFS/Btrfs

  • FAT: 简单链表结构,文件最大限制 2GB,无权限管理,常用于嵌入式设备
  • ext2: 使用 inode,支持大文件,但无日志,崩溃后需 fsck 检查
  • ext3: 引入日志,支持三种模式(journal/ordered/writeback)
  • ext4: 支持大文件 (16TB)、extent、延迟分配、多块分配
  • ZFS / Btrfs: 写时复制 (CoW)、快照、数据校验、透明压缩
"文件系统的演进方向是:更大、更快、更可靠。从 FAT 到 ZFS,我们在性能、一致性和功能上不断做加法。"
—— 存储系统设计总结

取舍 设计中的权衡

📝 日志模式权衡
ordered 模式(先写元数据再写数据)性能好但数据可能丢失;journal 模式(全部记日志)更可靠但写放大严重。
🔍 数据块大小
4KB 块适合小文件,64KB 块适合大文件。大块提升吞吐量但浪费空间,小块节省空间但读写次数多。
⚡ 一致性 vs 性能
严格保证崩溃一致性(如 ZFS)需要 CoW,带来额外开销;弱一致性(如 ext4 默认)性能好但风险高。
04

IO 模型

背景 CPU 太快,设备太慢,如何协同?

CPU 主频高达 GHz 级别,而磁盘、网卡等外设速度慢几个数量级。如果 CPU 等待设备完成 I/O,将浪费大量时间。需要设计合理的 I/O 模型,让 CPU 在设备工作时做其他事情,或者减少等待

第一性原理: I/O 模型解决的核心问题是「谁等谁」「怎么通知」。用户进程是主动等待还是被动通知?通知机制是轮询还是中断?这些选择决定了 I/O 的效率。

原理 阻塞 · 非阻塞 · 多路复用 · 异步

操作系统提供多种 I/O 模型,核心区别在于数据准备数据拷贝两个阶段是否阻塞。

阻塞 I/O (BIO) 进程等待数据 → 阻塞 非阻塞 I/O (NIO) 立即返回 EAGAIN,轮询 IO 多路复用 select/epoll 一次等多个 异步 I/O (AIO) 完成时回调 四种 I/O 模型对比 模型 数据准备 数据拷贝 是否阻塞 BIO 阻塞 阻塞 全程阻塞 NIO 非阻塞 (轮询) 阻塞 数据准备不阻塞 IO多路复用 阻塞 (事件通知) 阻塞 可同时等待多个 AIO 非阻塞 非阻塞 (回调) 完全不阻塞
图:四种 I/O 模型在数据准备和拷贝阶段的阻塞情况

IO 多路复用(select/poll/epoll)是高性能网络服务的基础。它允许一个进程同时监视多个文件描述符,当任意一个准备好数据时,进程被唤醒。epoll 是 Linux 下的高效实现,使用事件驱动机制,支持水平触发和边缘触发。

▸ C 语言 · epoll 多路复用示例
// 使用 epoll 同时监听多个 socket int epoll_demo() { int epfd = epoll_create1(0); struct epoll_event ev, events[10]; // 添加监听 socket ev.events = EPOLLIN | EPOLLET; // 边缘触发 ev.data.fd = listen_sock; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev); while (1) { int nfds = epoll_wait(epfd, events, 10, -1); // 阻塞等待 for (int i = 0; i < nfds; ++i) { if (events[i].data.fd == listen_sock) { // 新连接到达 accept_connection(listen_sock); } else { // 普通数据可读 handle_data(events[i].data.fd); } } } }

演进 轮询 → 中断 → DMA → 零拷贝

  • 轮询 (Polling): CPU 不断检查设备状态,简单但浪费 CPU
  • 中断 (Interrupt): 设备完成时通知 CPU,减少等待,但高频中断仍有开销
  • DMA: 设备直接内存访问,CPU 只需配置 DMA 控制器,数据拷贝由硬件完成
  • 零拷贝 (Zero-copy): 避免数据在内核态和用户态之间多次拷贝(如 sendfile、mmap)
  • io_uring: Linux 最新的异步 I/O 框架,共享环形队列,减少系统调用
"I/O 模型的演进就是不断让 CPU 从等待中解放:轮询→中断→DMA→零拷贝→io_uring。每一步都是让 CPU 更专注于计算而非搬运数据。"
—— 高性能 I/O 设计总结

取舍 设计中的权衡

🔄 阻塞 vs 非阻塞
阻塞编程简单但效率低;非阻塞编程效率高但复杂性大增。epoll 是折中方案:只在事件就绪时通知,不用轮询。
📦 缓冲 vs 零拷贝
传统 I/O 需要多份内存拷贝(内核缓冲→用户缓冲→内核缓冲→网卡)。零拷贝(sendfile)减少拷贝但要求数据不需要修改。
⚡ 事件驱动 vs 线程池
事件驱动(异步)单线程高效但编程复杂;线程池(同步)简单但线程切换有开销。Nginx 用事件驱动,Apache 用线程池,各有利弊。