ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用创业公司类比讲透操作系统:进程线程、调度与同步互斥

用创业公司类比讲透操作系统:进程线程、调度与同步互斥

1. 为什么我非要用一个创业故事来讲操作系统

带过几届刚入行的同事,我发现一个挺有意思的规律:操作系统原理这门课,考试分数高的人不少,但真到写多线程程序的时候,还是该卡死卡死、该内存泄漏泄漏。问题不在知识量,而在于这些知识是散的——进程一个章节、调度一个章节、内存一个章节、文件系统一个章节,学完像手里攥了一把零件,却从来没拼成过一台能跑的机器。

所以我后来带人,都不从概念表开始讲,而是先讲一个故事:假设你从零开始创办一家公司,从注册、招人、排班,到抢会议室、管文件柜、应付前台,这一整套折腾下来,其实就是在把操作系统原理走一遍。公司是操作系统,员工是进程和线程,工位和会议室是内存,排班表是调度器,共享白板是临界资源,收发室是 I/O 子系统。你把这套人事管理琢磨透了,回头再看进程调度、虚拟内存、同步互斥这些概念,它们会自动归位到一张图上。

这个系列我打算分两篇写。第一篇聚焦“人和活”——也就是内核与用户态的分工、进程与线程的本质差别、调度器怎么排班、锁怎么抢;第二篇再讲“场地和物资”——虚拟内存、分页缺页、文件系统、I/O 与缓存。适合谁看?我的定位是:写过一些代码、被并发问题坑过、但一直觉得操作系统抽象得没法落地的朋友。你不需要多深的数学基础,只要能耐心跟着公司从 0 开到 A 轮,这些原理自然就长在故事里了。

提示:文中所有公司、人物、场景都是虚构的教学设定,用于建立类比,不对应任何真实主体。

1.1 操作系统这门课到底卡在哪儿

我观察下来,卡点通常有三个。第一个是概念孤岛化,进程和内存看起来毫无关系,实际上进程的一切资源都由地址空间承载,脱离了内存讲进程,等于讲员工却不说他在哪张工位上干活。第二个是抽象层太厚,教材一上来就是“进程是资源分配的基本单位,线程是调度的基本单位”,这句话背一百遍也没用,因为你没见过一个真实的调度单位长什么样。第三个是缺少代价感,很多原理其实是工程折中,比如为什么要有用户态和内核态,为什么上下文切换那么贵,不体会一次代价,你就记不住这些设计的意义。

创业故事恰好能治这三个病。公司运营本身就是一堆折中:老板不可能每件小事都亲自跑,所以要分层授权;部门不能无限扩张,所以要控制编制;会议室只有一间,所以要定规矩排队。这些约束和内核的设计约束是一一对应的,你只要把“为什么公司必须这么管”想通了,操作系统的设计动机也就跟着通了。

1.2 一家公司就是一台跑起来的操作系统

我把核心映射整理成一张表,建议你先扫一眼,后面每一节都会回到这张表上做展开。

操作系统概念公司里的对应物关键约束
内核老板办公室 + 行政制度只处理关键事务,不干具体业务
用户态程序各部门的日常业务权限受限,不能直接动公司核心资产
系统调用行政办事窗口唯一合规的越权通道,效率低但安全
进程一个独立部门有独立预算、独立办公室、独立档案
线程部门内的员工共享部门资源,独立干活
PCB员工档案袋记录状态、工位、当前进度
调度器排班经理决定谁先上工,追求整体产出
虚拟内存工位编号体系每人看到独立编号,背后共用物理场地
互斥锁会议室钥匙一把钥匙同一时刻只能一个人拿
文件系统档案柜和目录索引按名取物,持久保存
中断前台突然闯进来叫人打断当前工作,优先级最高

这张表不严谨的地方肯定有,但对建立直觉足够了。真到抠细节的时候,你自然会知道哪里需要修正——而我恰恰认为,先有一张不太准但完整的地图,比先有一堆绝对准确但互不相连的坐标点更有价值。

1.3 这一篇的路线图

本篇分四个大块推进:先讲公司注册那天的事,也就是内核、用户态和系统调用这条分界线是怎么划出来的;接着讲招人和派活,把进程、线程、PCB、上下文切换掰开揉碎;然后讲排班经理,也就是各种调度算法为什么长成那个样子;最后讲抢会议室,把同步互斥、死锁、无锁编程这套东西讲透。每一块我都会给出一个“为什么这么设计”的推演,而不是直接甩结论。

2. 注册公司那一天:内核、用户态和系统调用

公司刚注册下来,第一件事不是招人干活,而是定规矩:哪些事老板必须亲自签字,哪些事部门自己批了就行。这条线划不清楚,公司要么乱成一团,要么老板累死。操作系统里同一条线,叫用户态与内核态的边界。

2.1 老板办公室和员工工位:为什么必须分层

