一、前言
作为 Kotlin Multiplatform 体系重要组成部分之一的 Kotlin/Native,目前还是一项处于 beta 阶段的技术。Kotlin/Native 与 Kotlin/JVM 的异步并发模型有着极大的不同,因此实践 Kotlin Multiplatform 前,对 Kotlin/Native 的异步并发模型进行探究很有必要。
Kotlin/JVM 基于 JVM 的异步并发机制,通过编译器与线程池实现的协程来完成异步并发任务。其协程既能处理异步请求,也能进行并行计算,并且由于挂起(suspend)机制,可以在协程层面解决并发竞争问题,避免使用 JVM 重量级锁,这是相对于传统 JDK 异步 API 的一个优势。
但 Kotlin/Native 作为原生二进制程序,没有现成的异步并发机制依赖,必须实现自己的模型。Kotlin 吸收了函数式编程特性,因此 Kotlin/Native 的同步方案向对象不变性靠拢,即如果对象不可变,就不存在线程安全问题。
Kotlin/Native 实现异步和并发主要有三种方案:
- 基于宿主环境(操作系统)实现,如使用 POSIX C 的 pthread_create 函数创建线程。但这种方式违反平台通用性,可移植性差,在多平台程序中基本行不通。
- Kotlin/Native 自身提供的协程,但当前版本是单线程的,只能执行不占用 CPU 资源的异步任务(如网络请求),无法利用多核进行并行计算。官方已于 2019 年 12 月发布了 Native 多线程协程的预览版本。
- 官方在 Kotlin/Native 诞生之初提供的 Worker 工具,与异步并发模型紧密相连,既能利用 CPU 多核能力,又能保障线程安全。
本文基于 Kotlin 1.3.61,将先介绍基于 Worker 与对象子图的现有异步并发模型,再讨论当前预览版本的多线程协程。注意 Kotlin/Native 作为实验性项目,版本变动可能导致 API 破坏性变更。
二、原生并发模型:Worker 与对象子图(Subgraph)
官方文档较少,本文部分结论可能与现有文档不符,期待后续更新。
Worker 与线程类似,通过打印线程 id 验证,一个 Worker 基本对应一个线程。编写程序时,如需开启线程,应创建 Worker。Kotlin/Native 对跨线程/Worker 访问对象有严格限制,对象分为 Freeze(冻结)与 Unfreeze(非冻结)两种状态。
冻结对象是编译期可证明为不可变或手动添加 @SharedImmutable 注解的对象,系统默认其不可变,可在任意线程/Worker 中访问。非冻结对象通常不能在创建它的线程/Worker 之外访问。Kotlin/Native 通过生成对象子图,在运行时遍历检测是否发生跨线程/Worker 访问。
2.1 对象冻结
对象冻结指对象创建后与当前线程/Worker 绑定,不加特殊标记时,在其他线程/Worker 访问该对象会抛出异常。但存在一类编译期可证明不可变的对象,称为冻结对象,可在任意线程内访问。目前冻结对象包括:
- 枚举类型
- 不加特殊修饰的单例对象(使用 object 关键字声明)
- 所有使用 val 修饰的原生类型变量与 String(包含 const 修饰的常量)
若要将其他类型的全局变量/成员变量声明为冻结,可使用 @SharedImmutable 注解,它允许变量多线程访问通过编译,但运行时修改变量会抛出 IncorrectDereferenceException 异常。官方未来可能增加对象动态冻结功能,但冻结对象不能被解除冻结。
2.2 Worker 的基本用法
在 Kotlin/Native 中,使用 Worker 开启子线程进行异步计算。Worker 用法接近 Java 的 Future/Promise 或 Kotlin 协程的 async/await,与传统 Java Thread 相比,对参数传入和结果获取更严格。
示例:
fun main() {
val worker = Worker.start(true, "worker1")
println("Position 1, thread id: ${pthread_self()!!.rawValue.toLong()}")
val future = worker.execute(TransferMode.SAFE, {
println("Position 2, thread id: ${pthread_self()!!.rawValue.toLong()}")
1 + 2
}) {
println("Position 3, thread id: ${pthread_self()!!.rawValue.toLong()}")
(it + 100).toString()
}
future.consume {
println("Position 4, thread id: :${pthread_self()!!.rawValue.toLong()}")
println("Result: $it")
}
}
Worker.start 创建新 Worker,execute 函数在别的线程执行任务。execute 接收三个参数:对象转移模式、生产者(producer,在外线程执行)、任务(job,在别的线程执行)。producer 返回值作为 job 参数,job 执行结果通过 Future 的 consume 获取。
打印线程 id 显示,producer 和 consume 在外线程执行,job 在后台线程执行。
注意事项:job 作为 lambda 表达式,不能随意捕捉上下文变量,参数必须从 producer 传入。例如,传递非冻结对象时,需在 producer 返回前解除引用,以避免并发访问问题。
execute 的第一个参数是对象转移校验模式 TransferMode,有 SAFE 与 UNSAFE 两个值。SAFE 模式严格限制对象传递,UNSAFE 模式不做任何线程安全校验,类似于 Java 中不加同步机制的并发访问,极其不安全,应尽量避免使用。
2.3 对象子图
Kotlin/Native 通过对象子图检测对象是否在多个线程/Worker 中可访问。Worker 将 producer 返回的对象包装生成对象子图,每次访问对象时,通过 O(N) 复杂度算法检测对象多线程可见性。对象冻结也通过对象子图实现。
对象子图在某些情况下可与对象分离,允许对象在多个线程间自由访问,虽不安全,但为使用其他同步机制(如平台相关同步或协程 Mutex)所需。
2.4 单例与全局变量
单例与全局变量在 Worker 中直接访问不可避免,Kotlin/Native 有特别规则。
@ThreadLocal 注解使全局变量在每个线程维护单独副本,线程内修改对其他线程不可见。
@SharedImmutable 注解用于手动冻结对象,并发读取无问题,但修改会抛出 InvalidMutabilityException 异常。
单例(object 声明)默认冻结,类似添加 @SharedImmutable 注解的全局变量,也可添加 @ThreadLocal 注解变为线程局部可变变量。
三、预览版的多线程协程
近期协程官方 Github 仓库发布了第一个多线程协程预览版本,展示了官方支持多线程协程的决心。但当前仅为早期预览版,后续改动可能较大。
导入依赖:
- 主分支版本:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-native:1.3.3" - 预览版:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-native:1.3.3-native-mt"
3.1 Default 与 Main 调度器的指向发生破坏性变更
主分支协程中,Dispatchers.Main 与 Dispatchers.Default 指向同一线程(主线程)。多线程版协程中,Dispatchers.Default 变更为指向后台单线程,Dispatchers.Main 仍指向主线程。
在 Darwin 平台(Apple 系统)上,Dispatchers.Main 调度器改用平台相关 RunLoop,需使用 CFRunLoopRun 开启主线程循环。若使用 runBlocking,Dispatchers.Main 在 Darwin 上可能失效。
3.2 利用 CPU 多核能力的主要方式:newSingleThreadContext() 函数
Dispatchers.Default 调度器功能有限,当前预览版没有线程池实现,需手动创建多线程上下文。newSingleThreadContext() 函数可创建新线程,是使用 CPU 多核能力的主力调度器。
每次 newSingleThreadContext() 创建新线程,应保存 CoroutineContext 重复使用,不再需要时手动 close 释放资源。通过 newSingleThreadContext() 产生的 CoroutineContext 可直接引用 worker 属性访问对应 Worker。
3.3 对象子图分离与失效的 Mutex
协程构建器参数 lambda 表达式默认捕捉冻结变量,如果协程运行在不同线程并修改变量,会抛出 InvalidMutabilityException 异常。
使用协程的 Mutex 锁保证并发安全,需先进行对象子图分离,将变量更改摆脱 Worker-对象子图机制。使用 DetachedObjectGraph 类包装对象实现分离,在协程中调用 attach 获取原对象。
示例显示,对象子图分离后并发修改可能导致数据竞争。添加 Mutex 锁理论上应保证安全,但当前预览版 Mutex 存在 bug,锁竞争会导致协程长时间挂起不恢复,功能有待修复。
除 Mutex 外,官方建议使用 actor 协程构建器与 Channel 消息机制,但当前 actor 在 Kotlin/Native 上不可用。
四、总结
本文体验了 Kotlin/Native 中两套异步与并发实现方式。Worker-对象子图模式可确保并发安全,但做法较粗暴,是目前较成熟的机制。
多线程版协程处于预览阶段,问题较多:
- Dispatchers.Default 调度器功能有限,与 JVM 版差距大,后续可能变更为多线程版本。
- Mutex 锁存在 bug,造成协程长时间挂起。
- 存在内存泄漏问题。
- Dispatchers.Default 与 Dispatchers.Main 指向变更可能导致代码迁移问题。
协程与 Worker-对象子图模型不协调,使用协程并发安全机制需进行对象子图分离,而对象子图在 JVM 上不存在,影响代码平台无关性。
长远看,协程-挂起机制是 Kotlin 核心,未来可能统一异步并发场景。Worker-对象子图模型与多线程协程如何优雅调和,有待官方完善。
Kotlin/Native 已进入相对稳定状态,Kotlin 1.4 可能让其进入正式版。经过完备测试,可在线上产品中试验。但预览版多线程协程非常早期,需等待更稳定版本。
参考文档
参考链接 1:Kotlin 编译器实现协程的 CPS 变换与状态机,官方 KEEP:https://github.com/Kotlin/KEEP/blob/master/proposals/coroutines.md
参考链接 2:Java Project Loom 添加类似协程的异步并发工具:https://wiki.openjdk.java.net/display/loom/Main
参考链接 3:Kotlin/Native 异步并发模型官方文档:https://kotlinlang.org/docs/reference/native/concurrency.html
参考链接 4:多线程版 Native 协程官方资料:https://github.com/Kotlin/kotlinx.coroutines/blob/native-mt/kotlin-native-sharing.md
参考链接 5:Native 多线程协程 issue:https://github.com/Kotlin/kotlinx.coroutines/issues/462