协程

255 阅读10分钟

什么是协程

其实可以把它理解成一种轻量级的线程。但是线程是非常重量级的,它需要依靠操作系统的调度才能实现不同线程之间的切换。使用协程可以仅在编程语言的层面实现不同协程的切换,从而大大提升并发编程的运行效率。

我们可以通过 官方文档 来学习协程:

第一个协程

Kotlin 并没有将协程纳入标准库的 API 当中,所以我们需要在 build.gradle 中添加如下依赖:

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2' 
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'

如果是纯 Kotlin 程序,可以只添加 coroutines-core 就行了。如果是用于 Android 平台的话,可以只添加 coroutines-android,它里面已经包含了 coroutines-core。

接下来创建一个 CoroutinesTest.kt,其代码如下:

fun main() = runBlocking { // this: CoroutineScope
    launch { // launch a new coroutine and continue
        delay(1000L) // non-blocking delay for 1 second 
        println("World!") // print after delay
    }
    println("Hello") // main coroutine continues while a previous one is delayed
}

运行后打印如下:

Hello 
World!

先打印 Hello,一秒之后打印 World!。

下面我们分析一下这段代码。

launch 函数:launch 函数是一个协程构建器函数(Coroutine builder functions),用于启动一个新的协程,launch 函数之外的代码可以继续执行,所以上面的例子中 Hello 会先打印,launch 函数中的代码与 launch 函数之外的代码是并行的关系。launch 函数的源码如下:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

可以看到 launch 函数是 CoroutineScope 的扩展函数,它必须在 CoroutineScope 中调用。launch 函数总共有 3 个参数,其中前 2 个是普通类型的参数,第 3 个参数 block 是一个 lamda 类型的参数。第 3 个参数有 suspend 修饰,是一个挂起函数,并且定义在 CoroutineScope 类中,这样 lambda 表达式自动拥有了 CoroutineScope 的上下文。

第一个参数 context 用于指定协程的上下文,默认值为 EmptyCoroutineContext。第二个参数 start 用于指定协程的启动方式,默认值为 CoroutineStart.DEFAULT,CoroutineStart 是一个枚举类,有 4 个值:DEFAULT、LAZY、ATOMIC、UNDISPATCHED。CoroutineStart.DEFAULT 会在调用 launch 函数后立即执行,可以通过将参数 start 设置为 CoroutineStart.LAZY 来实现惰性启动,此时需要调用 Job 实例的 start() 方法才会启动协程。第三个参数 block 是交由协程执行的任务。

delay函数:delay 函数的源码如下:

public suspend fun delay(timeMillis: Long) {
    ...
}

delay 函数前面有 suspend 修饰,表示它是一个挂起函数,意味着它可以挂起当前协程一段时间,挂起协程不会阻塞其所在的线程,它允许其他协程继续执行。

runBlocking函数:它也是一个协程构建器函数,它把非协程的代码(比如上例中的 fun main())与协程的代码(比如上例中的 runBlocking{...})连接起来,IDE 中会在 runBlocking 大括号的开头高亮显示this: CoroutineScope,表示这里是 CoroutineScope 的范围。如果在上例中你忘了加 runBlocking,代码会报错,因为 launch 函数是 CoroutineScope 的扩展函数,它只能在 CoroutineScope 中调用。

runBlocking 函数的源码如下:

public actual fun <T> runBlocking(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T {
    ...
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
}

runBlocking 函数创建的协程会阻塞当前线程直到协程执行结束,runBlocking 函数通常只应该在测试环境下使用,因为线程是宝贵的资源,阻塞线程是不明智的。

随着 launch 函数中的代码逻辑越来越复杂,你可能需要把其中的代码放到一个单独的函数中,如果你试图在一个普通的函数中调用 delay() 函数,IDE 会提示错误:Suspend function 'delay' should be called only from a coroutine or another suspend function。意思是 delay() 函数是一个挂起函数,只能在协程或者挂起函数中调用

这时候可以将该函数(下例中的 doWorld() 函数)声明成挂起函数,代码如下:

fun main() = runBlocking { // this: CoroutineScope
    launch { doWorld() }
    println("Hello")
}

// this is your first suspending function
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}