设想一家公司允许所有人随便进老板办公室、随便翻财务账本、随便改公司章程。效率可能短期内很高,但任何一个人手滑一次,公司就直接完蛋。操作系统面临的是同一个问题:如果应用程序能直接操作硬件寄存器、直接改写页表、直接访问磁盘控制器,那么一个有 bug 的程序就能让整台机器崩溃,更别说恶意程序了。

于是硬件层面就提供了支持:处理器把执行状态分成特权级,主流架构一般是两级实际使用——高特权级跑内核代码,低特权级跑用户程序。进入内核态才能执行特权指令,比如修改页表基址寄存器、关闭中断、直接访问 I/O 端口。用户态程序想干这些事,只能“申请”,由内核代劳。

这个设计和公司分层授权是同构的。老板办公室(内核)掌握的是不可委托的关键权力,比如银行账户动账、公章使用;部门(用户程序)掌握的是日常业务决策权,比如本部门怎么排任务、怎么分配自己的预算。分工的意义不是限制,而是让错误的影响范围可控——用户态程序崩了,只是这个部门解散,公司还在;内核崩了,整个公司关门。

2.2 行政办事窗口:系统调用的真实形态

部门要用公章,不能自己伸手去拿,得填单子走窗口。这个窗口在操作系统里就是系统调用。它的形态比很多人想的要“重”:不是一条 jump 指令跳过去,而是一套完整的流程。

以主流类 Unix 系统为例,用户程序调用一个封装好的库函数(比如read、write、fork),库函数会把系统调用号放进约定的寄存器,然后执行一条专门的陷入指令(不同架构叫法不同,x86 上常见的是syscall或int 0x80)。这条指令会触发处理器从用户态切换到内核态,硬件自动保存当前的部分上下文,然后跳到内核预先注册好的入口点。内核根据调用号查表分发到对应处理函数,执行完再恢复上下文、切回用户态、把返回值交回去。

这里面有几个点值得停下来琢磨。

第一,参数传递是有讲究的。用户态和内核态共享同一套寄存器,但内核不能直接相信用户传进来的指针。为什么?因为用户完全可能传一个指向内核地址的指针,让内核帮他读写内核数据。所以内核在拷贝数据时会做地址合法性校验,这也是很多系统调用在传递大块数据时性能敏感的根源。

第二,系统调用号是一份固定契约。内核和用户库之间靠这份编号表沟通,编号一旦定下来基本不能改,只能往后加。这就是为什么你会看到有些系统调用号中间留着空洞——那是历史遗留,删不得。

第三,返回值风格是刻意的。类 Unix 系统里,系统调用通常返回-1表示失败,具体原因放在一个全局的错误码里,这个错误码在每次调用后会被覆盖。所以正确写法是先判断返回值,再立刻取错误码,中间不能插别的调用。这个坑我见过太多人踩,尤其是在循环里连续调用两个系统调用,然后去读“上一次”的错误码,结果读到的是第二次的。

2.3 每去一次窗口都要填一遍表:切换开销实测

公司里最容易被低估的成本,是“走流程”的成本。你要盖个章,填单、找人签、等审批,这件事本身可能只值十秒钟,但流程走下来半小时。系统调用也是一样。

一次用户态到内核态的切换,涉及的东西比大多数人想象的多:保存当前寄存器现场、切换栈指针(用户栈到内核栈)、可能刷新部分 TLB 和缓存状态、执行内核代码、再恢复现场切回来。在常规服务器硬件上,一次空载的系统调用往返,量级通常在几百纳秒到一两微秒之间。单看不大,但乘上调用次数就很吓人。

我做过一个粗略的估算,思路你可以直接套用。假设你的服务每秒处理 5 万个请求,每个请求里平均发生 20 次系统调用(读 socket、写日志、读时间、内存映射文件等等,这个数字在真实服务里很容易达到),那就是每秒 100 万次系统调用。按每次 0.5 微秒算,光切换开销就是 0.5 秒 CPU 时间;如果机器是 4 核,相当于有 12.5% 的 CPU 时间完全花在“进出门”上,一点业务没干。

这个计算当然是理想化的,但它说明了一个非常重要的工程直觉:系统调用是昂贵的,昂贵到值得你在设计阶段就考虑减少次数。这也是几乎所有高性能 I/O 框架都在做的事情——批量读写、内存映射、事件驱动多路复用,本质上都是在把“多次小窗口”合并成“一次大窗口”。

注意:不要为了减少系统调用而滥用缓冲。缓冲本身要占内存,还要考虑一致性——数据在缓冲区里没落盘,进程挂了就丢了。这里没有银弹,只有权衡。

2.4 哪些事必须走窗口,哪些事自己在工位干完

这条边界感一旦建立,很多性能问题就有了第一层判断依据。我一般这么分类:

