刚开始接触并发程序设计那几年我最抵触的一件事就是TDD。理由和大多数同事完全一样并发代码本身就不确定多线程一跑起来千变万化拿测试去框它测试自己先崩。做高并发IM服务那阵子我的开发流程永远是“先把功能写完、再加锁、最后补两层测试交差”。直到一次线上偶发死锁把我从睡梦中叫醒第二天我做的第一件事是把那个组件整个拆掉从头开始用TDD重建。这篇文章想聊的就是TDD模式下并发程序的设计与实现到底该怎么做以及它为什么没有传统观念里那么难。我会把我踩过的坑一并讲——写过不稳定的并发测试导致CI红灯、用线程状态断言跑出假阳性、加锁顺序不一致还浑然不觉。无论你写Java、Go还是别的语言核心方法是通用的。这篇文章适合正在被并发Bug折磨、又总觉得TDD会束缚手脚的后端开发者也适合想要在设计阶段就把并发问题想清楚的同学。1. “并发没法TDD”是最大的误解先看清劝退人的三道坎很多并发代码的Bug本质是概率事件。两条线程同时访问一个共享计数器只有在线程切换的特定间隙才会出错其余成千上万次运行都正常。我见过一个同事对并发测试的吐槽“这个测试我跑了十遍九遍通过一遍挂掉我不知道该修代码还是该修测试。”这吐槽特别真实但如果细想会发现问题不在TDD而在我们拿验证串行逻辑的方法去验证并发逻辑。1.1 结果不确定性测试过不过像抛硬币断言本身先崩了拿经典问题举例i在Java里不是原子操作两个线程同时对共享计数器执行i一百万次操作里只要有一次同时读到旧值最终结果就少了1。你写一个测试100个线程各加1万次最后断言结果等于100万。这条断言逻辑上没错但测试结果是概率性的——是否失败取决于操作系统调度本地无法稳定复现。这给TDD带来的打击是毁灭性的测试必须稳定可复现。一个测试跑三次一次失败两次通过CI马上乱掉你甚至分不清是代码问题还是测试环境抖动。所以面对并发第一件事不是放弃测试而是要把测试从“概率事件”改造成“必然事件”。后面讲的三层模型核心就是干这个。1.2 单元测试把线程关进笼子里真实交错只在生产环境现身单元测试的默认前提是相互独立、线程安全这本身没错。但并发问题恰好发生在共享边界——两个线程交叉访问同一个变量或者分别在两把锁的相反顺序上相遇。你在单测里按顺序调用一个线程的方法根本不会产生任何交错。还有一个更隐蔽的现象很多并发Bug只在生产环境的线程数、CPU核数、GC停顿模式下才会被放大。本地四核机器怎么跑都不挂上线16核就开始抽风。这不是测试没价值而是只靠小规模压力测试远远不够你还需要知道该在哪个层级布防。把测试的粒度切到“能单独验证的并发行为”上才能躲开这个坎。1.3 并发测试慢且飘CI里红灯比新功能先来说实话并发测试确实比普通单测慢。sleep几十毫秒、循环上万次、还要反复跑一套下来十几秒对开发-测试循环的节奏影响不小。但这个焦虑的根源是不分层——很多人把大而全的压力测试当成了唯一手段。我的经验是真正需要上大量级的并发测试占比很小大部分并发相关逻辑可以用毫秒级单测解决。问题要拆开看拆完再测时间成本完全可控。这也是下面三层模型要解决的问题。2. 我在并发项目里落地TDD的三层模型先纯逻辑再并发层最后点燃碰撞理解了三道坎方法就清晰了并发程序设计要被TDD驾驭不能把“并发”当一个整体硬测而要拆成三层。这三层各有各的测试策略互不干扰。2.1 第一层业务逻辑像纯函数一样设计没有共享可变量把业务计算抽出去做成不共享任何状态的纯函数。比如订单过期判定、余额校验、消息路由这些都可以写成“输入-输出”明确的函数。TDD在这一层如鱼得水红-绿-重构完全适用跑得飞快断言精准。我通常会在写第一行并发代码之前先把这些纯函数全部用测试钉死。有一个很实用的信号一旦发现业务逻辑里出现synchronized、Lock这类字眼就说明这一层没拆干净。并发控制和业务计算混在一起是绝大多数并发项目后期维护成本飙升的根源。// 反例业务计算和锁混在一起 synchronized void deductBalance(String userId, int amount) { int balance userRepo.getBalance(userId); if (balance amount) throw new IllegalArgumentException(余额不足); userRepo.setBalance(userId, balance - amount); }更好的拆法是先把“扣减后的余额是多少”“余额够不够”算清楚再让调用方决定怎么锁。2.2 第二层把锁、队列、信号量包装成“可被确定性测试”的单元锁、队列、信号量这类并发原语行为其实是可以被确定性验证的。关键是要有一个“探针”来确认“线程正在阻塞”和“线程被唤醒”。很多人一上来就写多线程并发抓Bug其实单个并发原语的阻塞/唤醒行为完全可以在受控的单测里完成。关于探针我最常用的不是检查线程状态而是配合Future设置超时。Future在指定时间内没完成说明线程确实卡在某个操作上指定时间内完成说明阻塞被及时解除。这种方式比检查Thread.getState()稳定得多——后者受操作系统调度影响偶尔会给你假阳性。2.3 第三层用固定种子和同步屏障主动制造线程碰撞靠运气等爆并发Bug效率太低。要主动让线程在同一个碰撞点相遇。常用的工具是同步屏障Java里的CyclicBarrier或CountDownLatchGo里的sync.WaitGroup配合channel。让所有线程在同一起跑线出发甚至故意让两个线程在同一把锁上撞头。如果测试里需要随机路径一定要用固定随机种子比如new Random(42)。这样一旦测试失败你重新跑一遍就能稳定复现而不是再等十万次抽奖。这就像验证一个交叉路口会不会出事故——不是等到高峰碰运气而是故意让两辆车在同一毫秒开过路口。3. 实战用TDD手写一个线程安全的有界队列从串行到并发逐步烤熟理论说完了来点能直接抄作业的。我带大家从一个线程安全的有界队列开始完整走一遍红-绿-重构过程。这个组件是并发编程里的经典模型线程池、消息系统、生产者消费者模式里到处都是它的影子。3.1 第一轮红-绿-重构不聊并发先让入队出队语义通过测试第一步先别碰并发把队列的基础行为定下来先进先出、容量限制。写下第一轮测试Test public void queueRespectsFifoOrder() throws Exception { BoundedBlockingQueueInteger q new BoundedBlockingQueue(5); q.put(1); q.put(2); q.put(3); assertEquals(Integer.valueOf(1), q.take()); assertEquals(Integer.valueOf(2), q.take()); assertEquals(Integer.valueOf(3), q.take()); assertEquals(0, q.size()); }此时BoundedBlockingQueue类还没实现编译都过不去——这就是红的阶段。接下来用最简单的方式让测试变绿先不管线程安全用一个普通ArrayList打底public class BoundedBlockingQueueT { private final ListT items new ArrayList(); private final int capacity; public BoundedBlockingQueue(int capacity) { this.capacity capacity; } public synchronized void put(T value) { if (items.size() capacity) { throw new IllegalStateException(队列已满); } items.add(value); } public synchronized T take() { return items.remove(0); } public synchronized int size() { return items.size(); } }注意这里的“容量已满时直接抛异常”只是第一轮的最小实现。真实队列在满的时候应该阻塞等待但在串行阶段不必急着实现。先把接口语义钉死后面每一轮改动都有明确的回归依据。3.2 第二轮阻塞行为怎么测——Future超时是最好的“线程状态探针”现在加入阻塞语义队列满了就等队列空了就等。这一轮测试要用到探针。我们故意让队列容量为1先放入一个元素然后启动一个子线程去put第二个元素正常情况下它会阻塞。主线程通过Future.get(timeout)验证它确实没完成然后主线程take释放空间再验证子线程立刻完成。Test public void putBlocksWhenQueueIsFull() throws Exception { BoundedBlockingQueueInteger queue new BoundedBlockingQueue(1); queue.put(1); ExecutorService executor Executors.newSingleThreadExecutor(); Future? future executor.submit(() - queue.put(2)); // 200ms内没完成说明线程被阻塞了 try { future.get(200, TimeUnit.MILLISECONDS); fail(队列满时put应该阻塞但它却直接返回了); } catch (TimeoutException expected) { // 正常put被阻塞 } // 释放一个位置阻塞中的put应该立刻完成 queue.take(); future.get(2, TimeUnit.SECONDS); executor.shutdown(); }这个测试是确定性的因为你控制了时间线。它精确验证了“阻塞”和“唤醒”两个行为。如果换成检查Thread.getState()线程可能刚好在RUNNABLE和WAITING之间切换断言就会不稳定。用Future超时就干净利落。此时需要把实现从“满了抛异常”改成“满了等待”。标准的做法是synchronizedwait/notifyAllpublic synchronized void put(T value) throws InterruptedException { while (items.size() capacity) { wait(); } items.add(value); notifyAll(); } public synchronized T take() throws InterruptedException { while (items.isEmpty()) { wait(); } T value items.remove(0); notifyAll(); return value; }这里有个经典陷阱为什么是while而不是if因为wait()可能被虚假唤醒也可能被多个线程同时唤醒。如果是if被唤醒后不会重新检查条件队列空了你还去remove就会越界。这个坑我后面压力测试部分还会回来说。3.3 第三轮4生产者4消费者用守恒定律验证原子性前置条件说完了进入最刺激的一轮真实的多生产者多消费者碰撞。核心思路是守恒——生产者总共放了N个元素消费者就应该恰好拿到N个元素一个不多一个不少最后队列为空。Test Timeout(10) public void concurrentProducersAndConsumersPreserveTotal() throws Exception { BoundedBlockingQueueInteger queue new BoundedBlockingQueue(10); int producers 4; int consumers 4; int perThread 1000; int expectedTotal producers * perThread; ExecutorService pool Executors.newFixedThreadPool(producers consumers); CountDownLatch start new CountDownLatch(1); AtomicInteger totalTaken new AtomicInteger(); AtomicBoolean failed new AtomicBoolean(false); for (int i 0; i producers; i) { pool.submit(() - { try { start.await(); for (int j 0; j perThread; j) { queue.put(1); } } catch (Exception e) { failed.set(true); } }); } for (int i 0; i consumers; i) { pool.submit(() - { try { start.await(); for (int j 0; j perThread; j) { queue.take(); totalTaken.incrementAndGet(); } } catch (Exception e) { failed.set(true); } }); } start.countDown(); pool.shutdown(); assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); assertFalse(出现异常, failed.get()); assertEquals(总数不守恒说明有丢失或重复, expectedTotal, totalTaken.get()); assertEquals(队列应该为空, 0, queue.size()); }为什么用CountDownLatch因为它能让8个线程在同一个起跑线上同时出发最大化碰撞概率。如果你不控制启动时机线程可能是一个一个串行完成的等于把串行测试拉长了几倍根本没测到并发。如果实现里用notify()而不是notifyAll()这个测试大概率会间歇性超时——因为一个生产者拿走了空出的位置其他等待的生产者没人唤醒。这种Bug在常规功能测试里根本不会出现但是在这个确定性压力测试里跑几十次就能暴露出来。3.4 测试参数照抄清单线程数、迭代数、种子、超时分别怎么定很多读者到这里会问参数到底设多少合适我给出一个可以直接照抄的参考表都是基于我在多个项目里的实测经验参数建议值目的生产者/消费者线程数CPU核数×2左右一般4~8保证足够并行又不至于过度切换拖慢测试每个线程迭代次数1000~10000次数越多碰撞概率越大但耗时越长启动屏障CountDownLatch(1)或CyclicBarrier让所有线程同一起跑制造碰撞窗口单步Future超时200ms~2s太小容易误报太大浪费CI时间全流程兜底超时10s以内防止死锁时挂死整个构建随机路径种子固定值如42失败后可稳定复现而不是碰运气这里补充一个技巧压力测试只能提高Bug出现的概率不能证明代码绝对正确。所以关键并发测试要么固定种子要么在CI里重复执行多次把概率问题转化成可复现问题。4. 真实排障复盘一次线上偶发死锁教我学会“路径测试”工具和流程再好真实世界的坑总是更狡猾。我讲一次让我印象深刻的线上事故——那次之后我才真正理解TDD该把测试打在什么位置。4.1 现象服务偶发卡顿重启后恢复日志没有异常当时维护一个IM后端某个版本上线后开始收到零星的超时报警。现象很怪进程还在CPU不高但部分请求像卡住了一样迟迟不返回几分钟后自己恢复。重启就能缓解过一阵又出现。所有人第一反应都是“是不是网络抖动”——但网络指标完全正常。4.2 排查链路jstack抓现场再叠加业务日志还原时间线凌晨事故再次出现时我连续抓了两次线程堆栈。第一次有两组线程停在BLOCKED状态第二次间隔数秒再抓同样的两组线程还停在原地。这基本排除偶然抖动是典型的死锁现场。线程堆栈显示线程A持有UserLock等待SessionLock线程B持有SessionLock等待UserLock。两者互相咬住形成循环等待。把业务日志的时间线叠上去真相浮出水面。线程A在处理“用户发送消息”流程先给User对象加锁更新最后在线时间再给Session对象加锁更新会话状态。线程B在处理“会话心跳”流程先给Session对象加锁更新心跳时间再给User对象加锁记录在线状态。两个流程的加锁顺序正好相反一旦在交叉点相遇就死锁了。这里我常用一个厨房比喻两个厨师都需要砧板和炒锅一个先拿砧板再等炒锅另一个先拿炒锅再等砧板谁也不肯先松手后厨就僵住了。4.3 根因加锁顺序不一致两笔操作互相咬死根因不是“忘了加锁”而是“锁的顺序没有全局约定”。这两个功能由不同同事开发各自按照自己业务流的自然顺序写合到一起就形成了环。死锁的四个必要条件里循环等待这一条被满足整个系统就瘫痪。修复方案说来简单全局统一锁顺序凡是同时涉及User和Session的操作一律先锁User再锁Session。改完后两个线程即使在交叉点相遇也只会排队等待而不是互咬。4.4 补上的那个测试锁定两条调用路径的交叉点复盘时最扎心的问题是为什么之前的测试没拦住因为当时对这个组件的测试全是“单独调一个方法”验证的是单条路径的正确性从来没有人测试“两条路径并发执行时互相叠加”的效果。我后来在项目里补上了一种测试姑且叫路径测试——核心思路是把两条会同时操作同一组资源的业务路径并发地跑起来断言它们能在超时时间内都正常完成。代码骨架大致是这样Test Timeout(5) public void concurrentUserAndSessionPathsDoNotDeadlock() throws Exception { ExecutorService pool Executors.newFixedThreadPool(2); Future? a pool.submit(() - userService.handleSendMessage(user1, session1)); Future? b pool.submit(() - sessionService.handleHeartbeat(session1, user1)); a.get(2, TimeUnit.SECONDS); b.get(2, TimeUnit.SECONDS); pool.shutdown(); }如果锁顺序不一致这个测试会稳定超时修正后两条路径都能在时限内完成。这件事之后我在评审并发代码时多了一个固定问题这段代码涉及的所有锁获取顺序在全局是唯一的吗如果没人能回答写出来的测试十有八九也覆盖不到这个维度。5. 要让并发测试在CI里稳定变绿这四类工具和三条策略值得投入落地到工程环境光靠单测还不够。并发测试需要一整套工具链和CI策略配合否则就算写了测试也会因为不稳定而慢慢被团队废弃。5.1 每门语言的“找茬工具”怎么选JCStress、-race、TSAN先看语言再看工具语言动态检测/正确性工具压力测试方式JavaJUnit JCStressThreadSanitizerJVM外部方案自定义多线程测试 JMH压测Gogo test -race原生支持go test -count100C/CThreadSanitizerTSANTSAN 自定义压力测试Rust所有权系统编译期防护unsafe用TSANcargo test常规重复PythonGIL降低部分风险但需测阻塞语义threading 同步屏障压力测试Rust是个异类所有权和借用检查让“数据竞争”在编译期就被拦截你能犯的错少一个大类。Go的-race是真正的神器它能在运行中检测到同一数据被多个goroutine同时读写即使结果碰巧没错也会报告。C/C的TSAN功能类似但要混入构建体系会复杂一些。做AI agent调度这类Python服务的同学也别放松GIL不是护身符跨线程共享状态、asyncio协程间共享数据照样能踩到竞态只是概率和表现形态不同。该测的阻塞语义和交互路径一个都不能少。5.2 稳定化三板斧固定随机种子、带计数重复、超时兜底很多团队写了并发测试但一个月后就注释掉了因为“太不稳定”。稳定化不是取消测试而是要做三件事第一固定随机种子。如果测试里生成了随机操作序列就把种子作为参数传进去失败后能在日志里看到种子并稳定复现。第二关键并发测试带计数重复执行。Java里用RepeatedTest(50)Go里用go test -count20把概率性Bug放大成必然事件。第三所有并发测试必须带超时兜底。我见过一次死锁把整个CI卡死半小时的惨案所以现在任何一个可能阻塞的测试我都会加Timeout(10)之类。失败时顺手把线程堆栈导出为构建产物排查时直接看现场效率高很多。5.3 功能测试与性能测试分家别在同一个测试里干两件事最后一条原则很重要正确性测试和性能测试不要混在一起。很多人写并发测试时既想断言结果正确又想“顺便”验证一下效率结果两边都不讨好。加了同步锁导致性能下降断言不会报错偶尔一次GC停顿让耗时超标正确性断言也莫名其妙失败。我的习惯是并发正确性测试严格控制在几秒内只验证行为是否符合契约性能交给独立的压测流水线比如JMH、wrk、专门的压测环境。这样每次提交的CI反馈又快又稳性能波动也不会干扰功能判断。6. 最后想对你的团队说的一段话回到标题。TDD模式下的并发程序设计并不是让你对着一个不确定的怪物写测试而是用确定化的手段一步步逼近它。我现在的习惯是任何一个并发组件先问它有没有串行契约有就走红-绿-重构涉及多线程交互就设计确定性屏障最后才交给压力测试和race detector去捡漏。下次你在代码评审里看到一堆synchronized嵌套先别急着问性能先问一句这些锁的获取顺序我们的测试覆盖到了吗这句话比任何框架都能帮你防住线上事故。