协程简介

响应迅速的界面是优秀的应所不可或缺的。尽管在您到目前为止所构建的应用中,您可能认为这是理所当然的,但随着您开始添加更多高级功能(例如网络或数据库功能),编写功能和性能俱佳的代码就会变得愈发困难。以下示例说明了长时间运行的任务(例如从互联网下载图片)如果处理不正确可能会发生什么情况。尽管图片功能正常,但滚动时会有跳跃,使界面看起来响应不佳(并且不专业!)。

9f8c54ba29f548cd.gif

为了避免出现上述应用中的问题,您需要学习一些有关线程的知识。线程是一个比较抽象的概念,但您可以将它看作执行应用代码的一种路径。您编写的每一行代码都是一条指令,将会在同一线程上按顺序执行。

您已经在 Android 中使用过线程。每个 Android 应用都有一个默认的“主”线程。这(通常)就是界面线程。到目前为止,您编写的所有代码均在主线程上执行。每条指令(即一行代码)都会按顺序执行,即等前一行代码执行完成后再执行下一行。

不过,在实际运行的应用中,除了主线程外还有其他线程。在后台,处理器实际上并不会处理单独的线程,而是会在不同系列的指令之间来回切换,呈现一种多任务处理的状态。线程是一种抽象表示,您在编写代码时可以用它来确定每条指令应使用的执行路径。使用主线程以外的其他线程,可使您的应用能够在后台执行复杂的任务(例如下载图片),同时使应用的界面仍保持响应。这称为“并发代码”(或简称为“并发”)。

在此 Codelab 中,您将了解线程,并学习如何使用被称为“协程”的 Kotlin 功能来编写清晰的非阻塞并发代码。

前提条件

学习内容

  • 并发的含义及其重要性
  • 如何使用协程和线程编写非阻塞并发代码
  • 如何访问主线程,以便在后台执行任务的同时安全地执行界面更新
  • 如何以及何时使用不同的并发模式(作用域/调度程序/延迟)
  • 如何编写与网络资源互动的代码

您将构建的内容

  • 在此 Codelab 中,您将通过编写一些小程序来探索如何在 Kotlin 中使用线程和协程

所需条件

  • 一台安装了新版网络浏览器(如最新版 Chrome)的计算机
  • 计算机可以访问互联网

多线程和并发

到目前为止,我们一直将 Android 应用视为具有单一执行路径的程序。您可以通过这种单一执行路径完成很多任务,但随着应用功能的增长,您需要考虑采用并发执行。

通过采用并发执行,多个代码单元可以乱序执行,或近似于并行执行,从而更有效地利用资源。操作系统可以根据系统、编程语言和并发单元的特性来管理多任务。

fe71122b40bdb5e3.png

为什么需要使用并发?随着应用越来越复杂,确保代码不会发生阻塞变得至关重要。这意味着,执行长时间运行的任务(例如网络请求)不会使应用中的其他任务停止执行。未正确实现并发可能会使应用无法响应用户请求。

我们将通过几个示例来说明如何在 Kotlin 中进行并发编程。所有示例都可以在 Kotlin 园地中运行:

https://developer.android.com/training/kotlinplayground

线程是能够在程序范围内调度和运行的最小代码单元。以下是一个能够运行并发代码的小示例。

您可以通过提供 lambda 来创建一个简单的线程。在 Kotlin 园地中尝试以下代码:

fun main() {
    val thread = Thread {
        println("${Thread.currentThread()} has run.")
    }
    thread.start()
}

该线程在调用 start() 函数之后才会执行。输出应类似如下:

Thread[Thread-0,5,main] has run.

请注意,currentThread() 会返回一个 Thread 实例,该实例已转换为其字符串表示形式,其中包含了该线程的名称、优先级和线程组。以上输出可能会略有不同。

创建和运行多个线程

为了演示简单的并发机制,我们来创建几个要执行的线程。以下代码将创建 3 个线程,用于输出前一示例中的信息行。

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

Kotlin 园地中的输出

Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

AS(控制台)中的输出

Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending

多次运行该代码。您会看到不同的输出。这些线程有时看起来像是按顺序运行,而有时则会打乱顺序执行。

