这篇讲我对并发认知的三次翻车。每次翻车之后,我都以为自己懂了,然后过一年再翻一次。
阶段一:我以为并发就是加锁
我以为:多个线程访问共享变量会出错,加锁就好了。
其实:我第一个自认为「正确」的并发程序,运行了四个月后死锁了。
场景是这样:两个业务流程都需要锁住用户账户和商品库存。一个流程先锁账户再锁库存,另一个先锁库存再锁账户。平时并发低,撞不上。某天做活动,QPS 上去了,两个流程在同一微秒里各拿了一半,然后互相等,永远等下去。
线程 dump 出来的时候我看到了那两行熟悉的代码,愣了半天。我写它们的时候相隔三个月,写第二个的时候根本没想起第一个。
那次我学到的第一课是:加锁的正确性不是局部的。你看一段代码看不出死锁,死锁是两段代码之间的关系。
我后来定了个土规矩:全项目所有的锁按名字排个全序,必须按顺序获取。土,但七年没再死锁过。
阶段二:我以为无锁就是更好的锁
我以为:锁有性能开销,用原子操作和无锁结构会更快更安全。
其实:我写了一个无锁队列,跑了一周,出现了一次数据丢失,我花了两周没复现出来。
那是我职业生涯最挫败的两周。我读了自己的代码大概五十遍,逻辑上完全正确。同事也看了,说没问题。
后来是一个前辈提醒我去看内存序。我当时的反应是「什么内存序」。
问题在于我用了默认的宽松原子操作,两个写操作在我的代码里有明确顺序,但在实际执行时被重排了。消费者看到了「已发布」的标志位,但数据本身还没写进去。
这一课改变了我对程序的基本认知。我一直以为代码的执行顺序就是我写的顺序。它不是。编译器会重排,CPU 会重排,缓存会让不同核心看到不同的世界。
我后来画了一张图贴在桌上,上面写着一句话:「你写的不是执行顺序,是一份意向声明。」
另外还有一个更实际的教训:无锁代码的正确性极难验证,而它带来的性能提升通常小于你的预期。那个无锁队列我最后换成了加锁版本,吞吐下降了 8%,但我睡得着了。
阶段三:我以为并发的难点是同步
我以为:把同步搞对,并发就搞定了。
其实:搞对同步只是入场券。真正的难点是资源边界。
这个认知来自一次事故。我们有个服务用线程池处理任务,池子大小 200。某天上游一个依赖变慢,从 50ms 变成 3 秒。
没有死锁,没有数据竞争,同步完全正确。但服务挂了。
因为 200 个线程全部卡在等那个慢依赖上,新任务进不来,队列涨到几万,内存告警,然后 OOM。
我当时才真正理解,并发编程里最大的风险不是「结果算错了」,而是「资源被吃光了」。而资源包括线程、连接、内存、文件句柄,甚至只是队列长度。
从那之后我对每一个并发结构都问三个问题:
- 它的上界是多少?无界队列是最危险的东西,因为它把「立刻失败」变成了「慢慢死掉」。
- 它满了会发生什么?阻塞、丢弃、还是抛异常?必须是显式选择,不能是默认行为。
- 谁跟它共享资源?后台任务和在线请求共用一个池子,是我见过最常见的隐雷。
现在我怎么看并发
三年、三次翻车之后,我的心智模型变成了这样:
并发不是「让程序跑得更快的技术」,而是「管理不确定性的技术」。你引入并发,就是引入了大量你无法预知顺序的执行路径。你能做的不是控制它们,而是缩小它们能造成破坏的范围。
所以我现在的策略非常保守:
- 能不共享就不共享。数据复制一份的成本,通常远低于同步的成本。
- 能用消息传递就不用共享内存。让数据有唯一的所有者。
- 所有池子和队列都有明确上界,满了立即拒绝。
- 并发代码必须有压力测试,跑一万次统计不一致率,而不是跑一次看结果对不对。
- 能用成熟库就绝不自己写。我这辈子写过的每一个自制并发原语,最后都被证明有 bug。
最后
写这篇的时候我翻出了阶段二那个无锁队列的代码,还留在我的备份里,378 行。我又读了一遍,居然又发现一处之前没注意的问题——一个 ABA 场景。
七年了,我以为我早就懂了。
并发这个东西大概就是这样:它不给你「学会了」的时刻,只给你一次次「原来还有这个」的时刻。我现在对它的态度是敬畏加回避——能不用就不用,必须用就用最笨最保守的方式。
这不是谦虚,是被打怕了。