Kotlin Coroutines(协程) 完全解析系列:
Kotlin Coroutines(协程) 完全解析(一),协程简介
Kotlin Coroutines(协程) 完全解析(二),深入理解协程的挂起、恢复与调度
Kotlin Coroutines(协程) 完全解析(三),封装异步回调、协程间关系及协程的取消
Kotlin Coroutines(协程) 完全解析(四),协程的异常处理
Kotlin Coroutines(协程) 完全解析(五),协程的并发
本文基于 Kotlin v1.3.0-rc-146,Kotlin-Coroutines v1.0.0-RC1
前面两篇文章解析了挂起函数通过状态机来实现,协程的本质就是有三层包装的Continuation
,这篇文章进一步解析协程的运用。主要介绍如何将异步回调封装为挂起函数,解析协程之间的关系以及协程的取消。
1. 封装异步回调为挂起函数
在异步编程中,回调是非常常见的写法,那么如何将回调转换为协程中的挂起函数呢?可以通过两个挂起函数suspendCoroutine{}
或suspendCancellableCoroutine{}
,下面看如何将 OkHttp 的网络请求转换为挂起函数。
|
|
上面的await()
的扩展函数调用时,首先会挂起当前协程,然后执行enqueue
将网络请求放入队列中,当请求成功时,通过cont.resume(response.body()!!)
来恢复之前的协程。
再来看下suspendCoroutine{}
和suspendCancellableCoroutine{}
的定义:
|
|
它们的关键实现都是调用suspendCoroutineUninterceptedOrReturn()
函数,它的作用是获取当前协程的实例,并且挂起当前协程或者不挂起直接返回结果。
协程中还有两个常见的挂起函数使用到了suspendCoroutineUninterceptedOrReturn()
函数,分别是delay()
和yield()
。
1.1 delay 的实现
|
|
delay 使用suspendCancellableCoroutine
挂起协程,而协程恢复的一般情况下是关键在DefaultExecutor.scheduleResumeAfterDelay()
,其中实现是schedule(DelayedResumeTask(timeMillis, continuation))
,其中的关键逻辑是将 DelayedResumeTask 放到 DefaultExecutor 的队列最后,在延迟的时间到达就会执行 DelayedResumeTask,那么该 task 里面的实现是什么:
|
|
1.2 yield 的实现
yield()
的作用是挂起当前协程,然后将协程分发到 Dispatcher 的队列,这样可以让该协程所在线程或线程池可以运行其他协程逻辑,然后在 Dispatcher 空闲的时候继续执行原来协程。简单的来说就是让出自己的执行权,给其他协程使用,当其他协程执行完成或也让出执行权时,一开始的协程可以恢复继续运行。
看下面的代码示例:
|
|
通过yield()
实现 job1 和 job2 两个协程交替运行,输出如下:
|
|
现在来看其实现:
|
|
所以注意到,yield()
需要依赖协程的线程调度器,而调度器再次执行该协程时,在第二篇中有讲过会调用resume
来恢复协程运行。
现在来看封装异步逻辑为挂起函数的关键是用suspendCoroutineUninterceptedOrReturn
函数包装,然后在异步逻辑完成时调用resume
手动恢复协程。
2. 协程之间的关系
官方文档中有提到协程之间可能存在父子关系,取消父协程时,也会取消所有子协程。在Job
的源码中有这样一段话描述协程间父子关系:
|
|
所以协程间父子关系有三种影响:
父协程手动调用
cancel()
或者异常结束,会立即取消它的所有子协程。父协程必须等待所有子协程完成(处于完成或者取消状态)才能完成。
子协程抛出未捕获的异常时,默认情况下会取消其父协程。
下面先来看看协程是如何建立父子关系的,launch
和async
新建协程时,首先都是newCoroutineContext(context)
新建协程的 CoroutineContext 上下文,下面看其具体细节:
|
|
所以新的协程的 CoroutineContext 都继承了原来 CoroutineScope 的 coroutineContext,然后launch
和async
新建协程最后都会调用start(start: CoroutineStart, receiver: R, block: suspend R.() -> T)
,里面第一行是initParentJob()
,通过注释可以知道就是这个函数建立父子关系的,下面看其实现细节:
|
|
这里需要注意的是GlobalScope
和普通协程的CoroutineScope
的区别,GlobalScope
的 Job 是为空的,GlobalScope.launch{}
和GlobalScope.async{}
新建的协程是没有父协程的。
下面继续看attachChild
的实现:
|
|
invokeOnCompletion()
函数在前一篇解析 Deferred.await() 中有提到,关键是将 handler 节点添加到父协程 state.list 的末尾。
2.1 父协程手动调用cancel()
或者异常结束,会立即取消它的所有子协程
跟踪父协程的cancel()
调用过程,其中关键过程为 cancel() -> cancel(null) -> cancelImpl(null) -> makeCancelling(null) -> tryMakeCancelling(state, causeException) -> notifyCancelling(list, rootCause),下面继续分析notifyCancelling(list, rootCause)
的实现:
|
|
2.2 父协程必须等待所有子协程完成(处于完成或者取消状态)才能完成
前一篇文章有提到协程的完成通过AbstractCoroutine.resumeWith(result)
实现,调用过程为 makeCompletingOnce(result.toState(), defaultResumeMode) -> tryMakeCompleting(),其中关键源码如下:
|
|
tryWaitForChild()
也是通过invokeOnCompletion()
添加节点到子协程的 state.list 中,当子协程完成时会调用 ChildCompletion.invoke():
|
|
2.3 子协程抛出未捕获的异常时,默认情况下会取消其父协程。
子线程抛出未捕获的异常时,后续的处理会如何呢?在前一篇解析中协程的运算在第二层包装 BaseContinuationImpl 中,我们再看一次:
|
|
所以协程有未捕获的异常中,会在第二层包装中的resumeWith()
捕获到,然后调用第一层包装 AbstractCoroutine.resumeWith() 来取消当前协程,处理过程为 AbstractCoroutine.resumeWith(Result.failure(exception)) -> JobSupport.makeCompletingOnce(CompletedExceptionally(exception), defaultResumeMode) -> tryMakeCompleting(state, CompletedExceptionally(exception), defaultResumeMode) -> notifyCancelling(list, exception) -> cancelParent(exception),所以出现未捕获的异常时,和手动调用cancel()
一样会调用到 notifyCancelling(list, exception) 来取消当前协程,和手动调用cancel()
的区别在于 exception 不是 CancellationException。
|
|
3. 协程的取消
前面分析协程父子关系中的取消协程时,可以知道协程的取消只是在协程的第一层包装中 AbstractCoroutine 中修改协程的状态,并没有影响到第二层包装中 BaseContinuationImpl 中协程的实际运算逻辑。所以协程的取消只是状态的变化,并不会取消协程的实际运算逻辑,看下面的代码示例:
|
|
输出结果如下:
|
|
上面代码中 job1 取消后,delay()
会检测协程是否已取消,所以 job1 之后的运算就结束了;而 job2 取消后,没有检测协程状态的逻辑,都是计算逻辑,所以 job2 的运算逻辑还是会继续运行。
所以为了可以及时取消协程的运算逻辑,可以检测协程的状态,使用isActive
来判断,上面示例中可以将while(i <= 3)
替换为while(isActive)
。
3.1 运行不能取消的代码块
当手动取消协程后,像delay()
这样的可取消挂起函数会在检测到已取消状态时,抛出 CancellationException 异常,然后退出协程。此时可以使用try { ... } finally { ... }
表达式或<T : Closeable?, R> T.use {}
函数执行终结动作或关闭资源。
但是如果在finally
块中调用自定义的或系统的可取消挂起函数,都会再次抛出 CancellationException 异常。通常我们在finally
块中关闭一个文件,取消一个任务或者关闭一个通信通道都是非阻塞,并且不会调用任何挂起函数。当需要挂起一个被取消的协程时,可以将代码包装在withContext(NonCancellable) { ... }
中。
3.2 超时取消
实际上大多数时候取消一个协程的理由是因为超时。协程库中已经提供来withTimeout() { ... }
挂起函数来实现在超时后自动取消协程。它会在超时后抛出TimeoutCancellationException
,它是 CancellationException 的子类,它是协程结束的正常原因,不会打印堆栈跟踪信息,更详细的原因见下一篇解析Kotlin Coroutines(协程) 完全解析(四),协程的异常处理。如果在取消后需要执行一些关闭资源的操作可以使用前面提到的try { ... } finally { ... }
表达式。
|
|
还有一个withTimeoutOrNull() { ... }
挂起函数在超时后返回null
,而不是抛出一个异常:
|
|
上面代码运行输出如下,不再抛出异常:
|
|
4. 小结
最后总结下本文的内容,封装异步代码为挂起函数其实非常简单,只需要用suspendCoroutine{}
或suspendCancellableCoroutine{}
,还要异步逻辑完成用resume()
或resumeWithException
来恢复协程。
新建协程时需要协程间关系,GlobalScope.launch{}
和GlobalScope.async{}
新建的协程是没有父协程的,而在协程中使用launch{}
和aysnc{}
一般都是子协程。对于父子协程需要注意下面三种关系:
父协程手动调用
cancel()
或者异常结束,会立即取消它的所有子协程。父协程必须等待所有子协程完成(处于完成或者取消状态)才能完成。
子协程抛出未捕获的异常时,默认情况下会取消其父协程。
对于协程的取消,cancel()
只是将协程的状态修改为已取消状态,并不能取消协程的运算逻辑,协程库中很多挂起函数都会检测协程状态,如果想及时取消协程的运算,最好使用isActive
判断协程状态。而withContext(NonCancellable) { ... }
可以执行不会被取消的代码,withTimeout() { ... }
和withTimeoutOrNull() { ... }
则可以简化超时处理逻辑。