必须走内核的:任何涉及硬件资源的操作,包括文件读写、网络收发、内存申请(大块)、进程创建、时间获取(某些实现下)、信号处理注册。以及任何需要跨进程协调的操作,比如管道、共享内存的建立。

可以完全不进内核的:纯计算、字符串处理、哈希、编解码、小对象的内存池分配(在已经申请好的大块内存里切分)。这类操作如果被误写成了频繁系统调用,性能会掉一个数量级。

还有一个灰色地带值得单独说:内存分配。很多人以为malloc是系统调用,其实不是。它是一个库函数,绝大多数情况下只是从一个预先申请好的堆区里切一块出来给你,属于纯用户态操作,速度快到几十纳秒。只有当堆区不够、需要向内核要更多页的时候,才会真正触发一次系统调用。这就是为什么“内存池”这种优化手法能生效——它把偶发的昂贵操作,摊薄成了大量廉价的本地操作。

3. 招人和派活:进程、线程与上下文切换

公司架子搭好,接下来是招人。这里有个绕不开的问题:到底按“部门”招,还是按“个人”招?操作系统给出的两个答案,就是进程和线程。

3.1 独立部门与部门里的员工

进程像是一个独立核算的部门:有自己的办公场地(地址空间)、自己的预算(内存配额)、自己的档案(PCB)、自己的办公用品清单(打开的文件描述符)、自己的对外联系方式(信号处理、管道端点)。部门之间默认是隔离的,A 部门的文件 B 部门看不到,除非走正式流程(进程间通信)。

线程像是同一个部门里的员工:他们共用这间办公室、共用打印机、共用墙上那块白板。谁有事谁去干,彼此之间沟通成本极低——直接喊一嗓子就行,因为大家看着同一块白板。但代价也在这里:一个员工把白板擦了,所有人都受影响。这就是为什么多线程编程里,共享状态的管理是主要难点。

这个类比的准确性在于资源边界。进程切换要换整个“办公室”,包括地址空间,代价高;线程切换只是在同一个办公室里换个人干活,地址空间不动,代价低得多。但很多人只记住了“线程比进程轻”,却忽略了后半句:轻的是切换,重的是共享带来的同步成本。你在一个进程里开 8 个线程,省下了 8 次地址空间切换,但你同时引入了 8 路并发访问共享数据的可能,锁的复杂度会指数级上升。这笔账要一起算。

3.2 每个员工的档案袋:PCB 里到底装了什么

招一个员工,人事要建档。进程的档案袋就是 PCB,它决定了这个进程能不能被正确恢复执行。我把里面最关键的内容列一下,你对照着理解为什么每一项都必不可少。

档案内容作用缺了会怎样
进程标识唯一编号,用于索引和权限判断系统分不清谁是谁
进程状态就绪、运行、阻塞等调度器无法判断该不该让它上工
程序计数器下一条要执行的指令地址切回来不知道从哪继续
寄存器现场通用寄存器、栈指针等计算中间结果全丢
内存管理信息页表基址、地址空间范围切回来找不到自己的数据
打开文件表已打开的文件和偏移量读文件读到一半,位置丢了
调度信息优先级、已用时间、队列指针无法公平排班
信号与统计待处理信号、CPU 用量无法响应异步事件

这张表里最容易被忽视的是打开文件表里的偏移量。文件描述符本身只是个整数索引,真正有价值的是那份记录里保存的“当前读到哪了”。这也是为什么多线程共享同一个文件描述符时,会出现互相干扰读取位置的问题——如果两个人共用一个索引,你读一半我读一半,位置就乱了。要避免这个问题,要么各自打开独立的描述符,要么在读的时候加锁并显式指定偏移。

3.3 员工的一天:进程状态机

一个新员工进来,他的状态变化其实很有限,但每一条转移都有明确的触发条件。

刚创建,处于新建状态,档案建好了但还没分配资源。资源到位,进入就绪队列,等着排班经理点名。被调度上 CPU,进入运行。运行中有三种出路:时间片用完,退回就绪;需要等待某个事件(读磁盘、等锁、等信号),进入阻塞;活干完了,进入终止,档案归档。

这里有两个细节值得抠。第一,阻塞状态不能直接变运行。一个人去等快递了,快递到了他得先回工位待命,不能直接从收发室冲进会议室开始汇报。进程也一样,等待的事件发生后,它只能回到就绪队列,还需要再被调度一次。第二,就绪和运行在单核上不会同时存在多个,但在多核上会,这就是多核调度复杂度的来源。

另外还有一个容易被忽略的状态:挂起。当内存不够的时候,系统可能把某个阻塞进程的整个地址空间换到磁盘上,这时候它既不占内存也不占 CPU,但档案还在。这个机制和后面的虚拟内存章节关系密切,这里先埋个伏笔。

3.4 复制一个部门:fork 与写时复制的精妙之处

