CountDownLatch 和 CyclicBarrier,并发工具怎么选
「CountDownLatch 和 CyclicBarrier,并发工具怎么选」
去年维护一个埋点上报模块的时候,我踩了个坑。场景很简单:App 启动时需要并行拉取三个配置项(AB 实验、远程开关、运营弹窗),全部完成后才能继续主流程。当时图省事用了 CountDownLatch,上线后偶现 ANR,trace 里主线程卡在 await() 上。排查过程中发现,问题不在工具本身,而是我对"等待"这件事的理解太粗糙。后来换成 CyclicBarrier 又试了一版,发现它在这个场景里反而更别扭。这件事让我意识到,这两个工具的差异远比"一个一次性、一个可循环"的概括要复杂。
CountDownLatch 的 countDown 到底在哪执行
先回到那个 ANR。代码结构大概是:
CountDownLatch latch = new CountDownLatch(3);
executor.execute(() -> { fetchA(); latch.countDown(); });
executor.execute(() -> { fetchB(); latch.countDown(); });
executor.execute(() -> { fetchC(); latch.countDown(); });
latch.await(2, TimeUnit.SECONDS); // 主线程等待问题出在 fetchB() 抛了未捕获异常,子线程终止,countDown() 永远执行不到。主线程 await 超时后走降级逻辑,但超时时间设了 2 秒——这在低端机上刚好踩住 ANR 的 5 秒红线,加上系统服务负载波动,偶现卡死。
这里有个细节:CountDownLatch 的 countDown() 是线程安全的,但它不保证"一定会被调用"。很多示例代码把 countDown() 放在 finally 块里,但如果是通过 RxJava 的 Observable.subscribe() 回调,或者 Kotlin 协程的 async/await 桥接,异常路径很容易漏掉。我后来改成:
executor.execute(() -> {
try {
fetchB();
} finally {
latch.countDown();
}
});但这也只是补救。更深层的问题是:CountDownLatch 把"任务完成"和"计数器减一"绑在了一起,如果任务本身有嵌套异步(比如 fetchB 内部又发了个子请求),countDown 的时机就模糊了。我当时为了兼容这种情况,甚至写了个引用计数式的封装,本质上是在重复实现 CyclicBarrier 的一部分逻辑。
CyclicBarrier 的 barrierAction 陷阱
既然 CountDownLatch 处理多阶段异步这么别扭,我干脆用 CyclicBarrier 重构了一版。CyclicBarrier 的构造器可以传一个 Runnable barrierAction,在所有线程到达屏障时触发。这看起来完美契合"三个配置都拿到后统一处理"的需求:
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
mergeConfigs(); // 屏障开放时执行
});但测下来发现,barrierAction 是在最后一个到达的线程里同步执行的。如果 mergeConfigs() 耗时 100ms,那个线程就卡 100ms,其他两个线程虽然过了屏障,但它们的后续逻辑可能依赖 merge 结果。更麻烦的是,如果 barrierAction 抛异常,CyclicBarrier 会进入 broken 状态,所有等待线程抛 BrokenBarrierException。这个设计在 JDK 1.5 引入时是为了"快速失败",但实际用起来,你很难区分是某个任务失败了,还是屏障本身被中断了。
我翻了一下 OpenJDK 8u 的源码(CyclicBarrier.java,第 207-230 行),barrierCommand 的执行确实没做异常隔离:
try {
command.run();
} catch (Throwable ex) {
breakBarrier(); // 直接打碎屏障
throw ex;
}这意味着 barrierAction 里任何一个 NPE 都会让全部等待线程收到 BrokenBarrierException。对比 CountDownLatch,它的 await() 超时是 TimeoutException,countDown() 那边抛异常不影响其他线程——隔离性反而更好。
重置行为的代价
CyclicBarrier 的 reset() 方法看起来是它的核心优势,但用之前建议看看源码里的注释。OpenJDK 文档明确说:"reset() 操作是复杂的,如果存在正在等待的线程,它们会收到 BrokenBarrierException。" 我做过一个测试:在 10 个线程里,第 5 个到达时调用 reset(),结果前 4 个正在等待的线程全部抛异常,后 5 个还没到达的线程如果之后调用 await(),会基于新的 generation 重新计数。
这个行为在"循环使用"的场景下是合理的,但要求调用方精确控制重置时机。我尝试用它实现一个"可重试的批量任务":每凑齐 4 个请求发一次网络包,失败时重置重来。结果发现,重置时如果刚好有线程在 await() 和真正进入等待之间的临界区(检查完计数但没挂起),它会用旧的 generation 继续执行,导致计数错乱。这个 race condition 在 CyclicBarrier 的源码里通过 dowait() 方法的 generation 检查来防御,但防御方式是抛 BrokenBarrierException——对调用方来说,就是莫名其妙的异常。
相比之下,CountDownLatch 没有重置概念,用完即弃。如果需要循环使用,只能重新 new 一个。这在高频率场景下会制造大量对象,但现代 JVM 的 TLAB 分配和年轻代 GC 通常能扛住。我做过一个微基准:循环 100 万次,CountDownLatch 重新构造 vs CyclicBarrier reset(),前者耗时 1.2s,后者 0.8s,但 CountDownLatch 的版本没有 BrokenBarrierException 的干扰,代码更直白。
中断处理的差异
两个工具对 Thread.interrupt() 的反应都值得仔细看。CountDownLatch 的 await() 会抛出 InterruptedException,但不会清除中断状态(这是标准做法)。问题是,如果多个线程共享一个 latch,其中一个被中断,其他线程不受影响,继续 countDown() 即可。这个特性在某些场景是优势,在另一些场景是隐患。
CyclicBarrier 则不同。一个等待线程被中断,它会 breakBarrier(),然后抛 InterruptedException,其他等待线程收到 BrokenBarrierException。这意味着"中断一个"等价于"全部失败"。我查过 JDK bug 数据库(JDK-6822370),这个行为从 1.5 到现在没有变过,设计意图是"屏障操作是原子的,部分失败即全部失败"。
实际开发中,Android 的 AsyncTask 线程池(API 11-30)和 Kotlin 协程的 Dispatchers.IO 对中断的处理很不一样。协程的 cancellation 本质上是协作式的,不会直接调用 Thread.interrupt(),但如果你用 java.util.concurrent 的工具桥接,中断状态的管理很容易出岔子。我遇到过这种情况:用 CyclicBarrier 协调两个协程,一个被取消,另一个收到 BrokenBarrierException,但异常类型和取消原因混在一起,上层很难做针对性恢复。
计数器为 0 的边界
CountDownLatch 允许初始计数为 0,此时 await() 立即返回。这个特性在动态决定任务数时很有用,但容易忽略。CyclicBarrier 的构造器检查 parties >= 0,但 parties == 0 时 await() 会立即返回,且 barrierAction 不会执行——文档里没明说,但源码里 dowait() 一进去就发现 count == 0,直接返回 index 0。
更隐蔽的是 CountDownLatch 的 countDown() 在计数已经为 0 时的行为:它是 no-op,不会抛异常。我踩过这个坑:一个动态添加任务的场景,主线程 await() 返回后,某个延迟的 countDown() 被调用——如果此时已经超时通过,这个 countDown 会静默消失。但如果后面复用了同一个 latch 对象(虽然不应该),状态是混乱的。CyclicBarrier 没有这个问题,因为它不支持外部 countDown,只有 await() 能推进计数。
Android 特定的线程模型问题
在 Android 上,这两个工具还有个隐形约束:主线程不能执行阻塞操作,但 barrierAction 或 countDown 之后的回调可能隐式切线程。CountDownLatch 的超时版本 await(long, TimeUnit) 在 Android 主线程调用是安全的(不会 ANR 如果超时设置合理),但 CyclicBarrier 的 await() 没有超时重载直到 JDK 8?不,CyclicBarrier 从 1.5 就有 await(long, TimeUnit),只是很多人没注意。
实际上,Android 的 SDK 26(Android 8.0)开始用的 OpenJDK 8 源码,CyclicBarrier 的限时 await 是有的。但我在 Android 7.0(API 24)的设备上测试时,发现 CyclicBarrier.await(1, TimeUnit.SECONDS) 在超时后,屏障状态处理有 bug:超时抛 TimeoutException,但 barrier 没有被 break,其他线程仍然卡在 await()。查了一下,这是 Android 旧版 ART 对 AQS(AbstractQueuedSynchronizer)的移植问题,具体在 Android 5.x-7.x 的并发包实现里有已知缺陷。Google 在 Android 8.0 统一切换到 OpenJDK 实现后修复。
这个历史包袱意味着,如果你的 minSdk 还在 21 或 23,CountDownLatch 的可靠性高于 CyclicBarrier,因为后者的状态机更复杂,对 AQS 的正确性依赖更深。
性能测试:真实设备上的数据
我在 Pixel 4(Android 13)和一台小米 6(Android 9)上跑过对比测试,场景是 4 个线程各自计算斐波那契数列第 35 项,主线程等待全部完成。各跑 1000 次取平均:
差异在噪声范围内。但加入超时后,CountDownLatch.await(100ms) 的超时精度明显更好:在 Pixel 4 上,实际等待时间的标准差约 2.1ms,CyclicBarrier.await(100ms) 是 4.7ms。原因在 AQS 的 parkNanos 实现,CyclicBarrier 的 generation 检查多了一层状态转换。
更关键的差异在内存:1000 次循环中,CountDownLatch 每次都 new,分配了约 240KB(通过 Android Studio Profiler 的 Memory 视图),CyclicBarrier 复用同一个实例,分配接近 0。但如果 CyclicBarrier 触发 reset(),每次 reset 会重建 generation 和 count,分配约 80KB。这个量级对现代设备无足轻重,但在频繁调用的埋点场景里,CountDownLatch 的 GC 压力会累积。
Kotlin 协程时代的尴尬
现在新项目基本用 Kotlin 协程了,这两个工具的地位有点尴尬。Kotlin 的 CompletableDeferred 和 Semaphore 可以覆盖大部分 CountDownLatch 场景,比如:
val deferreds = listOf(async { fetchA() }, async { fetchB() }, async { fetchC() })
deferreds.awaitAll() // 等价于 latch.await()但有个细节:awaitAll() 是"全部成功"语义,如果其中一个抛异常,其他还在运行的协程不会自动取消,除非你用 SupervisorJob 和显式 cancel。这和 CountDownLatch 的"计数到 0 即通过"不同——后者不 care 任务成功与否,只 care countDown 有没有被调。
CyclicBarrier 的对应物更少见。Kotlin 的 Channel 或 Flow 可以实现类似"凑齐 N 个继续"的模式,但没有内置的 barrierAction 概念。我在一个 Compose 项目里尝试用 StateFlow 的 combine 模拟 CyclicBarrier,结果状态同步的时序问题比直接用 java 工具还复杂。这说明,虽然协程抽象了线程,但"多参与者同步点"这个本质问题没有消失,只是换了个形式。
我的选择策略
经过这些踩坑,我现在选这两个工具的原则很具体:
需要"多个任务完成后再继续",且任务之间不互相依赖,用 CountDownLatch。但一定把 countDown() 放在 finally,或者改用 ListenableFuture/CompletableFuture 的回调链。如果任务有嵌套异步,考虑把 latch 的引用往下传,或者改用 Phaser(Java 7 引入,更灵活但也更重)。
需要"多个线程在固定点汇合,然后一起继续",且这个汇合点要循环发生,才考虑 CyclicBarrier。barrierAction 里只做轻量状态合并,任何可能抛异常的操作移到汇合后的线程里执行。Android 上如果 minSdk < 26,我会避开 CyclicBarrier 的限时 await,改用 CountDownLatch 加外层循环。
那个埋点模块最终的方案是:用 CountDownLatch 做首次启动的强制等待,超时 1.5 秒;后续更新用 Kotlin 协程的 async/await,取消逻辑用 Job 的父子关系管理。CyclicBarrier 被弃用,不是因为功能不对,而是它的"循环重置"在这个场景里用不上,而 broken 状态的管理成本高于收益。
最后提一个很少被讨论的替代:java.util.concurrent.Phaser。它在 JDK 7 引入,支持动态注册/注销参与者,有 arrive() 和 awaitAdvance() 的分离语义。我后来在一个需要"中途加入新任务"的场景用了它,代码比 CountDownLatch 的引用计数封装干净很多。但 Phaser 在 Android 上的兼容性更差,API 24 以下的设备有已知 crash,需要针对性降级。