协程1:背景知识

这篇文章翻译自 Coroutines on Android (part I): Getting the background



1. 协程能解决什么问题?

Kotlin协程引入了一种新的并发样式,可以用在Android上以简化异步代码。虽然在 Kotlin1.3版本才被引入,但是协程的概念从编程语言起始之初就已经出现了。探索使用协程的第一种语言是1967年的 Simula

在过去几年里,协程变得越来越流行并且已经被用在多种流行的语言当中,比如 JavascriptC#PythonRubyGo语言。Kotlin协程正是基于已经用于构建大型应用程序的既定概念。

在Android中,协程可以很好地解决两个问题:

  1. 长时间运行任务(Long running task): 会长时间阻塞主线程的任务
  2. 主线程安全(Main-safety):可以在主线程中调用任意挂起函数(suspend function)

让我们深入每一个问题,来看看协程如何帮助我们构建更简洁的代码!


2. 长时间运行任务

获取一个网页或者与API交互都涉及到发起网络请求。同样,读取数据库或者从磁盘加载一张图片都涉及到文件读取。这些任务就是我所说的长时间运行任务——需要花费太长时间,使得App停止并等待它们的任务。

与网络请求相比,很难理解现代手机的代码执行速度有多快。在Pixel 2手机上,单个指令周期(CPU cycle)只需不到0.0000000004秒,从人类的角度很难理解的时长。然而,如果你把一个网络请求视作一次眨眼,大约0.4秒,就很容易理解CPU的运行速度有多快。在一眨眼间,或者一次稍慢的网络请求间,CPU可以执行超过100万个指令。

在Android中,所有的App都有一个主线程负责处理UI,像绘制视图或响应用户交互。如果主线程里有太多工作,App就会显得卡顿或者变慢,导致糟糕的用户体验。任何长时间运行的任务都不应该在阻塞主线程的情况下完成,以使得您的App不会出现所谓的 jank现象,比如动画卡顿或触摸响应缓慢。

为了不在主线程中执行网络请求,常用做法是使用回调模式(Callback)。回调提供了库的句柄,能在将来某个时间点回调代码。使用回调来获取网页数据的代码大致如下:

class MyViewModel: ViewModel() {
    fun fetchDocs() {
        get("developer.android.com") { result ->
            show(result)
        }
    }
}

虽然 get方法是在主线程调用的,它会使用另一个线程来执行网络请求。然后,一旦获取到结果,回调就会在主线程中被调用。这是处理长时间运行任务的好方法,像 Retrofit这样的库就可以帮助你在不阻塞主线程的情况下发起网络请求。


3. 使用协程执行长时间运行任务

协程提供一种简化长时间运行任务代码(比如上面的 fetchDocs)的方式。为了探究协程如何简化代码,我们用协程来重写上面的回调代码。

// Dispatchers.Main
suspend fun fetchDocs() {    
    // Dispatchers.Main    
    val result = get("developer.android.com")    
    // Dispatchers.Main    
    show(result)
}
    
// look at this in the next sectionsuspend 
fun get(url: String) = withContext(Dispatchers.IO){/*...*/}

这段代码不会阻塞主线程吗?它如何在不等待网络请求和阻塞的情况下从get方法中返回结果?事实证明,协程为Kotlin提供了一种执行这种代码并且永不阻塞主线程的方法。

协程在常规方法的基础上新增了两个操作。除了调用(invoke)和返回(return)之外,协程还增加了挂起(suspend)和恢复(resume)。

  • 挂起(suspend)—— 暂停当前协程的执行,保存所有的局部变量
  • 恢复(resume)—— 从暂停的地方继续执行被挂起的协程

Kotlin通过在方法上添加 suspend关键字来实现这一功能。挂起函数(或者挂起方法)只能在其它挂起函数中被调用,或者使用协程构建器,比如 launch开始一个新的协程。

挂起(suspend)和恢复(resume)一起使用就可以替代回调(callback)

在上面的例子中,get方法将在网络请求开始之前挂起协程,然后负责在主线程之外运行网络请求。当网络请求完成之后,它无需通过回调通知主线程,而是简单地恢复它所暂停的协程。如下图所示:
在这里插入图片描述

查看 fetchDocs的执行方式,你能看到挂起如何工作。当一个协程被挂起,当前的栈帧(Kotlin用来跟踪哪个函数正在运行及其变量的地方)会被复制和保存起来留待后用。当协程恢复时,栈帧从它被保存的地方复制回来再次运行。在动画的中间部分,当所有协程都在主线程中被挂起的时候,主线程可以继续更新屏幕和处理用户事件。挂起和恢复一起替代了回调,非常酷!

当主线程中的所有协程都被挂起的时候,主线程可以继续处理其它工作

即使我们写了直接看起来像阻塞的简单顺序的网络请求代码,协程也会按照我们所期望的那样运行,并且避免阻塞主线程!

接下来,我们将研究如何使用协程保证主线程安全,并探究调度器(Dispatchers)


4. 协程的主线程安全

在Kotlin协程中,在主线程调用书写规范的挂起函数总是安全的。不管挂起函数是做什么的,它们应该始终允许任何线程调用它们。

