FXJ Wiki

Back

操作系统如何托住程序:进程、内存、文件与 I/OBlur image

运行现场 虚拟内存 I/O 模型

操作系统这章最适合从一个很朴素的问题开始学:程序为什么能跑起来

因为只要你真顺着这个问题走,进程、线程、地址空间、页表、文件系统、系统调用、页缓存、mmapepoll,这些原本看上去很分散的概念,突然都会变成同一条链上的零件。

反过来,如果只是按课本章节记术语,OS 特别容易学成“每个点都听过,但合起来又像没学过”。

所以这一篇就不想按目录背,而想回到程序的运行现场去看:操作系统到底在替应用程序做哪些托底工作

先看一个程序从启动到运行的大概过程#

  • 装载程序

    操作系统把可执行文件、代码段、数据段、栈等运行所需内容准备到进程上下文里。

  • 创建进程 / 线程

    给程序分配地址空间、资源句柄和执行流,调度器开始可管理地安排它运行。

  • 访问内存

    程序看到的是虚拟地址,操作系统负责把它映射到真实物理页。

  • 发起系统调用

    遇到文件、网络、进程管理等受保护资源时,用户态要进入内核态请求服务。

  • 与设备交互

    磁盘、网卡、终端这些慢设备由 I/O 子系统协调,页缓存和多路复用帮忙提高效率。

  • 回收资源

    程序退出后,内核释放地址空间、文件描述符和其它资源。

把这条时间线立住,OS 这章基本就有骨架了。

进程和线程,先解决的是“谁在跑、彼此怎么隔开”#

很多教程喜欢先抛结论:进程是资源分配基本单位,线程是调度基本单位。

这句话当然重要,但如果不带运行现场,很容易变成口号。

进程更像资源容器#

一个进程通常会带着这些上下文:

  • 自己的地址空间;
  • 打开的文件描述符;
  • 信号处理相关状态;
  • 权限与运行环境;
  • 至少一条执行流。

所以进程的价值,不只是“正在执行的程序”,更是一组隔离好的资源边界

线程更像资源容器里的执行流#

同一进程里的多个线程:

  • 共享代码段、堆、打开的文件等资源;
  • 但各自有自己的程序计数器、栈等执行现场。

这也是为什么线程切换通常比进程切换更轻。

因为线程很多资源天生共享,不需要像进程那样把边界重新拉一遍。

为什么线程轻,不等于线程切换没成本#

这也是面试里特别适合补的一句。

线程切换再轻,也仍然要:

  • 保存与恢复寄存器现场;
  • 切换栈和调度上下文;
  • 可能引发缓存命中率下降;
  • 在锁竞争严重时放大调度开销。

“线程比进程轻量”是相对的。

  • 隔离性强。
  • 资源边界清楚。
  • 通信成本通常更高。

调度器决定“谁先跑、谁后跑、谁得等一下”#

只要系统里同时有很多可运行任务,调度就是绕不过去的。

为什么有的程序响应快,有的程序像被冻住一样,其实背后都和调度策略、时间片和等待状态有关。

线程常见状态别只背单词#

像就绪、运行、阻塞、结束,这些状态真正有意义的地方在于:

  • 线程不是“不运行就是坏了”;
  • 很多线程看上去没占 CPU,其实是在等锁、等 I/O、等事件;
  • 高并发系统变慢,很多时候不是 CPU 算不过来,而是线程被阻塞得太多、切换得太频繁。

这也是为什么 Java 并发和操作系统总会在这里重新接上。

虚拟内存最重要的意义,不是“把内存变大”#

很多人第一次学虚拟内存,会把重点放在“内存不够时能用磁盘顶上”。

这当然是现象之一,但它最核心的价值其实是:把程序看到的地址,与物理内存上的真实位置解耦

解耦之后,操作系统就有了很多空间#

  • 不同进程可以拥有彼此独立的地址空间;
  • 程序会觉得自己面对的是连续内存;
  • 页可以按需装入,不一定一开始全搬进来;
  • 热页、冷页可以分别管理;
  • 共享库、写时复制等机制都更容易成立。

所以虚拟内存不只是“让内存看起来更多”,它是隔离性、灵活性和按需装载的前提。

页表、TLB、缺页异常为什么老被问#