使用线程是开始运用多任务和并发的简单方式,但并非没有任何问题。如果您直接在代码中使用 Thread,可能会出现许多问题。

线程需要大量的资源。

创建、切换和管理线程会占用系统资源和时间,从而限制能够同时管理的原始线程数量。创建成本也会大幅增加。

尽管实际运行的应用将会有多个线程,但每个应用都会有一个专门的线程来负责处理应用界面。此线程通常称为主线程或界面线程。

由于主线程负责运行应用界面,因此该线程必须具有较高的性能,以便应用能够顺畅运行。任何长时间运行的任务在完成之前都会阻塞该线程,从而导致应用无响应。

操作系统会想方设法保持对用户的响应。目前,手机每秒会尝试更新界面 60 到 120 次(最低 60 次)。用于准备和绘制界面的时间很有限(在每秒 60 帧的速率下,每次屏幕更新所需的时间应为 16 毫秒或更短)。Android 会丢掉帧,或者中止尝试完成单个更新周期,以试图跟上进度。丢掉部分帧和帧数波动属于正常现象,但丢帧过多就会导致应用无响应。

竞态条件和不可预测的行为

如前所述,线程是一种抽象表示,用于说明处理器如何同时处理多个任务。由于处理器会在不同线程上的各个指令集之间切换,因此您无法控制线程的确切执行时间和暂停时间。直接使用线程时,您不能总是期望能生成可预测的输出。

例如,以下代码使用一个简单的循环来从 1 到 50 进行计数,但在此例中,每当计数递增时,系统便会创建一个新的线程。想一想您预期会有怎样的输出,然后运行几次代码。

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

输出是否符合您的预期?每次输出都一样吗?以下是我们获得的一个示例输出:

Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

与代码显示的相反,最后一个线程似乎最先执行,而其他的一些线程则是乱序执行的。如果查看某些迭代的“count”值,就会发现,该值在经过多个线程之后没有改变。更加奇怪的是,线程 43 的计数达到了 50,尽管输出表明这只是执行的第二个线程。仅根据输出无法判断 count 的最终值是多少。

这只是线程可能导致不可预测的行为的一种方式。在使用多个线程时,您可能还会遇到“竞态条件”。这是指多个线程尝试同时访问内存中的同一个值的情况。竞态条件会导致出现难以重现且看起来随机的错误,这样的错误可能会导致应用崩溃(通常是不可预测的)。

鉴于性能问题、竞态条件和难以重现的错误等原因,我们不建议您直接使用线程。您应该学习 Kotlin 中一项名为“协程”的功能,该功能可帮助您编写并发代码。

Android 中提供了直接针对后台任务创建和使用线程的方式,但 Kotlin 也提供了协程,该功能可作为一种更加灵活、简单的方式来管理并发。

协程能够处理多任务,但比直接使用线程更为抽象。协程的一项重要功能是能够存储状态,以便协程可以暂停和恢复。协程可以执行,也可以不执行。

借助状态(由“连续性”表示),部分代码可以在需要移交控制权或需要等待其他协程完成后才能恢复时发出信号。此流程称为“协作式多任务处理”。Kotlin 的协程实现增加了一些协助处理多任务的功能。除了连续性以外,创建协程还涉及到作用于 CoroutineScope 内的 Job(具有生命周期的可取消工作单元)的内容。CoroutineScope 表示以递归方式对其子级以及这些子级的子级强制执行取消和其他规则的一种上下文。Dispatcher 会管理协程将使用哪个后备线程来执行任务,从而使开发者无需管理使用新线程的时间和位置。

作业

表示可取消的工作单元,例如使用 launch() 函数创建的工作单元。

CoroutineScope

用于创建新协程的函数,例如 launch()async()CoroutineScope 进行扩展。

调度程序

确定协程将使用的线程。Main 调度程序将始终在主线程上运行协程,而 DefaultIOUnconfined 等调度程序则会使用其他线程。

您稍后将会详细了解这些内容,但 Dispatchers 是使协程如此高性能的方式之一。这种方式能够避免初始化新线程所耗费的性能成本。

我们来将之前的示例调整为使用协程。

