06 - 多个线程一起改数据会出乱子
第 03 章说过,同一进程的多个线程共享内存。这带来效率,也埋下最经典的坑:多个线程一起改同一份数据,结果可能是错的。这章讲清楚坑在哪、怎么补。
一个「凭空消失的钱」的例子
假设账户余额是 100 元,两个线程同时各存 100 元。你期望最后是 300 元。但「存钱」这个动作,在底层其实是三步:
1. 读出当前余额 (读到 100)
2. 加上 100 (算出 200)
3. 把结果写回余额 (写入 200)
如果两个线程「插着队」执行,就可能变成:
线程A:读出余额 → 100
线程B:读出余额 → 100 (此时A还没写回,B读到的还是旧值)
线程A:算 100+100=200,写回 → 200
线程B:算 100+100=200,写回 → 200 (把A的结果覆盖了!)
结果余额是 200,凭空少了 100 块。这种「结果取决于线程执行的先后顺序,且可能出错」的现象,叫竞态条件(Race Condition)。
根源:那三步操作不是一气呵成的,中间可能被别的线程插进来搅局。
关键概念:临界区
像「改余额」这种一次只能允许一个线程进去操作的代码段,叫临界区(Critical Section)。
我们需要一种机制,保证临界区里的操作「要么全做完,要么没开始,中间不许别人插队」。这种「不可被打断」的特性,叫原子性(Atomicity)。
解决办法:加锁
最常见的手段是锁(Lock)。规则很简单,像上厕所:
- 进临界区前,先拿锁(把门锁上)。
- 拿到锁的线程才能操作,其他线程在门外等。
- 操作完,释放锁(开门出来),下一个等待的线程才能进。
加了锁,上面的存钱就变成:线程 A 锁上门,安安稳稳做完读-加-写三步,再开门;这期间 B 只能干等。这样就不会互相覆盖了。
锁的本质:把「多个线程能同时进」的地方,强制变成「一次只放一个进」,用牺牲一点并发,换来正确性。
新的坑:死锁
锁用不好,会引出更麻烦的问题——死锁(Deadlock):几个线程互相卡住,谁也动不了。
经典画面:一条窄路上两辆车对头开进来。
- A 车占了路的左半,等右半通行;
- B 车占了路的右半,等左半通行;
- 两边都不肯退,永远僵在那。
对应到程序:线程 A 拿着锁 1,想要锁 2;线程 B 拿着锁 2,想要锁 1。两人都攥着对方想要的东西不放,于是永久等待。
死锁发生需要同时满足几个条件(记住直觉即可):资源不能抢、拿着一个还想要另一个、循环等待……破坏其中任何一个就能避免。实践中最常用的一招是:
规定所有线程都按同一个固定顺序去拿锁(比如永远先拿锁 1 再拿锁 2),循环等待的环就断了,死锁自然不会发生。
小结
- 多线程共享数据,若操作「不是一气呵成」,就可能被插队,产生竞态条件,结果出错。
- 一次只允许一个线程进入的代码段叫临界区;我们希望它的操作具备原子性(不可被打断)。
- 锁是最常用的解法:进临界区先拿锁,别人在门外等,用完释放——牺牲一点并发换正确性。
- 锁用不好会死锁:线程互相攥着对方要的锁,僵死。常用解法是统一加锁顺序。
下一章 → 07 - 内存不够用怎么办 | 回到 README 目录