类 Unix 系统里创建进程的经典方式是fork,它的语义很有意思:调用一次,返回两次。父进程收到子进程的编号,子进程收到 0。第一次见这个设计的人基本都会懵,觉得这是历史包袱。但站在公司视角看,它其实非常自然——老板说“照着咱们部门复制一个新部门出来”,然后两个部门各自拿着自己的工牌继续干活。

如果真的一比一复制,那么复制一个占用几百兆内存的进程,就要把这几百兆完整拷一遍,这个开销根本无法接受。所以现代实现都用了写时复制:fork之后,父子进程共享同一份物理内存页,但所有页表项都被标记成只读。谁先写,谁触发一次缺页异常,内核此时才给它单独复制一份。读操作完全共享,写操作按需分离。

这个设计的精妙在于它完美匹配了真实使用模式。绝大多数情况下,fork之后马上跟一个exec载入新程序,原来的地址空间根本用不上,白白拷贝纯属浪费。写时复制让这条最常用的路径几乎零成本。而如果子进程真的要和父进程分道扬镳各干各的,那么只有被改动的页才会产生复制,没碰过的页继续共享。

实操心得:写时复制带来一个隐蔽的坑,就是内存统计的迷惑性。父子进程共享的页在统计时可能被重复计算,导致你以为内存爆了,其实没有。排查内存问题时,看进程的私有脏页(独占的、被改过的页)比看总驻留内存更能反映真实压力。这个指标在一些监控工具里叫 RSS 的一个子项,具体名字各系统不同,你查一下手上环境的文档就行。

3.5 换人干活的那几十微秒:上下文切换到底贵在哪

“上下文切换很贵”这句话人人都说,但贵在哪,能说清楚的人不多。我把成本拆成四块。

第一块,保存和恢复现场。寄存器、程序计数器、栈指针,这些必须存进 PCB 再取出来,指令数不多,但都是访存操作。

第二块,切换地址空间。这是大头。进程切换要换页表基址,换完之后整个 TLB 里缓存的地址翻译全部失效,后续所有内存访问都要重新走页表遍历,这个惩罚会持续一段时间,直到 TLB 被重新填充。线程切换因为共享地址空间,这一块成本可以省掉大半。

第三块,缓存污染。新上来的进程访问的数据,和上一个进程用的数据大概率不在一块,CPU 各级缓存里存的东西对新进程基本没用,需要重新加载。这个成本最隐蔽,因为它不体现在切换指令本身,而体现在切换后那几百微秒内的性能下降。

第四块,调度器自身的开销。选谁上、选谁下,这个决策本身要跑代码、要操作队列、要拿锁,在多核高并发场景下,调度器锁的竞争本身就可能成为瓶颈。

量级上,一次进程上下文切换在常规服务器上大致是几微秒,线程切换会低一些,但也就低到一两微秒的水平。听起来不多,但回到那个估算方法:如果你的服务每秒触发 20 万次切换,按 3 微秒算就是 0.6 秒 CPU 时间,在 8 核机器上约占 7.5%。而且这还没算缓存污染带来的隐性损失,实际影响会更大。

所以一条重要的工程经验是:控制并发度。线程不是越多越好,把线程数开到几百上千,上下文切换就会变成主要开销,吞吐量反而下降。正确的做法是先测出系统的“拐点”——逐步加压,观察切换次数和吞吐量的关系,找到吞吐量开始走平甚至下降的那个并发数,然后留在它左边。

3.6 三种用工模式:线程模型怎么选

用户态线程和内核态线程之间怎么对应,业界试过三种方案。

多对一:所有用户线程映射到一个内核线程。切换极快,因为完全在用户态完成,但缺点是只要有一个线程发起阻塞调用,整个进程的所有线程全被卡住,而且无法利用多核。早期的一些语言运行时用过这条路,后来基本放弃了。

一对一:每个用户线程对应一个内核线程。并发能力强,能利用多核,某个线程阻塞不影响别人。缺点是每个线程都要占内核资源,线程数上限受限,切换成本也是内核级的。这是目前主流方案。

多对多:N 个用户线程映射到 M 个内核线程,M 小于 N。理论上最优,兼顾了轻量和并行,但实现复杂度极高,调度器要处理用户态和内核态两层调度,还要处理阻塞调用的“提权”问题。真正把它做到生产可用的实现很少。

我的选型建议很直接:默认选一对一,除非你有非常明确的理由。绝大多数业务场景下,线程数在几十到几百这个量级,一对一的资源开销完全可接受,而它带来的调试便利性和行为可预测性,远比省那点资源值钱。多对多方案的问题在于,出问题的时候极难排查——你看到的线程栈和实际执行流可能对不上,抓瞎的概率很高。

4. 谁先上工:调度器与调度算法

公司人招齐了,工位不够,不可能所有人同时干活。于是必须有个排班经理。这个角色在操作系统里就是调度器,而它的每一个决策,背后都藏着一组权衡。

