“ 协程是轻量级的线程 ”,置信大家不止一次听到这种说法。然而您真的了解其中的含意吗?恐怕答案是否定的。接下来的内容会通知大家 协程是如何在 Android 运行时中被运行的 ,它们和线程之间的关系是什么,以及在应用 Java 编程语言线程模型时所遇到的 并发问题。
协程和线程
协程旨在简化异步执行的代码。对于 Android 运行时的协程,lambda 表达式的代码块会在专门的线程中执行。例如,示例中的 斐波那契 运算:
// 在后盾线程中运算第十级斐波那契数
someScope.launch(Dispatchers.Default) {val fibonacci10 = synchronousFibonacci(10)
saveFibonacciInMemory(10, fibonacci10)
}
private fun synchronousFibonacci(n: Long): Long {/* ... */}
下面 async 协程的代码块,会被散发到由协程库所治理的线程池中执行,实现了同步且阻塞的斐波那契数值运算,并且将后果存入内存,上例中的线程池属于 Dispatchers.Default。该代码块会在将来某些工夫在线程池中的某一线程中执行,具体执行工夫取决于线程池的策略。
请留神因为上述代码中未蕴含挂起操作,因而它会在同一个线程中执行。而协程是有可能在不同的线程中执行的,比方将执行局部挪动到不同的散发器,或者在应用线程池的散发器中蕴含带有挂起操作的代码。
如果不应用协程的话,您还能够应用线程自行实现相似的逻辑,代码如下:
// 创立蕴含 4 个线程的线程池
val executorService = Executors.newFixedThreadPool(4)
// 在其中的一个线程中安顿并执行代码
executorService.execute {val fibonacci10 = synchronousFibonacci(10)
saveFibonacciInMemory(10, fibonacci10)
}
尽管您能够自行实现线程池的治理,然而咱们依然举荐应用协程作为 Android 开发中首选的异步实现计划,它具备内置的勾销机制,能够提供更便捷的异样捕获和结构式并发,后者能够缩小相似内存透露问题的产生几率,并且与 Jetpack 库集成度更高。
工作原理
从您创立协程到代码被线程执行这期间产生了什么呢?当您应用规范的协程 builder 创立协程时,您能够指定该协程所运行的 CoroutineDispatcher,如果未指定,零碎会默认应用 Dispatchers.Default
。
CoroutineDispatcher 会负责将协程的执行调配到具体的线程 。在底层,当 CoroutineDispatcher
被调用时,它会调用 封装了 Continuation (比方这里的协程) interceptContinuation 办法来拦挡协程。该流程是以 CoroutineDispatcher 实现了 CoroutineInterceptor 接口作为前提。
如果您浏览了我之前的对于 协程在底层是如何实现 的文章,您应该曾经晓得了编译器会创立状态机,以及对于状态机的相干信息 (比方接下来要执行的操作) 是被存储在 Continuation 对象中。
一旦 Continuation 对象须要在另外的 Dispatcher 中执行,DispatchedContinuation
的 resumeWith 办法会负责将协程散发到适合的 Dispatcher。
此外,在 Java 编程语言的实现中,继承自 DispatchedTask 抽象类的 DispatchedContinuation 也属于 Runnable
接口的一种实现类型。因而,DispatchedContinuation
对象也能够在线程中执行。其中的益处是当指定了 CoroutineDispatcher
时,协程就会转换为 DispatchedTask
,并且作为 Runnable
在线程中执行。
那么当您创立协程后,dispatch
办法如何被调用呢?当您应用规范的协程 builder 创立协程时,您能够指定启动参数,它的类型是 CoroutineStart。例如,您能够设置协程在须要的时候才启动,这时能够将参数设置为 CoroutineStart.LAZY
。默认状况下,零碎会应用 CoroutineStart.DEFAULT
依据 CoroutineDispatcher
来安顿执行机会。
△ 协程的代码块如何在线程中执行的示意图
散发器和线程池
您能够应用 Executor.asCoroutineDispatcher() 扩大函数将协程转换为 CoroutineDispatcher
后,即可在利用中的任何线程池中执行该协程。此外,您还能够应用协程库默认的 Dispatchers。
您能够看到 createDefaultDispatcher
办法中是如何初始化 Dispatchers.Default
的。默认状况下,零碎会应用 DefaultScheduler。如果您看一下 Dispatcher.IO 的实现代码,它也应用了 DefaultScheduler
,反对按需创立至多 64 个线程。Dispatchers.Default
和 Dispatchers.IO 是隐式关联的,因为它们应用了同一个线程池,这就引出了咱们下一个话题,应用不同的散发器调用 withContext 会带来哪些运行时的开销呢?
线程和 withContext 的性能体现
在 Android 运行时中,如果运行的线程比 CPU 的可用内核数多,那么切换线程会带来肯定的运行时开销。上下文切换 并不轻松!操作系统须要保留和复原执行的上下文,而且 CPU 除了执行理论的利用性能之外,还须要花工夫布局线程。除此之外,当线程中所运行代码阻塞的时候也会造成上下文切换。如果上述的问题是针对线程的,那么在不同的 Dispatchers 中应用 withContext 会带来哪些性能上的损失呢?
还好线程池会帮咱们解决这些简单的操作,它会尝试尽量多地执行工作 (这也是为什么在线程池中执行操作要优于手动创立线程)。协程因为被安顿在线程池中执行,所以也会从中受害。基于此,协程不会阻塞线程,它们反而会挂起本人的工作,因此更加无效。
Java 编程语言中默认应用的线程池是 CoroutineScheduler。它以最高效的形式将协程散发到工作线程 。因为 Dispatchers.Default
和 Dispatchers.IO 应用雷同的线程池,在它们之间切换会尽量避免线程切换。协程库会优化这些切换调用,放弃在同一个散发器和线程上,并且尽量走捷径。
因为 Dispatchers.Main 在带有 UI 的利用中通常属于不同的线程,所以协程中 Dispatchers.Default 和 Dispatchers.Main 之间的切换并不会带来太大的性能损失,因为协程会挂起 (比方在某个线程中进行执行),而后会被安顿在另外的线程中继续执行。
协程中的并发问题
协程因为其可能简略地在不同线程上布局操作,确实使得异步编程更加轻松。然而另一方面,便捷是一把双刃剑: 因为协程是运行在 Java 编程语言的线程模型之上,它们难以逃脱线程模型所带来的并发问题。因而,您须要留神并且尽量避免该问题。
近年来,像不可变性这样的策略绝对加重了由线程所引发的问题。然而,有些场景下,不可变性策略也无奈完全避免问题的呈现。所有并发问题的源头都是状态治理!尤其是在一个多线程环境下拜访 可变的状态。
在多线程利用中,操作的执行程序是不可预测的。与编译器优化操作执行程序不同,线程无奈保障以特定的程序执行,而上下文切换会随时产生。如果在拜访可变状态时没有采取必要的防范措施,线程就会拜访到过期的数据,失落更新,或者遇到 资源竞争 问题等等。
请留神这里所探讨的可变状态和拜访程序并不仅限于 Java 编程语言。它们在其它平台上同样会影响协程执行。
应用了协程的利用实质上就是多线程利用。应用了协程并且波及可变状态的类必须采取措施使其可控,比方保障协程中的代码所拜访的数据是最新的。这样一来,不同的线程之间就不会相互烦扰。并发问题会引起潜在的 bug,使您很难在利用中调试和定位问题,甚至呈现 海森堡 bug。
这一类型的类十分常见。比方该类须要将用户的登录信息缓存在内存中,或者当利用在沉闷状态时缓存一些值。如果您稍有粗心,那么并发问题就会乘虚而入!应用 withContext(defaultDispatcher) 的挂起函数无奈保障会在同一个线程中执行。
比方咱们有一个类须要缓存用户所做的交易。如果缓存没有被正确拜访,比方上面代码所示,就会呈现并发问题:
class TransactionsRepository(private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default) {private val transactionsCache = mutableMapOf<User, List<Transaction>()
private suspend fun addTransaction(user: User, transaction: Transaction) =
// 留神!拜访缓存的操作未被爱护!// 会呈现并发问题:线程会拜访到过期数据
// 并且呈现资源竞争问题
withContext(defaultDispatcher) {if (transactionsCache.contains(user)) {val oldList = transactionsCache[user]
val newList = oldList!!.toMutableList()
newList.add(transaction)
transactionsCache.put(user, newList)
} else {transactionsCache.put(user, listOf(transaction))
}
}
}
即便咱们这里所探讨的是 Kotlin,由 Brian Goetz 所编撰的《Java 并发编程实际》对于理解本文主题和 Java 编程语言零碎是十分好的参考资料。此外,Jetbrains 针对 共享可变的状态和并发 的主题也提供了相干的文档。
爱护可变状态
对于如何爱护可变状态,或者找到适合的 同步) 策略,取决于数据自身和相干的操作。本节内容启发大家留神可能会遇到的并发问题,而不是简略列举爱护可变状态的办法和 API。总而言之,这里为大家筹备了一些提醒和 API 能够帮忙大家针对可变变量实现线程平安。
封装
可变状态应该属于并被封装在类里。该类应该将状态的拜访操作集中起来,依据利用场景应用同步策略爱护变量的拜访和批改操作。
线程限度
一种计划是将读取和写入操作限度在一个线程里。能够应用队列基于生产者 - 消费者模式实现对可变状态的拜访。Jetbrains 对此提供了很棒的 文档。
防止反复工作
在 Android 运行时中,蕴含线程平安的数据结构可供您爱护可变变量。比方,在计数器示例中,您能够应用 AtomicInteger。又比方,要爱护上述代码中的 Map,您能够应用 ConcurrentHashMap。ConcurrentHashMap
是线程平安的,并且优化了 map 的读取和写入操作的吞吐量。
请留神,线程平安的数据结构并不能解决调用程序问题,它们只是确保内存数据的拜访是原子操作。当逻辑不太简单的时候,它们能够防止应用 lock。比方,它们无奈用在下面的 transactionCache 示例中,因为它们之间的操作程序和逻辑须要应用线程并进行拜访爱护。
而且,当已批改的对象曾经存储在这些线程平安的数据结构中时,其中的数据须要放弃不可变或者受爱护状态来防止资源竞争问题。
自定义计划
如果您有复合的操作须要被同步,@Volatile 和线程平安的数据结构也不会有成果。有可能内置的 @Synchronized 注解的粒度也不足以达到现实成果。
在这些状况下,您可能须要应用并发工具创立您本人的同步机制,比方 latches、semaphores) 或者 barriers)。其它场景下,您能够应用 lock) 和 mutex 无条件地爱护多线程拜访。
Kotlin 中的 Mute 蕴含挂起函数 lock 和 unlock,能够手动管制爱护协程的代码。而扩大函数 Mutex.withLock 使其更加易用:
class TransactionsRepository(private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default) {
// Mutex 爱护可变状态的缓存
private val cacheMutex = Mutex()
private val transactionsCache = mutableMapOf<User, List<Transaction>()
private suspend fun addTransaction(user: User, transaction: Transaction) =
withContext(defaultDispatcher) {
// Mutex 保障了读写缓存的线程平安
cacheMutex.withLock {if (transactionsCache.contains(user)) {val oldList = transactionsCache[user]
val newList = oldList!!.toMutableList()
newList.add(transaction)
transactionsCache.put(user, newList)
} else {transactionsCache.put(user, listOf(transaction))
}
}
}
}
因为应用 Mutex 的协程在能够继续执行之前会挂起操作,因而要比 Java 编程语言中的 lock 高效很多,因为后者会阻塞整个线程。在协程中请审慎应用 Java 语言中的同步类,因为它们会阻塞整个协程所处的线程,并且引发 活跃度 问题。
传入协程中的代码最终会在一个或者多个线程中执行。同样的,协程在 Android 运行时的线程模型下仍然须要遵循约束条件。所以,应用协程也同样会呈现存在隐患的多线程代码。所以,在代码中请审慎访问共享的可变状态。