这样代码就可以正常执行了,打印跟前面一样。

CoroutineScope

CoroutineScope 即协程作用域, CoroutineScope 是一个接口,其代码如下:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

该接口中只有一个成员 coroutineContext,launch、async 这些协程构建器函数都是 CoroutineScope 的扩展函数,并且继承了这个 coroutineContext,通过 coroutineContext 可以拿到 Job 的实例,通过 Job 实例可以对协程进行各种操作。

协程遵循结构化并发(Structured Concurrency)的编程规范,具体表现在以下几个方面:

  • CoroutineScope 作用域:所有协程必须在某个 CoroutineScope 中启动,它定义了协程的生命周期边界。当作用域被取消(scope.cancel())时,其内部所有子协程也会被自动取消。
  • 父子协程关系:父协程启动的子协程会继承父协程的上下文(如 Job、Dispatcher),父协程会等待所有子协程完成后才会结束自身。
  • 异常传播:子协程的未捕获异常会取消父协程及其兄弟协程,并向上传播。

可以使用下面这些方式来创建协程作用域:

  • GlobalScope,它不跟任何 job 绑定,用于启动一个顶层协程,这个顶层协程协程生命周期与应用程序生命周期相同,使用 GlobalScope 启动的协程并不遵循结构化并发的原则。
  • runBlocking,和 GlobalScope 不一样,它会阻塞当前线程直到其内部所有相同作用域的协程执行结束。
  • coroutineScope,与 runBlocking 不同的是它不会阻塞当前线程,只会挂起外部的协程,直到其内部所有子协程执行完毕。
  • 自定义 CoroutineScope,可以自己控制协程的生命周期。在 Android 开发中,可以在 Activity、Fragment、ViewModel 等具有生命周期的对象中按需取消所有的协程,从而避免内存泄露。
1. GlobalScope

GlobalScope 不会阻塞其所在线程,所以以下代码中主线程的日志输出会早于 GlobalScope 内部的日志。