4.1 排班经理的三条硬指标

在讨论具体算法之前,得先说清楚调度器到底在优化什么。这里面有几个互相冲突的目标,任何一个算法都不可能同时满足。

吞吐量:单位时间内完成的任务数。批处理场景最看重这个。

周转时间:从任务提交到完成的总耗时。用户提交一个大任务,关心的是多久能拿到结果。

响应时间:从任务就绪到第一次被调度的时间。交互式场景最看重这个,你点一下鼠标,界面必须马上有反应。

公平性:每个任务都能分到合理的 CPU 时间,不能饿死。

开销:调度决策本身要快,不能为了排班把时间都花在排班上。

这几个目标天然打架。想提高吞吐量,就让长任务一次跑完,减少切换,但这会让短任务等很久,响应时间爆炸。想保证公平,就频繁切换、雨露均沾,但切换开销上去了,吞吐量下来。所以你看所有调度算法,本质上都是在这几个目标之间选一个站位。

4.2 几种排班方式的正面对比

我把常见的几种算法摆在一起对比,重点看它们的取舍逻辑。

算法核心思路优点致命短板适用场景
先来先服务按到达顺序排队实现最简单,绝对公平长任务堵在前面,后面全等批处理、任务长度相近
短作业优先谁短谁先上平均周转时间最优长任务可能饿死,长度难预知任务长度可估算的批处理
时间片轮转每人一小段时间,轮流来响应时间可控,无饥饿时间片选不好,切换开销大交互式、通用分时
优先级调度重要的人先上关键任务有保障低优先级可能饿死实时系统、有明确优先级
多级反馈队列多队列,用完时间片就降级兼顾短任务和长任务,自适应参数多,调优麻烦通用操作系统
公平调度按权重分配时间,用虚拟时间排序公平且高效,交互友好实现较复杂,权重需配置桌面与服务器通用

这里重点说两个。时间片轮转的关键参数是时间片长度。选太短,切换开销占比高,比如 1 毫秒时间片配 3 微秒切换,开销是 0.3%,还不算缓存污染;但如果你把时间片压到 100 微秒,切换开销占比就飙到 3% 以上,而且缓存基本没法复用。选太长,交互体验就差,用户感觉卡顿。业界经验值一般在几毫秒到几十毫秒之间,具体看负载特征。

多级反馈队列是我个人最欣赏的设计,因为它解决了一个根本矛盾:调度器事先不知道任务有多长。它的做法是设多个优先级队列,新任务进最高优先级队列,给一个很短的时间片;用完还没结束,就降到下一级队列,时间片加倍。这样短任务在高层就被快速处理完,长任务自然下沉到底层,用长时间片慢慢跑,切换频率也降下来了。这个设计的美妙之处在于,它不需要预知任何信息,靠运行时的反馈自动把任务分到合适的位置。

4.3 排班踩过的坑:优先级反转有多要命

这是我特别想讲的一个案例,因为它的危害程度和隐蔽程度严重不成比例。

场景是这样的:有三个任务,高优先级 H、中优先级 M、低优先级 L。L 先运行,拿了一把锁。此时 H 就绪,抢占 L 开始运行,但 H 也需要那把锁,于是 H 阻塞,等 L 释放。正常情况下,L 会继续跑完临界区然后释放锁,H 就能拿到。但问题在于,M 此时就绪了,M 的优先级比 L 高,于是 M 抢占了 L。L 拿不到 CPU,锁一直不释放,H 就一直等。结果是:高优先级的 H 被中优先级的 M 间接阻塞了,而 M 完全无辜,它甚至不知道这把锁的存在。

这个问题在实时系统里是致命的。经典的解决思路有两条。优先级继承:当 H 因为等锁而阻塞时,临时把持有锁的 L 提升到 H 的优先级,这样 M 就抢不过 L 了,L 能尽快跑完释放锁,然后恢复原优先级。优先级天花板:给每把锁设定一个天花板优先级,任何持有这把锁的任务,优先级自动抬到天花板,从源头上避免被中间优先级插队。

我在这里踩过的坑是:以为用了优先级继承就万事大吉。实际上优先级继承只在“锁”这个层面生效,如果你的代码里有别的阻塞点——比如等一个信号量、等一个条件变量、等 I/O——这些地方不参与优先级继承,反转依然会发生。真正的解法是系统性地审查所有可能造成阻塞的路径,而不是指望某一个机制兜底。

4.4 多核之后:排班问题突然变复杂了

单核时代的调度器只需要回答一个问题:下一个谁上。多核时代要回答两个:谁上,以及在哪上。

这就引出了负载均衡问题。如果所有任务都堆在一个核的队列里,其他核闲着,那多核等于白搭。所以主流设计采用每核一个运行队列,各自调度,然后定期做负载均衡,把任务从繁忙队列迁移到空闲队列。迁移本身有成本,因为要处理缓存失效,所以不能太频繁,这就是“迁移间隔”这个参数的由来。

