协程二三事

163 阅读4分钟

挂起与恢复原理

日常使用协程时,虽然可自定义各种挂起函数,但这些自定义挂起函数最终还是需要调用 kotlin 提供的挂起函数,可称为元挂起函数(如 delay、suspendCancellableCoroutine 等)。元挂起函数有很多,但底层基本都调用到 suspendCoroutineUninterceptedOrReturn,如 suspendCancellableCoroutine 的源码如下:

public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
        
        cancellable.initCancellability()
        block(cancellable)
        // 返回 CancellableContinuationImpl::getResult() 的结果
        cancellable.getResult()
    }
    
   @PublishedApi
    internal fun getResult(): Any? {
        val isReusable = isReusable()
        if (trySuspend()) {
            // 如果能挂起,就先返回一个自定义的 Object 实例
            // 这不是调用挂起函数第一次返回的结果
            return COROUTINE_SUSPENDED
        }
        //...
    }

现在来看挂起与恢复是如何实现的,协程通过状态机+回调机制实现结构化并发

调用到挂起函数时,会像正常的方法调用一样调用。根据上面讨论它最终会调用到元挂起函数,并由元挂起函数返回 COROUTINE_SUSPENDED 对象。这样调用者拿到了第一个返回结果,它会判断返回的是不是 COROUTINE_SUSPENDED 对象,如果是它也直接返回。这样,调用者本次方法执行结束,挂起函数下面的部分都不会再本次调用时执行了。这就是所谓的挂起。

当被调用的挂起函数执行结束后会通过回调方式将真正的结果返回给调用者,调用者内部通过状态机记录本次返回结果后需要执行的起始位置 —— 即被调用的挂起函数的下一行。因此,挂起函数就恢复了。

如果未调用元挂起函数内部不会有状态机,就跟普通的方法一样逐行执行,然后返回真正的结果。但是调用者还是会形成状态机+回调机制,这也就是为何我们不调用挂起函数时 as 建议我们不要用 suspend 关键字的原因 —— 对调用者来说这是一种浪费。

finally 代码块

java 中的 finnaly 比较特殊,即使在 try 中使用了 return 语句,finally 也一定会执行。

那如果 try 中调用了挂起函数,finally 代码块该在何时执行呢?答案是在挂起函数返回真正结果后执行,与调用普通函数一致

原因是:由 kt 生成的 java 代码中并没有使用 finnaly,finally 代码块会放在所有代码后执行

如下绿框中的代码是 finally 代码块中的代码,可以发现它被放到了 invokeSuspend 的最后面,保证是在最后执行

image.png

使用 finally 时可能会存在一个问题:如果在 finally 中使用协程,该协程有可能不会被执行。原因在于:如果 try 中代码期间协程被取消了,那么 finally 中的协程就不会在执行(协程不会运行在已取消的 scope 中)。

如下:在 try 执行时如果协程被取消了,deleteFile 永远不会执行到。

viewModelScope.launch {
    try {
        file.deleteOnExit()
        file.createNewFile()
        val consume = write(file)
    } finally {
        deleteFile(file)
    }
}

解决方式是在 deleteFile 外层使用 withContext(NonCancellable)包裹,但要注意使用该方式时一定要注意内存泄露等:因为该作用域内的协程已经脱离的掌控,没有办法进行取消了。

withContext(NonCancellable) {
    deleteFile(file)
}

cancel

scope 中可以使用 cancel() 方法取消当前 scope 内的所有协程,但要注意:cancel() 不会往上传播,只影响当前 scope 及其子协程

private fun test() {
    scope.launch {
        launch {
            Log.e(TAG, "test: before delay")
            delay(1000)
            Log.e(TAG, "test: after delay")
        }
        launch {
            launch {
                delay(1000)
                // 该句不会执行,因为 cancel() 影响了它外层 scope
                Log.e(TAG, "test: launch22")
            }
            Log.e(TAG, "test: launch2")
            cancel()
            Log.e(TAG, "test: launch2 cancel")
        }
    }
    scope.launch {
        Log.e(TAG, "test: before scope")
        delay(1000)
        Log.e(TAG, "test: after scope")
    }
}


// output
test: launch2
test: launch2 cancel
test: before scope
test: before delay
test: after scope
test: after delay

Mutex

概述

Mutex 是协程独的“锁”,但它是挂起函数,因此只挂起协程,并不阻塞线程。在协程中使用 Mutex 保持同步比较好

不可重入

Mutex 是不可重入锁,在 withLock 中使用 withLock 会发生死锁。如下,永远不会输出 lock2,因为 lock1 并没有释放锁,所以 lock2 永远处于等待状态

fun test() {
    viewModelScope.launch {
       mutex.withLock {
           e("lock1")
           mutex.withLock {
               e("lock2")
           }
       }
    }
}
// output
lock1

单线程 scope 中多协程串行执行

通过一个 scope 启动多个协程,如何实现这些协程按启动先后顺序执行呢?

协程的调度本质就是往一个线程池中添加任务,一旦当前任务(执行任务的方法)结束了那么线程就会从任务池中再次获取任务去执行,所以单纯地通过挂起函数实现任务串行不可行。特别是协程中有线程切换时,如网络请求,通过挂起函数更无法保证串行执行。

  1. 如果挂起函数不需要返回结果,则使用 runBlocking 包裹一层,在挂起函数执行结束前当前线程不会往下执行,实现串行效果
    private suspend fun test(index: Int) {
        Log.e(TAG, "test: $index")
        runBlocking {
            DatabaseHelper.getDb(this@MainActivity).userDao().getAll()
            delay(1000)
        }
        Log.e(TAG, "test: $index after")
    }
  1. 如果需要返回结果,且被调用协程还会切线程,则无法通过协程自身逻辑实现串行,需要外界在上一个协程回调后再请求下一个。