当前位置: 首页 > news >正文

Kotlin 随记 (1)

最近在使用Kotlin做科研项目开发,这里随手记录下开发过程中遇到的问题与积累的经验。

ConcurrentSkipListSet 大坑

ConcurrentSkipListSet是Java实现的一个线程安全的Set,说到底,它是直接服务于Java那套线程部署方式的,而在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定义如下:

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核心数)为例:

第一段代码:

  val currentResult = doSomething()  // 协程在此处可挂起,释放线程给其他协程
  matchResults.addAll(currentResult) // 锁竞争期间线程被阻塞,但此时大部分计算已完成

计算阶段协程频繁挂起/恢复 → 线程池利用率高

第二段代码:

  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核环境线程池更大,但锁竞争导致的线程阻塞会 成比例消耗更多线程资源,恶化程度远超小核环境。

相关文章:

  • RAG-202502
  • Mellanox的LAG全称是什么?网卡的创建机制如何?(Link Aggregation Group 链路聚合组)
  • WiFi IEEE 802.11协议精读:IEEE 802.11-2007,6,MAC service definition MAC服务定义
  • TinyEngine v2.2版本发布:支持页面嵌套路由,提升多层级路由管理能力开发分支调整
  • HBase常用的Filter过滤器操作
  • Spring Data JPA vs MyBatis:ORM框架如何选择?
  • 【IntelliJ IDEA】关于设置固定的文件格式(包括注释、版权信息等)的方法
  • 轨迹控制--odrive的位置控制---负载设置
  • lmstdio大模型——本地大模型python函数调用设计
  • Python中有哪些基本数据类型?
  • 软考~系统规划与管理师考试——真题篇——2021年5月——论文——纯享题目版
  • ClickHouse 的分区、分桶和分片详解
  • 计算机毕业设计SpringBoot+Vue.js学科竞赛管理系统(源码+文档+PPT+讲解)
  • qt5的中文乱码问题,QString、QStringLiteral 为 UTF-16 编码
  • 状态模式
  • RK3588部署YOLOv8(1):YOLOv8和YOLOv8-pose转ONNX及Python后处理代码实现
  • 人工智能定义
  • AI学习第五天-python的基础使用-趣味图形
  • DeepSeek:面向效率与垂直领域的下一代大语言模型技术解析
  • 2025年电气工程与智能系统国际学术会议(IC2EIS 2025)
  • 西湖大学本科招生新增三省两市,首次面向上海招生
  • 牛市早报|国家发改委:将推出做好稳就业稳经济推动高质量发展若干举措
  • 中方会否公布铁线礁的领海基线?外交部:中方执法活动旨在反制菲方侵权挑衅
  • 国家发改委答澎湃:将建立和实施育儿补贴制度,深入实施提振消费专项行动
  • 原创话剧风向标!这个展演上《大宅门》《白鹿原》先后上演
  • 印方称与巴基斯坦军队在克什米尔交火