fun main() {
    log("start")
    GlobalScope.launch {
        launch {
            delay(400)
            log("launch A")
        }
        launch {
            delay(300)
            log("launch B")
        }
        log("GlobalScope")
    }
    log("end")
    Thread.sleep(500)
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

运行后打印如下:

[main] start
[main] end
[DefaultDispatcher-worker-1] GlobalScope
[DefaultDispatcher-worker-3] launch B
[DefaultDispatcher-worker-3] launch A

GlobalScope 的源码如下:

public object GlobalScope : CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

可以看到 GlobalScope 是一个实现了 CoroutineScope 接口的单例。GlobalScope 启动的协程并不遵循结构化并发的原则,如果在 Activity 中使用 GlobalScope.launch,即使 Activity 被销毁,协程仍会继续执行。在使用过程中很容易意外地造成资源或内存泄漏。如果由于网络慢造成协程延迟执行,它会保持工作状态持续耗费资源。比如下面这段代码:

fun loadConfiguration() {
     GlobalScope.launch {
         val config = fetchConfigFromServer() // 执行网络请求
         updateConfiguration(config)
     }
}

如果网络很慢,这个协程会持续在后台等待并耗费资源,所以在日常开发中应该谨慎使用 GlobalScope。

2. runBlocking

runBlocking 会阻塞当前线程直到其内部所有相同作用域的协程执行结束。

fun main() {
    runBlocking {
        log("launch")
        delay(1500)
        log("launch end")
    }
    log("end")
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

运行后打印如下:

[main] launch
[main] launch end
[main] end

从上面的打印可以看出,这里 runBlocking 函数内部启动的协程默认在调用线程执行。

下面我们看看创建多个协程的场景:

fun main() {
    log("start")
    runBlocking {
        launch {
            repeat(3) {
                delay(100)
                log("launchA - $it")
            }
        }
        launch {
            repeat(3) {
                delay(100)
                log("launchB - $it")
            }
        }
        GlobalScope.launch {
            repeat(3) {
                delay(120)
                log("GlobalScope - $it")
            }
        }
    }
    log("end")
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

运行后打印如下:

[main] start
[main] launchA - 0
[main] launchB - 0
[DefaultDispatcher-worker-1] GlobalScope - 0
[main] launchA - 1
[main] launchB - 1
[DefaultDispatcher-worker-1] GlobalScope - 1
[main] launchA - 2
[main] launchB - 2
[main] end

从打印结果可以看到,runBlocking 没有等 GlobalScope 作用域的协程打印完就结束了,证实了它只会等待相同协程作用域(CoroutineScope)的协程。子协程的日志是交替打印的,通过 launch 函数启动的两个协程都运行在主线程,但是却实现了类似运行两个子线程的效果。由编程语言来决定如何在多个协程之间调度的,调度的过程完全不需要操作系统的参与,这也就使得协程的并发效率会出奇的高。

具体会有多高呢,看看下面的例子:

fun main() {
    val start = System.currentTimeMillis()
    var i = 0
    runBlocking {
        repeat(100000){
            launch { println(++i) }
        }
    }
    val end = System.currentTimeMillis()
    println("cost ${end - start} milliseconds")
}

打印如下:

...
99995
99996
99997
99998
99999
100000
cost 357 milliseconds

上面的代码使用 repeat 函数创建了 10 万个协程,看打印耗时仅 357 毫秒,可见协程有多高效。试想一下,如果开启的是 10 万个线程,程序或许早就已经 OOM 了。

3. coroutineScope

coroutineScope 函数是一个挂起函数,它不会阻塞当前线程,但会挂起外部的协程,直到其内部所有子协程执行完毕。,coroutineScope 的源码如下:

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

从源码可以看出 coroutineScope 是定义在 CoroutineScope.kt 中的函数,前面有 suspend 修饰,coroutineScope 只能在协程或其他挂起函数中调用。

看下面的示例:

fun main() {
    runBlocking {
        coroutineScope {
            launch {
                for(i in 1..3){
                    println(i)
                    delay(1000)
                }
            }
        }
        println("coroutineScope end")
    }
    println("runBlocking end")
}

打印如下:

1
2
3
coroutineScope end
runBlocking end

从打印可以看到,控制台会每隔 1 秒依次输出数字 1 到 3 ,然后打印 coroutineScope 函数结尾的日志,最后打印 runBlocking 函数结尾的日志。由此可见,coroutineScope 函数确实挂起了外部的协程,如果这里不加 coroutineScope , "coroutineScope end" 会先输出。

但是 coroutineScope 函数只会挂起外部的协程,不会影响其他协程,也不会影响任何线程,因此不会影响性能。而 runBlocking 函数会阻塞当前线程,如果你在主线程中调用它,可能会造成界面卡死的情况,所以不太推荐在实际项目中使用。另外,runBlocking 是一个普通函数,而 coroutineScope 是一个挂起函数。

4. 自定义 CoroutineScope

launch 函数会返回一个 Job 对象,可以调用 Job 对象的 cancel() 方法来取消协程从而避免内存泄漏,代码如下:

val job = GlobalScope.launch {
    // 处理具体的逻辑
}

// 取消协程
job.cancel()

但是如果通过这种方式创建的协程有多个,就需要逐个调用 Job 对象的 cancel() 方法,有没有更好的办法呢?

kotlinx.coroutines 提供了 CoroutineScope,同一个协程作用域启动的协程可以使用它来一次性全部取消,在 Activity 生命周期相关的方法中调用就很方便了。

可以通过 CoroutineScope() 函数  或 MainScope() 函数 来创建 CoroutineScope 的实例,CoroutineScope 是一个接口,我们来看下 CoroutineScope() 函数是如何创建的。

CoroutineScope() 函数的代码如下:

public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

可以看到 CoroutineScope() 函数直接通过参数 context 创建了一个 ContextScope 实例返回,而 ContextScope 的代码如下:

internal class ContextScope(context: CoroutineContext) : CoroutineScope {
    override val coroutineContext: CoroutineContext = context
    // CoroutineScope is used intentionally for user-friendly representation
    override fun toString(): String = "CoroutineScope(coroutineContext=$coroutineContext)"
}

原来它实现了 CoroutineScope 接口,并把 context 赋给了其中的 coroutineContext。

下面是一个 CoroutineScope() 函数在 ViewModel 中使用的示例:

class MyViewModel : ViewModel() {
    // 使用 SupervisorJob,这样子协程的失败不会相互影响
    private val viewModelJob = SupervisorJob()
    // 创建自定义作用域,并指定 UI 线程为主线程
    private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)

    fun loadData() {
        uiScope.launch {
            // 在这里启动的所有协程都与 viewModelJob 绑定
            try {
                val data = fetchData()
                updateUi(data)
            } catch (e: Exception) {
                showError(e)
            }
        }
    }

    // 当 ViewModel 被清除时,取消所有协程
    override fun onCleared() {
        super.onCleared()
        uiScope.cancel() // 取消作用域,从而取消所有子协程
    }
}

当 ViewModel 被清除时,调用 uiScope.cancel() 取消作用域,从而取消所有子协程。这里也可以用 viewModelJob.cancel(),效果是一样的。

在 ViewModel 中,androidx 提供了 viewModelScope,我们可以使用它来启动协程,就不需要自己去创建 CoroutineScope 的实例了,viewModelScope 的代码如下:

//androidx.lifecycle
private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"

public val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        )
    }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}

