文章目录
1.什么是协程
从本质上来说,协程是一种轻量级的线程,不同协程之间的切换仅在编程语言的层面就可以实现。而线程之间的切换需要依靠操作系统的调度才能实现。
我们可以在单个线程中创建多个协程,协程支持挂起,挂起的同时又不会阻塞当前线程,这也是我们常说的非阻塞式挂起。
如何理解Android中的协程,我们来看ChatGPT给出的答案:
2.在Android中如何使用协程
由于协程并没有写入Android的内置API中,所以在Android中使用协程,我们需要在Android项目中app目录下的build.gradle.kts文件中加入以下依赖:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
目前官方最新的协程依赖库已经更新到1.8.1-Beta,而笔者对于协程方面的知识介绍都是基于上面我们引入的1.7.3版本。
3.协程的几种创建方式
GlobalScope
GlobalScope是一个单例类,它实现了CoroutineScope接口。
public object GlobalScope : CoroutineScope {
override val coroutineContext: CoroutineContext get() = EmptyCoroutineContext
}
launch()则是CoroutineScope的一个扩展函数,该函数的返回值类型则是一个Job,我们使用该函数来启动一个协程,如下代码示例:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) : Job
fun main() {
GlobalScope.launch {
println("hello launch.")
}
}
运行这段代码,会发现没有任何日志输出。这是因为,Global.launch()每次创建的都是一个顶层协程,当应用程序运行结束时也会跟着一起结束,协程中的代码还未来得及去执行。要解决这个问题也比较简单,我们让程序延迟一段时间来保证JVM的存活,如下代码示例:
fun main() {
GlobalScope.launch {
println("hello launch.")
}
Thread.sleep(100)
}
// 输出
hello launch.
这里我们使用Thread.sleep()方法让当前线程阻塞100毫秒,然后成功输出了launch()函数中的日志。
CoroutineScope
CoroutineScope是Kotin协程库中的一个顶层函数:
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
internal class ContextScope(context: CoroutineContext) : CoroutineScope {
override val coroutineContext: CoroutineContext = context
...
}
public interface CoroutineScope {
// 此范围的上下文。上下文由作用域封装,并用于实现作为作用域扩展的协程生成器。
// 不建议出于任何目的在常规代码中访问此属性,除非出于高级用途访问 Job 实例。
public val coroutineContext: CoroutineContext
}
这里我们把CoroutineScope()函数、ContextScope类、以及CoroutineScope接口的代码都贴出来了,希望大家不要弄混了,因为这里的函数名和接口名确实是相同的。
CoroutineScope()函数要求我们传入一个协程上下文的参数,并将该上下文对象保存在ContextScope类中然后返回。
ContextScope(if (context[Job] != null) context else context + Job())
创建ContextScope对象的时候,这里会有一个if条件的判断,就是我们上下文中是否包含了Job类型的对象,如果没有就会帮我们强行添加一个。这里涉及到了协程上下文对象的数据结构,关于这块的内容,我们后面的文章也会详细说明,这里就不展开介绍了。而两个上下文对象可以相加,是重载了 + 操作符,在操作符重载的文章里我们也已经详细介绍了。
其用法和GlobalScope是一致的,都是使用CoroutineScope的扩展函数launch()来启动一个协程,如下代码示例:
fun main() {
CoroutineScope(Dispatchers.IO).launch {
println("hello launch.")
}
Thread.sleep(100)
}
GlobalScope创建的是一个全局的协程作用域,而CoroutineScope()函数创建的是一个临时的协程作用域。所以在实际开发中我们更多的是使用CoroutineScope()函数,而不是GlobalScope这个单例类。
runBlocking
runBlocking同样也是Kotlin协程库中的一个顶层函数:
public actual fun <T> runBlocking(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T {
...
val currentThread = Thread.currentThread()
...
val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
return coroutine.joinBlocking()
}
和上面介绍到的GlobalScope和CoroutineScope()不同的是,使用runBlocking来创建一个协程作用域,该方法会先阻塞当前线程,直到协程中的代码执行完成,才会释放当前线程,而且该方法拥有返回值。具体用法也比较简单:
fun main() {
val result = runBlocking {
"hello runBlocking"
}
println("$result")
}
// 输出
hello runBlocking.
那么runBlocking是如何阻塞当前线程的呢?我们可以看BlockingCoroutine中的joinBlocking方法:
fun joinBlocking(): T {
...
while (true) {
if (isCompleted) break
}
...
val state = this.state.unboxState()
(state as? CompletedExceptionally)?.let { throw it.cause }
return state as T
}
为了方便阅读这里对源码进行了一些简化。在这里我们可以看到,runBlocking函数是通过while循环来阻塞当前线程的,通过判断当前协程中的任务是否已经执行完成,来取消循环,从而释放当前线程。
所以在实际开发中我们一定要慎用它,特别是在主线程中,一不小心可能就会造成主线程阻塞,随之就喜提一个ANR。
MainScope
MainScope同样是一个顶层函数,该方法返回一个CoroutineScope对象,并将我们的代码逻辑指定在主线中去运行。
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
关于参数SupervisorJob + Dispatchers.Main我们会在后面的文章详细讲解,这里我们只需要了解MainScope()函数可以为我们快速的创建一个运行在主线程的协程作用域。使用起来也比较方便,如下代码示例:
MainScope().launch { println("MainScope called...") }
4.上下文调度器
上面我们已经介绍到协程是一种轻量级的线程,我们可以在单个线程中创建多个协程。但是这并不意味着我们永远不需要开启线程了,比如说Android中要求网络请求必须在子线程中进行,即使你开启了协程去执行网络请求,假如它是在主线程中的协程,那么程序依然会出错。
所以我们对协程的定义:“轻量级的线程”。这是在协程的创建和切换的维度去定义的,实际开发中我们依然需要结合线程来使用。关于在协程中切换线程Kotlin协程库中已经帮我们封装好了,使用起来也比较方便。
在Dispatchers类中,Kotlin协程库为我们提供了四种不同的协程上下文调度器,它们共同特点是都实现了CoroutineDispatcher这个抽象类:
public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor
public actual object Dispatchers {
public actual val Default: CoroutineDispatcher = DefaultScheduler
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
public val IO: CoroutineDispatcher = DefaultIoScheduler
具体的区别如下:
类型 | 区别 |
---|---|
Default | 默认使用一种低并发的线程策略,适合低密度的任务计算,在子线程中执行代码 |
Main | 切换到主线中执行代码 |
Unconfined | 不切换线程,在当前线程中执行代码 |
IO | 使用较高并发的线程策略,适合高密度的任务计算,在子线程中执行代码 |
类型区别Default默认使用一种低并发的线程策略,适合低密度的任务计算,在子线程中执行代码Main切换到主线中执行代码Unconfined不切换线程,在当前线程中执行代码IO使用较高并发的线程策略,适合高密度的任务计算,在子线程中执行代码
熟悉了协程的上下文调度器,我们就可以在实际开发中灵活的运用它们了,如下代码示例:
CoroutineScope(Dispatchers.Main).launch {
val useInfo = requestUserInfo()
tvUserName.text = useInfo
val homeInfo = requestHomeInfo()
tvHomeInfo.text = homeInfo
}
suspend fun requestUserInfo() = withContext(Dispatchers.IO) {
delay(1000)
"requestUserInfo"
}
suspend fun requestHomeInfo() = withContext(Dispatchers.IO) {
delay(1000)
"requestHomeInfo"
}
在挂起函数withContext中,我们使用挂起函数delay来模拟网络请求中的耗时任务在子线程中去执行,获取到结果在切换到主线中更新UI。切换流程如下示意图:
5. withContext
withContext是一个挂起函数,关于挂起函数后面我们会详细的介绍。这里我们只需要知道使用suspend关键字修饰的函数就是挂起函数,挂起函数只能在协程作用域中或者在另一个挂起函数中访问。下面我们就来看下withContext函数的使用:
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T {
return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
...
val coroutine = DispatchedCoroutine(newContext, uCont)
block.startCoroutineCancellable(coroutine, coroutine)
coroutine.getResult()
}
}
withContext函数接收两个参数,一个是协程上下文对象context,一个是带有接收者的函数类型参数block,而且这个函数类型是挂起函数类型。在该函数类型初始化的Lambda表达式中将会拥有当前协程的作用域。Lambda表达式中的最后一行代码也会作为withContext函数的返回值。withContext函数会将当前协程挂起,直到获取到结果后才会恢复当前协程。
fun main() {
CoroutineScope(Dispatchers.IO).launch {
val result = withContext<String>(coroutineContext) {
return@withContext "result"
}
println(result)
}
Thread.sleep(100)
}
// 输出
result
这里我们使用父协程中的上下文对象作为参数传递给了withContext()函数,并且在Lambda表达式中返回了一个字符串result。在withContext函数内部又调用了suspendCoroutineUninterceptedOrReturn挂起函数,该函数会捕获到当前协程作用域中的Continuation实例,具体代码如下:
public suspend inline fun <T> suspendCoroutineUninterceptedOrReturn(crossinline block: (Continuation<T>) -> Any?): T {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
throw NotImplementedError("Implementation of suspendCoroutineUninterceptedOrReturn is intrinsic")
}
在这里我们并不能看到它是如何捕获到协程中的Continuation实例,我想这也是Kotlin协程设计Continuation的初衷,开发者只能通过suspendCoroutineUninterceptedOrReturn这个方法来获取,如果你对协程的使用比较熟悉,你会发现像suspendCoroutine()、suspendCancelableCoroutine()、delay()、yield()等方法中都是通过该方法来获取当前协程的Cotinuation实例。
6. async、await
async也是CoroutineScope的一个扩展函数,它总是和挂起函数await()成对的出现。该函数拥有一个Deferred类型的返回值:
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T>
当我们调用async函数时,其拥有协程作用域的Lambda表达式中的代码是立即执行的,而当我们调用await()函数时,如果async函数中的逻辑还没有执行完成,await()函数就会将当前协程挂起。下面我们就来看一个具体的案例:
fun main() {
val curTimeMills = System.currentTimeMillis()
runBlocking {
val left = async { 5 + 5 }.await()
val right = async { 6 + 6 }.await()
println("result = ${left + right}")
}
val endTimeMills = System.currentTimeMillis() - curTimeMills
println("time = $endTimeMills")
}
// 输出
result = 22
time = 262
在上面这段示例代码中我们可以看到,我们使用两个async()函数执行了一个简单的求和逻辑运算,并将最终的结果输出,总耗时是 262 毫秒。上面我们说了await()函数是会阻塞当前协程的,我们可以将上面的代码稍作改动:
fun main() {
val curTimeMills = System.currentTimeMillis()
runBlocking {
val leftDef = async { 5 + 5 }
val rightDef = async { 6 + 6 }
println("result = ${leftDef.await() + rightDef.await()}")
}
val endTimeMills = System.currentTimeMillis() - curTimeMills
println("time = $endTimeMills")
}
// 输出
result = 22
time = 186
最终我们的逻辑执行时长从 262 毫秒变成了 186 毫秒,当然这个例子还不够明显,如果aysnc函数中执行的是较耗时的任务,那么结果会更明显。我们再来分析一下这两段代码的区别:
1.第一种写法在调用完async函数后,立即执行了await()函数,那么后面的逻辑会等待async函数中的逻辑处理完成以后才会执行。
2.第二种写法开始先分别执行async()函数中的逻辑,然后再分别调用await()函数。这样我们两个async()函数在开始都可以执行,当我们调用await()方法的时候再去判断是否挂起当前协程。如果async()中的任务执行完成,则直接返回结果;如果未执行完成,则先挂起协程,等待任务执行完成。
7. coroutineScope
coroutineScope()函数同样也是一个挂起函数,与我们上面所介绍的CoroutineScope()函数并不是同一个函数,该函数首字母是小写的。它的特点是会继承外部的协程作用域并创建一个子作用域。同withContext函数一样两者都是使用Kotlin协程库中的顶层函数
suspendCoroutineUninterceptedOrReturn()函数来实现的。所以coroutineScope函数也会挂起当前协程,在任务执行完成后,再恢复当前协程。然后将获取到的结果在Lambda表达式中返回。
fun main() {
runBlocking {
val result = coroutineScope {
println("coroutineScope called.")
return@coroutineScope "coroutineScope"
}
println("result = $result")
}
}
// 输出
coroutineScope called.
result = coroutineScope
从这段示例代码的输出结果来看,coroutineScope函数确实会先挂起当前协程,直到自己创建的子协程中的逻辑执行完成,才会释放当前协程。
8.挂起函数
1.挂起函数
Kotlin协程的挂起和恢复能力本质上就是挂起函数的挂起和恢复。在Kotlin中我们使用关键字suspend来声明一个挂起函数。
挂起函数只能在协程作用域内或其他挂起函数内调用。普通的函数无法调用挂起函数
如下代码示例,我们声明一个名为reqeust()的挂起函数:
suspend fun reqeust() { //doSomething }
2.挂起点
在协程内部挂起函数的调用处被称为挂起点,挂起点如果出现异步调用,那么当前协程就被挂起,直到对应的Continuation的resume函数被调用才会恢复当前协程。
例如,通过suspendCoroutine函数获得的Continuation是一个SafeContinuation的实例,与创建协程时得到的用来启动协程的Continuation实例没有本质上的差别。SafeContinuation类的作用也很简单,它可以确保只有发生异步调用时才会挂起,如下代码示例,虽然也有resume函数的调用,但协程并不会真的挂起。
suspend fun notSuspend() = suspendCoroutine<Int> {
it.resume(100)
}
异步调用是否发生,取决于resume函数与对应的挂起函数的调用是否在相同的调用栈上,切换函数调用栈的方法可以是切换到其他线程上执行,也可以是不切换线程但在当前函数返回之后的某一时刻再执行。前者比较容易理解,后者其实通常就是先将Continuation的实例保存下来,在后续合适的时机再调用。
这段描述来自霍丙乾老师的深入理解Kotlin协程。
3.CPS
挂起函数通过 Continuation-Passing-Style(CPS, 续体传递风格)实现。每个挂起函数都有一个附加的 Continuation [续体] 参数,在调用时隐式传入,这一过程是Kotlin编译器帮我们实现的,无需开发者自己动手实现。
那么Cotinuation是什么呢?事实上Cotinuation就是一个接口,它的具体源代码如下:
// 表示挂起点之后的延续接口。
public interface Continuation<in T> {
// 与此延续相对应的协程上下文、
public val context: CoroutineContext
// 恢复执行相应的协程,传递一个成功或失败的 result 作为最后一个挂起点的返回值。
public fun resumeWith(result: Result<T>)
}
关于这个接口,注释写的也比较清楚,context属性用来保存当前协程中的上下文对象,resumeWith方法用于恢复执行协程,并将执行结果作为最后一个挂起点的返回值。
例如我们有这么一个挂起函数request:
suspend fun <T> request() : T
在 CPS 变换之后,它的实现实际是这样的:
suspend fun request(completion: Cotinuation<T>): Any?
其返回类型 T 移动到了附加的续体参数的类型参数位置。
实现中的返回值类型 Any? 被设计用于表示挂起函数的动作。当挂起函数 挂起 协程时,函数返回一个特别的标识值 COROUTINE_SUSPENDED。如果一个挂起函数没有挂起协程,协程继续执行时,它直接返回一个结果或者抛出一个异常。这样,request() 函数实现中的返回值类型 Any? 实际上是 T 与 COROUTINE_SUSPENDED 的联合类型,这并不能在 Kotlin 的类型系统中表示出来。
而COROUTINE_SUSPENDED标志是个常量,定义在Intinsics.kt中
public val COROUTINE_SUSPENDED: Any get() = CoroutineSingletons.COROUTINE_SUSPENDED
internal enum class CoroutineSingletons { COROUTINE_SUSPENDED, UNDECIDED, RESUMED }
4.状态机
这里我们还是用上面所例举的例子来说明:
CoroutineScope(Dispatchers.Main).launch {
// requestUserInfo()先挂起,返回COROUTINE_SUSPENDED,切换到子线程获取到结果
// 再将获取到的结果传递给主线程
val useInfo = requestUserInfo()
tvUserName.text = useInfo
// 同上requestUserInfo()的执行过程
val homeInfo = requestHomeInfo()
tvHomeInfo.text = homeInfo
}
这里涉及到我们requestUserInfo()函数内部withContext函数中创建的DispathcerCoroutine将会持有外部协程的Cotinuation实例,以及线程的切换流程,所以想要更清晰的看执行流程的同学可以把这段代码放到一个Activity中去,然后通过debug的方式来查看详细的执行流程。这里我们主要来介绍下有关这块代码涉及到的协程执行流程。
在Android Studio中依次打开Tools -> Kotlin -> Show Kotlin ByteCode我们就可以在右边的视图中看到其生成的字节码。
final class .../ContinuationKt$main$1 extends SuspendLambda implements Function2 {
public final invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
public final create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
public final invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
}
为了方便阅读这里将字节码做了一些简化。ContinuationKt$main$1熟悉字节码的同学应该不难看出这是一个内部类的命名方式,该类继承自SuspendLambda并实现了Kotlin中函数类型对应的接口Function2,也就是我们launch函数中所对应的挂起函数类型:
suspend CoroutineScope.() -> Unit的实例。
在Android Studio中我们双击shift在弹出的搜索框中,我们输入SuspendLambda,接着我们进入SuspendLambda类中我们可以看到其继承关系如下:
-- Continuation
-- BaseContinuationImpl
-- ContinuationImpl
-- SuspendLambda
在BaseContinautionImpl这个抽象类中我们可以看到有一个名为invokeSuspend的抽象方法,并且我们在resumeWith方法中调用了该方法,invokeSuspend方法在协程状态流转中起着关键性的作用。下面是BaseContinautionImpl类的源码:
internal abstract class BaseContinuationImpl(
// 启动该协程时创建的Cotinuation实例,对于launch函数是StandaloneCoroutine
// 对于withContext是DisPatchedCoroutine
public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
public final override fun resumeWith(result: Result<Any?>) {
var current = this
var param = result
while (true) {
// 无需关注
probeCoroutineResumed(current)
with(current) {
val outcome: Result<Any?> =
try {
// 状态流转的核心代码
val outcome = invokeSuspend(param)
// 协程处于挂起状态
if (outcome === COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
releaseIntercepted()
if (completion is BaseContinuationImpl) {
current = completion
param = outcome
} else {
// 恢复协程,传递结果值
completion.resumeWith(outcome)
return
}
}
}
}
protected abstract fun invokeSuspend(result: Result<Any?>): Any?
...
}
launch()函数反编译后的Java代码:
launch$default(CoroutineScope((CoroutineContext)Dispatchers.getMain()), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
public final Object invokeSuspend(Object $result) {
Object result;
label: {
Object state = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch (this.label) {
case 0:
// 检查结果
ResultKt.throwOnFailure($result);
this.label = 1;
// 挂起,执行异步操作
if (state == requestUserInfo(this))
return state
break;
case 1:
// 检查结果
ResultKt.throwOnFailure($result);
result = $result;
break;
case 2:
// 检查结果
ResultKt.throwOnFailure($result);
result = $result;
break label;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
String requestUserInfo = (String)result;
this.label = 2;
// 挂起,执行异步操作
if (state == requestHomeInfo(this))
return state
}
String requestHomeInfo = (String)result;
return Unit.INSTANCE;
}
public final Continuation create(Object value, Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 3, (Object)null);
为了方便阅读,这里将反编译的代码进行了一些调整。这里你可能会感到疑惑,invokeSuspend方法是否会多次调用,答案是一定的。每当withContext函数挂起后,切换到子线程执行异步任务获取结果后,都会将结果值传递给外层的Continuation实例,获取结果后再传递到主线程,然后进行下一个状态的流转,也就是通过我们上面的lable来控制的。代码可能不太好理解,这里笔者就结合我们上面这个示例用几张流程图来说明:
类的继承关系:
- 1、处标记的地方是上下文调度器Dispatchers.Main、Dispatchers.Io。
- 2、3、4处标记的地方是我们协程执行流程中所创建的Continuation[续体]的几种类型:
其中2处是编译器为我们创建的,3处是我们在launch、withContext函数中创建的、4处是我们在ContinuationImpl中的intercepted方法中创建的。而协程启动过程中使用create方法创建的Continuation实例和我们标记的2处是同一类型的。
执行流程示意简图:
对于上述的代码示例来说,这是一个比较完整的协程执行流程闭环图。当然这里有很多细节省略了。
- 1、2、3、4、5、6代表着Continuaiton实例,其中1、3、5是相同的类型:
$name extends SuspendLambda implement Function2
为什么intercepted方法会创建一个DispachedContinuation?下面我们就根据具体的源码来分析一下:
public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
(this as? ContinuationImpl)?.intercepted() ?: this
internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {
...
public fun intercepted(): Continuation<Any?> =
intercepted?.(context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }
}
这里的context[ContinuationInterceptor]中的context是一个CombinedContext,我们获得的结果是Dispacher.Main或Dispacher.IO。它们都是CoroutineDisptacher的子类,所以这里调用的是CoroutineDisptacher中覆盖的方法interceptContinuation,具体代码如下:
public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
DispatchedContinuation(this, continuation)
该方法的返回值就是一个DispatchedContinuation实例。
上下文和续体流转示意简图:
总结:
首先我们先来回顾下这篇文章的主要内容:
1.通过单例类GlobleScope、顶层函数CoroutineScope、runBlocking、MainScope来创建并启动一个协程
2.挂起函数withContext、async/await、coroutineScope的运用
3.协程内部通过CPS + 状态机的方式来控制协程的执行流程
第一次写有关协程的文章。这篇文章可能也不是那么好理解,但是笔者的个人水平也有限。如有什么不对的地方,欢迎指出,大家一起学习。