可露希尔的协程笔记(上)

114 阅读21分钟

前言

现在这个时间点,关于协程的优秀文章已经很多了。在此也分享一下我学习协程时的笔记。我写东西比较啰嗦,整个笔记字数超过了限制,所以分上下两部分发了。上篇记录协程的基础内容,下篇记录 Channel 和 Flow.但记录下篇时正值我辞职换工作,所以下篇可能不那么完善,缺失了一些东西,等忙完这段时间稳定下来以后再补上吧。

协程定义

相对于“线程是操作系统能够进行运算掉的的最小单位”与“进程是系统进行资源分配的基本单位”这种有具体定义的概念,协程更多是一种抽象概念。所以可能我们需要盲人摸象般的,感受协程的各种特性来认识协程。

我现在还记得我在问我大哥关于协程该怎么理解时,大哥的回答:

协程就是用同步的方式写异步代码

我还是很认可这个观点的。协程的主要好处之一就是我们无需再编写需要回调的异步代码。

这里分别搬运一下谷歌官网文档以及 Kotlin 文档对于协程的描述:

协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。

协程是可挂起计算的实例。它在概念上类似于线程,因为它也需要运行一段代码,并且可以与其余代码并发执行。但是,协程并不绑定到任何特定的线程。它可以在一个线程中挂起执行,然后在另一个线程中恢复执行。

协程可以被认为是轻量级的线程,但它们与线程在实际使用中有许多重要的区别。

可以看到协程是一种更轻量,简洁且高效的异步实现方式,是官方推荐的在 Android 上进行异步编程的解决方案。

协程的核心是一段程序可被挂起恢复

使用线程实现异步的原理很简单,线程切换导致的函数栈切换自然的产生异步操作。

但异步不强制需要切换线程。协程的轻量体现在协程可以在单线程中实现协程的切换。协程与多线程无关,当我们在主线程上启动协程,代码的行为与过去使用回调时的行为无异。并没有创建额外的线程。

并且协程的挂起相较线程的阻塞更节省内存,且支持多个并行操作。此外,创建和切换线程的开销也大于相应的协程操作。比如官方文档中给出的,启动 5 万个协程的例子。而这种行为很难用线程复刻出来,众所周知创建一个线程的代价是昂贵的,创建 5 万条线程大概率会爆掉 JVM 的内存,抛出OutOfMemoryError

因此我理解使用协程就不要考虑底层的线程问题,只关心在不同场景使用不同的调度器,而不需要关心协程的底层实现。我们认为“协程可以被认为是轻量级线程”只是因为协程的关键 Api 与线程的关键 Api 很相似,这样更方便降低学习成本,但它们实际编程风格的差异是很大的。

安卓项目使用协程的话,androidx.appcompat:appcompat包含了相关依赖项,但最好还是显式添加依赖:

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
	// 提供 viewmodelScope
	implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1"
	// 提供 lifecycleScope
	implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.1"
	
}

CoroutineScope

CoroutineScope协程作用域 。所有协程都必须在一个作用域内运行。作用域会控制协程的生命周期。

协程总是配合一些局部的,拥有生命周期的实体一起使用,比如 UI ,ViewModel 或者网络通信。这一点也与更偏向全局的线程池有差异。

协程遵循 结构化并发 的原则,该原则确保子协程不会失控。即作用域内部开启新的子协程进行并发,子协程的生命周期附属于父协程的生命周,子协程的上下文继承父协程的上下文。它使得在所有的子协程完成之前,‌作用域无法完成。父协程取消,所有子协程也自动取消。子协程抛出异常,父协程也会取消。

创建协程

开启主协程有三种方式,分别看几段不同的开启协程的代码。

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello")
}

👉尝试一下

  • runBlocking 是一个全局函数,它可以在常规代码中创建协程。但这东西人如其名会阻塞当前线程直到作用域内所有协程执行完毕。所以一般在实际中很少见,只在调试或者测试的时候出场,通常没什么必要用它。

  • launch 是一个协程构建器,它可以在不阻塞当前线程的情况下启动一个新的协程。它返回一个 Job 对象。Job 对象用来操作这个协程或者查询这个协程的状态。Job 后面细说。


fun main() {
    GlobalScope.launch {
        ...
    }
    Thread.sleep(2000)
}

👉尝试一下

  • GlobalScope 用于启动与应用程序生命周期一致的顶级协程。它不会阻塞线程。所以我们 sleep 2秒手动阻塞线程防止 JVM 退出。

GlobalScope 的调度器为 Dispatchers.Default。调度器后文细说。

但此 Scope 需要慎重使用,Kotlin 的前工作人员写过这样一篇文章:避免使用GlobalScope。Google 的工作人员也写过这样一篇文章:不应取消的工作的模式,这些文章可以等对协程理解深入以后看一看,但至少他们的共同结论是不要用 GlobalScope

本文会于「取消与异常-不被取消的协程」这一节整理这两篇文章的内容。


fun main() {
    CoroutineScope(Dispatchers.IO).launch {
    	...
    }
    Thread.sleep(2000)
}

👉尝试一下

自定义 CoroutineScope。需要创建 CoroutineScope 接口的对象。有以下两种方法:

  • CoroutineScope 不会阻塞线程,可以取消。
  • MainScope 相当于CoroutineScope(SupervisorJob() + Dispatchers.Main),至于传入的参数后文细说。

使用它们需要注意生命周期问题,比如当协程依托的 Activity 关闭时,我们需要手动取消协程,否则会导致内存泄漏。

取消协程后文细说,但在不需要协程的时候手动取消它是一件很样板且很容易忘记的操作。一些 ktx 库也提供了一些预定义的 CoroutineScope,比如 viewmodelScopelifecycleScope ,它们会辅助管理协程的生命周期。