这里提供了一个 close() 方法,在 ViewModel 的 clear() 方法中会调用它:

@MainThread
final void clear() {
    mCleared = true;
    
    // 清除所有存储的数据
    if (mBagOfTags != null) {
        synchronized (mBagOfTags) {
            for (Object value : mBagOfTags.values()) {
                // see comment for the similar call in setTagIfAbsent
                // 这里会调用 CloseableCoroutineScope.close() 方法
                closeWithRuntimeException(value);
            }
        }
    }
    ...
    onCleared();
}

这样就在 ViewModel 销毁的时候就会取消协程作用域。

AndroidX 的源码可以在这里查看: github.com/androidx/an…

MainScope() 的源码如下:

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

可以看到 MainScope() 是一个函数,可以看到 MainScope() 跟前面实例中的 uiScope 其实是一样的。

具体用法看下面示例:

class MainActivity : AppCompatActivity() {

    private val mainScope = MainScope()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        mainScope.launch {
            log("launch:0")
        }

        mainScope.launch {
            delay(5000)
            log("launch:5")
        }

        mainScope.launch {
            delay(10000)
            log("launch:10")
        }

        mainScope.launch {
            delay(15000)
            log("launch:15")
        }

        mainScope.launch {
            delay(20000)
            log("launch:20")
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        mainScope.cancel()
    }

    private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")
}

这里在 onCreate() 中创建了 5 个协程,只需要在 onDestroy() 方法中调用一次 mainScope.cancel() 即可将所有协程全部取消。

在 Activity 中,我们也不需要自己创建 CoroutineScope 实例,androidx 为我们提供了 lifecycleScope,我们使用它来启动协程即可,当 Activity 销毁时,它会自动调用相应的 cancel() 方法

已取消的作用域无法再创建协程。因此,仅当控制其生命周期的类被销毁时,才应调用 scope.cancel()。

CoroutineBuilder(协程构建器)

1. launch

launch 函数的源码前面已经分析过了。

2. async

launch 函数的返回值永远是一个 Job 对象,如果我们想要获取协程的执行结果该怎么办?这时候就需要使用 async 函数。async 函数也是 CoroutineScope 的扩展函数,代码如下:

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