还有一个和硬件相关的细节:缓存亲和性。一个任务在某个核上跑了一段时间,它的数据已经在这个核的缓存里了,如果把它迁到另一个核,缓存全废。所以调度器会倾向于让任务留在原来的核上,只在负载严重不均时才迁移。这个策略在 NUMA 架构下更复杂,因为跨节点访问内存的延迟可能是本地的两倍以上,这时候调度器不仅要考虑核,还要考虑内存节点。

参数选择上,我给个粗略的判断依据:计算密集型任务,减少迁移,重视缓存亲和;I/O 密集型任务,允许更积极的均衡,因为它们本来就经常让出 CPU,缓存价值不高。

4.5 怎么看排班情况:几个观测动作

理论说再多,不如自己看一眼真实数据。我常用的几个动作,你可以在自己环境里试试。

第一步,看整体负载和上下文切换。用vmstat看cs这一列,也就是每秒上下文切换次数,同时看r列,也就是就绪队列长度。如果cs很高但 CPU 利用率不高,说明切换太频繁;如果r长期大于核数,说明 CPU 不够用。

第二步,看每个核的分布。用mpstat -P ALL观察各核的利用率是否均衡。如果某个核长期 100% 而其他核很闲,可能是调度策略问题,也可能是你的并发模型本身有问题(比如所有线程抢同一把锁,实际上退化成单线程了)。

第三步,看单进程的调度行为。pidstat -w能看到进程的上下文切换次数,分自愿和非自愿两列。自愿切换多,说明进程经常主动等 I/O 或等锁;非自愿切换多,说明时间片用完了被踢下来,可能是 CPU 竞争激烈,也可能是时间片设得太短。

这些数字本身不说明问题,关键是和你的预期对比。一个每秒处理几千请求的服务,每秒几十万次上下文切换就是异常的;一个起了一千个线程的服务,切换多反而是正常的。先建立基线,再看偏离,这个方法论比记住任何阈值都有用。

5. 抢会议室和共享白板:同步与互斥

公司里最稀缺的资源不是人,是会议室。三个人同时要用同一间会议室,谁先进?进去了待多久?后面的人怎么等?这套规则没定好,轻则吵架,重则项目延期。操作系统里的同步互斥机制,管的就是这件事。

5.1 临界区:那段谁都不能同时碰的代码

先给一个精确的界定。临界区是指访问共享资源的那段代码。互斥是指同一时刻只允许一个执行流进入临界区。这两个概念必须分清楚,因为很多并发 bug 的根源就是把“这段代码”和“这个资源”搞混了。

举个例子。一个计数器,多个线程都要做自增。自增看起来是一条语句,但在机器层面是三步:读值、加一、写回。如果两个线程同时执行,可能发生这种情况:A 读到 5,B 也读到 5,A 写回 6,B 写回 6。两次自增,结果只增了一次。这就是典型的竞态条件。

解决方法是把“读-加-写”这三步包成一个不可分割的整体。用锁的话,就是进临界区之前加锁,出来解锁。注意,被保护的是那三步操作,而不是那个变量本身——锁保护的是代码路径,不是内存地址。这个区分很重要,因为如果你在别的地方也直接访问这个变量却忘了加锁,那么锁就形同虚设。

提示:临界区的范围要尽可能小。锁持有的时间越长,其他线程等待越久,并发度越低。一个常见的反模式是在锁里做 I/O 或做耗时计算,这相当于拿着会议室钥匙在里面睡觉。

5.2 四种常见锁:什么时候用哪个

锁的种类比新手想象的多,每一种都有自己的适用边界。用错了不会立刻出错,但性能会莫名其妙地差,或者在某些极端情况下死锁。

锁类型等待方式优点缺点适用场景
互斥锁拿不到就睡,等待唤醒不占 CPU,适合长等待唤醒有延迟,切换有开销临界区较长、竞争激烈
自旋锁拿不到就原地循环重试无切换开销,延迟极低空耗 CPU,不能长时间持有临界区极短、多核环境
读写锁读共享、写独占读多写少时并发度高实现复杂,写者可能饥饿配置读取、缓存查询
信号量计数器,可控制并发数量能限制同时访问的数量容易用错,计数逻辑需谨慎连接池、限流
条件变量等待某个条件成立避免忙等,配合锁使用必须配合互斥锁,虚假唤醒需处理生产者消费者、任务队列

这里重点说两个新手最容易踩的坑。

第一个是自旋锁不能持有太久。自旋锁的原理是拿不到就原地空转,这在一个核上等另一个核释放锁,是合理的——因为持锁的那个线程在另一个核上真的在跑。但如果你在一个单核环境里用自旋锁,或者持锁线程被调度下去了,那么等待者就会一直空转,把 CPU 时间全浪费掉,而持锁者根本没机会跑。所以自旋锁必须配合“临界区极短”这个前提,而且绝不能在临界区里做任何可能阻塞的操作。