ps. 谷歌的工作人员写过这样一篇文章:Android 中的简单协程:viewModelScope

创建作用域

协程内部还可以使用函数创建其他协程作用域。

一种是异步的协程构建器,即 launch 或者 async。它们会立即返回,但其接收的代码块会与程序的其余部分同时执行。它们都是 CoroutineScope 的扩展函数,它们会启动一个新协程来执行作用域内的代码。同一个协程的异步任务遵守顺序原则开始执行。

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

协程挂起需要时间,所以异步协程永远比同步代码执行慢。

fun main() = runBlocking {
    launch {
        println("后执行")
    }
    println("先执行")
}

👉尝试一下

默认情况下协程会立即执行,但也可以指定 start 参数为 CoroutineStart.LAZY 以延迟启动线程,此时执行 Deferred 对象的 await 或者 start 函数才会执行。

start 接收的 CoroutineStart 参数一共接收 4 个参数:

  • DEFAULT 立即根据上下文安排协程执行
  • LAZY Job 执行 start 或者 join 才开始执行
  • ATOMIC 防止协程在启动前被取消,确保代码在任何情况下都将开始执行
  • UNDISPATCHED 立即执行协程而不会执行调度器(Dispatchers),直到执行到第一个挂起点。类似 ATOMIC,即使协程已经被取消,仍然会执行代码。
fun main() = runBlocking {
    println("1. 准备启动新协程")
    launch(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) {
        println("2. 先执行,在相同线程中立即执行")
        // 第一个挂起点
        delay(100)
        println("4. 第一个挂起函数后,切换调度器 ${currentCoroutineContext()[CoroutineDispatcher]}")
    }
    println("3. 后执行")

    // 1. 准备启动新协程
    // 2. 先执行,在相同线程中立即执行
    // 3. 后执行
    // 4. 第一个挂起函数后,切换调度器 Dispatchers.Default
}

👉尝试一下

fun main() = runBlocking {
    println("1. 首先,取消协程")
    cancel()
    println("2. 新建使用 UNDISPATCHED 的子协程")
    launch(start = CoroutineStart.UNDISPATCHED) {
        check(!isActive) // the child is already cancelled
        println("3. 尽管被取消,还是进入了子协程")
    }
    println("4. 外部协程的执行仅在稍后继续。")
}

👉尝试一下

协程取消后文细说。

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

asynclaunch 的主要区别在于返回值,async 用于启动计算某些结果的协程,并返回这个结果。

async 返回的 Deferred 继承自 Job。对其使用 await 函数以等待得到结果。 Deferred 集合也可以使用 awaitAll 函数等待全部完成。

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            val nameAsync = async { getName() } 	// 模拟一个1s的通信
            val titleAsync = async { getTitle() }	// 模拟一个2s的通信
            println("result: ${nameAsync.await()} - ${titleAsync.await()}")
        }
    }
    println("Done in $time ms")
    // 打印结果:
    // result: name - title
	// Done in 2005 ms
}

👉尝试一下

launchasync 处理异常的方式不同,async 希望在某一时刻调用 await,因此它持有异常并将其作为 await 的一部分重新抛出。如果不执行 await 函数则 async 函数内部抛出的异常不会被捕获,但依旧会导致崩溃。

fun main() {
    runBlocking {
        try {
            val nameAsync = async { getName() }	// 模拟1s通信,但抛出异常
            nameAsync.await()	// 注释掉await() 就不会打印 catch Exception 了
        } catch (_: Exception) {
            println("catch Exception")
        }
    }
}

👉尝试一下


一种是同步作用域函数,它们会阻塞当前作用域。它们都属于 suspend 函数,用 suspend 修饰的函数就是挂起函数,挂起函数只能有协程或其他挂起函数调用。挂起和恢复后文细说。

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            val name = withContext(Dispatchers.Default) {
                delay(2_000)
                "name"
            }
            val title = withContext(Dispatchers.Default) {
                delay(1_000)
                "title"
            }
            println("result: $name - $title")
        }
    }
    println("Done in $time ms")
    
    // result: name - title
	// Done in 3010 ms
}

👉尝试一下

  • coroutineScope 该函数是专门为并发分解设计的,此作用域中的任何子协程因异常失败取消时,会取消其他子协程,并且此作用域也会失败。会返回结果。
fun main() {
    val time = measureTimeMillis {
        try {
            runBlocking {
                val result = coroutineScope {
                    val nameAsync = async { getName() }		// 模拟1s通信
                    val titleAsync = async { getTitle() }	// 模拟2s通信
                    "${nameAsync.await()} - ${titleAsync.await()}"
                }
                println("result: $result")
            }
        } catch(_: Exception) {
            println("Exception")
        }
    }
    println("Done in $time ms")
	// 如果 getName 方法等待1s后抛出异常
    // Exception
	// Done in 1008 ms
    
	// 如果 正常结束
    // result: name - title
	// Done in 2011 ms
}

  • supervisorScope 创建了一个使用 SupervisorJob 的 coroutineScope,它的子作用域的异常不会影响该作用域本身以及其他子作用域。这个 Job 后文细说。如果作用域本身内发生异常,那所有子作用域也会被取消。