async 函数的代码与 launch 函数非常像,不同的是 async 函数返回 Deferred<T> 对象,如果想获取 async 函数代码块的执行结果,只需要调用 Deferred<T> 对象的 await() 方法即可,代码如下:

fun main() {
    runBlocking {
        var result = async {
            5 + 6
        }.await()
        println(result)
    }
}

运行后打印如下:

11

await() 是一个挂起函数,它的作用是等待并获取异步计算的结果。看下面的代码:

fun main() {
    runBlocking {
        val start = System.currentTimeMillis();
        val result1 = async {
            delay(1000)
            5 + 6
        }.await()

        val result2 = async {
            delay(1000)
            4 + 6
        }.await()

        println("result is ${result1 + result2}.")
        val end = System.currentTimeMillis();
        println("cost ${end - start} ms.")

    }
}

运行后打印如下:

result is 21.
cost 2058 ms.

这里连续使用了 2 个 async 函数来执行任务,并在代码块中调用 delay() 函数进行 1 秒的延迟。从打印结果可以看出,整段代码运行耗时 2058 毫秒,证实了 await() 方法会挂起当前协程。

上面的代码是非常低效的,有没有办法提高运行效率同时拿到正确的运行结果呢?把上面的代码修改一下:

fun main() {
    runBlocking {
        val start = System.currentTimeMillis();
        val deferred1 = async {
            delay(1000)
            5 + 6
        }

        val deferred2 = async {
            delay(1000)
            4 + 6
        }

        println("result is ${deferred1.await() + deferred2.await()}.")
        val end = System.currentTimeMillis();
        println("cost ${end - start} ms.")

    }
}

现在我们仅在需要用到 async 函数的执行结果的时候才调用 await() 方法,这样两个 async 函数就是并行的关系。运行后打印如下:

result is 21.
cost 1049 ms.

从打印结果可以看到,耗时变成了 1049 毫秒,运行效率的提升是显而易见的。

3. withContext

withContext() 函数也是一个挂起函数,他有两个参数:context 和 block,代码如下:

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
        ...
        val coroutine = DispatchedCoroutine(newContext, uCont)
        block.startCoroutineCancellable(coroutine, coroutine)
        coroutine.getResult()
    }
}

大体可以将它理解成 async 函数的一种简化版写法,看如下示例:

fun main() {
    runBlocking {
        var result = withContext(Dispatchers.Default){
            5 + 6
        }
        println(result)
    }
}

在调用 withContext() 函数后,会立即执行代码块中的代码,同时挂起当前协程。代码块中的代码执行完之后,会将最后一行作为 withContext() 函数的返回值返回,这样看来与 async 函数的用法差不多。唯一不同的是, withContext() 函数还需要传一个线程参数。

4. Job

Job 是协程的句柄,使用 launch 函数创建的协程都会返回一个 Job 实例,该实例唯一标识协程并管理其生命周期。Job 是一个接口,它继承自 CoroutineContext.Element,这里列举 Job 几个比较有用的属性和函数:

public interface Job : CoroutineContext.Element {

    // job 处于 active 状态时为 true,表示协程启动后、执行结束前并且没有被取消的状态
    // 如果 job 没有被取消或者执行失败,job 在等待它的孩子执行结束也被认为是 active 状态
    public val isActive: Boolean
    
    // job 正常或者异常结束,均返回 true
    // job 被取消或者失败导致结束执行也认为是 complete 状态
    public val isCompleted: Boolean
      
    // job 因为任何原因被取消,或者主动调用了 cancel() 方法,
    // 或者执行失败了,或者它的孩子或父亲被取消了,均返回 true
    public val isCancelled: Boolean
    
    // 启动与这个 job 关联的协程(如果该协程没有启动的话)
    // 如果此调用的确启动了协程,返回 true
    // 如果协程此前已经处于 started 或者是 completed 状态,则返回 false 
    public fun start(): Boolean
    
