别看教科书把进程和线程拆成两章来讲在我心里这两样东西从来都是打包在一起理解的。最近回头整理某高校操作系统课程里关于进程和线程管理的笔记越整理越觉得这部分内容才是整个操作系统的“心脏”。你搞懂了进程调度、上下文切换、线程模型这堆东西后面再去看内存管理、文件系统、甚至分布式系统都会顺很多。这篇不是课程实录是我结合自己平时写代码、排查线上问题时的体会把这部分知识重新捋了一遍希望能帮你把知识点串成一条线。先说清楚这篇覆盖的范围进程与线程的基本概念、进程状态机和上下文切换、常见的调度算法、线程的三种实现模型以及同步互斥里最容易让人绕晕的几个经典问题。无论你是正在准备考试的学生还是工作几年想回头补基础的同学这篇应该都能给你一些不一样的视角。进程与线程操作系统的“任务管理”核心1.1 进程不是“正在运行的程序”这么简单很多教材第一句话就是“进程是正在运行的程序”这话没错但容易误导人。程序是静态的躺在磁盘上的一堆指令和数据进程是动态的是程序被加载到内存里、分配了资源、准备被CPU执行时的那个“活动实体”。我更喜欢把进程理解成“一个独立的执行环境加上一条执行流”。这个执行环境包括独立的地址空间代码段、数据段、堆、栈、打开的文件描述符表、当前工作目录、信号处理方式、用户ID等等。这条执行流就是CPU一条一条执行指令的过程。两者合在一起才是一个完整的进程。这里有个关键点进程是资源分配的基本单位。操作系统给进程分配内存、文件、设备都是以进程为粒度来记的。两个进程之间天然隔离A进程把内存写穿了B进程完全无感。这个隔离性是操作系统稳定性的基石没有这一层任何应用崩溃都可能拖垮整个系统。进程的地址空间布局值得多看一眼。虚拟地址空间从低到高依次是代码段、已初始化数据段、未初始化数据段BSS、堆、共享库映射区、栈最顶部是内核空间。堆向高地址增长栈向低地址增长两者相向而行中间是空闲区域。C语言里经典的“栈溢出覆盖堆”、“堆溢出覆盖栈”本质上就是这两个区域“相向而行”撞在一起的问题。1.2 线程进程内部的“轻量级分身”进程解决了资源隔离的问题但副作用是切换成本高、通信麻烦。你想让两个任务共享同一份大缓存数据用进程的话要么用共享内存绕开进程隔离要么用管道/消息队列绕开进程通信怎么都别扭。线程的出现就是干这个事的。线程是CPU调度的基本单位同一个进程里的所有线程共享进程的地址空间、文件描述符表和全局数据。每个线程私有的东西只有三项线程ID、栈空间、寄存器上下文包括程序计数器也就是PC指针。因为大部分资源都是共享的线程创建比进程快得多同一进程内线程切换也比进程切换快得多。这里要纠正一个常见误解线程不是“更轻量的进程”进程和线程的语义从根本上就不一样。进程是“资源容器执行流”线程只是“执行流”。把线程理解成“在进程这个房子里的多个佣人”更贴切——房子是进程的资源佣人们共用这个房子各自干各自的活。线程栈的问题值得说道说道。每个线程有独立的栈默认大小通常在1MB到8MB之间Linux x86-64下主线程栈默认8MB其他线程默认2MB左右。线程栈增长过快会撞到共享库映射区或者堆这类问题特别难排查。我见过一次诡异的崩溃最后定位到是某个线程栈上分配了一个几百KB的临时数组导致的。栈这种东西分配大对象真的要谨慎。状态机思维把进程看成“状态事件”的流转2.1 五状态模型阻塞态为什么存在进程不是一直在跑的。CPU就那么几个核进程却可能有几百个所以进程必须在不同状态之间流转。教科书上的经典五状态模型包含新建态、就绪态、运行态、阻塞态、退出态。我加了“挂起态”变成七状态不过对理解核心机制来说五状态完全够用。就绪态进程万事俱备只欠CPU。所有资源都分配好了就等调度器把它选上。运行态进程正在CPU上执行指令。单核CPU任意时刻只能有一个进程处于运行态。阻塞态进程在等某个外部事件典型如等待磁盘I/O完成、等待网络数据到达、等待用户键盘输入。此时进程用不了CPU给它CPU也是在空等。这里需要理解一个反直觉的事实阻塞态的进程虽然“不干活”但它占着内存里的资源。如果一个系统里大量进程都在阻塞内存很快会被占满这就是后来出现“协程”和“异步I/O”的根本动机——不想为了等I/O就占着一个系统的资源配额。进程状态之间只有四种合法切换路径运行-就绪时间片耗尽或被更高优先级进程抢占 运行-阻塞主动发起I/O或等待锁 阻塞-就绪I/O完成被中断唤醒 就绪-运行被调度器选中这个状态图看着简单但很多面试题考的就是边界情况一个进程能在阻塞态主动切换到运行态吗不能必须经过就绪态。一个新建的进程可以直接进运行态吗不能必须先进就绪态排队。2.2 上下文切换操作系统的“换人”上下文切换是进程管理的核心机制也常常是性能瓶颈。所谓上下文就是CPU执行一个进程所需的全部状态通用寄存器、PC指针、栈指针、程序状态字、页表基址寄存器、浮点寄存器等等。切换进程时内核把这些状态保存到旧进程的PCB进程控制块里再从新进程的PCB里加载对应状态CPU才能“无缝”继续新进程的执行。这个过程听着简单代价却并不小。一次上下文切换通常要花几微秒到十几微秒而一条普通指令只有不到一纳秒——差了好几个数量级。如果系统每秒发生几万次上下文切换CPU时间的浪费就非常可观了。我平时查性能问题最喜欢看系统上下文切换次数这个指标。在Linux下用vmstat 1如果cs列的数据持续很高比如每秒几万基本可以断定系统在“忙于切换而无心干活”。这时候排查的方向就是是不是线程开太多了是不是锁竞争太严重导致频繁阻塞唤醒是不是定时器太多导致频繁时钟中断上下文切换的代价还和地址空间有关。进程切换因为是两个不同地址空间之间切换必须刷TLB快表代价更大同一进程内的线程切换不涉及地址空间切换TLB不用刷所以便宜得多。这也是为什么“多线程”比“多进程”在很多场景下性能更好的原因之一——不只是创建时省资源运行时切换也更省钱。调度算法怎么分CPU这块蛋糕3.1 调度器的目标永远在多个目标之间做权衡很多初学者以为调度就是“按顺序来”实际上调度器每天在做的都是多目标权衡的脏活。这些目标常常互相打架CPU利用率让CPU尽可能时刻有事做别闲着。吞吐量单位时间内完成的进程数量。让CPU忙并不等于吞吐高——CPU忙在空转上吞吐照样上不去。周转时间从进程进入就绪队列到完成的总时间。用户感知的“卡不卡”主要看这个。响应时间从提交请求到产生第一响应的时间。对交互式系统这比总完成时间更重要。公平性别让某个进程饿死也别让某个进程太霸道。批处理系统、交互式系统、实时系统对这几个目标的优先级排序完全不一样。银行后台跑批量任务关心吞吐量你打开手机App关心响应时间自动驾驶的决策模块关心的是可预测的截止时间。没有最好的调度算法只有最适合场景的调度算法。3.2 经典算法逐个说先看几个基础算法。先来先服务FCFS是最朴素的按到达顺序排队像银行取号。优点是公平简单缺点是平均等待时间可能被长任务拖垮。三个任务执行时间分别是1秒、2秒、10秒按这个顺序到达平均等待时间是 (013)/3 1.33秒如果10秒的任务先到平均等待时间变成 (01012)/3 7.33秒。这就是所谓的“护航效应”。最短作业优先SJF按预估执行时间排序平均周转时间最优但问题是“短任务一直插队长任务饿死”而且现实中很难预估执行时间。时间片轮转RR是FCFS加了一个时钟中断每个进程跑一个时间片就切走。这保证了响应时间上限但时间片大小很讲究太短则上下文切换开销太大太长则退化成FCFS。经验值一般是10ms到100ms之间具体看系统负载和硬件速度。Linux下默认时间片通常在1ms到4ms之间CFS的设计更复杂不是固定时间片而是用虚拟运行时间来做权重的动态调节。再看多级反馈队列MLFQ这是综合方案。多个队列优先级从高到低高优先级队列时间片短低优先级队列时间片长。新任务先进最高优先级队列用完时间片还没完成就降级。这样做的好处是短任务在高层队列快速完成长任务也不会饿死只是慢慢在底层队列磨。同时配合老化机制防止底层任务永远得不到CPU。Linux早期的调度器就是MLFQ思路后来CFS完全公平调度器登场改用红黑树按虚拟运行时间来选下一个进程但MLFQ的思路依然深刻影响了现代操作系统的调度设计。实时调度里常用的最早截止时间优先EDF也要提一下。每个任务带一个截止时间调度器优先选截止时间最近的。这个策略在理论上是最优的但实现时需要精确的时钟管理Linux的实时调度类SCHED_FIFO和SCHED_RR只是优先级抢占并不是严格意义的EDF但基本思想是一致的。线程的三种实现模型用户态还是内核态4.1 用户级线程快但“聋子”用户级线程ULT完全在用户空间实现内核根本不知道线程的存在。线程切换不涉及系统调用不经过内核所以速度非常快——和函数调用差不多。但致命缺点是如果一个线程发起了阻塞I/O系统调用整个进程都会被阻塞。因为内核只认进程不知道你这个进程内部还有多个线程看到进程发起阻塞调用就把整个进程挂起了。结果是你用用户级线程想提升并发一旦遇到阻塞I/O所有线程一起“聋掉”。这就像你在一个办公室里安排了好几个人工位但办公室只有一部电话一个人打电话占线时其他人也没法用电话。4.2 内核级线程慢但“耳聪目明”内核级线程KLT由内核管理每个线程在内核空间有对应的TCB线程控制块。线程切换要经过内核慢一些但内核知道每个线程的状态一个线程阻塞不影响同进程的其他线程。多核CPU上内核级线程还能真正并行。主流操作系统Linux、Windows提供的基本都是内核级线程。Java的Thread、pthread库创建的原生线程本质上都是内核级线程。代价是创建和切换的系统调用开销以及内核对象占用的资源。你开1万个线程试试内存先吃紧调度也会变慢。4.3 混合模型现代语言的选择混合模型就是用户级线程 内核级线程的组合用户级线程协程、纤程跑在内核级线程之上由一个调度器把M个用户线程映射到N个内核线程上。这解决了两个问题用户线程数量可以很大几万几十万没问题同时阻塞I/O不会导致全部“聋掉”——因为底层有多个内核线程可以轮换。典型例子是Go语言的GMP调度器G是goroutineM是内核线程P是逻辑处理器负责把G调度到M上执行。再比如Java的虚拟线程Project Loom以及Python的asyncio协程。这类模型的核心价值在于用少量内核线程承载大量轻量级执行流大大降低“每任务一线程”的资源开销。我实际用下来Go的goroutine开个十万个毫无压力Java的Thread开一万个内存就报警了。这就是用户级线程批量造“廉价工人”的优势。但代价是调度器成了你必须依赖的一个复杂组件调度策略不可控排障也更难。同步与互斥并发编程的“火药桶”5.1 竞态条件两个线程同时改一个变量同步问题的根源是竞态条件race condition。假设两个线程同时执行counter这条语句在CPU层面其实是三步读取counter到寄存器、寄存器1、写回counter。两个线程交错执行就可能丢失更新线程A读到了counter5线程B也读到了5A写回6B也写回6——两次自增最终却只加了1。这就像两个人同时往一个钱箱里放钱都看到箱子里原有100块都放进去10块都想写回110但两个人是同时看的、同时写的最后箱子里还是110而不是120。丢失更新的根子在于“读-改-写”不是原子操作。要解决竞态就需要原子性要么整个操作不可分割要么用锁把这几个步骤串起来。硬件提供了原子指令如x86的lock前缀指令、CAS指令操作系统在此基础上提供了锁、信号量这些工具。5.2 锁和信号量从自旋锁到互斥量自旋锁spinlock是最简单的锁。线程拿不到锁就一直循环重试不主动让出CPU。好处是等待时间短时开销极小坏处是等待期间CPU空转。适合多核CPU上临界区极短的场景。互斥量mutex更常见。拿不到锁的线程会阻塞自己进入睡眠状态等锁释放后被唤醒。好处是不浪费CPU坏处是涉及系统调用和线程状态切换开销比自旋锁大。所以内核里临界区很短的地方用自旋锁用户态一般用互斥量。信号量semaphore是更通用的同步原语本质是一个计数器加两个原子操作Pwait操作把计数减1如果计数小于0就阻塞Vsignal操作把计数加1唤醒一个等待者。计数初始化为1时就是互斥锁初始化为N时就是允许N个线程同时进入的资源池控制。这里我想强调一个很多人没想明白的点**锁不是解决同步问题的唯一方式而且锁带来的问题死锁、性能下降本身也很麻烦。**无锁编程Lock-free用CAS循环解决一部分问题但ABA问题、内存序问题又冒出来了。真正理解同步核心是把“竞态条件不可避免但可以通过设计避免其危害”这句话想透。5.3 三个经典问题哲学家进餐、生产者消费者、读者写者这三个问题不是考试题它们是并发场景的浓缩模型某种意义上是实际系统设计的抽象。C的生产者消费者模式用来解耦I/O与业务逻辑Java的读写锁对应读者写者问题Rust的所有权设计其实就是从根源上消灭数据竞争——这些现代工程实践追根溯源都能落到这三个经典问题上。生产者消费者问题里最关键的一点是缓冲区空时消费者不能取缓冲满时生产者不能往进放。这需要两把锁加两个条件变量或者一个信号量。实际实现里最容易踩的坑是条件变量必须和互斥锁配合使用且检查条件要在while循环里而不是if里因为可能有“虚假唤醒”。哲学家进餐问题的核心是死锁每个哲学家都拿起左手的筷子然后等右手的筷子结果全部饿死。解决方案有限制同时用餐的人数、奇数位哲学家先拿左边偶数位先拿右边、用一个互斥锁保护拿筷子的过程。解决死锁的通用思路在真实系统里同样适用资源有序分配。读者写者问题核心是读者之间可以并行写者与任何其他人都不能并行。实现上有读者优先、写者优先、公平版本之分。读者优先会导致写者被饿死——写者一直等读者不断涌入。在真实数据库系统里这种优先级反转往往需要专门的处理机制如写者超时。常见问题与排查技巧实录6.1 进程状态查看别再只看任务管理器Linux下排查进程状态ps aux是最基础的但很多人忽略STAT列的含义。STAT列里常见的状态码需要背下来Rrunning运行中或就绪队列中 Ssleeping可中断睡眠通常正在等待I/O Ddisk sleep不可中断睡眠通常正在等待磁盘I/O Tstopped被停止或暂停 Zzombie僵尸进程已经退出但父进程尚未回收其状态D状态尤其值得注意。如果系统里大量进程卡在D状态往往是磁盘I/O子系统出问题了比如NFS挂载点不可用。这种状态下进程无法被kill唯一的办法是把底层I/O问题解决掉。我有一次排查服务器“假死”就是某进程卡在D状态访问一个不可用的网络存储卷连带一堆进程排队等I/O整个系统像冻住了一样。僵尸进程是另一个高频问题。正常的进程退出流程是子进程exit后进入僵尸态父进程调用wait/waitpid回收子进程的状态信息。如果父进程一直不回收僵尸进程就留在那里虽然不占CPU和内存但占着内核的进程表项。系统进程表是有限的僵尸进程积累多了新进程就fork不出来了。解决办法是找到僵尸进程的父进程修好它不回收子进程的bug或者直接杀掉父进程让init进程收养并回收。写长时间运行的服务端程序时一定要记得处理SIGCHLD信号或在循环里wait子进程。6.2 线程相关指标CPU占用和上下文切换排查多线程程序性能问题时有几个工具组合很好用。top设置按CPU占用排序可以快速找到最“热”的线程ps -Lf pid查看进程里所有线程的状态perf top看热点函数strace -p pid看线程正在干什么系统调用。压测多线程应用时发现CPU占用率只跑到单核的一半先别怀疑机器大概率是锁竞争太厉害了——线程们大部分时间在等锁而不是干活。上下文切换过高的问题我之前提到过再补充一个排查路径先用vmstat 1确认cs列数值再用pidstat -w 1定位是哪几个进程在剧烈切换最后针对进程内部分析是不是线程数量过多或者锁粒度太粗。一次生产环境性能抖动排查下来是某个服务线程池默认配置128个线程实际只需要16个多余的线程反复被唤醒又阻塞白白消耗CPU和锁开销。6.3 死锁排查jstack和gdb的实战用法Java应用死锁最快捷的办法是用jstack pid输出线程堆栈搜索“Found one Java-level deadlock”它会直接告诉你哪两个线程互相持锁等待。C/C程序死锁就麻烦一些用gdb -p pid附加到进程用thread命令切换线程用bt看每个线程的调用栈人工判断锁的持有关系。如果加了pthread锁gdb还能显示Owner信息。预防死锁的经验法则我这几年总结下来就三条锁的粒度尽量小但别太小所有地方按同一顺序加锁能不用锁就别用锁优先考虑无锁结构和不可变数据。尤其第二条“按固定顺序加锁”在代码审查里是最容易发现的死锁隐患——两处代码分别以A-B和B-A的顺序加锁这就是教科书式的死锁温床。我参与过的项目里规范里明确要求多重加锁必须走统一的加锁顺序函数禁止跨模块直接操作多个锁。这条规定生效之后生产环境的死锁问题几乎绝迹。6.4 多线程常见bug速查表我整理了一个高频问题清单基本覆盖了多线程入门者会踩的大多数坑现象可能原因排查思路程序偶发性崩溃数据竞争未加锁修改共享数据ThreadSanitizer或helgrind跑一遍测试程序卡死CPU占用为0死锁或条件变量等待永远不会被唤醒jstack/gdb看线程栈CPU占用100%但没进展自旋锁死循环或忙等gdb查看热点函数确认是否在spin数据看起来“脏”缓存未刷新或编译重排加内存屏障或用volatile注意volatile不等价于原子线程创建失败进程线程数达到系统上限ulimit -u和cat /proc/sys/kernel/threads-max查看限制性能随线程数增加反而下降锁竞争严重压测时用perf统计锁等待时间减小临界区这些问题的共同点在于它们都难以稳定复现最有效的防护手段不是“遇到问题再调试”而是从设计上减少共享可变状态。Rust语言把“所有权借用检查”放在编译期就是为了把这类问题消灭在编译阶段这个设计思路值得所有写并发代码的人在写代码之前就先想一想。我自己的体会是把进程和线程这一块理解透收益比想象中大得多。它不只是考研试卷上的题目而是理解一切并发系统的地基。你再看语言层面的协程、框架层的线程池、数据库的事务隔离级别甚至分布式系统里的分布式锁底层逻辑全都是这套东西在延伸。把这章吃透后面很多内容你会觉得似曾相识。最后分享一个我实践中的小习惯每接触一个新系统不管是什么语言什么框架第一件事就是搞清楚它的线程模型——是进程模型还是事件驱动模型是用户级线程还是内核级线程线程怎么切换阻塞I/O会有什么影响。搞清楚这几个问题这个系统的性能特征、扩展方式和瓶颈点基本就能猜个七七八八了。这是进程线程这一章教给我最重要的思维方式。