使用协程已经有较长的时间了,但一直停留在launch、async启动协程,suspend方法挂起的阶段。这段时间系统梳理Kotlin知识时才发现,对协程(仅对Kotlin)还有很多概念不甚了解。例如CoroutineScope对协程生命周期的重要性、协程父子结构的作用、结构化并发、一些Kotlin协程中约定俗称的规定等。
我们尝试从几个比较流行的说法来解释协程到底是个什么东西,而不是再增加一种让人猜不透的说法
协程是轻量级线程(官方表述)
可以换个说法,协程就是方法调用封装成类线程的API。方法调用当然比线程切换轻量;而封装成类线程的API后,它形似线程(可手动启动、有各种运行状态、能够协作工作、能够并发执行)。因此从这个角度说,它是轻量级线程没错。
当然,协程绝不仅仅是方法调用,因为方法调用不能在一个方法执行到一半时挂起,之后又在原点恢复。这一点可以使用EventLoop之类的方式实现。想象一下在库级别将回调风格或Promise/Future风格的异步代码封装成同步风格,封装的结果就非常接近协程了。
而协程和线程之间的区别,往大了说,那就是普通函数与线程的区别;往小了说,就是EventLoop和线程的区别。他们之间的唯一的关系,仅仅在于协程的代码是运行在线程中。一个不恰当的类比,人和地球(地球提供生成环境,人在其中生存)
线程运行在内核态,协程运行在用户态
主要明白什么叫用户态,我们写的几乎所有代码,都执行在用户态,协程对于操作系统来说仅仅是第三方提供的库而已,当然运行在用户态。而线程是操作系统级别的东西,运行在内核态。
协程是一个线程框架(扔物线表述)
对某些语言,比如Kotlin,这样说是没有问题的,Kotlin的协程库可以指定协程运行的线程池,我们只需要操作协程,必要的线程切换操作交给库,从这个角度来说,协程就是一个线程框架。
但理论上我们可以在单线程语言如JavaScript、Python上实现协程(事实上他们已经实现了协程),这时我们再叫它线程框架可能就不合适了。
根据Kotlin协程设计提案,Kotlin协程的设计目标有如下三点
可以认为,Kotlin是想在自己的代码环境中用协程消除传统的异步API,以原语的方式提供。
runBlocking{}
启动一个新协程,并阻塞当前线程,直到其内部所有逻辑及子协程逻辑全部执行完成。
该方法的设计目的是让suspend风格编写的库能够在常规阻塞代码中使用,常在main方法和测试中使用。
GlobalScope.launch{}
在应用范围内启动一个新协程,协程的生命周期与应用程序一致。这样启动的协程并不能使线程保活,就像守护线程。
由于这样启动的协程存在启动协程的组件已被销毁但协程还存在的情况,极限情况下可能导致资源耗尽,因此并不推荐这样启动,尤其是在客户端这种需要频繁创建销毁组件的场景。
实现CoroutineScope
+ launch{}
这是在应用中最推荐使用的协程使用方式——为自己的组件实现CoroutieScope
接口,在需要的地方使用launch{}
方法启动协程。使得协程和该组件生命周期绑定,组件销毁时,协程一并销毁。从而实现安全可靠地协程调用。
launch{}
async{}
class TtpServiceImpl(val vertx: Vertx): TtpService, CoroutineScope {
override val coroutineContext: CoroutineContext by lazy { vertx.dispatcher() }
override fun getContentList(resultHandler: Handler<AsyncResult<OperationResponse>>){
launch{
val deffer1 = async{ awaitResult<List<JsonObject>>{ dbService.getContentList(it) } }
val deffer2 = async{ awaitResult<List<JsonObject>>{ dbService.getAuthorList(it) } }
val contents = deffer1.await()
val authors = deffer2.await()
val reuslt = contents.map{ content ->
content.put("author", authors.filter{ ... }.first())
}
resultHandler.succeed(reuslt)
}
}
}
launch{}
返回Job
,async{}
返回Deffer
,Job
和Deffer
都有cancel()
方法,用于取消协程。
从协程内部看取消的效果
上面两个特性和线程的interrupt机制非常类似,理解起来并不难。
val job = launch {
// 如果这里不检测isActive标记,协程就不会被正常cancel,而是执行直到正常结束
while (isActive) {
......
}
}
job.cancelAndJoin() // 取消该作业并等待它结束
了解协程的启动和取消,对于最基本的使用已经足够了。不过为了更加安全放心地使用,需要更加深入地了解,我们从核心组件说起。
Kotlin协程的异常有两种
suspend
方法抛出的CancellationException
launch
:将异常自动向父协程抛出,将会导致父协程退出async
: 将异常暴露给用户(通过捕获deffer.await()
抛出的异常)这里借用官方例子讲解
fun main() = runBlocking {
val job = GlobalScope.launch { // root coroutine with launch
println("Throwing exception from launch")
throw IndexOutOfBoundsException() // 我们将在控制台打印 Thread.defaultUncaughtExceptionHandler
}
job.join()
println("Joined failed job")
val deferred = GlobalScope.async { // root coroutine with async
println("Throwing exception from async")
throw ArithmeticException() // 没有打印任何东西,依赖用户去调用等待
}
try {
deferred.await()
println("Unreached")
} catch (e: ArithmeticException) {
println("Caught ArithmeticException")
}
}
输出结果
Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException
注意,例子是在GlobalScope.launch{}
中抛异常,不会导致父协程退出。
Kotlin的协程实现是以附加库kotlinx-coroutines-core
的形式提供的,但其实协程的接口定义在核心库kotlin-stdlib-common
的kotlin.coroutines
中。
顾名思义,协程上下文表示协程的运行环境,包括协程调度器、代表协程本身的Job、协程名称、协程ID等。通过CoroutineContext
定义,CoroutineContext
被定义为一个带索引的集合,集合的元素为Element
,上面所提到调度器、Job
等都实现了Eelement
接口。
由于CoroutineContext
被定义为集合,因此在实际使用时可以自由组合加减各种上下文元素。
启动子协程时,子协程默认会继承除Job
外的所有父协程上下文元素,创建新的Job
,并将父Job
设置为当前Job
的父亲。
启动子协程时,可以指定协程上下文元素,如果父上下文中存在该元素则覆盖,不存在则添加。
override fun getContentList(resultHandler: Handler<AsyncResult<OperationResponse>>){
// 自定义新协程名称
launch(CoroutineName("customName")){
... ...
}
}
调度器是协程上下文中众多元素中最重要的一个,通过CoroutineDispatcher
定义,它控制了协程以何种策略分配到哪些线程上运行。这里介绍几种常见的调度器
Dispatcher.Default
默认调度器。它使用JVM的共享线程池,该调度器的最大并发度是CPU的核心数,默认为2
Dispatcher.Unconfined
非受限调度器,它不会将操作限制在任何线程上执行——在发起协程的线程上执行第一个挂起点之前的操作,在挂起点恢复后由对应的挂起函数决定接下来在哪个线程上执行。
Dispathcer.IO
IO调度器,他将阻塞的IO任务分流到一个共享的线程池中,使得不阻塞当前线程。该线程池大小为环境变量kotlinx.coroutines.io.parallelism
的值,默认是64或核心数的较大者。
该调度器和Dispatchers.Default
共享线程,因此使用withContext(Dispatchers.IO)
创建新的协程不一定会导致线程的切换。
Dispathcer.Main
该调度器限制所有执行都在UI主线程,它是专门用于UI的,并且会随着平台的不同而不同
注意,由于上下文具有继承关系,因此启动子协程时不显式指定调度器时,子协程和父协程是使用相同调度器的。
Job也是上下文元素,它代表协程本身。Job能够被组织成父子层次结构,并具有如下重要特性。
类似Thread,一个Job可能存在多种状态
要区分是主动取消还是异常导致一个协程退出,可以getCancellationException()查看退出原因。
协程作用域——CoroutineScope
,用于管理协程,管理的内容有
launch
、async
、withContext
等协程启动方法(以extention
的方式),并在这些方法内定义了启动子协程时上下文的继承方式。cancel()
方法,用于取消当前作用域,同时取消作用域内所有协程。从类定义看,CoroutineScope
和CoroutineContext
非常类似,最终目的都是协程上下文,但正如Kotlin协程负责人Roman Elizarov在Coroutine Context and Scope中所说,二者的区别只在于使用目的的不同——作用域用于管理协程;而上下文只是一个记录协程运行环境的集合。他们的关系如下。