    // 用于取消 job,可同时通过传入 CancellationException 来标明取消原因
    public fun cancel(cause: CancellationException? = null)
    
    // 用于阻塞协程直到此 job 执行结束
    public suspend fun join()

    // 用于注册 job 结束运行时(不管由于什么原因)触发执行的 handler
    // 如果调用该方法时,job 已经执行结束,handler 会被立刻触发
    public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle

}

Job 有以下几种状态,每种状态对应的 isActive、isCompleted、isCancelled 的属性值如下:

StateisActiveisCompletedisCancelled
New (optional initial state)falsefalsefalse
Active (default initial state)truefalsefalse
Completing (transient state)truefalsefalse
Cancelling (transient state)falsefalsetrue
Cancelled (final state)falsetruetrue
Completed (final state)falsetruefalse
fun main() {
    // 将协程设置为延迟启动
    val job = GlobalScope.launch(start = CoroutineStart.LAZY) {
        for (i in 0..100) {
            // 每循环一次延迟一百毫秒
            delay(100)
        }
    }
    job.invokeOnCompletion {
        log("invokeOnCompletion:$it")  // job 执行结束时触发
    }
    log("1. job.isActive:${job.isActive}")
    log("1. job.isCancelled:${job.isCancelled}")
    log("1. job.isCompleted:${job.isCompleted}")

    job.start()

    log("2. job.isActive:${job.isActive}")
    log("2. job.isCancelled:${job.isCancelled}")
    log("2. job.isCompleted:${job.isCompleted}")

    // 休眠四百毫秒后再主动取消协程
    Thread.sleep(400)
    job.cancel(CancellationException("test"))

    // 休眠四百毫秒防止 JVM 过快停止导致 invokeOnCompletion 来不及回调
    Thread.sleep(400)

    log("3. job.isActive:${job.isActive}")
    log("3. job.isCancelled:${job.isCancelled}")
    log("3. job.isCompleted:${job.isCompleted}")
}

打印如下:

    [main] 1. job.isActive:false
    [main] 1. job.isCancelled:false
    [main] 1. job.isCompleted:false
    [main] 2. job.isActive:true
    [main] 2. job.isCancelled:false
    [main] 2. job.isCompleted:false
    [DefaultDispatcher-worker-2] invokeOnCompletion:java.util.concurrent.CancellationException: test
    [main] 3. job.isActive:false
    [main] 3. job.isCancelled:true
    [main] 3. job.isCompleted:true
5. Deferred

async 函数的返回值是一个 Deferred<T> 对象,Deferred 是一个接口,继承自 Job,所以 Job 包含的属性和方法 Deferred 都有,其主要是在 Job 的基础上扩展了 await() 方法。其代码如下:

public interface Deferred<out T> : Job {

    // 等待 T 的返回,不会阻塞线程,在 deferred 计算完成时恢复,并返回结果值。
    // 在 deferred 被取消时会抛出相应的异常。
    public suspend fun await(): T

    public val onAwait: SelectClause1<T>

    public fun getCompleted(): T

    public fun getCompletionExceptionOrNull(): Throwable?
}

CoroutineContext

协程总是在某个环境中执行,该环境由 CoroutineContext 中的 Element 来决定:

public interface CoroutineContext {
    
    // CoroutineContext 中的一个 element
    public interface Element : CoroutineContext {

    }
}    

CoroutineContext 使用以下元素集定义协程的执行环境:

  • Job:控制协程的生命周期
  • CoroutineDispatcher:将任务指派给适当的线程
  • CoroutineName:协程的名称,可用于调试
  • CoroutineExceptionHandler:处理未捕获的异常
1. Job

可以通过 Job 来控制协程的生命周期,代码如下:

val job = Job()

val scope = CoroutineScope(job)