因为它们刚好解释了虚拟地址如何变成真实访问。

  • 页表负责记录映射关系;
  • TLB 负责给高频地址翻译做缓存;
  • 缺页异常说明当前访问的页并不在可直接使用的位置,需要内核介入处理。

这些概念一旦放回运行现场,就不会那么抽象。

比如:

  • 程序第一次访问某块内存为什么会慢一点;
  • 为什么随机访问巨量数据更容易抖;
  • 为什么内存压力大时系统会明显卡顿。

多级页表结构(以x86-64为例)#

现代操作系统使用多级页表来节省内存空间。x86-64架构使用四级页表:

虚拟地址(48位)
┌─────────┬─────────┬─────────┬─────────┬──────────────┐
│  PML4   │  PDPT   │   PD    │   PT    │    Offset    │
│  (9位)  │  (9位)  │  (9位)  │  (9位)  │    (12位)    │
└─────────┴─────────┴─────────┴─────────┴──────────────┘

CR3寄存器 → PML4表 → PDPT表 → PD表 → PT表 → 物理页
plaintext

地址转换流程:

  1. CR3寄存器指向PML4表基址
  2. 使用虚拟地址的PML4索引(9位)找到PDPT表
  3. 使用PDPT索引找到PD表
  4. 使用PD索引找到PT表
  5. 使用PT索引找到物理页基址
  6. 加上页内偏移(12位)得到最终物理地址

为什么使用多级页表:

  • 节省空间: 不需要为整个地址空间分配连续页表
  • 按需分配: 只为实际使用的内存区域创建页表
  • 灵活性: 可以轻松实现大页(2MB, 1GB)

查看进程页表信息#

# 查看进程的内存映射
cat /proc/<pid>/maps

# 输出示例:
# 00400000-00401000 r-xp 00000000 08:01 123456  /bin/program  ← 代码段
# 00600000-00601000 r--p 00000000 08:01 123456  /bin/program  ← 只读数据
# 00601000-00602000 rw-p 00001000 08:01 123456  /bin/program  ← 可写数据
# 7ffff7a00000-7ffff7bc0000 r-xp 00000000 08:01 789012  /lib/libc.so.6  ← 共享库
# 7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0  [stack]  ← 栈

# 查看详细的内存统计
cat /proc/<pid>/smaps

# 输出包含:
# Size:                  4 kB  ← 虚拟内存大小
# Rss:                   4 kB  ← 实际物理内存
# Pss:                   2 kB  ← 按比例分摊的物理内存
# Shared_Clean:          0 kB  ← 共享的干净页
# Private_Dirty:         4 kB  ← 私有的脏页

# 查看页面换入换出统计
vmstat 1

# 输出示例:
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
#  r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
#  1  0      0 123456  12345 234567    0    0     0     0  100  200  1  1 98  0  0
#                                      ↑    ↑
#                                      换入  换出(页/秒)
bash

TLB 缓存的重要性#

TLB(Translation Lookaside Buffer)是页表的硬件缓存:

虚拟地址

TLB查找 ─命中→ 物理地址

    未命中

页表遍历(4次内存访问)

更新TLB

物理地址
plaintext

TLB未命中的代价:

  • 需要4次内存访问(四级页表)
  • 每次访问约100个CPU周期
  • 总计约400个周期 vs TLB命中的1个周期

查看TLB统计:

# 使用perf查看TLB未命中率
perf stat -e dTLB-loads,dTLB-load-misses ./program

# 输出示例:
# 1,234,567,890  dTLB-loads
#    12,345,678  dTLB-load-misses  # 约1%的未命中率
bash

mmap 为什么总和虚拟内存绑在一起问#

因为它做的是另一种映射思路:

  • 不把文件内容先读到用户态缓冲区再拷贝;
  • 而是把文件映射进进程地址空间;
  • 让程序像访问内存一样访问文件数据。

这不意味着”磁盘变内存”这么神奇,而是操作系统把文件页和虚拟地址空间接上了。

这样既能减少一部分数据搬运,也更适合某些随机访问型场景。

mmap 实战案例#

基本使用:

#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    int fd = open(“data.txt”, O_RDONLY);
    if (fd == -1) {
        perror(“open”);
        return 1;
    }

    // 获取文件大小
    off_t size = lseek(fd, 0, SEEK_END);

    // 映射文件到内存
    char *data = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (data == MAP_FAILED) {
        perror(“mmap”);
        return 1;
    }

    // 像访问内存一样访问文件
    printf(“First byte: %c\n”, data[0]);

    // 解除映射
    munmap(data, size);
    close(fd);
    return 0;
}
c

