操作系统核心知识串讲:从进程管理到文件系统与I/O

发布时间:2026/9/16 6:57:43
操作系统核心知识串讲:从进程管理到文件系统与I/O
做了这么多年系统运维和底层开发我越来越觉得操作系统这门课的价值被低估了。很多人把它当纯理论背考完就忘也有人觉得现在都是云原生、容器化时代没必要抠这些底层细节。但实际上无论是排查线上CPU飙高、分析容器内存泄露还是搞懂为什么某个进程卡在了D状态最后都得回到操作系统的基本原理上。这篇笔记不是教科书式的概念罗列而是我这几年把理论对照着实操场景重新梳理后的一些沉淀核心围绕进程管理、内存寻址、文件系统、设备IO这几大块希望能帮正在学操作系统或者准备期末复习的朋友把散落的知识点串成一条真正能用的线。1. 先建立宏观地图操作系统到底在管哪些事很多人翻开《操作系统》教材第一反应是被CPU调度、内存分页、文件系统、设备驱动这些章节搞晕感觉每个都是孤立的。其实操作系统做的事情用一句话就能概括对下管理硬件资源对上为应用程序提供稳定、高效的抽象接口。1.1 四大核心资源与对应抽象硬件层面的资源归根结底就四类CPU、内存、磁盘/SSD、I/O设备。操作系统针对每一类都做了一层包装CPU资源抽象成进程/线程通过调度算法决定谁先用、用多久内存资源抽象成虚拟地址空间每个进程都以为自己独占几GB内存实际上物理内存由内核统一分配映射磁盘资源抽象成文件和文件系统让你不用关心数据到底存在哪个柱面、哪个扇区I/O设备抽象成设备文件或系统调用接口统一用open/read/write/ioctl等方式访问千奇百怪的硬件。这个抽象层级非常关键。比如在Linux里一切皆文件——你在应用层用read(fd, buf, 1024)去读数据底层可能是磁盘、网卡、串口或者一个管道但调用方式是一样的。1.2 系统调用到底是怎么陷进内核的应用跑在用户态内核跑在内核态两者之间通过系统调用syscall过渡。很多人不理解为什么要分两个态直接让应用随便访问硬件不是更快吗原因就一个字安全。如果任何程序都能直接操作物理内存和磁盘那一个Bug就能搞崩整台机器更别说恶意程序了。用户态程序需要内核帮忙时通过一条特殊指令如x86的syscall指令或int 0x80触发CPU切换到内核态再进入对应的系统调用处理函数。以Linux上最简单的read为例大致链路是// 应用层代码 char buf[1024]; ssize_t n read(fd, buf, sizeof(buf));从read到真正读到数据中间经历了libc封装函数把read的调用号和参数放入寄存器执行syscall指令CPU切到内核态内核根据系统调用号查sys_call_table表找到ksys_read函数ksys_read通过文件描述符找到对应的文件结构体调用具体文件系统的read_iter方法如果数据不在页缓存page cache里则触发磁盘I/O把数据读进内存内核把数据从内核缓冲区拷贝到用户传入的buf返回用户态。这一步从内核缓冲区拷贝到用户缓冲区看似多此一举但这是隔离和安全的关键用户态不能直接引用内核内存。正因为每次系统调用都有上下文切换和拷贝开销高性能服务才会想尽办法减少syscall次数比如用epoll替代阻塞IO、用mmap减少拷贝。1.3 学习顺序建议我建议初学者按CPU虚拟化 - 内存虚拟化 - 并发 - 文件系统 - I/O这个顺序学也就是先搞清楚进程再搞清楚每个进程怎么管自己的内存然后是进程之间怎么协作、怎么竞争最后才是数据怎么落到磁盘、怎么和设备交互。这样的顺序知识之间有天然的递进关系比按教材章节死磕容易理解得多。2. 进程与线程从PCB到调度器的完整链路2.1 进程不是正在运行的程序这么简单教科书里说进程是正在运行的程序这句话对但不够。一个进程在内核里对应的核心数据结构是PCB进程控制块在Linux里叫task_struct。这是一个超级庞大的结构体包含了进程的状态、PID、父子关系、打开的文件表、内存描述符mm_struct、信号处理、时间片、上下文信息等所有东西。创建进程时经典做法是fork。fork会从内核返回两次——一次在父进程返回子进程PID一次在子进程返回0。这个返回两次困扰过很多人我的理解方式是fork相当于把当前进程的地址空间做了一份几乎完整的拷贝子进程从fork的下一条指令开始执行只是返回值不同。#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); } else if (pid 0) { printf(Im child, pid%d\n, getpid()); } else { printf(Im parent, child%d\n, pid); } return 0; }所以你在终端里看到两个输出不是因为fork执行了两次printf而是父子两个进程各自执行了一次。这里有个比较有意思的细节fork采用的是**写时复制Copy-on-Write, COW**策略。也就是说fork时并不真正把父进程所有内存页复制一份而是让子进程和父进程共享同一片物理内存页并标记为只读。只有当其中一方试图写入时内核才真正复制这一页。这样可以大大加快fork速度对shell频繁启动子进程的场景特别友好。2.2 进程状态中的不可中断睡眠到底是什么Linux进程状态里R运行/可运行、S可中断睡眠、D不可中断睡眠、Z僵尸、T停止这五个是比较常见的。当年学到D状态时我一直觉得它只是睡得比较死的S状态直到线上遇到一次故障才彻底理解。D状态的意思是进程在内核态执行某个操作最常见的是等待磁盘I/O这个时候如果来了信号进程无法立即处理。为什么内核不顺便把信号处理了呢因为进程当前正在操作某些内核数据结构比如正在读写磁盘设备驱动里的缓冲区如果中途跳去处理信号再回来接着操作数据可能已经被信号处理函数改动一致性就破坏了。所以在排查ps aux出现大量D状态进程时通常不是进程本身卡住而是底层的磁盘、NFS文件系统或者存储设备出了状况。我之前遇到过一次内核日志里满是task blocked for more than 120 seconds的报警一整批进程都挂在D状态最后查下来是后端存储的iSCSI链路断开了磁盘I/O迟迟无法完成。这种问题靠kill进程是解决不了的得先把存储链路恢复。2.3 调度器从O(1)到CFS再到多队列CPU调度学起来最怕死记算法名称。我建议挑两个核心场景去理解交互型任务比如编辑器输入、鼠标移动希望响应快得优先调度计算密集任务比如编译代码希望吞吐高尽量别频繁切换。Linux从2.6开始用CFS完全公平调度器它的核心逻辑是维护一个红黑树按**vruntime虚拟运行时间**排序调度器每次挑vruntime最小的进程运行。这个设计思路很巧妙它不追求平均给每个进程分时间片而是保证系统中每个进程的vruntime尽量接近——你运行得越久优先级就越低你被落下的越多越会被优先选中。到了多核时代CFS又演进成调度域scheduling domain和负载均衡机制每个CPU有自己的运行队列尽量避免进程在不同CPU之间频繁迁移因为会破坏缓存热度和TLB局部性。现在的新内核里CFS逐渐被新的EEVDF调度器取代但核心思想仍然类似。理解这些对做性能优化很有用。如果你的服务是延迟敏感型可以把CPU亲和性taskset绑定到特定核心或者用isolcpus内核参数让某些CPU完全避开普通调度专门给实时任务用。2.4 线程的本质轻量级进程线程和进程的区别很多人背了线程共享地址空间、进程隔离地址空间就不深究了。其实在Linux里线程本质上就是共享了特定资源的进程——用clone系统调用创建参数里指定CLONE_VM共享地址空间、CLONE_FILES共享文件表等flag。也就是说Linux并不存在独立的线程结构线程是跑在共享地址空间里的执行流轻量级进程。线程切换比进程切换快根本原因是切换时不需要切换页表进程里mm_struct对应的pgd不需要换TLB不用全部失效缓存命中率更高。多线程编程中常见的坑比如共享变量的并发修改根源也来自这个设计线程之间共享整个地址空间不加锁操作同一块内存就会产生数据竞争。用pthread_mutex或用原子操作都能解决但关键还是要理解竞争发生的位置在CPU缓存和内存之间——一个线程改了变量另一个线程可能还在自己的L1缓存里读到旧值这就是为什么需要内存屏障。3. 内存管理虚拟地址空间和物理内存的映射游戏3.1 为什么每个进程都能独占 огромное 地址空间32位系统里每个进程有4GB虚拟地址空间64位更大。但物理内存可能只有16GB运行几十个进程肯定不够分靠的是虚拟内存和按需分配。每个进程有自己的页表将虚拟地址映射到物理地址。CPU里的MMU内存管理单元在每条指令访问内存时自动完成这个翻译。如果页表里某个页的映射不存在即还没分配物理页MMU会触发缺页异常page fault内核在异常处理里真正分配物理页并建立映射。这里有个常被忽视的细节malloc分配内存时内核并没有立刻给你物理内存只是改了一下进程的虚拟地址空间区域VMA标记。真正分配物理页的时机是你第一次写入这块内存时——也就是触碰到那些页的那一刻触发性分配机制。所以你会看到进程申请了10GB虚拟内存RSS可能很小这太正常了。3.2 多级页表和TLB让地址翻译不太慢如果每个进程都维护一张虚拟页到物理页框的线性表32位系统、4KB页大小就需要100万个页表项每个进程一张想想就爆炸。所以现代CPU普遍使用多级页表x86-64上通常是4级按需为已使用的区间建立中间层级。就算进程的虚拟地址空间跨度很大空洞部分不建立页表页可以节省大量内存。为了加快翻译速度CPU里还有一个叫TLBTranslation Lookaside Buffer的缓存专门缓存最近的虚拟页到物理页的映射。为什么线程切换比进程切换快除了前面说的共享地址空间之外一个重要的原因就是不换页表TLB不用刷掉。进程切换时TLB里全是上一个进程的缓存映射如果不刷新进程的虚拟地址就会翻译到上一个进程的物理页上所以必须全部作废。这开销不小也是很多高性能系统尽量用线程而不是进程的底层原因之一。3.3 页面置换算法LRU的近似实现当物理内存不够时内核要选择某些页换出到磁盘的交换区swap。理想做法是换出最远将来不会再访问的页但这个问题无法预知于是退而求其次用最近最少使用LRU来近似。真正在Linux内核里实现不是维护严格的LRU链表而是用双链近似LRU算法把内存页分成active_list和inactive_list页每次被访问时会打上PG_active或PG_referenced标记。系统通过kswapd内核线程周期性扫描不活跃的页逐步从活跃链表挪到非活跃链表再不访问就会被换出。这段知识对排查内存问题很有用。比如你看到free命令里swap占用很高不要本能觉得swap满了就加内存先去vmstat看si/so是否频繁。如果频繁交换可能是page cache被大量回收造成的颠簸thrashing也可能是应用真的有内存泄露。可以临时用sysctl vm.swappiness调低交换倾向但这只是治标根因还得从应用层查。3.4 共享内存和mmap少一次拷贝的玄机进程间通信常见的共享内存SysV shm或mmap共享文件为什么是效率最高的IPC因为多个进程的虚拟地址空间映射到同一组物理页面数据不需要在内核态和用户态之间来回拷贝写一份大家立即可见。很多高性能中间件就是靠这个思路起家的。比如当你用mmap读一个大文件时内核把文件页映射到进程地址空间应用直接像访问内存一样读它不需要read系统调用中的内核缓冲到用户缓冲拷贝。这也是为什么数据库、消息队列经常和mmap扯上关系。不过mmap也不是完全没有代价首次访问文件页如果没有缓存依然会缺页然后从磁盘读mmap方式无法控制读入的时机可能造成更多IO等待。所以到底是mmap还是传统read得结合IO模式权衡每句话都存在各自的取舍。4. 文件系统从inode到文件描述符的层层抽丝4.1 文件是无结构字节流但也只是表象抽象教材里说文件是字节流操作系统不认识文件里的内部结构。这句话帮我解开过很多疑惑。.txt还是.png对内核无所谓它只关心字节的存储位置和权限。文件的元数据大小、权限、修改时间、数据块位置全部保存在inode里。每个文件对应一个inode一个目录就是一个包含文件名和inode号映射关系的特殊文件。你用ls -i可以看到文件的inode号。inode里不存文件名因为目录项里才存名字和inode的对应关系这也意味着硬链接的本质是在目录项里新增一个名字指向同一个inode。4.2 硬链接和软链接别再搞混了这是期末必考、面试常问的题硬链接新创建了一个目录项指向同一个inode。inode的链接计数nlink加1。删除任意一个名字只是把计数减1直到计数为0才真正释放数据块。软链接符号链接新建了一个独立inode里面存放的是指向目标文件的路径字符串类似于快捷方式。目标被删除软链接就成了悬空链接dangling。实操里有个点很容易踩坑硬链接不能跨文件系统因为inode号只在同一个文件系统内有意义也不能对目录创建硬链接防止成环。如果你用ln -s做软链接后面跟绝对路径还是相对路径要格外小心——很多人把软链接移动到别的目录后挂了就是因为在创建时用了相对路径而相对路径的解析是相对于链接所在目录而不是当前目录的。4.3 打开文件的完整过程应用层open(hello.txt, O_RDONLY)到内核流程大致如下内核根据路径逐级查找目录项找到文件inode把inode读入内存在进程的文件表里创建一个文件结构体也叫file结构体把fd文件描述符返回给用户它是一个非负整数本质是进程的fd数组下标。回到上面说的一切皆文件管道、socket、设备都有自己的file_operations函数表open后返回的fdread、write、ioctl都能走同一套入口差异只在于函数表里装的是哪个具体实现。比如read一个socket最终会走到网络协议栈的tcp_recvmsgread一个块设备文件最终会调用磁盘驱动。想彻底理解I/O重定向、管道是怎么实现的抓住这个fd指向file结构体、file结构体指向具体操作函数表就行。5. 设备管理中断、DMA与阻塞非阻塞的真相5.1 轮询太笨纯中断又太低效所以有了中断下半部CPU和外部设备交互的基本方式有两种轮询polling和中断interrupt。轮询简单但CPU一直在那里问好了没好了没太浪费了。中断更先进但对高频设备比如网卡来说数据包一个接一个频繁触发中断反而让CPU疲于处理中断上下文吞吐量上不去。Linux的解决方式是中断上下半部机制上半部hardirq设备发出中断CPU立刻进入中断处理程序只做最紧急的事情比如告诉硬件我知道了先把数据放到内存里要求短平快下半部softirq / tasklet / workqueue把耗时工作比如网卡收包后交给协议栈处理推迟到中断上下文以外执行。处理网卡数据就是典型场景。网卡收到包后通过DMA直接写到内存的环形缓冲区ring buffer不需要CPU一个字节一个字节搬。然后网卡触发中断CPU在中断上半部简单确认后就把后续的协议栈处理外包给软中断。这就是为什么top里能看到si软中断占用——网络繁忙时si高是正常现象但如果一直100%通常是有中断风暴或驱动Bug。5.2 阻塞、非阻塞、多路复用和异步IO的递进关系这四者的区别我建议用点外卖来理解阻塞I/O你在窗口等着菜做好递给你期间你什么都干不了。非阻塞I/O你隔一会儿就问一次好了没没就好就继续干别的但是反复询问有成本。IO多路复用你把外卖订单号交给一个调度员select/poll/epoll调度员一次性盯着几百个窗口任何一个窗口喊你的餐好了他再通知你去取。异步IO你把地址留给商家做好了直接送到家期间你完全不用管。货送到了再通知你收货。Linux高性能网络编程之所以绕不开epoll核心就是当连接数巨大、大多数连接空闲时用阻塞/非阻塞模型都会让线程被无效等待浪费而epoll只把有事件发生的fd汇报给应用复杂度从O(N)降到O(就绪事件数)。5.3 零拷贝为什么零传统readwrite发送文件数据经历了至少4次拷贝磁盘到内核读缓冲、内核读缓冲到用户缓冲、用户缓冲到内核socket缓冲、内核socket缓冲到网卡。每次拷贝都涉及CPU参与和上下文切换。零拷贝技术的目标不是完全没有移动数据而是尽量减少CPU参与的数据拷贝。以sendfile为例它直接让网卡从page cache读数据或者用DMA控制器做内核态到内核态的直接搬运省掉两次用户态和内核态的切换及两次CPU拷贝。Kafka这些消息队列能保持极高吞吐大量使用了类似的sendfile机制来发送日志文件。理解这个点对排查网络IO密集型服务的性能瓶颈特别有用。6. 期末复习与应试哪些知识点值得花时间如果你的目标是操作系统期末考试直接看一堆扩展技术容易抓不住重点。我根据教材和常见试卷把复习重点排了个序。6.1 必考基础题分布与复习重点以下是常见考试的知识点优先级按出现频率大致排列进程与线程进程状态转换图、PCB内容、线程与进程区别必考简答调度算法FCFS、SJF、RR、优先级调度、多级反馈队列计算平均周转时间/平均等待时间必考大题同步与互斥信号量机制、PV操作解决生产者消费者问题、读者写者问题必考大题死锁四个必要条件、银行家算法安全性检测常考大题内存管理分页/分段、逻辑地址到物理地址的转换计算、页面置换算法FIFO/LRU/OPT计算缺页次数高频大题文件系统文件目录、i节点、磁盘调度算法先来先服务、最短寻道时间优先、电梯算法计算I/O管理中断处理流程、DMA、缓冲技术低频简答。6.2 计算题的通用套路操作系统考题里计算题占比很大考前最容易提分。我的经验是地址转换题一定要画图明确逻辑地址中的页号/页内偏移长度别急着套公式。题目给了页表就先查页表查出物理块号再拼接偏移量。注意页大小的二进制位数直接决定偏移位数例如4KB2的12次方偏移量占12位。调度算法题先把进程的到达时间和服务时间按表格列清楚。FCFS按到达排SJF非抢占时在调度时刻挑短作业RR的时间片单位不可忽略计算平均周转时间时把每个进程的完成时间减去到达时间。页面置换题最好一行一页地画内存状态变化。OPT每次淘汰未来最远才访问的页FIFO注意换进来的页进入时间。注意判断可能存在的Belady异常——分配物理块反而缺页更多。6.3 常见易错点清单进程和程序的区别进程是动态的程序是静态的一个程序可被多个进程执行。死锁不是死循环四个必要条件互斥、占有且等待、不可剥夺、循环等待缺一不可。P操作信号量减一V操作加一。P阻塞发生在资源不足时V唤醒发生在有进程等待时。虚拟存储基于局部性原理不是所有分页系统都叫虚拟存储。文件系统中磁盘的物理块大小和内存页大小、文件系统块大小通常不是同一个概念转换时别混。6.4 一些实验/实操类考点现在很多学校操作系统课会配Linux实验几个高频实验包括用fork()创建子进程、观察ps下的父子关系用pthread_create创建多线程并复现临界区问题然后用pthread_mutex修复用signal()或sigaction()捕捉SIGINT对比read/write与mmap拷贝文件的时间差异在/proc文件系统里查看进程的状态/proc/[pid]/status里的State、VmRSS等字段。这类实验如果只看书不看实操很容易漏掉一些真实行为比如fork之后子进程从哪一行代码继续执行mmap拷贝小文件可能比read还慢信号处理函数里能不能调用printf其实很不安全。7. 学习操作系统的笨办法用一个实际问题串起全部知识我是实践派最后再分享一个我常用的学习操作系统的方法找一个线上真实问题和一本教材对照着看。比如当你的Java应用报OutOfMemoryError: unable to create new native thread时你会下意识去查[1]是不是线程数的限制但其实再往深想一步这个报错背后就是我之前反复说的那几件事每个线程默认占8MB的栈空间虚拟内存线程数多时虚拟地址空间或内存配额耗尽线程创建本质上是给进程开了新的执行流受ulimit -u最大用户进程数/线程数、vm.max_map_count可映射区域上限、cgroup的pids控制器等多个限制叠加影响。脱离这些限制谈线程开少了没有意义。再比如线上MySQL偶发Disk I/O error或者某个SQL特别慢你可以从文件系统缓存、InnoDB缓冲池、磁盘调度算法、SSD固件等多个层面去分析——每一层都对应你学过的操作系统知识点。这就是为什么我强烈建议基础不牢的人不要只盯着书看多去看看那些一线上真实的排障案例尝试用学过的理论去解释它们。8. 总结心得与几个值得养成的习惯不知不觉写了这么多这篇笔记最核心的收获大概有三点第一操作系统是一层一层的抽象从CPU到进程、从物理内存到虚拟地址空间、从磁盘到文件系统。遇到任何问题先搞清楚当前处于哪一层然后分析这一层和上下层的关系问题定位会快很多。第二不要孤立地记概念。进程切换为什么比线程切换贵这个疑问如果深挖能串出页表、TLB、缓存甚至多核架构中的cache一致性协议。一个连接点可以带出一张知识网这是最好的记忆方式。第三学操作系统必须做实验。我见过很多人把PV操作背得滚瓜烂熟却不敢用fork也有很多人能解释零拷贝的原理却没真正对比过一个文件在不同拷贝方式下的耗时差距。理论知识如果不是在现实场景里碰撞过是很容易忘的。如果你想深入学习建议把源码也纳入视野Linux内核的sched/core.c里藏着调度器入口mm/memory.c里藏着缺页中断的处理逻辑fs/open.c里藏着文件open的真正实现。哪怕不去逐行读用grep找找关键函数看看注释都能比自己凭空想象准确得多。操作系统不是一个背完就扔的学科它值得你在之后的每一个性能问题里回过头来翻一翻。希望这份笔记能在某个关键时刻提醒你去查一下进程状态去看一眼swap去翻一翻中断计数——很多难题的答案其实早就写在教科书里了。