但是,在Android应用中有许多事太慢了,不能发生在主线程上。网络请求、解析JSON、读写数据库,甚至是遍历大的列表,这些事情都有可能造成用户可见的卡顿而不应该在主线程上运行。

使用 suspend关键字并不是告诉Kotlin在后台线程中运行函数。值得一提的是,协程通常都在主线程中运行。事实上,当创建协程以处理UI事件的时候,使用 Disptachers.Main.immediate是非常好的主意,这样,如果你无需执行主线程安全的长时间任务,那么结果会直接提供给用户。

解释一下调度器 Disptachers.Main.immediate的作用:当一个挂起方法可能被主线程调用,也可能被其它线程调用时,使用该调度器可以用避免多余的调度以提升性能。当主线程调用了使用 Disptachers.Main.immediate调度器的挂起方法时,方法将直接执行,不会产生任何调度。否则,挂起方法会等到 Dispatchers.Main调度周期被触发时再执行。

要使得一个会导致主线程变慢的函数主线程安全,你可以告诉Kotlin协程在 Default或者 IO调度器上工作。在Kotlin中,所有的协程都必须在调度器中运行,即使是运行在主线程中的协程。协程可以把自己挂起,而调度器知道如何恢复它们。

为了指定协程应该在哪里运行,Kotlin提供了三种调度器(Dispatchers)以用于线程分派。

  • Dispatchers.Main:对于Android来说就是UI线程
  • Dispatchers.IO:非主线程,处理网络请求、数据库读写、磁盘文件读写
  • Dispatchers.Default:非主线程,默认调度器,处理需要大量计算的任务

补充:Dispatchers.IODispatchers.Default背后都是使用线程池作支撑的。
对于 IO来说,该调度器线程池中可用的线程数量受限于 kotlinx.coroutines.io.parallelism的值。该值默认是64,也可能是CPU的核心数(如果核心数大于64)。
对于 default,Kotlin提供的标准构建器比如 launchasync默认使用该调度器。该调度器线程池中的最大线程数等于CPU的核心数,但是最少有两个。

我们继续用前面的例子,使用调度器来定义 get方法。

// Dispatchers.Mainsuspend 
fun fetchDocs() {    
    // Dispatchers.Main    
    val result = get("developer.android.com")    
    // Dispatchers.Main    
    show(result)
}

// Dispatchers.Main
suspend fun get(url: String) =    
    // Dispatchers.Main    
    withContext(Dispatchers.IO) {        
        // Dispatchers.IO        
        /* perform blocking network IO here */    
    }   

get方法的内部,使用 withContext(Dispatchers.IO)创建一个将运行在 IO调度器的代码块。任何写在该代码块内的代码都将在 IO调度器上被执行。由于 withContext本身也是一个挂起函数,它将使用协程提供主线程安全。

使用协程你可以精细地处理线程调度。因为 withContext让你可以控制任意行的代码的执行线程,而无需使用回调得到结果。因此好的做法是使用 withContext来确保每个方法在任意调度器上都可以安全调用,包括 Main。这样,调用者就不必考虑执行该方法所需要的线程。

在这个例子中,fetchDocs方法在主线程中执行,但是能安全地调用在后台线程执行网络请求的 get方法。因为协程支持挂起和恢复,在 withContext代码块执行完成之后,主线程上的协程会立即恢复并返回结果。

在主线中调用编写良好的挂起函数总是安全的。

使得每一个挂起函数主线程安全是非常好的主意。如果它所做的事情涉及磁盘、网络或者甚至只是占用太多CPU,请使用 withContext来使它主线程安全。这是基于协程的库,比如 RetrofitRoom,所遵循的模式。如果你在整个代码库中都遵循这一风格,你的代码将变得更加简单,并且可以避免将应用逻辑与线程问题混在一起。当持续遵循这一原则,协程可以在主线程上自由地启用,并且用简单的代码发起网络或数据库请求,同时确保用户不会看到卡顿。


5. withContext 的性能

对于确保主线程安全来说,withContext的速度和回调或者RxJava一样快。在某些情况下,甚至可以优化 withContext,使之比回调更好。如果一个方法需要对数据库做10次调用,你可以告诉Kotlin在外部 withContext中对所有10次调用进行一次切换。然后,即使数据库反复调用 withContext,它也将在同一个调度器上被执行并遵循快速路径(fast-path)。此外,Dispatchers.DefaultDispatchers.IO 之间的切换也做了优化,来尽量避免线程切换。


6. 总结

在这一篇里,我们探索了协程最擅长解决哪些问题。协程在编程语言里是一个非常老的概念,由于其使与网络交互的代码变得更简洁的能力,最近已经流行起来。

在Android中,你可以用协程来解决两个常见的问题:

  1. 简化长时间运行任务(比如网络请求,磁盘读写,甚至是解析大的JSON文件)的代码。
  2. 执行精确的主线程安全,在使得代码不难以读写的情况下确保你不会阻塞主线程。

下一篇里我们将探究协程在Android中的适用性,以跟踪您从屏幕开始的所有工作!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值