性能对比: mmap vs read:

// 传统read方式
char buffer[4096];
while (read(fd, buffer, sizeof(buffer)) > 0) {
    // 处理数据
    // 涉及: 内核读取 → 内核缓冲区 → 用户缓冲区
}

// mmap方式
char *data = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
// 直接访问,缺页时才真正读取
// 涉及: 内核读取 → 页缓存 → 直接映射到用户空间
c

mmap的优势:

  • 减少数据拷贝(零拷贝的一种实现)
  • 适合随机访问大文件
  • 多个进程可以共享同一映射(MAP_SHARED)
  • 延迟加载(按需分页)

mmap的劣势:

  • 小文件开销大(页对齐)
  • 顺序读取不如read高效
  • 地址空间有限(32位系统)

使用 strace 分析系统调用#

# 跟踪程序的所有系统调用
strace ./program

# 输出示例:
# open(“/etc/ld.so.cache”, O_RDONLY|O_CLOEXEC) = 3
# read(3, “\177ELF\2\1\1\0\0\0\0\0\0\0\0\0”..., 832) = 832
# mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1234567000

# 统计系统调用次数和耗时
strace -c ./program

# 输出示例:
# % time     seconds  usecs/call     calls    errors syscall
# ------ ----------- ----------- --------- --------- ----------------
#  45.23    0.012345          12      1000           read
#  32.15    0.008765          87       100           write
#  12.34    0.003456          34       100           mmap
#  10.28    0.002800          28       100           munmap

# 只跟踪特定系统调用
strace -e open,read,write ./program

# 跟踪正在运行的进程
strace -p <pid>
bash

使用 perf 分析性能#

# 分析CPU缓存命中率
perf stat -e cache-references,cache-misses ./program

# 输出示例:
#  1,234,567,890  cache-references
#     12,345,678  cache-misses  # 约1%的缓存未命中率

# 分析页面错误
perf stat -e page-faults ./program

# 记录性能数据
perf record -g ./program

# 查看性能报告
perf report

# 实时监控系统性能
perf top
bash

系统调用的成本#

用户态 vs 内核态切换:

用户态程序
    ↓ (系统调用)
保存用户态上下文

切换到内核态

执行内核代码

恢复用户态上下文

返回用户态

成本: 约100-300个CPU周期
plaintext

减少系统调用的优化:

  • 批量操作(如writev代替多次write)
  • 使用缓冲(如stdio的缓冲区)
  • 使用mmap减少read/write
  • 使用sendfile实现零拷贝
mmap 和零拷贝都别答成魔法

它们真正减少的是部分不必要的数据复制和用户态 / 内核态来回折腾,不代表 CPU 一点活都不干,更不代表磁盘 I/O 本身不存在。答得越克制,越像真的理解了边界。

系统调用,是用户态和内核态之间那道门#

应用程序并不能随便碰硬件。

你写的业务代码通常跑在用户态,而磁盘、网卡、调度器、内存映射、文件系统这些核心资源,都由内核态托管。

所以应用一旦要:

  • 读文件;
  • 发网络包;
  • 创建进程;
  • 申请特定内存;
  • 等 I/O 事件;

就必须通过系统调用进入内核。

为什么系统调用有成本#

因为它不是普通函数调用。

它意味着:

  • 权限级别切换;
  • 内核接手检查与执行;
  • 可能伴随数据拷贝、队列排队、中断与唤醒。

这也是为什么很多性能优化,最后都会落到一句话:尽量减少不必要的系统调用和上下文切换

文件描述符为什么重要#

在 Unix 风格系统里,很多资源最后都会被抽象成“文件描述符”。

所以:

  • 读文件用它;
  • socket 也用它;
  • 管道也用它;
  • epoll 监听的还是它。

这个抽象特别值钱,因为它让大量 I/O 资源都能走相似的接口路径。

文件系统负责把“持久化数据”组织成人能用的样子#

程序看到的是文件、目录、路径、权限。

底层真正面对的,则是:

  • 块设备;
  • 数据块布局;
  • 元数据;
  • 日志;
  • 缓存;
  • 一致性恢复。

