进程同步基础:并发为什么会出错
有一类 bug,最让工程师崩溃:代码每一行单独看都对,单线程跑一万遍都正确,可一上多线程、一上生产环境的高并发,结果就时不时算错——而且你想复现它,它又好端端的。订单被重复扣款、库存变成负数、计数器总是比预期少几个……这些"偶发""低概率"的诡异故障,背后往往是同一个根源:并发竞态。
上一组讲线程时我们埋了一句话:线程共享地址空间是它的威力,也是它一切麻烦的源头。这一篇就来正面拆解这个麻烦——为什么多个执行流共享数据时,看起来没问题的代码会算错。
很多教程上来就给你讲锁、信号量这些"解药"。但如果你没真正看清"病"长什么样,解药就是死记硬背。所以这一篇我们反过来:先把"并发为什么会出错"这件事的机制讲到你能亲手推演出错误结果,再回答为什么必须引入同步。看懂了病,药才有意义。
一、机制的起点:一行 count++ 其实不是一步
并发出错的全部根源,藏在一个被严重低估的事实里。看这行再普通不过的代码:
1 | count++; |
在你眼里它是"一步"——给 count 加一。但 CPU 不是这么干的。这行代码会被编译成大致三条机器指令:
- 读:把
count的值从内存读进 CPU 寄存器; - 改:在寄存器里把这个值加 1;
- 写:把寄存器里的新值写回
count的内存。
count++不是一个不可分割的动作,而是"读—改—写"三步。它在任意两步之间,都可能被调度器打断、切换到别的线程。这就是并发出错的物理根源。
回顾第六篇:抢占式调度靠时钟中断,可以在任意指令边界把当前线程换下。这意味着线程 A 执行完"读"、还没来得及"写",就可能被切走,让线程 B 上场。而 B 看到的,是 A 还没更新的旧值。问题就这么来了。
二、机制核心:亲手推演一次竞态
光说会被打断还不够直观。我们设定 count 初值为 5,两个线程 A、B 各执行一次 count++,正确结果显然应该是 7。但看下面这个交错执行的顺序:
跟着这个时序走一遍:
- A 读到
count=5,存进自己的寄存器,然后被切换出去(它的寄存器值 5 被保存进 PCB,回顾第五、六篇); - B 上场,也读到
count=5,加 1 得 6,写回内存。现在count=6; - A 被切换回来,恢复现场,它的寄存器里还是那个旧值 5,它在 5 的基础上加 1 得 6,写回内存。
最终 count=6,而不是正确的 7。A 和 B 各加了一次,却只生效了一次——B 的更新被 A 用旧值覆盖了,这次自增"丢失"了。
这就是竞态条件(race condition):程序的结果依赖于多个线程指令执行的先后交错顺序,而这个顺序是由调度器决定的、不可控的。同样的代码,时序不同,结果就不同。
这也解释了开头那个"偶发"之谜:大多数时候 A、B 没那么巧地交错,结果碰巧是对的;只有当切换恰好发生在"读"和"写"之间那个窗口时,才出错。概率低、依赖时序、所以难复现——但它是确定性的 bug,不是机器"随机"。
三、机制:问题的本质是"临界区"被同时进入
把上面的现象抽象一下,就能定位问题的核心。
那段"读—改—写"count 的代码,是一段访问共享资源、且不能被多个线程同时执行的代码。这种代码段有个专门的名字:临界区(critical section)。
竞态发生的精确条件是:多个线程同时进入了同一个临界区。线程 A 还在临界区里(读了值还没写回),线程 B 也进了同一个临界区,于是它们对共享数据的操作交错了,破坏了数据。
那么解决思路也就清晰了:
只要保证"同一时刻,最多只有一个线程能进入临界区",竞态就消失了。这个性质叫互斥(mutual exclusion)。同步机制的首要目标,就是实现互斥。
注意这个推导链:共享数据 → 读改写非原子 → 可被打断交错 → 临界区被同时进入 → 数据错。要打破它,最有效的一环就是在"临界区被同时进入"这里加锁——让临界区一次只放一个线程进。
四、机制:原子性——另一个看问题的角度
换个角度看同一个问题,会引出另一个核心概念。
count++ 出错,是因为它的"读—改—写"三步可以被中途打断。如果这三步能"要么全做完、要么一点不做,中间绝不被打断",竞态也就不会发生了。这种"不可分割、不被打断"的性质,叫原子性(atomicity)。
所以解决竞态有两条思路,本质相通:
- 保证互斥:用锁把临界区围起来,让它一次只进一个线程(从"不让同时进入"下手);
- 保证原子性:让那段操作整体不可分割(从"不让中途打断"下手)。
互斥和原子性是一枚硬币的两面:互斥是用锁让一段代码"看起来"不可打断,原子性是让操作"本身"不可分割。现代 CPU 提供的原子指令(如 CAS,比较并交换),就是用硬件直接实现某些操作的原子性。
后端常用的"原子整数"(如 Java 的 AtomicInteger)、无锁数据结构,底层靠的就是 CAS 这类原子指令——它们让 count++ 这种操作不用加锁也能正确,性能更好。这是从"原子性"这条路解决竞态的工程产物。
五、为什么单核也会出错——破除一个常见误解
很多人以为竞态只在多核并行时发生:多个线程真的同时跑,才会撞车。这是个危险的误解。
回看第二节的推演:A、B 的出错,根本没要求它们物理上同时运行。出错的关键是 A 在"读"和"写"之间被切换走了。而第六篇讲过,单核系统靠时间片轮换也会切换线程——A 完全可能在单核上执行到一半被换下、B 上来跑、再换回 A。
竞态的根源是"执行流的交错",不是"物理上的同时"。只要有抢占式调度,单核单 CPU 也照样会出现竞态。多核只是让交错的机会更多、问题更频繁。
这个认识极其重要:它意味着你不能因为"我的服务部署在单核容器上"就认为没有并发安全问题。只要是多线程 + 共享数据,同步就一个都不能少。
六、为什么必须由程序员负责同步
机制讲透了,回答一个设计层面的问题:既然竞态这么危险,为什么操作系统不自动帮我们防住,非要程序员手动加锁?
因为操作系统无法知道你的业务逻辑里,哪些数据需要保护、哪段代码构成一个临界区。count++ 要不要保护?取决于这个 count 会不会被多线程访问、它的一致性对你的业务重不重要——这些只有写程序的人知道。
操作系统能做的,是提供工具:它(和硬件、运行库一起)提供互斥锁、信号量、条件变量、原子指令这些原语,保证这些原语本身是正确、高效的。但"在哪里用、保护什么"的决策,只能交给程序员。
操作系统提供同步的"机制"(锁、信号量、原子指令),但"何处需要同步"的"策略"必须由程序员根据业务语义决定。这又是一次"机制与策略分离"(第一篇)的体现。
这就是为什么并发编程是程序员的核心硬功夫,也是为什么"线程安全"是你设计共享对象时必须主动思考、无法外包给系统的责任。
七、为什么同步不能滥用——代价与新问题
既然加锁能解决竞态,那把所有共享访问都锁起来不就万事大吉?不行,同步本身有代价,而且会引入新问题。
- 性能代价:锁会强制线程串行化。被锁保护的临界区一次只进一个线程,如果临界区太大或锁竞争激烈,本来能并行的多线程又退回了串行,并发优势尽失。加锁还伴随线程的阻塞与唤醒(回顾第六篇:阻塞→就绪的切换成本)。
- 死锁:多个线程互相等待对方持有的锁,谁也不让,全部卡死。这是同步引入的最经典新问题。
- 优先级反转等更隐蔽的问题。
同步是必要的,但每一把锁都是性能和复杂度的成本。好的并发设计追求的不是"锁住一切",而是"尽量减少共享、缩小临界区、降低锁竞争"。
这就引出后端并发设计的两大方向:一是老老实实用好锁(缩小临界区、避免死锁),二是从根上减少共享(无共享架构、不可变数据、每线程独立数据用 ThreadLocal、用消息传递代替共享内存——Go 的"用通信来共享内存,而不是用共享内存来通信"正是此意)。竞态的终极解法,往往是"一开始就别共享"。
八、和工程实践 / 后端开发的联系
这一篇的机制,直接对应大量真实生产事故:
- 超卖 / 余额扣成负数:经典的"读—改—写"竞态。查库存=10、判断够、扣减,两个请求交错就可能都扣成功。解法是数据库行锁、乐观锁(版本号/CAS)、或
UPDATE ... WHERE stock>0让数据库保证原子性。 - 缓存脏写 / 计数不准:多线程更新同一缓存项或计数器,丢失更新。和第二节的
count++是同一个病。 - “偶发” bug 的警觉:当线上出现"低概率、难复现、和并发量正相关"的诡异错误时,要立刻怀疑竞态,而不是当成玄学重启了事。
- 分布式系统的同型问题:多个服务实例操作共享存储,本质也是竞态,只是临界区跨了机器,解法升级为分布式锁、数据库事务、乐观并发控制——但底层逻辑和这一篇完全一致。
九、动手做一个实验:亲手制造一次竞态
理解竞态最好的方式是亲手造一个。写两个线程,各对同一个没加锁的全局计数器执行 count++ 一百万次,最后打印 count。
按理结果应该是 200 万,但你几乎每次都会得到一个小于 200 万、且每次还不一样的数。少掉的部分,就是第二节那种"被覆盖的丢失更新"累积出来的。
然后做两个对照:给 count++ 加上锁(或换成原子整数),结果稳定变回 200 万;再把循环次数调小、或在单核环境跑,观察出错概率的变化。亲眼看到那个"本该是 200 万却变成 1997653"的数字,你对竞态的理解会比背十遍定义都深刻——因为你亲手见证了"代码没错、结果却错"。
学习这一部分最容易踩的坑
1. 以为单条语句就是原子的
count++、i = i + 1 这种"一行代码"会被编译成多条指令,可以被中途打断。把高级语言的"一行"当成不可分割的"一步",是竞态认知的头号错误。
2. 以为程序偶尔跑对就是没 bug
竞态依赖时序,大多数时候碰巧不出错。“测了很多次都对"不能证明线程安全。并发正确性要靠分析临界区来保证,不能靠运行没报错来"撞大运”。
3. 以为只有多核才有竞态
单核 + 抢占式调度(时间片轮换)照样让线程交错执行、照样出竞态。别因为部署在单核就放松同步。竞态的根源是交错,不是物理同时。
4. 以为加锁越多越安全
锁会把并行退化成串行、带来阻塞唤醒成本,还可能引入死锁。同步要精准(缩小临界区、减少共享),不是"锁住一切"。滥用锁,性能和正确性会双输。
总结
并发出错并不是因为机器"随机",而是因为多个执行流对共享资源的访问顺序没有受到约束。看清这一点,后面的互斥锁、信号量、条件变量等同步机制才有真正的意义——它们都是为了给这种不可控的交错套上约束。
- 竞态的物理根源:
count++这类操作是"读—改—写"多步,可被调度在任意指令边界打断; - 竞态条件 = 结果依赖于线程交错的先后顺序,而顺序由调度器决定、不可控,所以"偶发难复现";
- 问题本质是"临界区被多个线程同时进入",解法是保证互斥(一次只进一个)或原子性(操作不可分割);
- 单核 + 抢占式调度同样会出竞态,根源是交错而非物理同时,单核部署绝不能放松同步;
- "何处需要同步"是业务策略,必须由程序员决定,操作系统只提供锁/信号量/原子指令等机制;
- 同步有代价(串行化、阻塞、死锁),好设计追求缩小临界区、减少共享,而非锁住一切。
当你能亲手推演出 count++ 在交错下为什么变成错值、并说清互斥和原子性如何分别堵住这个漏洞,你就真正理解了"并发为什么会出错"。这是后续所有同步机制(互斥锁、信号量、条件变量、死锁处理)的共同地基。
参考资源:
- 《现代操作系统》(Andrew S. Tanenbaum)进程间通信与同步章节
- 《操作系统导论》(OSTEP)并发:锁与条件变量章节
- 《深入理解计算机系统》(CSAPP)并发编程章节
- 《Java 并发编程实战》(Brian Goetz)