Kotlin 随记 (1)

最近在使用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核场景)

第二段代码性能骤降的根本原因:

  1. 锁竞争引发的线程池枯竭
  • 即使 doSomething() 耗时不同,只要存在 部分协程同时进入 addAll
  • 这些协程的线程被阻塞在锁等待 → 可用线程数减少
  • 剩余协程的 doSomething() 因无空闲线程而无法启动 → 并行度塌缩
  1. 协程调度器的挂起策略 Kotlin协程在遇到阻塞操作(如锁竞争)时:
  • 若使用 Dispatchers.IO:会创建新线程,可能缓解问题
  • 若使用 Dispatchers.Default:严格限制线程数,导致饥饿

🔄 最终结论

性能差距根源:第二段代码因 锁竞争范围意外扩大至计算阶段,导致协程调度器无法有效复用线程,最终引发线程池枯竭(Thread Starvation)。

动态耗时分布的影响:即使 doSomething() 耗时不同,只要存在持续的新协程加入竞争,就会导致 锁等待队列长度指数增长,最终等效于串行化。

极端硬件放大效应:128核环境线程池更大,但锁竞争导致的线程阻塞会 成比例消耗更多线程资源,恶化程度远超小核环境。

相关推荐
灿灿的金23 分钟前
pip 与当前python环境版本不匹配,python安装库成功,还是提示没有该库
开发语言·python·pip
小黄人软件37 分钟前
C++ openssl AES/CBC/PKCS7Padding 256位加密 解密示例 MD5示例
开发语言·c++
姜来可期38 分钟前
Go Test 单元测试简明教程
开发语言·后端·学习·golang·单元测试
walkskyer1 小时前
Golang `testing`包使用指南:单元测试、性能测试与并发测试
开发语言·golang·单元测试
飞升不如收破烂~1 小时前
WebSocketHandler 是 Spring Framework 中用于处理 WebSocket 通信的接口
开发语言
爱吃柠檬呀1 小时前
C语言中的内存函数使用与模拟实现
c语言·开发语言
奔跑吧邓邓子1 小时前
【Python爬虫(67)】Python爬虫实战:探秘旅游网站数据宝藏
开发语言·爬虫·python·旅游网站
土豆炒马铃薯。2 小时前
彻底卸载MySQL
java·开发语言·前端·数据库·mysql·删除·数据
卓大胖_2 小时前
SEO炼金术(4)| Next.js SEO 全攻略
开发语言·javascript·dreamweaver
数据小小爬虫2 小时前
如何使用Java爬虫按关键字搜索VIP商品实践指南
java·开发语言·爬虫