协程2:结构化并发

这篇文章翻译自: Coroutines on Android (part II): getting started



1. 协程跟踪

在第一篇里,我们探究了协程善于解决的问题。作为回顾,协程可以很好地解决两个常见的编程问题:

  1. 长时间运行任务:需要花费太长时间,会阻塞主线程的任务;
  2. 主线程安全:确保能在主线程中调用任何挂起函数;

为了解决这些问题,协程在常规方法的基础上添加了 挂起恢复功能。当特定线程上的所有协程都被挂起时,该线程可以做其它工作。

但是,协程本身并不能帮助你跟踪正在进行的工作。拥有大量的协程——数以百计甚至数以千计——并且同时挂起它们都是OK的。并且,虽然协程是轻量级的,它们做的工作通常都是重量级的,比如读文件和发起网络请求。

使用代码手动跟踪数以千计的协程是非常困难的。你可以尝试手动跟踪协程以确保它们执行完成或取消,但是这样的代码繁琐且容易出错。如果代码不够完美,它就会跟丢协程,我称之为 工作泄漏(work leak)。

工作泄漏就像内存泄漏,但是更加糟糕。这是协程的丢失。除了使用内存,工作泄漏还可以恢复自身并继续使用CPU,磁盘,甚至发起一个网络请求。

一个泄漏的协程能浪费内存,CPU,磁盘,甚至发起一个不需要的网络请求。

为了帮助避免协程泄漏,Kotlin引入了 结构化并发(Structured Concurrency)。结构化并发是语言特性和最佳实践的结合,当遵循这些最佳实践时,可以帮助你跟踪协程中运行的所有工作。

在Android中,我们可以使用结构化并发来做三件事:

  1. 取消任务,当任务不再被需要的时候。
  2. 跟踪任务,当任务正在运行的时候。
  3. 报错,当协程失败的时候。

让我们深入探究其中的每一项,看看结构化并发如何帮助我们确保永远不会跟丢协程和泄漏任务。


2. 使用 scope 取消任务

在Kotlin中,协程必须在 CoroutineScope中运行。CoroutineScope可以跟踪协程,即使是已经被挂起的协程。和第一篇里讲到的 Dispatchers不同,CoroutineScope并不真正执行协程,它只用来确保不会跟丢它们。

为了确保跟踪所有的协程,Kotlin不允许在没有 CoroutineScope的情况下开启一个新的协程。你可以把CoroutineScope想象成一种具有超能力的轻量级版本 ExecutorService。它使你能启动新的协程,这些协程拥有我们在第一篇中探讨过的所有挂起和恢复的优势。

CoroutineScope会跟踪所有的协程,并且可以取消在它里面启动的所有协程。这一特性非常适合Android开发,使你可以确保在用户离开时清理由屏幕启动的所有内容。

CoroutineScope会跟踪所有的协程,并且可以取消(cancel)在它里面启动的所有协程

2.1 启动新的协程

请务必注意,你不能在任意地方调用挂起方法。挂起和恢复的机制要求你从常规方法切换到协程。

有两种方式可以开启协程,它们有不同的用处:

  1. launch 构造器会启动一个没有返回结果的新协程。
  2. aysnc 构造器会启动一个新的协程,它允许挂起方法返回一个可以通过调用 await方法获取的结果。

大多数情况下,在常规方法中开启一个协程的正确方式是使用 launch。由于常规方法没办法调用 await(常规方法无法直接调用挂起方法),使用 async作为协程的主入口是没有太大意义的。我们会在后面讨论什么时候适合使用 async

你应该使用协程 scope调用 launch来启动一个协程。

scope.launch {
    //This block starts a new coroutine "in" the scope.
    
    //It can call suspend functions
    fetchDocs()
}

你可以把 launch想作把代码从常规方法带入协程世界的一个桥梁。在 launch体里面,你可以调用挂起方法并创建主线程安全性,像我们在上一篇中讨论过的那样。

launch是常规方法到协程的桥梁。

提醒:launchasync之间有一个大的不同之处在于他们如何处理异常。async默认你最终会调用 await来获取结果(或者异常),因此它默认不会抛异常。那意味着如果你使用 async来开启新协程,它将默默地丢弃异常。

由于launchasync只在CoroutineScope中可用,可知你创建的任何协程都会被 scope跟踪。Kotlin不允许创建不可跟踪的协程,这样可以避免工作泄漏。

2.2 在 ViewModel 中开启

因此,如果一个 CoroutineScope跟踪它里面启动的所有协程,并且 launch能创建新协程,那么你应该在哪里调用 launch并放置 scope呢?并且,什么时候取消 scope里面启动的所有协程合适呢?

在Android里,把 CoroutineScope与用户屏幕关联起来通常是合理的。这能让你避免协程泄漏或者给与用户不再关联的 ActivityFragment做多余的工作。当用户离开屏幕的时候,与屏幕关联的 CoroutineScope能取消所有的工作。