fun main() = runBlocking {
    supervisorScope {
        val job1 = launch {
            delay(1000)
            println("Job1 completes")
        }

        val job2 = launch {
            delay(500)
            throw RuntimeException("Job2 failed") // Job2 抛出异常
			println("Job2 completes")
        }

        val job3 = launch {
            delay(1500)
            println("Job3 completes")
        }
        
        try {
            delay(2000)
            println("SupervisorScope completes")
        } catch (e: Exception) {
            // 子协程异常不会影响其本身,所以这里的catch不会触发,
            // 如果你把 supervisorScope 改成 coroutineScope 那么子协程的异常就会传达到这里并且被捕获
            // 但是作用域本身抛出异常的话,所有子协程都会被取消
            // 可以在try外抛出异常,或者把 job2 的launch 改成 async().await() 那么job1和3就也不会完成
            println("SupervisorScope Exception")
        }
    }
}

👉尝试一下

ps. 这段代码不能直接复制到 IDE 内通过 app 执行,会导致 app 崩溃,需要额外添加异常捕获的处理,比如对 job2 使用后文提到的 CoroutineExceptionHandler。代码

挂起与恢复

挂起函数,带有 suspend 关键字的函数。挂起函数只能被其他挂起函数或协程调用。它可以暂停代码的执行以等待其他挂起函数的执行,等待其完成而不会阻塞当前线程,再在别的时机恢复执行暂停的代码。

挂起函数会被编译器转换为一个额外携带 Continuation<T> 类型参数,Continuation 接口有一个 CoroutineContext 类型的属性 contextresumeWith 方法。该方法恢复协程的执行,传递成功或失败的结果。分别由 resumeresumeWithException 两个扩展方法处理成功及失败。

@SinceKotlin("1.3")
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

@SinceKotlin("1.3")
@InlineOnly
public inline fun <T> Continuation<T>.resume(value: T): Unit =
    resumeWith(Result.success(value))

@SinceKotlin("1.3")
@InlineOnly
public inline fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit =
    resumeWith(Result.failure(exception))

👉带文档源码地址

从注解可以看到这个结构是 Kotlin 1.3 版本以来的状况,在这之前两个扩展函数就是 Continuation 的方法。这里推荐站内另一篇博客,这个动图效果很赞。

suspend 关键字不指定运行代码的线程。挂起函数可以在后台线程或主线程上运行。

安卓程序中,阻塞主线程会导致 UI 冻结。协程通过挂起与恢复提供了线程阻塞的替代方案。但需要注意仅仅向一个阻塞函数添加 suspend 关键字并不会使它变成非阻塞的。

例如以下函数:

suspend fun blockFun() {
    Thread.sleep(2000)
}

在主线程中调用该函数照样会冻结 UI,并且其实 Android Studio 也给出了他的提示。在 IDE 中这里的 suspend 会变暗,鼠标划上去的话也会看到 Redundant 'suspend' modifier,即冗余 suspend 修饰符,并提示你可以删除它。

Snipaste_2024-09-13_11-00-41.png

由于协程的特性是挂起函数不会阻塞调用者线程。所以将阻塞函数改为不会阻塞的挂起函数的思路是将阻塞部分的代码放在挂起函数中。实现这个思路的方法是 withContext

suspend fun notBlockFun() = withContext(Dispatchers.IO) {
    Thread.sleep(2000)
}

现在从安卓程序的主线程调用此程序也不会冻结 UI 了。

因为这里实际上不消耗 CPU 资源进行计算,所以使用 Dispatchers.IO。如果阻塞函数涉及到计算则应使用 Dispatchers.Default。关于 Dispatchers 的内容后文细说。

但需要注意此处不能使用 Dispatchers.Main 。因为虽然 withContext(Dispatchers.Main) 不会阻塞调用者线程(主线程),但其内部的 Thread.sleep 还是会阻塞住的 Dispatchers.Main 代表的主线程。调度器与线程的关系可以看下文「CoroutineContext-CoroutineDispatcher」部分的叙述。

如果考虑到后文会讲述到的取消,也可以考虑使用 suspendCancellableCoroutine,比如唐子玄大佬这篇文章里的「对生命周期不友好」这一节,将 MMKV 异步化的操作。

当然最好的解决方案永远是使用相同功能但适配了协程的,真正的异步函数。比如使用 delay 而不是上例中的 Thread.sleep

CoroutineContext

协程上下文( CoroutineContext )是定义协程行为的一组元素。主要元素是 Job 以及 Dispatcher

  • Job 控制协程生命周期
  • CoroutineDispatcher 将任务分派给适当的线程
  • CoroutineName 协程名称,调试有用
  • CoroutineExceptionHandler 处理未捕获的异常,后文细说

可以使用 currentCoroutineContext() 函数获取当前上下文

fun main() = runBlocking {
    println("My context is: ${currentCoroutineContext()}")

    // My context is: [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@2471cca7, BlockingEventLoop@5fe5c6f]
}

👉尝试一下

协程上下文是不可变的,如果需要改变上下文中的元素就需要使用 launch 或者 async 新建一个协程。

新的协程会优先使用构建器中传递的参数。创建新的 Job 实例,以便控制其生命周期。其余的元素将继承父协程上下文。

比如以 launch 为例

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    // ...
): Job

它是 CoroutineScope 的扩展函数且以 CoroutineContext 作为参数。因此它实际上需要两个协程上下文。他会用加号运算符合并它们。加号运算符右侧的元素会替换左侧具有相同键的元素。合并后的新上下文用于启动新协程,但它不是新协程的上下文。它是新协程的父上下文。新协程使用此父上下文加上新创建的 Job 实例组成的子上下文。

1_zuX5Ozc2TwofXlmDajxpzg.webp

协程上下文本身有四个函数

  • get 提供其的类型安全访问,可以与 [..] 表示法一起使用
println("coroutineName : ${currentCoroutineContext()[CoroutineName]}")
listOf(Job(), CoroutineName("coroutine B"), Dispatchers.Default).fold(
    EmptyCoroutineContext
) { acc: CoroutineContext, element ->
    acc + element
}
  • plus 会返回两个上下文的组合,其中右侧的元素会替换左侧具有相同键的元素。
