深刻浅出协程、线程和并发问题

"协程是轻量级的线程",相信你们不止一次听到这种说法。可是您真的理解其中的含义吗?恐怕答案是否认的。接下来的内容会告诉你们协程是如何在 Android 运行时中被运行的,它们和线程之间的关系是什么,以及在使用 Java 编程语言线程模型时所遇到的并发问题html

协程和线程

协程旨在简化异步执行的代码。对于 Android 运行时的协程,lambda 表达式的代码块会在专门的线程中执行。例如,示例中的 斐波那契 运算:java

// 在后台线程中运算第十级斐波那契数
someScope.launch(Dispatchers.Default) {
    val fibonacci10 = synchronousFibonacci(10)
    saveFibonacciInMemory(10, fibonacci10)
}

private fun synchronousFibonacci(n: Long): Long { /* ... */ }

上面 async 协程的代码块,会被分发到由协程库所管理的线程池中执行,实现了同步且阻塞的斐波那契数值运算,而且将结果存入内存,上例中的线程池属于 Dispatchers.Default。该代码块会在将来某些时间在线程池中的某一线程中执行,具体执行时间取决于线程池的策略。git

请注意因为上述代码中未包含挂起操做,所以它会在同一个线程中执行。而协程是有可能在不一样的线程中执行的,好比将执行部分移动到不一样的分发器,或者在使用线程池的分发器中包含带有挂起操做的代码。github

若是不使用协程的话,您还可使用线程自行实现相似的逻辑,代码以下:编程

// 建立包含 4 个线程的线程池
val executorService = Executors.newFixedThreadPool(4)
 
// 在其中的一个线程中安排并执行代码
executorService.execute {
    val fibonacci10 = synchronousFibonacci(10)
    saveFibonacciInMemory(10, fibonacci10)
}

虽然您能够自行实现线程池的管理,可是咱们仍然推荐使用协程做为 Android 开发中首选的异步实现方案,它具有内置的取消机制,能够提供更便捷的异常捕捉和结构式并发,后者能够减小相似内存泄漏问题的发生概率,而且与 Jetpack 库集成度更高。api

工做原理缓存

从您建立协程到代码被线程执行这期间发生了什么呢?当您使用标准的协程 builder 建立协程时,您能够指定该协程所运行的 CoroutineDispatcher,若是未指定,系统会默认使用 Dispatchers.Default安全

CoroutineDispatcher 会负责将协程的执行分配到具体的线程 。在底层,当 CoroutineDispatcher 被调用时,它会调用封装了 Continuation (好比这里的协程) interceptContinuation 方法来拦截协程。该流程是以 CoroutineDispatcher 实现了 CoroutineInterceptor 接口做为前提。数据结构

若是您阅读了我以前的关于 协程在底层是如何实现 的文章,您应该已经知道了编译器会建立状态机,以及关于状态机的相关信息 (好比接下来要执行的操做) 是被存储在 Continuation 对象中。

一旦 Continuation 对象须要在另外的 Dispatcher 中执行,DispatchedContinuationresumeWith 方法会负责将协程分发到合适的 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.DefaultDispatchers.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,您可使用 ConcurrentHashMapConcurrentHashMap 是线程安全的,而且优化了 map 的读取和写入操做的吞吐量。

请注意,线程安全的数据结构并不能解决调用顺序问题,它们只是确保内存数据的访问是原子操做。当逻辑不太复杂的时候,它们能够避免使用 lock。好比,它们没法用在上面的 transactionCache 示例中,由于它们之间的操做顺序和逻辑须要使用线程并进行访问保护。

并且,当已修改的对象已经存储在这些线程安全的数据结构中时,其中的数据须要保持不可变或者受保护状态来避免资源竞争问题。

自定义方案

若是您有复合的操做须要被同步,@Volatile 和线程安全的数据结构也不会有效果。有可能内置的 @Synchronized 注解的粒度也不足以达到理想效果。

在这些状况下,您可能须要使用并发工具建立您本身的同步机制,好比 latchessemaphores) 或者 barriers)。其它场景下,您可使用 lock) 和 mutex 无条件地保护多线程访问。

Kotlin 中的 Mute 包含挂起函数 lockunlock,能够手动控制保护协程的代码。而扩展函数 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 运行时的线程模型下依然须要遵循约束条件。因此,使用协程也一样会出现存在隐患的多线程代码。因此,在代码中请谨慎访问共享的可变状态。

相关文章
相关标签/搜索