结构并发保证当 scope取消时,其所有协程都会取消。

当把协程和Android结构组件整合使用,你通常想在 ViewModel中启动协程。这是大多数工作开始的地方,并且你不必担心旋转(手机横竖排切换)会杀掉你所有的协程。

要在 ViewModel中使用协程,你可以使用它的扩展属性 viewModelScope。让我们来看一个例子:

class MyViewModel() : ViewModel() {
    fun userNeedsDocs() {
        //start a new coroutine in a ViewModel
        viewModelScope.launch {
            fetchDocs()
        }
    }
}

ViewModel被清除的时候(ViewModelonCleared()回调方法被调用的时候), viewModelScope会自动取消由 ViewModel启动的所有协程。这通常是正确的行为——如果我们还没有获取到文档,而用户已经关掉app,继续完成请求可能只是在浪费电量。

更加安全的是,viewModelScope可以自行传递。因此,如果在一个协程里启动了另一个协程,它们都会在同一个 scope里。这意味着即使你依赖的库从viewModelScope里启动了一个协程,你也可以取消它们!

注意:当协程挂起的时候,它是通过抛出 CancellationException异常来协同取消的。捕获顶级异常(比如Throwable)的异常处理器将会捕获该异常。如果你在异常处理器中消费该异常,或者从不挂起,协程将徘徊在一种半取消状态。

因此,当你需要一个和 ViewModel运行周期一样长的协程,可以使用 viewModelScope来从常规方法切换到协程。然后,由于 viewModelScope会为你自动取消协程,即使在里面写一个无限循环也不会造成泄漏。

fun runForever() {
    //start a new coroutine in the ViewModel
    viewModelScope.launch {
        //cancelled when the ViewModel is cleared
        while(true){
            delay(1000)
            //do something every second
        }
    }
}

通过使用 viewModelScope,你能确保所有的工作,即使是无限循环,都能在不需要的时候被取消。


3. 跟踪任务

启动一个协程就很好——而且对于很多代码来说,这就是你真正需要做的。 启动协程,发出网络请求,并将结果写入数据库。

但有时候,你会遇到更复杂的场景。比如说你想在一个协程里同时发起两个网络请求——要实现这一功能你需要启动更多的协程!

要创建更多的协程,任意挂起函数都可以通过 coroutineScope构建器或与其相似的 supervisorScope构建器来开启更多协程。老实说,这个API有点令人困惑,coroutineScope构建器和 CoroutineScope是不同的东西,尽管它们的名字只有一个字符的区别。

随意地创建协程会导致潜在的工作泄漏。调用者也许无法知道新建的协程,那么它如何跟踪该任务呢?

结构化并发(structured concurrency)能帮助我们解决这一问题。也就是说,它提供了一个保证,当 suspend 方法 return 时,表示它所有的任务都已经执行完成。

结构化并发保证,当一个 suspend方法 return时,它里面的所有任务都已执行完成。

下面有一个使用 coroutineScope获取两个文档的例子:

suspend fun fetchTwoDocs() {
    coroutineScope {
        launch{ fetchDoc(1) }
        async{ fetchDoc(2) }
    }
}

在这个例子中,同时通过网络获取两个文档。第一个在由 launch发起的协程中获取,launch发起的协程不会返回结果给调用者;第二个文档在由 async发起的协程中获取,而 async会返回结果给调用者。

这个例子有些奇怪,通常你会都使用 async来获取这两个文档,但我想展示一下,你可以根据自己的需求,混合使用 launchasync

coroutineScopesupervisorScope让你可以安全地在 suspend方法中发起协程。

但是请注意,这段代码没有明确地等待两个新的协程的执行。看起来 fetchTwoDocs方法似乎会在协程仍在运行的时候返回。

使用 coroutineScope构建器可以确保 fetchTwoDocs方法中的任务不会泄露。coroutineScope构建器会挂起自身,直到它里面启动的所有协程都执行结束。因此,fetchTwoDocs方法不可能会 return,除非它里面启动的所有协程都已执行完毕。

3.1 跟踪海量任务

目前我们已经探索过跟踪一两个协程,是时候全力以赴来尝试跟踪1000个协程了!

请看下面的动画:

在这里插入图片描述

这个例子展示了同时发起1000个网络请求。但实际情况下不推荐在Android中采取这种做法,这将占用大量的资源。

在上面的代码里,我们在 coroutineScope构建器中使用 launch开启了1000个协程。你可以看见事情变得愈加诡异。某个地方的代码一定使用了 CoroutineScope来创建一个协程,我们不知道关于这个 CoroutineScope的任何情况,它可能是一个 viewModelScope,也可能是在其它地方定义的 CoroutineScope。但不管这个 scope是什么,coroutineScope构建器都将使用它作为 parent 来构建新的 scope