第二个是条件变量必须放在循环里等。几乎所有条件变量的等待接口,都允许“虚假唤醒”——也就是没有任何人通知,等待也可能返回。所以正确的写法永远是while (条件不成立) { 等待; },而不是if (条件不成立) { 等待; }。这个坑非常经典,我在代码评审里见过无数次。

5.3 死锁:四个条件凑齐才会发生

死锁的教科书定义是:一组进程中的每一个都在等待一个只能由该组中另一个进程释放的资源。听起来很绕,但用公司场景一说就明白:A 拿了会议室的钥匙,等着用打印机;B 拿了打印机的钥匙,等着用会议室。两个人都不放手,永远等下去。

死锁发生必须同时满足四个条件,缺一不可:互斥(资源不能共享)、占有并等待(拿着一个还等另一个)、不可抢占(别人不能强行抢走)、循环等待(形成等待环)。这四个条件之所以重要,是因为打破任意一个,死锁就不会发生。

工程上的应对策略,我按实用性排序:

破坏循环等待——最有效。给所有锁定义一个全局顺序,任何线程都必须按这个顺序加锁。A 先拿 1 号再拿 2 号,B 也必须先拿 1 号再拿 2 号,那就永远形不成环。这个方法的成本是设计时要维护一份锁的层级表,好处是一旦定下来,死锁从根本上消失了。

破坏占有并等待——一次性申请所有锁。要么全拿到,要么一个不拿。实现上要注意,如果拿不到就得释放已拿到的,然后重试,这可能引入活锁。

破坏不可抢占——允许超时放弃。拿不到就等一段时间,超时了释放已持有的锁,退回去重来。这个方法简单粗暴,但会带来重试开销,而且在实时性要求高的场景下不可接受。

破坏互斥——用无锁数据结构或者读写分离。这是最优雅但也最难的方案,只适用于特定场景。

注意:死锁和饥饿是两回事。死锁是永远等下去,饥饿是等很久但最终会轮到。饿死问题通常靠公平性机制解决,比如排队、按等待时间排序,而不是靠打破那四个条件。

5.4 无锁和原子操作:不用锁也能保证正确

锁的问题是它太重了。加锁解锁本身要访存、要处理竞争、还可能引起线程切换。所以对于简单的操作,硬件提供了更轻的手段:原子指令。

最典型的是比较并交换,语义是“如果当前值等于期望值,就把它改成新值,否则什么都不做,并告诉我成功与否”。这条指令由硬件保证不可分割,所以可以基于它实现无锁的计数器、无锁的栈、无锁的队列。

无锁编程的核心思路叫CAS 循环:读出当前值,算出新值,用 CAS 尝试写入,如果失败说明别人改过了,重新读、重新算、再试。这个循环保证了最终一定成功,但要注意 ABA 问题:值从 A 变成 B 又变回 A,CAS 会误以为没人改过。解决方法是给值配一个版本号,或者用更宽的原子类型打包版本。

无锁编程的收益是避免线程阻塞和切换,在竞争不激烈的时候性能很好。但代价也很明显:竞争激烈时,大量线程在 CAS 循环里空转,性能可能比加锁还差;而且正确性极难验证,内存序、可见性这些细节稍有疏忽就会出微妙的 bug。

我的实际建议是:除非你在写基础设施级别的代码,否则不要自己造无锁结构。用成熟的并发容器,把精力放在业务逻辑上。无锁编程的投入产出比,对绝大多数业务代码来说是不划算的。

5.5 一次死锁的复现与定位实录

讲个我印象比较深的排查过程,虽然细节做了模糊化处理,但方法是可以复用的。

现象是服务运行一段时间后,所有工作线程全部卡住,CPU 占用为 0,请求全部超时,重启后恢复,过几小时又复现。日志里没有任何异常。

第一步,确认是死锁还是别的问题。既然 CPU 是 0,说明不是死循环空转,而是所有线程都在等待。这时候抓一份线程栈。用调试工具或者系统自带的分析工具,把进程里所有线程的调用栈打出来。

第二步,看等待链。栈里会显示每个线程卡在哪个函数、在等哪把锁。把“谁持有这把锁”和“谁在等这把锁”对应起来,画成一张等待图。如果这张图里出现了环,死锁就坐实了。

第三步,找环的成因。我遇到的那次,是两条业务路径的加锁顺序不一致:路径 A 先拿订单锁再拿账户锁,路径 B 先拿账户锁再拿订单锁。平时两条路径很少并发,所以问题潜伏了很久,直到某个业务量上来才暴露。

