最近在使用Kotlin做科研项目开发,这里随手记录下开发过程中遇到的问题与积累的经验。
ConcurrentSkipListSet 大坑
ConcurrentSkipListSet
是Java实现的一个线程安全的Set,说到底,它是直接服务于Java那套线程部署方式的,而在Kotlin中,我们习惯使用协程来完成高并发工作。在开发过程中,我发现Kotlin中的并发问题有很多都是因为线程与协程混用导致的。这里就是一个典型的例子。
请看下面两段代码:
kotlin
coroutineScope {
list.map { ivt -> async(dispatcher) {
val currentResult = doSomething()
results.addAll(currentResult)
} }.awaitAll()
}
coroutineScope {
list.map { ivt -> async(dispatcher) {
results.addAll(doSomething())
} }.awaitAll()
}
这里的doSomething
为某种耗时操作,dispatcher
定义如下:
kotlin
val dispatcher = ForkJoinPool(threadNumber).asCoroutineDispatcher()
乍一看,这两段代码似乎没啥区别,就是通过协程来并发处理一个列表,来加速多次耗时操作的调用。但如果将其运行起来,你会发现两者的性能截然不同。
第一段代码,只要CPU核心数量够多,threadNumber
设多少就可以用多少个核来跑doSomething
,非常符合我们对高并发的要求,而第二段代码,你会发现无论你设置多少threadNumber
,最终的CPU占用率始终高不过200%,也就是说,这里的并发变成了假的!!!
这看上去很匪夷所思,因为按照常规理解,嵌套的函数调用必然首先需要调用内层函数获取其返回值,然后才能将返回值作为参数传入外层参数,既然doSomething
先于addAll
执行,为什么addAll
的并发控制还能够影响到doSomething
?
这个问题如此反直觉当然是有原因的,以至于我问了DeepSeek一周时间,换了无数种解决方案,最终还是自己无意识的一次修改发现了问题,而当我将问题抛给DeepSeek之后,它用了好几次回答还是没有办法完全让我理解这个问题的根本成因。
下面是DeepSeek给出的回答,如果有大佬知道这个问题的根本原因是什么,还请在评论里不吝赐教:
DeepSeek 调教后的解答
核心差异:锁持有期间的协程调度行为:
代码版本 | 锁占用阶段 | 协程可调度性 |
---|---|---|
第一段代码(显式保存结果) | 仅 addAll 期间可能占用锁 | doSomething() 执行期间协程可被挂起并释放线程,其他协程能复用线程进行计算 |
第二段代码(链式调用) | 整个 doSomething() + addAll 期间隐式持锁 | doSomething() 执行期间协程无法让出线程,导致线程池资源耗尽 |
🧠 现象本质:锁的隐式持有范围被意外扩大
尽管语言规范保证 doSomething() 先于 addAll() 执行,但 ConcurrentSkipListSet.addAll 的实现会导致 计算阶段(doSomething())与写入阶段(addAll)在协程调度层面产生耦合:
1. JVM锁的粒度问题
ConcurrentSkipListSet 的线程安全是通过 细粒度锁(CAS + synchronized块) 实现的,但以下两种场景会触发全局锁:
跳跃表层级调整:当插入元素导致跳跃表层级变化时,需要短暂获取全局锁
批量写入竞争:多个线程同时调用 addAll 时,CAS操作失败率飙升 → 退化为悲观锁
2. 协程调度器的线程饥饿效应
以 Dispatchers.Default(线程数=CPU核心数)为例:
第一段代码:
kotlin
val currentResult = doSomething() // 协程在此处可挂起,释放线程给其他协程
matchResults.addAll(currentResult) // 锁竞争期间线程被阻塞,但此时大部分计算已完成
计算阶段协程频繁挂起/恢复 → 线程池利用率高
第二段代码:
kotlin
matchResults.addAll(doSomething()) // 编译器可能优化为连续操作
JIT编译器可能将 doSomething() 与 addAll 编译为连续机器码,减少挂起点
协程在计算阶段无法让出线程 → 所有线程被占用在计算或锁等待中
📊 动态竞争模型(考虑不同耗时)
假设有 N 个协程,其 doSomething() 耗时分别为 t₁, t₂,... tₙ(服从随机分布):
阶段 | 第一段代码 | 第二段代码 |
---|---|---|
计算阶段 | 协程在各自线程执行 tᵢ,可自由挂起/调度 | 协程因锁竞争提前进入等待队列,线程被阻塞无法释放 |
写入阶段 | 短耗时协程快速完成,释放线程给长耗时协程 | 所有协程的写入操作线性堆积,形成排队链 |
吞吐量瓶颈 | 受限于最长 tᵢ + 锁竞争时间 | 受限于锁处理速率(与 tᵢ 无关,趋于单线程性能) |
🔬 实验现象解释(128核场景)
第二段代码性能骤降的根本原因:
- 锁竞争引发的线程池枯竭
- 即使 doSomething() 耗时不同,只要存在 部分协程同时进入 addAll
- 这些协程的线程被阻塞在锁等待 → 可用线程数减少
- 剩余协程的 doSomething() 因无空闲线程而无法启动 → 并行度塌缩
- 协程调度器的挂起策略 Kotlin协程在遇到阻塞操作(如锁竞争)时:
- 若使用 Dispatchers.IO:会创建新线程,可能缓解问题
- 若使用 Dispatchers.Default:严格限制线程数,导致饥饿
🔄 最终结论
性能差距根源:第二段代码因 锁竞争范围意外扩大至计算阶段,导致协程调度器无法有效复用线程,最终引发线程池枯竭(Thread Starvation)。
动态耗时分布的影响:即使 doSomething() 耗时不同,只要存在持续的新协程加入竞争,就会导致 锁等待队列长度指数增长,最终等效于串行化。
极端硬件放大效应:128核环境线程池更大,但锁竞争导致的线程阻塞会 成比例消耗更多线程资源,恶化程度远超小核环境。