文件系统的价值,就是把这些复杂细节藏起来,让应用不需要自己直接管理扇区和块。

页缓存为什么这么关键#

这部分和数据库、Redis 一类中间件很容易互相类比。

操作系统做文件 I/O 时,常常不会每次都直通磁盘,而是先经由页缓存。

这意味着:

  • 读热点文件时,很多请求其实直接命中内存;
  • 写入时,也可能先落在缓存页里,再择机刷盘;
  • 顺序读写和随机读写的性能差别,会被进一步放大。

所以页缓存几乎是现代系统里文件性能体验的底盘之一。

为什么应用层还会自己做缓存#

因为 OS 页缓存解决的是“通用文件页复用”,而应用层往往还关心:

  • 业务对象级缓存;
  • 查询结果缓存;
  • 更具体的淘汰策略;
  • 跨请求、跨实例的数据语义。

这也是为什么数据库里还会有 Buffer Pool,为什么 Redis 还会放在应用前面。它们是在更上层做更贴业务语义的缓存。

I/O 模型真正回答的是:等待数据的时候,线程怎么办#

这是操作系统和网络编程最容易重新汇合的地方。

因为设备是慢的,CPU 是快的,线程如果一直傻等,系统资源就会浪费得很难看。

阻塞 I/O 最好理解,也最容易浪费等待时间#

阻塞模型的好处是代码直观:

  • 调用读;
  • 没数据就等;
  • 有了再返回。

问题是线程会在等待期间闲着。

如果连接数少、模型简单,这没什么;一旦高并发,大量线程都站着等 I/O,资源就被拖住了。

非阻塞、多路复用、异步 I/O 的核心区别#

  • 关注单次系统调用是否把线程卡住。
  • 非阻塞不等于高效,它可能只是让你不断轮询。

为什么 epoll 常和高并发服务绑在一起#

因为它特别适合“连接很多,但并不是每个连接都一直忙”的场景。

你不用给每个连接单独配一条线程去死等,而是让内核告诉你:哪些 fd 真正有事件了,再去处理。

这就是事件驱动网络库常见的基础之一。

零拷贝为什么是操作系统高频追问点#

因为它特别能体现“系统性能瓶颈不只在算力,还在搬运”。

传统路径下,数据可能会在:

  • 设备;
  • 内核缓冲区;
  • 用户态缓冲区;
  • 再回到内核 socket 缓冲区;
  • 最后再发出去。

中间每多一次拷贝、每多一次上下文切换,代价都是真实存在的。

零拷贝相关机制的意义,就是尽量少做这些无谓搬运。

所以它的价值,和 mmapsendfile、页缓存这些内容,本来就属于同一个性能话题。

零拷贝这题最稳的答法

别承诺“完全没有任何复制”,更准确一点说:它减少了传统路径中用户态和内核态之间的多次数据搬运,以及部分上下文切换成本,因此在大文件传输、网络发送等场景里尤其有价值。

如果面试官让你“讲一下操作系统主线”#

其实完全可以不用按书目录答。

  1. 先讲进程和线程,说明谁在执行、资源怎么隔离。
  2. 再讲虚拟内存,说明程序为什么能看到独立连续地址空间。
  3. 接着讲系统调用,说明用户态如何进入内核态请求服务。
  4. 再讲文件系统和页缓存,说明数据如何持久化、为什么文件 I/O 能被加速。
  5. 最后讲 I/O 模型、epollmmap、零拷贝,把性能视角收进来。

这样答,操作系统就不再是一堆零散概念,而像在描述一台机器如何稳稳托住程序。

最后把操作系统这章收成一句话#

操作系统真正做的事,可以浓缩成下面几句:

  • 用进程和线程组织执行与隔离;
  • 用虚拟内存把地址空间和物理资源解耦;
  • 用系统调用守住用户态与内核态的边界;
  • 用文件系统和页缓存组织持久化数据;
  • 用 I/O 模型协调 CPU、内存和外设之间巨大的速度差。

像一套现实中的托底机制:应用程序之所以能在复杂硬件上稳定运行,是因为操作系统一直在背后替它管理边界、协调资源、隐藏复杂性。

操作系统如何托住程序:进程、内存、文件与 I/O
https://fxj.wiki/blog/interview-operating-system
Author 玛卡巴卡
Published at 2025年3月22日
Comment seems to stuck. Try to refresh?✨