fun main() = runBlocking(CoroutineName("coroutine A") + CoroutineName("coroutine B")) {
    println("coroutineName : ${currentCoroutineContext()[CoroutineName]}")

    // coroutineName : CoroutineName(coroutine B)
}

👉尝试一下

  • minusKey 提供一个不返回指定键的上下文
fun main() = runBlocking(CoroutineName("coroutine A") + Dispatchers.Default) {
    println("currentCoroutine : ${currentCoroutineContext()}")
    println("coroutine.minusKey : ${currentCoroutineContext().minusKey(CoroutineName)}")

    // currentCoroutine : [CoroutineName(coroutine A), CoroutineId(1), "coroutine A#1":BlockingCoroutine{Active}@5a7606ea, Dispatchers.Default]
	// coroutine.minusKey : [CoroutineId(1), "coroutine A#1":BlockingCoroutine{Active}@5a7606ea, Dispatchers.Default]
}

👉尝试一下

Job

Job 是协程的句柄。使用 launchasync 创建的每个协程都会返回一个 Job 实例。该实例唯一标识该协程并管理其生命周期

Job 的生命周期内可以经历一组状态:New,Activie,Completing(瞬态),Completed,Cancelling(瞬态) 和 Cancelled。

0_zGHzocA6-lCxk0aX.webp

这些状态我们无权访问,只能访问 Job 的字段:

// Active 和 Completing 返回 true
public val isActive: Boolean
// Cancelling 和 Cancelled 返回 true
public val isCancelled: Boolean
// Cancelled 和 Completed 返回 true
public val isCompleted: Boolean

Job 还可以通过以下函数控制协程的生命周期:

// 取消协程
public fun cancel(cause: CancellationException? = null)
// 取消协程,并且阻塞调用者,直到取消完成
public suspend fun Job.cancelAndJoin()

// 作用域完成后调用一次,提供异常或取消的原因或 null。
public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle

// 阻塞直到当前协程执行完毕
public suspend fun join()
// 类似 awaitAll 相当于 jobs.forEach{ it.join() }
public suspend fun Collection<Job>.joinAll(): Unit
// 当Job完成后被调用,异常也会调用
public val onJoin: SelectClause0

// 启动协程。如果该调用启动了协程则返回true。如果已经启动或者完成则返回false
public fun start(): Boolean

其中 onJoin 的返回类型为 SelectClause0。这种 on{动作} 变量的值全部是 SelectClause{数字} 接口对象。这部分属于 select 表达式的内容,后文细说。

Deferred

如前文所述,Deferredasync 的返回值。它是有结果的 Job。

除了扩展了 await 方法,它还有以下变量和扩展:

// 当async 结果可用时调用的回调
public val onAwait: SelectClause1<T>

// 返回已完成的结果。如果尚未完成则抛出 IllegalStateException 异常,如果被取消则抛出相应异常。
// 实验性api
public fun getCompleted(): T

// 如果被取消或已完成则返回 completion exception.如果正常完成返回 null。如果尚未完成则抛出 IllegalStateException 异常。
// 实验性api
public fun getCompletionExceptionOrNull(): Throwable?

除了 async,还可以使用顶层函数创建 CompletableDeferred对象。

// 创建处于 active 状态的 CompletableDeferred。传参为可选的父作业。
fun <T> CompletableDeferred(parent: Job? = null): CompletableDeferred<T>
// 以给定值创建一个 completed 状态的 CompletableDeferred
public fun <T> CompletableDeferred(value: T): CompletableDeferred<T>

CompletableDeferred 的好处就是支持自定义。可以手动控制完成或者取消。

// 用给定值完成 若因此调用而完成则返回 true,若已经完成则返回 false
public fun complete(value: T): Boolean

// 用给定异常完成 异常发生在await()时 若因此调用而完成则返回 true,若已经完成则返回 false
public fun completeExceptionally(exception: Throwable): Boolean

// 使用给定 Result 的值或异常来完成 若因此调用而完成则返回 true,若已经完成则返回 false
public fun <T> CompletableDeferred<T>.completeWith(result: Result<T>): Boolean

SupervisorJob

SupervisorJob 用于协程的异常与取消,会在下文的「取消与异常」中提到。

NonCancellable

NonCancellable 用于协程的异常与取消,会在下文的「取消与异常」中提到。

CoroutineDispatcher

协程调度器 CoroutineDispatcher 用于将协程上的工作分派到适当的线程。Dispatchers 提供了四个实现:

  • Dispatchers.Default 默认调度器,有专门优化,使用共享的后台线程池,线程数量与 CPU 核心数相同,适用于占用CPU的计算操作。
  • Disaptchers.IO 有专门优化,使用共享的按需创建的线程池,适合执行磁盘或网络I/O。
  • Dispatchers.Main 安卓主线程,只能用于 UI 对象操作和执行快速工作。
  • Dispatchers.Unconfined 在当前线程执行,直到第一个挂起点。之后的代码也会运行在该线程上。
fun main() = runBlocking {
    println("1.thread: ${Thread.currentThread().name}")
	withContext(Dispatchers.Unconfined) {
        println("2.thread: ${Thread.currentThread().name}")
        withContext(Dispatchers.IO) {
            println("第一个挂起点")
            delay(100)
        }
        println("3.thread: ${Thread.currentThread().name}")
    }    

    // 1.thread: main @coroutine#1
    // 2.thread: main @coroutine#1
    // 第一个挂起点
    // 3.thread: DefaultDispatcher-worker-2 @coroutine#1
}

👉尝试一下

调度器身为协程上下文的一员,如果新协程没有指定调度器,则继承当前作用域的调度器。