fun main(): Unit = runBlocking {
    log("job is $job")
    val job = scope.launch {
        try {
            delay(3000)
        } catch (e: CancellationException) {
            log("job is cancelled")
            throw e
        }
        log("end")
    }
    delay(1000)
    log("scope job is ${scope.coroutineContext[Job]}")
//    scope.coroutineContext[Job]?.cancel()
//    scope.cancel()
    job.cancel()
}

打印如下:

[main] job is JobImpl{Active}@4b4523f8
[main] scope job is JobImpl{Active}@4b4523f8
[DefaultDispatcher-worker-1] job is cancelled

这里 job 可以通过 scope.coroutineContext[Job] 直接获取到。

2. CoroutineDispatcher

CoroutineDispatcher 也继承自 CoroutineContext.Element 接口,CoroutineDispatcher(协程调度器)用于指定协程运行于哪个线程。CoroutineDispatcher 可以将协程的执行操作限制在特定线程上,也可以将其分派到线程池中,或者对执行协程的线程不做限制。

所有的协程构造器(如 launch 和 async)都接受一个可选参数,即 CoroutineContext ,该参数可用于指定要创建的协程和其它上下文元素所要使用的 CoroutineDispatcher。

Kotlin 提供四种 Dispatcher 用于指定在哪一类线程中执行协程:

  • Dispatchers.Default,默认的 Dispatcher,使用一个具有合理并发度的线程池,它的并发度通常等于 CPU 核心数(或核心数 ± 1),目的是避免因过多线程竞争 CPU 资源而导致上下文切换开销过大。
  • Dispatchers.IO,表示会使用一种较高并发的线程策略,当你要执行的代码大多数时间是在阻塞和等待中,比如执行网络请求时,为了能够支持更高的并发数量,此时可以使用 Dispatchers.IO。适合用于执行磁盘或网络 I/O 的任务,例如:使用 Room 组件、读写磁盘文件,执行网络请求。
  • Dispatchers.Unconfined,对执行协程的线程不做限制,可以直接在当前调度器所在线程上执行。由于 Unconfined 协程可能在任意线程恢复执行,不应在其内部更新 UI 或访问非线程安全的对象,一般仅用于测试或桥接回调。
  • Dispatchers.Main,使用此调度程序可用于在 Android 主线程上运行协程,只能用于与界面交互和执行快速工作,例如:更新 UI、调用 LiveData.setValue()。但是这个值只能在 Android 项目中使用,纯 Kotlin 程序使用会出现错误。

看下面的代码:

fun main() = runBlocking<Unit> {
    launch {
        log("main runBlocking")
    }
    launch(Dispatchers.Default) {
        log("Default")
        launch(Dispatchers.Unconfined) {
            log("Unconfined 1")
        }
    }
    launch(Dispatchers.IO) {
        log("IO")
        launch(Dispatchers.Unconfined) {
            log("Unconfined 2")
        }
    }
    launch(newSingleThreadContext("MyOwnThread")) {
        log("newSingleThreadContext")
        launch(Dispatchers.Unconfined) {
            log("Unconfined 4")
        }
    }
    launch(Dispatchers.Unconfined) {
        log("Unconfined 3")
    }
    GlobalScope.launch {
        log("GlobalScope")
    }
}

运行后打印如下:

[DefaultDispatcher-worker-2] Default
[DefaultDispatcher-worker-2] Unconfined 1
[DefaultDispatcher-worker-1] IO
[DefaultDispatcher-worker-1] Unconfined 2
[main] Unconfined 3
[DefaultDispatcher-worker-1] GlobalScope
[MyOwnThread] newSingleThreadContext
[MyOwnThread] Unconfined 4
[main] main runBlocking

从打印结果可以看出:

  • launch 函数不使用 Dispatcher 时,它从调用 launch 的地方继承上下文环境 ,即和 runBlocking 保持一致,均在 main 线程执行;
  • IO 和 Default 均依靠后台线程池来执行;
  • Unconfined 则不限定具体的线程类型,当前调度器在哪个线程,就在该线程上进行执行,因此上述例子中每个 Unconfined 协程所在线程均不一样;
  • GlobalScope 启动协程时默认使用的调度器是 Dispatchers.Default,因此也是在后台线程池中执行;
  • newSingleThreadContext 用于为协程专门创建一个新的线程,专用线程是一种成本非常昂贵的资源,在实际开发时必需在不再需要时释放线程资源,或者存储在顶层变量中以便在整个应用程序中进行复用;
