操作系统基础:后端与 AI 工程视角¶
1. 进程、线程与地址空间¶
进程可以理解为操作系统提供的一层“资源与地址空间隔离容器”。一个进程通常拥有自己的虚拟地址空间、文件描述符表、信号处理状态以及一组线程。线程则是实际被 CPU 调度执行的基本单位。一个进程内部的多个线程会共享代码段、堆、全局变量、文件描述符等进程级资源,但每个线程拥有自己的寄存器状态、程序计数器、线程栈以及线程局部存储(Thread Local Storage,TLS)。
线程切换通常比进程切换轻,原因不是“线程切换只保存寄存器”这么简单,而是同一进程内线程切换通常不需要切换完整的虚拟地址空间。进程切换往往伴随页表相关状态变化,并可能破坏地址转换缓存和 CPU cache 的局部性。即使线程切换发生在同一进程内,也仍然需要保存和恢复寄存器、栈指针、程序计数器等执行上下文,同时调度器本身也需要工作,因此线程切换依然不是零成本。
需要区分两个容易混淆的概念:上下文切换(context switch)和用户态/内核态切换。上下文切换表示 CPU 从一个可执行实体切换到另一个可执行实体;用户态/内核态切换只是权限级别发生变化,并不意味着一定换了线程。一次系统调用会从用户态进入内核态,但如果调用很快完成,完全可能不发生线程上下文切换。
2. 虚拟内存:为什么程序看到的不是物理地址¶
应用程序访问的是虚拟地址。CPU 中的内存管理单元会借助页表把虚拟页翻译为物理页。这样做带来三个主要好处:第一,不同进程可以拥有彼此隔离的地址空间;第二,进程看到的地址空间可以是连续的,而物理内存无需连续;第三,操作系统可以按需分配物理页,甚至把暂时不需要的页面换出。
页表本身存放在内存里,如果每次访问内存都完整查询页表,成本会很高,因此 CPU 使用 TLB(Translation Lookaside Buffer,地址转换后备缓冲)缓存近期的虚拟页到物理页映射。TLB 命中时地址转换很快,TLB miss 时则需要进行页表遍历。对大内存、高吞吐程序而言,TLB miss、缓存局部性和 NUMA(Non-Uniform Memory Access,非一致内存访问)都可能影响性能。
3. Page Fault:缺页不等于程序错误¶
当 CPU 访问一个虚拟地址时,如果当前页表状态无法直接完成访问,会触发 page fault,也就是缺页异常。缺页异常本身并不等于程序错误,它是一种正常的内存管理机制。
例如,程序通过 mmap 映射了一个大文件。映射建立后,程序获得了一段虚拟地址,但对应文件内容未必已经全部进入物理内存。当程序第一次访问某一页时,就可能触发缺页异常,内核再把对应页面准备好。若数据已经在 page cache 中,通常不需要真实磁盘读取;若数据不在内存中,则可能需要磁盘 IO,这类缺页成本会高很多。
通常会区分 minor page fault 和 major page fault。前者往往不需要磁盘 IO,例如页已经存在于内存但尚未建立当前进程映射;后者则可能需要从磁盘读取数据。非法地址访问同样会触发 fault,但如果内核无法为该地址建立合法映射,程序最终可能收到 segmentation fault。
4. Heap、Stack 与 mmap¶
线程栈主要保存函数调用帧、返回地址、局部状态等信息。栈空间具有明显的调用层级和生命周期,分配回收非常高效。Heap 则用来存放生命周期更灵活的数据,由语言运行时或内存分配器负责管理。对于带 GC 的语言,大量短生命周期堆对象会增加分配器和垃圾回收压力;对于手动内存管理语言,则要关注碎片和泄漏。
mmap 的作用是把文件或匿名内存区域映射到进程的虚拟地址空间。映射完成后,程序可以像访问普通内存一样访问这些地址,但这不意味着整个文件已经读入物理内存。真正的数据通常按页、按需加载。mmap 的优势在于减少显式 read/write 调用和用户态缓冲区管理,并可以直接利用 page cache。
因此,“mmap 以后文件不占内存”是错误的。被真正访问并保留的页面仍然会占用物理内存,只是这些页面通常由 page cache 管理,而且可以在内存压力下被回收。对于大模型权重加载,mmap 能降低一次性拷贝和峰值内存压力,但实际读取、缺页和后续 CPU→GPU 传输仍然存在。
5. 用户态、内核态与系统调用¶
普通应用运行在用户态,权限受到限制,不能直接操作页表、磁盘控制器、网卡或调度器。文件、网络、进程、内存映射等操作最终都需要通过系统调用进入内核态。
以 read(fd, buf, 4096) 为例,调用本身一定会进入内核,因为 read 是系统调用。如果读取普通文件且数据已经在 page cache 中,内核可以直接从缓存页把数据复制到用户缓冲区,然后返回;这里发生了用户态到内核态再回到用户态的模式切换,但不一定发生线程上下文切换。如果数据不在内存,需要等待磁盘 IO,那么当前线程可能被置为睡眠状态,调度器去运行其他线程,此时才会发生真正的 context switch。等 IO 完成后,线程再被唤醒。
如果 fd 是 non-blocking socket 且当前没有数据,read 通常不会阻塞等待,而是立即返回错误,例如 EAGAIN 或 EWOULDBLOCK。这正是“非阻塞”的含义:当前条件不满足时立即返回,由调用方决定稍后重试或配合 epoll 等机制等待事件。
6. 文件描述符:为什么 socket 也是一个 fd¶
在 Unix/Linux 中,文件描述符(file descriptor,fd)是进程访问内核对象的整数句柄。普通文件、socket、pipe、eventfd 等都可以表现为 fd。进程维护 fd table,表项指向内核里的打开文件或 socket 对象。
因为每个连接通常都需要 socket fd,所以高并发网络服务会快速消耗 fd。达到限制后常见错误是 too many open files。这时不能只把 ulimit -n 调大,因为 fd 数量异常增长可能意味着连接没有及时关闭、异常路径漏掉 close、连接长期积压或下游迟迟不返回。
排查时要回答两个问题:当前 fd 数量是否和活跃连接规模匹配?已经结束的请求和连接是否真的释放了 fd?如果连接量和业务规模一致,可能只是 limit 太低;如果 fd 持续增长但完成请求数量并未同比增加,更应该怀疑资源泄漏或超时/关闭路径设计问题。
7. 阻塞、非阻塞、同步、异步¶
阻塞/非阻塞描述的是“当前调用在不能立即完成时是否等待”。同步/异步描述的是“操作完成后由谁负责感知结果并推进后续流程”。
阻塞 read 在没有数据时会让当前线程等待;非阻塞 read 则立即返回 EAGAIN。同步模型并不一定阻塞,例如一个线程可以使用 non-blocking socket 加 epoll,在事件就绪后主动读取,这仍然可以理解为同步事件驱动。异步模型则更接近“提交操作后由系统在完成时通知我”,例如某些异步 IO 接口。
因此不要把“同步=阻塞、异步=非阻塞”直接画等号。前者是完成语义,后者是调用行为。
8. select、poll 与 epoll:它们到底在解决什么¶
网络服务常常同时维护大量 socket。最直接的做法是给每个连接一个线程,但连接很多时线程成本太高,因此需要一种机制,让少量线程同时管理大量 fd,并只处理已经准备好的连接。
select 的基本思路是:应用每次调用时把“我关心哪些 fd”交给内核,内核检查哪些 fd 已经可读、可写或发生异常,然后返回结果。问题在于每次调用都要重复传递集合,并且需要扫描集合。poll 与 select 的核心思路类似,只是数据结构不同,避免了 select 传统 fd_set 的固定大小限制,但仍然要重复传入和扫描大量 fd。
epoll 把“长期关注哪些 fd”和“这次哪些 fd 已经就绪”拆成两个阶段。应用使用 epoll_ctl 把关心的 fd 注册到内核维护的 interest list;之后使用 epoll_wait 等待事件,内核把已经就绪的 fd 放入 ready list,应用只处理 ready list 中的事件。这样在“连接很多、但任意时刻只有少量连接活跃”的场景中,不需要每次都把所有连接重新扫描一遍。
这也是为什么不能简单把结论记成“select 是 O(n),epoll 是 O(1)”。epoll 仍然有注册、事件维护、ready list 遍历等成本;如果 fd 数量本来就很少,或者几乎所有连接每次都很活跃,epoll 的优势可能没有那么明显。它真正擅长的是大量稀疏活跃连接。
9. LT 与 ET:到底是什么¶
epoll 有两种常见触发方式:LT(Level Triggered,水平触发)和 ET(Edge Triggered,边缘触发)。
LT 可以理解为“只要条件仍然成立,就继续提醒”。假设一个 socket 接收缓冲区里有 100KB 数据,而你这次只读了 4KB,那么剩余 96KB 仍然意味着“这个 fd 处于可读状态”。下一次 epoll_wait 时,LT 模式仍然会返回这个 fd,提醒你还有数据没有读完。因此 LT 更容易写对。
ET 可以理解为“状态发生变化时提醒一次”。例如 socket 从“没有数据”变成“有数据”,这一次变化触发事件。如果应用收到通知后只读了 4KB,没有继续把剩余数据读到 EAGAIN,而后续又没有新的状态变化,那么应用可能不会再次收到同样的通知,导致剩余数据长期留在缓冲区里。
因此 ET 模式通常要求 fd 设置为 non-blocking,并在收到可读事件后循环读取,直到 read 返回 EAGAIN,表示当前能读的数据已经全部消费完。ET 的优势是减少重复事件通知,但它并不天然比 LT 更快,只是用更复杂的编程约束换取更少的通知次数。
10. Page Cache 与 Zero Copy¶
普通文件发送到网络的大致路径可以理解为:磁盘数据先进入内核 page cache,然后通过 read 被复制到用户态 buffer,再通过 write/send 被复制回内核 socket buffer,最后由网卡完成发送。这里存在用户态中转和额外 CPU copy。
sendfile 可以让内核直接把文件数据从 page cache 送到 socket 相关缓冲区,减少用户态中转;mmap 则让用户空间直接映射 page cache 中的页面,减少一次显式 read copy。所谓 zero-copy 并不是“数据完全不移动”,而是减少 CPU 主导的冗余复制和用户态/内核态来回搬运。
在 AI 工程 中,类似问题会出现在模型权重加载、数据集读取、对象存储下载和大结果文件传输中。但进入 GPU 后还会多出 CPU memory 到 GPU VRAM 的 H2D(Host to Device)传输,所以“文件 zero-copy”并不意味着整个模型加载链路没有复制。
11. IPC:为什么最快的不一定最适合¶
进程之间地址空间隔离,因此需要 IPC(Inter-Process Communication,进程间通信)。Pipe 和 named pipe 提供字节流;shared memory 让多个进程映射同一块物理内存;message queue 提供消息语义;signal 适合轻量通知;socket 既能同机通信,也能跨机扩展。
Shared memory 数据复制少,吞吐可能很高,但它只是解决“共享数据”的问题,不能自动解决同步、并发写入、生命周期和一致性,因此通常还需要锁、信号量或 ring buffer 等结构。对于 AI worker 和控制进程之间通信,如果传输的是大 tensor,共享内存可能有价值;如果更强调隔离、可维护性和跨机扩展,socket 或 RPC 往往更合适。
12. 系统排障时如何使用这些知识¶
看到 CPU 高,不能立刻归因于“业务计算”,还要区分系统调用、上下文切换、GC、锁竞争等;看到内存高,要区分 heap、匿名页、page cache、mmap 映射和真正泄漏;看到连接多,要同时看 fd、socket 状态、超时和 epoll;看到模型加载慢,要拆分磁盘读取、page fault、反序列化、CPU 内存占用以及后续 H2D。
操作系统基础真正的价值,是把一个模糊的“服务慢了”拆解成可以观测和验证的资源路径。