import kotlinx.coroutines.*

fun main() {
    repeat(3) {
        GlobalScope.launch {
            println("Hi from ${Thread.currentThread()}")
        }
    }
}
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

上述代码段使用默认调度程序在全局作用域内创建三个协程。只要应用在运行,GlobalScope 便允许其中的任何协程运行。鉴于我们讨论的关于主线程的原因,我们不建议在示例代码之外使用这种方法。在应用中使用协程时,我们会使用其他作用域。

launch() 函数会根据括起来的代码(封装在可取消作业对象中)创建协程。launch() 用于无需在协程范围之外返回值的情况。

我们来看一下 launch() 的完整签名,以了解协程中的下一个重要概念。

fun CoroutineScope.launch {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
}

在后台,您传递给 launch 函数的代码块会使用 suspend 关键字进行标记。Suspend 表示代码块或函数可以暂停或恢复。

runBlocking 简述

下面的示例将使用 runBlocking(),顾名思义,该函数会启动新协程并在新协程完成之前阻塞当前线程。它主要用于在主要函数和测试中的阻塞代码和非阻塞代码之间架起桥梁。该函数在典型的 Android 代码中并不常用。

import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
    println("entering getValue() at ${time()}")
    delay(3000)
    println("leaving getValue() at ${time()}")
    return Math.random()
}

fun main() {
    runBlocking {
        val num1 = getValue()
        val num2 = getValue()
        println("result of num1 + num2 is ${num1 + num2}")
    }
}

getValue() 会在设定的延迟时间后返回一个随机数字。该函数使用 DateTimeFormatter 来说明进入和退出阻塞的相应时间。main 函数会调用两次 getValue() 并返回总和。

entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

若要查看此函数的实际应用,请将 main() 函数替换为以下内容(保留所有其他代码):

fun main() {
    runBlocking {
        val num1 = async { getValue() }
        val num2 = async { getValue() }
        println("result of num1 + num2 is ${num1.await() + num2.await()}")
    }
}

getValue() 的两次调用是独立的,不需要协程挂起。Kotlin 的 async 函数与 launch 类似。async() 函数的定义如下:

Fun CoroutineScope.async() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
}: Deferred<T>

async() 函数会返回 Deferred 类型的值。Deferred 是一个可取消的 Job,可以存储对未来值的引用。使用 Deferred,您仍然可以调用函数,就像它会立即返回一个值一样 - Deferred 只充当占位符,因为您无法确定异步任务将何时返回。Deferred(在其他语言中也称为 promise 或 future)能够保证稍后会向此对象返回一个值。另一方面,异步任务默认不会阻塞或等待执行。若要启动异步任务,当前的代码行需要等待 Deferred 的输出,您可以对其调用 await()。它将会返回原始值。

entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

何时将函数标记为 suspend

在前面的示例中,您可能已经注意到 getValue() 函数也使用 suspend 关键字进行了定义。原因在于它会调用 delay(),这也是一个 suspend 函数。只要一个函数调用另一个 suspend 函数,那它也应是 suspend 函数。

如果这样的话,为什么我们示例中的 main() 函数不能用 suspend 进行标记呢?毕竟,它也调用 getValue()

不一定。getValue() 实际上是在传递给 runBlocking() 的函数中调用的,这是一个 suspend 函数,类似于传递给 launch()async() 的函数。而 getValue() 不是在 main() 本身中调用的,runBlocking() 也不是 suspend 函数,因此 main() 没有用 suspend 标记。如果函数未调用 suspend 函数,它本身就无需是 suspend 函数。

在本 Codelab 的开头部分,您看到了使用多个线程的以下示例。请利用您学到的协程知识,重写代码来使用协程而不是 Thread

注意:您无需修改 println() 语句,即使它们引用了 Thread

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}
import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               delay(5000)
           }
       }
   }
}

您已经学习了以下内容:

  • 并发的重要性
  • 什么是线程,以及线程对于并发的重要性
  • 如何在 Kotlin 中使用协程编写并发代码
  • 应该以及不应将函数标记为“suspend”的情况
  • CoroutineScope、作业和调度程序的作用
  • deferred 和 await 函数之间的区别