然后,在 coroutineScope代码块中,launch会在新的 scope中启动协程。由 launch发起的协程启动之后,新的 scope就会跟踪它们。最终,一旦在这个 coroutineScope里面启动的协程全部执行完毕,loadLots方法就可以 return了。

注意scope和协程之间的亲子(parent-child)关系由 Job对象创建。但通常你不必深究其细节。

coroutineScopesupervisorScope将等待其子协程执行完成。

协程在底层进行着大量的工作,但重要的是,使用 coroutineScopesupervisorScope,你可以在任意 suspend方法中安全地启动协程。虽然它们会开启一个新的协程,但可以确保不会意外地泄漏工作,因为它们总是会挂起调用者,直到新协程执行完成。

更酷的是 coroutineScope会创建一个子 scope。如果上一级 scope被取消了,它将会把取消操作向下传递到新的协程。如果前面代码的调用者是 viewModelScope,当用户退出界面之后,所有的1000个协程都会被自动取消掉。

在我们讨论异常之前,值得花一点时间讨论下 supervisorScopecoroutineScope的区别。它们最主要的区别在于:当 coroutineScope中任意一个子协程执行失败,整个 coroutineScope都会被取消。 因此,如果其中某个网络请求失败,其它所有的请求都会立马被取消。如果你希望其它的请求仍然继续执行,那么就可以使用 supervisorScope。在 supervisorScope中,一个子协程执行失败不会导致其它子协程被取消。

补充:想进一步了解 coroutineScopesupervisorScope,可以查看这篇文章:coroutineScope和supervisorScope的区别


4. 协程失败时的报错

协程就和常规方法一样,通过抛异常的方式来报错。suspend方法中的异常在 resume时会抛给调用者。和常规方法一样,你不仅限于使用 try/catch来处理错误,也可以构建抽象以使用其它方式来处理错误。

然而,在某些场景中,协程会丢失错误。

val unrelatedScope = MainScope()
//一个遗失错误的例子
suspend fun lostError() {
    //不使用结构化并发的async
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}

注意这段代码,声明了一个不相关的 scope,在不使用结构化并发的情况下发起了一个新的协程。我在这篇文章开始时说过,结构化并发是语言特性和编程实践的结合,在 suspend方法中引用不相干的 scope没有遵循这一编程实践。

这段代码中的错误被丢失是因为 async默认你最终会调用 await方法,然后它才会抛异常。然而,如果你不调用 await方法,异常就会被存起来一直等到该方法被调用。

结构化并发可以保证,当一个协程出错时,它的调用者或 scope会收到通知。

如果你在上面的代码中使用结构化并发,错误将被正确地抛给调用者。

suspend fun foundError() {
    croutineScope {
        async {
            throw StructuredConcurrencyWill("throw")
        }
    }
}

coroutineScope会等待所有子协程完成,当子协程出错时,他也会收到通知。如果一个由 coroutineScope启动的协程抛了异常,coroutineScope会把异常抛给它的调用者。由于我们用的是 coroutineScope而不是 supervisorScope,当异常出现时,它也会立即取消所有的子协程。


5. 使用结构化并发

在这篇文章中,我介绍了结构化并发,并且展示了它如何使我们的代码与Android的 ViewModel很好地搭配以避免工作泄漏。

我也讨论了它能保证在任务执行完成后才返回,并且遇到异常时能及时返回错误信息,使得 suspend方法更易于理解和使用。

假如我们不使用结构化并发,协程很容易意外泄漏工作,而调用者无从知晓。并且任务无法被取消,也无法保证异常会被抛出。这会让我们的代码充满意外,而且可能造成令人费解的bug。

你可以通过不相关的 CoroutineScope,或者是 GlobalScope来创建一个非结构化并发,但是你只应该在极少数情况下使用它,比如你需要启动一个比调用者的 scope存活更久的协程。在非结构化的协程中,自己添加结构以实现任务追踪错误处理任务取消是一个很好的主意。

如果你有非结构化并发的经验,结构化并发确实需要一些时间去适应。结构和保证确实使得 suspend方法更加安全,更易于交互。尽量使用结构化并发是很好的主意,因为它使得代码更易于阅读并且能减少意外。

在文章的开始我列举了结构化并发能为我们解决的三件事情:

  1. 取消任务,当任务不再被需要的时候;
  2. 跟踪任务,当任务正在运行的时候;
  3. 报错,当协程失败的时候;

为了能达到这一目的结构化并发给了我们这些保证:

  1. 当一个 scope取消的时候,它里面所有的协程都会被取消;
  2. 当一个 suspend方法 return的时候,它所有的工作都已完成;
  3. 当一个协程出错的时候,他的调用者或 scope会收到通知;

总之,结构化并发的保证使我们的代码更安全,更易于理解,并且能帮助我们避免泄漏工作!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值