第四步,修复和验证。修复方案就是统一加锁顺序,把账户锁定为第一层、订单锁定为第二层,所有代码路径一律按这个顺序。修复之后,我加了一个“锁顺序断言”的检查,在调试模式下检测到违反顺序的加锁就直接报错,防止以后再犯。

经验总结下来就三条:所有多把锁的场景必须有全局顺序;死锁排查的核心是画等待图,而不是猜;修完之后要加防护,否则同样的坑还会再踩一次。

6. 前几个月的踩坑清单

故事讲到这,公司算是勉强运转起来了。但真实运营中踩过的坑,比原理本身更值得记下来。我把最容易反复踩的几类问题整理成清单。

6.1 进程与线程相关的典型坑

坑一:线程数拍脑袋定。见过太多项目把线程池大小设成 200、500,理由是“并发高”。实际上线程数应该由任务类型决定:计算密集型任务,线程数取核数左右最合适,多了只会增加切换开销;I/O 密集型任务,可以适当放大,但也要有上限,并且最好用异步模型替代。正确做法是压测找拐点,而不是猜。

坑二:以为线程安全就是加锁。加锁只是手段,核心是搞清楚哪些状态是共享的、哪些是线程私有的。一个常见错误是把本该私有的缓冲区做成成员变量,多个线程轮流往里写,最后数据串了。这类问题加锁也救不了,因为逻辑本身就是错的。

坑三:子进程继承了不该继承的东西。创建子进程之后,父进程打开的文件描述符、建立的连接、持有的锁,都可能被子进程继承。如果子进程直接执行新程序倒还好,如果它继续跑业务逻辑,就可能出现两个进程操作同一个文件、同一把锁的诡异情况。踩过一次之后,我现在的习惯是:创建子进程后立刻检查并关闭不需要的描述符。

坑四:忽略进程退出的清理。进程被杀掉的时候,它持有的锁、占用的共享内存、临时文件,会不会被正确释放?如果不会,那么重启之后就会撞上残留状态。这类问题在高频重启的环境下特别容易暴露,而且现象往往和“锁泄漏”混在一起,很难定位。

6.2 锁与调度相关的典型坑

坑一:锁的粒度要么太大要么太小。锁太大,所有线程排队,并发退化成串行;锁太小,一个业务操作要拿好几次锁,中间状态可能被其他线程看到,产生一致性问题。经验法则是:按数据的不变式来划分锁的边界,一次业务操作要么全在一把锁里,要么用事务性的思维来设计中间状态。

坑二:在锁里做阻塞调用。等网络、等磁盘、等另一个锁,这些都可能在锁内部发生。一旦发生,持锁时间就不可控了,其他线程全被拖死。这个错误极其常见,因为业务逻辑往往很自然地把整个流程包在锁里,而流程中间就有 I/O。

坑三:把调度优先级当成万能药。提高某个线程的优先级,短期看问题解决了,长期看是把问题转移给了别人。优先级反转、优先级滥用导致低优先级任务饿死,这些坑我都见过。优先级应该是最后手段,而不是第一手段。

坑四:忽视上下文切换的隐性成本。切换次数的监控很多人会看,但缓存污染带来的性能损失很少有人量化。一个实际的做法是对比“同样工作量,单线程跑”和“多线程跑”的 CPU 时间差异,如果多线程的总 CPU 时间明显高于单线程,那切换和缓存的开销已经在吃你的性能了。

6.3 快速对照表:症状到方向的映射

最后给一张速查表,把常见症状和排查方向对应起来,遇到问题先按这个表定位方向,再去抠细节。

症状可能原因先查什么
CPU 高但吞吐低自旋锁空转、切换频繁、锁竞争上下文切换次数、锁等待时间
CPU 低但响应慢阻塞在 I/O、死锁、线程池耗尽线程栈、等待队列长度
内存缓慢增长泄漏、缓存无上限、连接未释放堆增长曲线、对象存活数
周期性卡顿定时任务抢占、垃圾回收、批量任务卡顿时间点与任务调度是否吻合
多核利用率不均锁竞争退化成串行、调度策略各核利用率分布、锁热点
重启后状态异常残留锁、临时文件、共享资源未清启动时的资源初始化逻辑

这张表只能给方向,不能给答案。真正定位问题,还是要靠数据——监控、日志、栈、火焰图,一个都不能少。我的习惯是先看现象能否被稳定复现,再看复现时哪些指标异常,最后才去读代码猜逻辑。顺序反了,很容易在错误的假设上浪费半天。

写到这里,公司的人事和排班算是理顺了。但一家公司要真正跑起来,还有两件大事没解决:工位怎么分配(内存管理),档案怎么存怎么找(文件系统)。这两块我放到下一篇讲,那里面的坑一点不比这里少——尤其是虚拟内存那一套地址翻译机制,理解了它,你才能明白为什么有些程序的性能会莫名其妙地差一个数量级。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进