Kotlin 优化了 Dispatchers.DefaultDispathcers.IO 之间的切换以尽可能避免线程切换。

对于使用线程池的调度器,如 Dispatchers.DefaultDispathcers.IO,不能保证代码块在同一线程上从上到下执行。在某些情况下,Kotlin 协程在 suspend 和 resume 后可能会将执行工作移交给另一个线程。这意味着,对于整个 withContext() 块,线程局部变量可能并不指向同一个值。

immediate

immediate 代表如果目标调度器与当前调度器一致则不需要额外的重新调度而直接执行。

Android 平台中 Dispatchers.Main 支持 immediate

CoroutineScope(Dispatchers.Main).launch {  
    CoroutineScope(Dispatchers.Main.immediate).launch {  
        println("1")  
    }  
    println("2")  
    CoroutineScope(Dispatchers.Main).launch {  
        println("4")  
    }  
    println("3")  
}

limitedParallelism

limitedParallelism 会创建当前调度器的视图(view),使用给定值来限制并行量。该视图不新建新线程而是重用现有的调度器线程。

但需要注意的是,该函数的作用是限制并行而不是限制并发

如果你和我一样对这些概念不是很了解,可以跟我看一下下面这个例子。下文会扯很多概念上的内容,如果你熟知这些概念可以跳过这节了。

我们考虑一个场景,我们需要调用一个接口若干次,但该接口有频率限制,我们同一时间最多只能保持 2 个请求。假设我们需要调用该接口 10 次,每次调用耗时 200ms,那我们预期总耗时为 1000ms 上下。

假如我们使用支持协程的异步网络库(用delay模拟调用接口耗时),那我们会发现使用该函数后,总耗时仍然是 200ms 上下。

val time = measureTimeMillis {
runBlocking {
val jobs = (1..10).map {
async(Dispatchers.IO) {
println("itit - {Thread.currentThread().name}")
delay(200)
}
}
jobs.awaitAll()
}
}
println("time: $time")
val limitDispatcher = Dispatchers.IO.limitedParallelism(2)
val time = measureTimeMillis {
runBlocking {
val jobs = (1..10).map {
async(limitDispatcher) {
println("itit - {Thread.currentThread().name}")
delay(200)
}
}
jobs.awaitAll()
}
}
println("time: $time")
👉尝试一下👉尝试一下
2 - DefaultDispatcher-worker-3
1 - DefaultDispatcher-worker-8
4 - DefaultDispatcher-worker-12
5 - DefaultDispatcher-worker-8
8 - DefaultDispatcher-worker-12
3 - DefaultDispatcher-worker-13
9 - DefaultDispatcher-worker-12
10 - DefaultDispatcher-worker-8
6 - DefaultDispatcher-worker-10
7 - DefaultDispatcher-worker-6
time: 206
1 - DefaultDispatcher-worker-1
2 - DefaultDispatcher-worker-2
3 - DefaultDispatcher-worker-2
4 - DefaultDispatcher-worker-1
5 - DefaultDispatcher-worker-2
6 - DefaultDispatcher-worker-1
7 - DefaultDispatcher-worker-2
8 - DefaultDispatcher-worker-1
9 - DefaultDispatcher-worker-2
10 - DefaultDispatcher-worker-1
time: 205

可以看到,使用普通的 Dispathcers.IO 的行为就是正常的无限制的并行,总耗时 200ms 上下。这段代码每次执行的结果都可能不一样,因为每一次使用的线程都有可能是不同的。

我们限制并行性,只是限制了我们使用 IO 线程池中的线程的数量,可以观察到后者的输出中,协程只会在依附两个线程上。

如果想完成我们的这个需求,我们需要的是限制并发性,而不是并行性。

  • 并发是多个任务同时发生的概念,它们可以在重叠的时间段内启动、运行和完成。
    • 但这并不意味着它们会同时完成,比如单核处理多个任务只是快速频繁的在不同任务间切换。
  • 并行是指任务同时运行。
    • 比如多核是实实在在的,同时处理多个任务。

并行是实现并发的方法之一(同时进行计算)。如果我一个接一个发出接口请求,然后等待返信结果,这是并发的,但不是并行的。

限制并发性也有很多种方法,比如在仍使用 limitedParallelism 的情况下,我们可以使用同步代码替换掉异步代码。

val time = measureTimeMillis {  
    runBlocking {  
        val jobs = (1..10).map {  
            async(limitDispatcher) {  
                println("$it - ${Thread.currentThread().name}")  
                // delay(200)  
                Thread.sleep(200)  
            }  
        }  
        jobs.awaitAll()  
    }  
}  
println("time: $time")

// 1 - DefaultDispatcher-worker-1
// 2 - DefaultDispatcher-worker-2
// 3 - DefaultDispatcher-worker-1
// 4 - DefaultDispatcher-worker-2
// 5 - DefaultDispatcher-worker-1
// 6 - DefaultDispatcher-worker-2
// 7 - DefaultDispatcher-worker-1
// 8 - DefaultDispatcher-worker-2
// 10 - DefaultDispatcher-worker-2
// 9 - DefaultDispatcher-worker-1
// time: 1008

👉尝试一下

我在刚学到这里时被频繁的并行、并发、同步、异步这些概念砸脸,而长时间没接触这些概念,难免对它们比较模糊,这里就又费了些劲才理解清楚这些概念。为了防止以后再忘记,这里也补充一下同步和异步的概念。

这里贴的是 SO 上的我很喜欢的一个答案。

同步/异步与多线程无关。

Synchronous (one thread):
同步(一个线程) :

1 thread ->   |<---A---->||<----B---------->||<------C----->|