3. CoroutineName

CoroutineName 用于为协程指定一个名字,方便调试和定位问题。

fun main() = runBlocking<Unit>(CoroutineName("RunBlocking")) {
    log("start")
    launch(CoroutineName("MainCoroutine")) {
        launch(CoroutineName("Coroutine#A")) {
            delay(400)
            log("launch A")
        }
        launch(CoroutineName("Coroutine#B")) {
            delay(300)
            log("launch B")
        }
    }
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

运行后打印如下:

[main] start
[main] launch B
[main] launch A
4. CoroutineExceptionHandler

协程的异常处理放到最后分析。

5. 组合上下文元素

有时我们需要为协程上下文定义多个元素,此时就可以用 + 运算符。例如,我们可以同时为协程指定 Dispatcher 和 CoroutineName:

fun main() = runBlocking<Unit> {
    launch(Dispatchers.Default + CoroutineName("test")) {
        log("Hello World")
    }
}

而由于 CoroutineContext 是由一组元素组成的,所以加号右侧的元素会覆盖加号左侧的元素,从而组成新的 CoroutineContext。比如,(Dispatchers.Main, "name") + (Dispatchers.IO) 的运行结果是:(Dispatchers.IO, "name")`

异常处理

在线程池的异常处理中,我们会使用这两种方法:

  • 添加 try catch 语句;
  • 给线程设置 UncaughtExceptionHandler;

因为协程底层也是使用的 Java 的线程模型,所以上述方法在协程的异常处理中同样有效。我们先添加 try catch 语句试试,代码如下:

fun main() {
    runBlocking {
        launch {
            try {
                throw RuntimeException("Exception1")
            }catch (e : RuntimeException){
                log(e.message)
            }

            kotlin.runCatching {
                throw RuntimeException("Exception2")
            }.onFailure {
                log(it.message)
            }
        }
        log("Hello, World!")
    }
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

运行后打印如下:

[main] Hello, World!
[main] Exception1
[main] Exception2

可以看到程序没有报错,说明添加 try catch 语句有效,其中 runCatching 是 Kotlin 中对 try catch 语句一种封装。

协程中也有自己的 ExceptionHandler —— CoroutineExceptionHandler,官方更推荐我们使用 CoroutineExceptionHandler 来处理协程中异常,如下所示:

fun main() {
    runBlocking {

        val handler = CoroutineExceptionHandler{ _, _ ->
            log("there is an exception")
        }

        val scope = CoroutineScope(SupervisorJob() + handler)

        scope.launch {
            throw NullPointerException();
        }

        delay(1000)
        log("Hello, World!")
    }
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

打印如下:

[DefaultDispatcher-worker-1] there is an exception
[main] Hello, World!

这里定义了一个 CoroutineExceptionHandler,并在初始化 CoroutineScope 时将其传入,这样这个协程作用域下的所有子协程发生异常时都将被这个 handler 拦截。

这里使用了 SupervisorJob() ,原因是协程的异常是会传递的,默认情况下,当一个子协程发生异常时,它会影响它的兄弟协程与它的父协程。默认情况下,任一子协程抛出未捕获异常,会立即取消整个作用域内的所有协程。而使用了 SupervisorJob() 则意味着,其子协程的异常都将由其自己处理,而不会向外扩散并影响它的兄弟协程与它的父协程。

还有一点需要注意的是, CoroutineExceptionHandler 只能用于初始化 CoroutineScope 或者其直接子协程(即 scope.launch ),否则就算创建子协程时携带了 CoroutineExceptionHandler,也不会生效。