乐趣区

关于android:深入浅出协程线程和并发问题

“ 协程是轻量级的线程 ”,置信大家不止一次听到这种说法。然而您真的了解其中的含意吗?恐怕答案是否定的。接下来的内容会通知大家 协程是如何在 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 运行时的线程模型下仍然须要遵循约束条件。所以,应用协程也同样会呈现存在隐患的多线程代码。所以,在代码中请审慎访问共享的可变状态。

退出移动版