Synchronous (multi-threaded):
同步(多线程) :

thread A -> |<---A---->|   
                        \  
thread B ------------>   ->|<----B---------->|   
                                              \   
thread C ---------------------------------->   ->|<------C----->| 

Asynchronous (one thread):
异步(单线程) :

         A-Start ------------------------------------------ A-End   
           | B-Start -----------------------------------------|--- B-End  
           |    |      C-Start ------------------- C-End      |      |   
           |    |       |                           |         |      |
           V    V       V                           V         V      V    
1 thread->|<-A-|<--B---|<-C-|-A-|-C-|--A--|-B-|--C-->|---A---->|--B-->| 

Asynchronous (multi-Threaded):
异步(多线程) :

 thread A ->     |<---A---->|
 thread B ----->     |<----B---------->| 
 thread C --------->     |<------C--------->|

我觉得有这个图差不多就够了。也可以去原文地址看看更多文字性的描述。

再说回我们假设的场景。前文我们说过,我们应该更多的使用支持协程的异步库,而非会阻塞线程的同步库。就算用同步库也应该用 withContext 将其改造成异步的。而我们现在将 delay 换成 Thread.sleep 这种手段无疑是倒反天罡了。

这个场景和并发性无关,那我们也应该去除掉 limitedParallelism(2) 转而使用其他的,协程中的控制并发性的手段。其中比较简单的手段是使用 semaphore

val limitSemaphore = Semaphore(2)
val time = measureTimeMillis {
	runBlocking {
		val jobs = (1..10).map {
			async(Dispatchers.IO) {
				limitSemaphore.withPermit {
					println("$it - ${Thread.currentThread().name}")
					delay(200)
				}
			}
		}
		jobs.awaitAll()
	}
}
println("time: $time")

// 1 - DefaultDispatcher-worker-1
// 2 - DefaultDispatcher-worker-2
// 3 - DefaultDispatcher-worker-10
// 4 - DefaultDispatcher-worker-1
// 5 - DefaultDispatcher-worker-3
// 6 - DefaultDispatcher-worker-7
// 7 - DefaultDispatcher-worker-7
// 8 - DefaultDispatcher-worker-10
// 9 - DefaultDispatcher-worker-8
// 10 - DefaultDispatcher-worker-7
// time: 1015

👉尝试一下

当然,这个场景也可以用 Flow 更优雅的完成。

CoroutineName

CoroutineName 用于为协程指定一个名字,调试用。

fun main() = runBlocking(CoroutineName("摸鱼是优质生产力")) {
    println("${currentCoroutineContext()[CoroutineName]}")
}

👉尝试一下

CoroutineExceptionHandler

CoroutineExceptionHandler 就在下一章中的「取消与异常」中提到。

取消与异常

取消与异常也是结构化并发重要的组成部分。

取消

主动取消

使用 cancel() 来取消协程。

如果想取消单个协程,则调用 Job.cancel 可取消指定的协程。取消单个协程不会影响其同级协程。

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            val job1 = launch {
                delay(2000)
                println("part 1")
            }
            val job2 = launch {
                delay(1000)
                println("part 2")
            }
            delay(700) 
            job1.cancel()
        }
    }
    println("time: $time")

    // part 2
	// time: 1004
}

👉尝试一下

也可以对整个协程作用域取消。取消作用域会取消其所有子协程。

val time = measureTimeMillis {  
    runBlocking {  
        val scope = CoroutineScope(Dispatchers.Default)  
        scope.launch {  
            delay(500)  
            println("scope child 1")  
        }  
        scope.launch {  
            delay(1000)  
            println("scope child 2")  
        }  
        delay(300)  
        println("scope cancel")  
        scope.cancel()  
    }  
}  
println("time: $time")

// scope cancel
// time: 306

👉尝试一下

协程取消时会抛出 CancellationException。取消函数可以自定义异常对象以提供取消原因。

fun cancel(cause: CancellationException? = null)

该异常比较特殊。正常情况下子级通过异常通知父级取消。但该异常会被默认的异常处理器忽略,但仍然可以捕捉到该异常。比如官方的捕获该异常但只打印不抛出异常的样例。

val job = launch(Dispatchers.Default) {
    repeat(5) { i ->
        try {
            // print a message twice a second
            println("job: I'm sleeping $i ...")
            delay(500)
        } catch (e: Exception) {
            // log the exception
            println(e)
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

👉尝试一下

需要注意 async.await。如果先取消了 async 再对其使用 await 的话,会导致 await 抛出 JobCancellationException 异常导致崩溃。

val job = async(Dispatchers.Default) {
	try {
		delay(1500)
		"Done"
	} catch (e: Exception) {
		// log the exception
		println(e)
		throw e
	}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel()
val result = job.await()
println("result: $result")

👉尝试一下

协程的取消是协作式的,协程代码需要配合才能取消。

如果我们只是调用 cancel ,并不意味协程就会停止。kotlinx.coroutines 中的所有挂起函数都是可取消的。如果协程正在运行其他需要长时间工作的代码则且没有检查当前状态,则协程不会被取消。

比如上面的例子,我们把 delay 改成 Thread.sleep 则协程不会被停止,异常也不会被捕获。

// ...
try {  
	println("job: I'm sleeping $i ...")  
	Thread.sleep(500)  // 如果这里是 delay 则会捕获并抛出异常,总耗时为 1300ms 上下
} catch (e: Exception) {  
	println(e)  
	throw e  // 这里抛出异常
} 
// ...

// job: I'm sleeping 0 ...
// job: I'm sleeping 1 ...
// job: I'm sleeping 2 ...
// main: I'm tired of waiting!
// job: I'm sleeping 3 ...
// job: I'm sleeping 4 ...
// main: Now I can quit.
// time: 2515

👉尝试一下

此时为了赋予代码协作性,我们需要检查协程当前状态 isActive

// ...
try {  
	println("job: I'm sleeping $i ...")  
	if (!isActive) return@launch
	Thread.sleep(500)
} catch (e: Exception) {  
	println(e)  
	throw e  // 这里抛出异常
} 
// ...

// job: I'm sleeping 0 ...
// job: I'm sleeping 1 ...
// job: I'm sleeping 2 ...
// main: I'm tired of waiting!
// job: I'm sleeping 3 ...
// main: Now I can quit.
// time: 1507

👉尝试一下

或者可以用协程库提供的方法 ensureActive

fun Job.ensureActive(): Unit {  
	if (!isActive) {  
		throw getCancellationException()  
	}  
}

除此以外,当耗时任务为 CPU 高占用运算、可能会耗尽线程池或允许线程执行其他工作而不向池中添加更多线程的情况时。可以使用 yieldyield 内部首先会调用 ensureActive 检查协程是否被取消,其次会暂时挂起当前协程,让出资源执行其他协程。

释放资源

可以使用 try {...} finally {...} 表达式中的 finally 块中释放资源。finally 一定会执行。

runBlocking {  
    val job = launch(Dispatchers.Default) {  
        try {  
            println("job: I'm sleeping ...")  
            delay(1500)  
        } finally {  
            println("释放资源")  
        }  
    }  
    delay(300L) // delay a bit  
    println("main: I'm tired of waiting!")  
    job.cancelAndJoin() // cancels the job and waits for its completion  
    println("main: Now I can quit.")  
}

👉尝试一下

NonCancellable

需要注意,处于取消状态的协程无法挂起。所以如果想在 finally 中使用协程的话,需要使用 withContext 以及 NonCancellable

// ...
try {  
	println("job: I'm sleeping ...")  
	delay(1500)  
} finally {  
	withContext(NonCancellable) {  
		delay(100)  
		println("释放资源")  
	}
}  
// ...

👉尝试一下

超时取消

withTimeout 用于执行时间超过给定时间时取消协程的场景。

withTimeout(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
}

// I'm sleeping 0 ... 
// I'm sleeping 1 ... 
// I'm sleeping 2 ... 
// ...TimeoutCancellationException: Timed out waiting for 1300 ms

👉尝试一下

它会抛出 TimeoutCancellationException 以结束协程。如果不想结束协程可以使用 withTimeoutOrNull,该函数超时时会返回 null 而非结束协程。

超时事件相对于块中代码运行是异步的。所以官方文档建议在 finally 中释放资源。

异常

当协程失败抛出异常时,抛出的异常会层层上抛。接受到异常的父级会取消其余子级、取消自身并将异常继续上抛到它的父级直到上传到根部。此时所有的根作用域启动的协程都会被取消。

异常这一节的代码就建议放在 Android 中执行并查看效果了。

SupervisorJob

SupervisorJob 会修改默认的「双向取消」的机制,改为单向取消。使用 SupervisorJob 的作用域的子级失败时,不会再取消其余子级,也不会继续上抛异常。

public fun SupervisorJob(parent: Job? = null) : CompletableJob

但阻止传播并不意味着不需要处理异常。如果没有处理异常,不论哪种 Job 都会抛出未捕获的异常导致崩溃。

CoroutineScope(Dispatchers.Default).launch {  
    val handler = CoroutineExceptionHandler { _, e -> println("handler work $e") }  
    with(CoroutineScope(Dispatchers.Default + handler + SupervisorJob(currentCoroutineContext()[Job]))) {  
        launch {  
            println("child 1 throw Exception")  
            throw IndexOutOfBoundsException()  
        }  
        launch {  
            delay(100)  
            println("child 2 done")  
        }  
    }  
}

// child 1 throw Exception
// handler work java.lang.IndexOutOfBoundsException
// child 2 done

👉尝试一下

如上代码中的 CoroutineExceptionHandler 稍后会介绍,可以认为使用handler处理了异常。

如果不传递 SupervisorJobjob1 的话,该子协程的异常就会取消同级 job2 以及父协程。handler work 之后的 log 便不会被打出。

直接创建 SupervisorJob() 对象传入作用域中会导致该父协程取消以后该子协程不会受到影响取消。可以将currentCoroutineContext()[Job] 指定为 SupervisorJob 的第一个参数 parent。但需要注意如果这样写的话父协程的 Job 就不能使用 join 函数了,否则会死锁。(原因我没分析出来😣)

官方建议用 supervisorScopeCoroutineScope(SupervisorJob()) 的形式使用它。如果直接在 launch 中传递的话要注意一下生效范围。

// 不要顺手写成这样
CoroutineScope().launch(SupervisorJob()) {
	launch {
		// Child 1 这里抛出异常会取消 Child 2
	}
	launch {
		// Child 2
	}
}

异常捕获

Kotlin 协程使用 try/catch 捕获异常。使用 CoroutineExceptionHandler 处理未捕获的异常。

launch内发生异常会立刻抛出。launch 抛出的异常不会被 try/catch 捕获,所以它只能用 CoroutineExceptionHandler 来处理。且由于 launch 会将异常逐渐上抛,所以一般要配合 SupervisorJob 使用,否则只会触发最外层的 CoroutineExceptionHandler 而不会触发当前 launch 上下文中的。

async 内发生异常不会立刻抛出,它会在调用 await 函数时才抛出异常,这个异常可以被 try/catch 捕获。

async 也会把异常逐级上抛,所以下例中的 launch 中的 CoroutineExceptionHandler 可以在不调用 await 的情况下捕获到异常。

val handler = CoroutineExceptionHandler { _, e -> println("handler work $e") }  
CoroutineScope(Dispatchers.Default).launch(handler) {  
    val deferred = async() {  
        throw IndexOutOfBoundsException()  
    }  
}

👉尝试一下

async 不调用 await 则不抛出异常,所以 asyncCoroutineExceptionHandler 不会生效。

val handler = CoroutineExceptionHandler { _, e -> println("handler work $e") }  
CoroutineScope(Dispatchers.Main).async(handler) {  
    throw IndexOutOfBoundsException()  
}

👉尝试一下

CoroutineExceptionHandler 并不能阻止协程作用域的取消,它只是监听异常避免JVM抛出异常导致退出程序。

不应被取消的协程

这一小节讨论在 App 后台运行协程执行工作的场景,我们不希望这些协程被随意取消。

比如网络通信可能会根据 Activity 的生命周期而自动取消,但我们希望写数据库的函数不受其影响一直工作直到完成。但这种场景下,GlobalScope 并不是一个好选择,Kotlin官方谷歌官方分别写了文章建议不要直接使用它。使用 GlobalScope 的话 IDE 会给出如下警告:

This is a delicate API and its use requires care. Make sure you fully read and understand documentation of the declaration that is marked as a delicate API.

这是一个微妙的 API,使用时需要小心。确保您完全阅读并理解标记为微妙 API 的声明文档。

需要这么谨慎的原因是 GlobalScope 的协程上下文中没有 Job

println("GlobalScope ${GlobalScope.coroutineContext[Job]}")  
println("CoroutineScope ${CoroutineScope(Dispatchers.Default).coroutineContext[Job]}")

// GlobalScope null
// CoroutineScope JobImpl{Active}@62038c9

👉尝试一下

没有 Job 意味着结构化并发的失效,也就意味着有内存泄漏的风险。GlobalScope 中启动的协程永远不会自动取消,需要手动跟踪控制。没有 Job 也意味着你无法对 GlobalScope 使用 cancel 函数。

官方博客中的建议是在 Application 中创建 CoroutineScope 配合 SupervisorJob 以及其他设置项。再通过这个作用域来启动协程。

Ktx

lifecycleScope

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.1"

每个 Lifecycle 对象都定义了 lifecycleScope,范围内的协程会在对象销毁的时候自动取消。

该作用域使用 SupervisorJob() + Dispatchers.Main.immediate.

可以在作用域内配合 repeatOnLifecycle 获得生命周期感知能力。

lifecycleScope.launch {  
    repeatOnLifecycle(Lifecycle.State.STARTED) {  
		// 块内代码会在至少处于 STARTED 状态是运行,处于 STOPPED 状态时停止运行
    }  
}

viewModelScope

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1"

lifecycleScope 类似,viewModelScope 会在 ViewModel 执行 onCleared 时取消。

它也使用 SupervisorJob() + Dispatchers.Main.immediate.

LiveData

implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.1"

提供了两个函数

public fun <T> liveData(  
    context: CoroutineContext = EmptyCoroutineContext,  
    timeoutInMs: Long = DEFAULT_TIMEOUT,  
    block: suspend LiveDataScope<T>.() -> Unit  
): LiveData<T>

@RequiresApi(Build.VERSION_CODES.O)  
public fun <T> liveData(  
    context: CoroutineContext = EmptyCoroutineContext,  
    timeout: Duration,  
    block: suspend LiveDataScope<T>.() -> Unit  
): LiveData<T>

这两个函数功能一样,用于异步更新 LiveData 值的情况。它们的区别是传入的超时时间单位不一致,如果 LivaData 没有处于活跃状态的观察者,则会根据超时时间取消作用域。block 作用域的默认调度器也是 Dispatchers.Main.immediate.

LiveDataScope 内通过 emitemitSource 设置数据。

public interface LiveDataScope<T> {
	public suspend fun emit(value: T)
	public suspend fun emitSource(source: LiveData<T>): DisposableHandle
	public val latestValue: T?
}

每次调用 emitemitSource 会移除之前调用过的 emitSource 设置的源。

后记及参考资料

因为收尾的时候,正值离职准备跳槽。有一些遗漏,也有点乱,也没有专门的重新读一下校对一下。之后有空了,补完了 Select 的内容后再整理一下 Flow 遗漏的部分。

  1. 站内博客,很全面
  2. 东哥的博客,这篇和上一篇的评论区也值得一看
  3. 京东技术的文,协程初探,兄弟们确实没老板上进
  4. 站内另一篇博客,动图很赞,文笔也很好
  5. Kotlin 官方文档
  6. Android 官方文档
  7. 官方GitHub上的文档
  8. 官方博客汇总
  9. M站博客,结构化并发
  10. M站博客,阻塞与挂起
  11. M站博客,避免使用GlobalScope
  12. 官方codelab
  13. M站博客,viewmodelScope
  14. M站博客,首要事项
  15. M站博客,协程上下文
  16. Flow codelab
  17. Flow 经验教训
  18. Flow shareIn 和 stateIn
  19. LiveData 迁移到 Flow
  20. 站内,Flow使用系列三篇
  21. 霍丙乾大佬的 Channel,Flow,Select系列三篇
  22. 油管,Roman Elizarov 的 Flow 实现异步流的演讲
  23. Roman Elizarov 的 Flow 的文章
  24. 冷热数据源
  25. JB 的 channel 介绍视频
  26. Shreyas Patil 的博客 select
  27. Android 官方 Flow 视频
  28. Roman 对结构化并发一周年的总结
  29. Roman Flow 的简单设计
  30. Roman Flow 和协程
  31. 反应流和 Kotlin Flow
  32. 回调和 Kotlin Flow