Kotlin提供了协程来更加方便的实现异步代码。主要的部分就是suspend
方法以及配套的丰富的API和库。本文尽可能的用简单的语言来解释协程的基础概念。
什么是协程
Kotlin团队把它定义为“轻量级的线程”,它们是实际的线程可以执行的某种任务。
线程可以在某个“挂起点”停止执行协程,而去处理其他的任务。然后可以在之后继续执行这个协程,也有可能是在另外一个线程上执行。
所以,一个协程不是一个任务,而是一系列的“子任务”。这些“子任务”会按照特定的顺序执行。即使代码看起来是在一个顺序的代码块里的,协程里对“挂起函数”的调用时间也是顺序执行的。这就需要我们一探挂起函数的究竟了。
看这段代码:
suspend fun printSomething(something: String) {
println(something ?: "something launched")
}
Button(
modifier = Modifier.padding(start = 20.dp),
onClick = {
println("Start")
scope.launch {
launch {
printSomething("launched 1")
}
println("launched 2")
launch {
printSomething("launched 3")
printSomething("launched 4")
}
}
println("End")
}) {
Text(text = "Try Coroutine", color = Color.White)
}
挂起函数printSomething
只干了一件事,打印输入的文字。还有就是在coroutine里跑一个挂起函数,也可以不是,如图:
在图里,suspend
关键字是灰色的,也就是可有可无。
重点在打印出来的文字:
这里我们只需要注意launched 3和launched 4,它们是在一个launch
里执行的,顺序也是调用的顺序。
挂起函数
在kotlinx的delay
或者是Ktor的HttpClient.post
函数的定义都带有关键字suspend
。
suspend fun delay(timeMillis: Long) {...}
suspend fun someNetworkCallReturningValue(): SomeType {
...
}
这些函数就叫做挂起函数。挂起函数可以挂起当前协程的执行而不会阻塞所在的线程。
也就是说挂起函数可能在某个点停止了执行,而在之后的某个时间点又继续执行。然而这里没有说到当前线程会干什么。
挂起函数都是顺序的
挂起函数并没有什么特别的返回类型。除了多了一个suspend
关键字并没有其他特别的地方。也不需要类似于Java的Future
或者JavaScript的Promise
之类的包装器。这也就更加确定了挂起函数本身并不是异步的(至少从调用者的角度看是这样),也不像JavaScript的async
方法需要返回一个promise。
在挂起函数里调用其他的挂起函数和平常的函数调用没什么区别:被调用的函数执行完之后才会继续执行剩下的代码。
suspend fun someNetworkCallReturningSomething(): Something {
// some networking operations making use of the suspending mechanism
}
suspend fun someBusyFunction(): Unit {
delay(1000L)
println("Printed after 1 second")
val something: Something = someNetworkCallReturningSomething()
println("Received $something from network")
}
如此一来复杂的异步代码写起来也就相当容易了。
挂起和非挂起怎么连接到一起
直接在非挂起函数里调用挂起函数是无法编译的。这是因为只有协程里才可以调用挂起函数,所以我们要新建一个协程先。这就需要用到协程构造器:
协程构造器
协程构造器就是新建了一个挂起函数,然后调用其他的挂起函数。他们可以在非挂起函数内被调用,是因为他们本身不是挂起函数,也就可以扮演一个普通函数和挂起函数的桥梁。
Kotlin提供了很多种不同的协程构造器,我们来认识几种:
阻塞当前线程的runBlocking
这是最简单的协程构造器了。它会阻塞当前线程一直等到里面的挂起函数都执行完毕:
fun main() {
println("Hello,")
// we create a coroutine running the provided suspending lambda
// and block the main thread while waiting for the coroutine to finish its execution
runBlocking {
// now we are inside a coroutine
delay(2000L) // suspends the current coroutine for 2 seconds
}
// will be executed after 2 seconds
println("World!")
}
可以看到runBlocking
的定义,需要传入的最后一个参数是一个挂起函数,但是它本身不是(阻塞线程):
fun <T> runBlocking(
...,
block: suspend CoroutineScope.() -> T
): T {
...
}
runBlocking
经常用在讲解协程时候的hello world例子里阻塞main方法显示挂起函数执行的结果。
“launch”发射后不管
一般协程是不阻塞所在的线程的,而是开始一个异步任务。协程构造器launch
就是用来在后台开始一个异步任务的。比如:
fun main() {
GlobalScope.launch { // launch new coroutine in background and continue
delay(1000L)
println("World!")
}
println("Hello,") // main thread continues here immediately
runBlocking { // but this expression blocks the main thread
delay(2000L) // ... while we delay for 2 seconds to keep JVM alive
}
}
这个会打印出“Hello”, 随后打印出“World”。
GlobalScope
不用急,后面会详细的讲到。
本例中为了可以看到输出的结果所以在最后还是阻塞的了线程。
使用“async”获得异步任务的结果
这是另外一个协程构造器async
。这个构造器可以得到异步任务执行的返回值。
fun main() {
val deferredResult: Deferred<String> = GlobalScope.async {
delay(1000L)
"World!"
}
runBlocking {
println("Hello, ${deferredResult.await()}")
}
}
async
构造器会返回一个Deferred
类型的对象,这和Future
或者Promise
类似。之后通过await
调用可以得到异步任务的返回结果。
await
不是一个简单的阻塞方法,它是一个挂起函数。也就是说这个不能直接在mian方法里调用await
,所以上例中是在runBlocking
里调用的。
这里再次出现了GlobalScope
。协程的scope是用来创建结构化的并发的。
但是上面的只能是作为一个例子使用的,反复强调的就是runBlocking
一般是不用的。各位还记不记得一段非常有名的代码,24小时后发出一个alert:
Thread.sleep(/* 24小时 */)
Android开发的面试官如果遇到这样的回答,面试的人可以直接回去等消息了🐶。一般不用runBlocking
!
所以,实际上调用挂起函数是如何的,这里通过一个Button
的点击事件来演示:
Button(
modifier = Modifier.padding(start = 20.dp),
onClick = {
println("Start")
scope.launch {
val firstUserDeferred: Deferred<UserInfo> = async {
fetchUser(1)
}
val secondUserDeferred: Deferred<UserInfo> = async {
fetchUser(2)
}
val firstUser = firstUserDeferred.await()
val secondUser = secondUserDeferred.await()
println("Fetch user completed: first: ${firstUser.userId}, second: ${secondUser.userId}")
}
println("End")
}) {
Text(text = "Coroutine Async", color = Color.White)
}
delay
用来模拟网络请求的延迟。最后打印出用户的userId。
运行结果:
让多个async请求
用上面的例子来说,如果需要两个用户的数据就两个请求,那么如果要一个数组很多个用户的数据要怎么发这个请求呢。同时还要保证一点,这些个用户的请求必须得是并行的。
要实现这个目的可以像上面的例子一样写,请求一个用户对应一个async:
val firstUserDeferred: Deferred<UserInfo> = async {
fetchUser(1)
}
val secondUserDeferred: Deferred<UserInfo> = async {
fetchUser(2)
}
val firstUser = firstUserDeferred.await()
val secondUser = secondUserDeferred.await()
还可以这样写:
val (firstUser, secondUser) = listOf(1,2).map {
async(Dispatchers.IO) {
fetchUser(it)
}
}.awaitAll()
把需要获取的用户id直接按照一个数组处理,map到async
协程上。最后使用awaitAll
获得结果值数组。本例中是按照tuple处理的。
结构化并发
从上面的例子里,你会发现他们有个共同点:阻塞并等待协程执行完成。
kotlin可以席间结构化的协程,这样父协程可以管理子协程的生命周期。他可以等待所有的子协程完成,或者一个子协程发生异常的时候取消所有的子协程。
创建结构化的协程
除了runBlocking
,一般不在协程里调用,所有的协程构造器都定义在CoroutineScope
的扩展里面:
fun <T> runBlocking(...): T {...}
fun <T> CoroutineScope.async(...): Deferred<T> {...}
fun <T> CoroutineScope.launch(...): Job {...}
fun <E> CoroutineScope.produce(...): ReceiveChannel<E> {...}
...
要新建一个协程,你要么用GlobalScope
(新建一个顶层协程),要么用一个已经存在的协程scope的扩展方法。有一个可以说是某种约定,最好是写一个CoroutineScope
的扩展方法来新建协程。
async
的定义如下:
fun <T> CoroutineScope.async(
...
block: suspend CoroutineScope.() -> T
): Deferred<T> {
...
}
看上面的代码,async传入的参数也是一个CoroutineScope
的扩展。也就是说你可以在里面调用协程构造器而不用指定调用对象。
上面的例子的可以这样修改:
fun main() = runBlocking {
val deferredResult = async {
delay(1000L)
"World!"
}
println("Hello, ${deferredResult.await()}")
}
fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
fun main() = runBlocking {
delay(1000L)
println("Hello, World!")
}
我们不再需要GlobalScope
了,因为runBlocking
已经提供了一个scope了。
coroutineScope构造器
上面说过runBlocking
不鼓励使用。因为Kotlin的协程的初衷就是不阻塞线程。不过runBlocking
就是一个coroutineScope
构造器。
coroutineScope
会挂起了当前的协程,一直到所有的子协程都执行完毕。例如:
fun main() = runBlocking { // this: CoroutineScope
launch {
delay(200L)
println("Task from runBlocking")
}
coroutineScope { // Creates a new coroutine scope
launch {
delay(500L)
println("Task from nested launch")
}
delay(100L)
println("Task from coroutine scope") // This line will be printed before nested launch
}
println("Coroutine scope is over") // This line is not printed until nested launch completes
}
调度器
前面代码中的协程构造器在调用的时候其实都包含了一个可选参数,那就是CoroutineContext
。CoroutineContext
就包含了Dispatchers
。默认的调度器就是Dispatchers.Unconfined
。
Dispatchers
主要是用来决定协程在哪个线程或者哪一批线程上执行。Dispatchers主要包括:
- Default
- Unconfined
- Main
- IO
Dispatchers.Default
用来处理CPU密集型任务
比如:
- 矩阵计算
- 对内存的数据较多的数组之类的进行排序,过滤或者查找
- 对内存里的bitmap做处理,比如加滤镜等
- 在内存里处理JSON,不是从文件读取这种事
所以现在知道在哪里可以使用Dispatchers.Default
了。这个和RxJava的Schedulers.computation()
非常相似。
launch(Dispatchers.Default) {
// 处理CPU密集任务
}
Dispatchers.IO
这个是专门用来处理磁盘、网络操作的。
比如:
- 网络请求
- 下载文件
- 磁盘移动文件、读写文件
- 数据库查询
- 记载SharedPreferences数据
如:
launch(Dispatchers.IO) {
// 处理CPU密集任务
}
Dispatchers.Main
在android开发中,我们可以使用Dispatchers.Main
来让协程在主线程中运行。只有在主线程里才可以更新UI。
比如:
- 更新UI
- 对一些数据量不大的数据结合做排序、过滤或者查找等
launch(Dispatchers.Main) {
// 处理CPU密集任务
}
Dispatchers.Unconfined
我们来先看看官方文档的说法:如果一个协程没有指定任何的线程。它会在调用它的当前帧上执行,并在任何挂起函数被唤起的线程继续执行,并且不特别指定线程策略。
在Android开发中遇到使用这个调度器的情况真不多见。
异常处理
默认情况下,launch
和await
对于异常的处理是不同的。launch
会让异常向上冒泡传递,async
会给用户一个捕捉异常的机会。
比如:
val job = scope.launch {
throw IndexOutOfBoundsException()
}
这种情况,app直接crash。
val deferred = scope.async {
println("Throw exception in async")
throw ArithmeticException()
}
这段只要不调用await是不会crash的。在调用await的时候可以使用try-catch处理异常。就是这样:
val deferred = scope.async {
println("Throw exception in async")
throw ArithmeticException()
}
try {
deferred.await()
} catch (e: ArithmeticException) {
println("Caught ArithmeticException")
}
在调用await
的时候使用try-catch可以处理异常。
CoroutineExceptionHandler
所以,在处理异常的时候,对于launch来说可以在内部把异常吞掉,不让launch捕获异常。或者,就可以使用ExceptionHandler
来处理。换句话说这个机制是专门给launch
启动的协程使用的。在async
启动的协程里这个handler不会被用到:
val handler =
CoroutineExceptionHandler { _, exception -> println("CoroutineExceptionHandler caught $exception") }
GlobalScope.launch(handler) {
val job = scope.launch(handler) {
throw IndexOutOfBoundsException()
}
job.join()
println("Joined failed job")
val deferred = scope.async(handler) {
println("Throw exception in async")
throw ArithmeticException()
}
try {
deferred.await()
} catch (e: ArithmeticException) {
println("Caught ArithmeticException")
}
}
- 在
GlobalScope
里使用handler没有效果,异常还是会冒泡并最终导致app崩掉 - 只有
launch
启动的协程会打印出CoroutineExceptionHandler caught java.lang.IndexOutOfBoundsException。async
启动的协程并不会打印handler的异常内容。
还有一种情况,如果同时有多个协程在运行,如:
suspend fun getUsers() {
delay(200L)
println("fetching users")
}
suspend fun getTodoList() {
throw NetworkErrorException()
}
try {
val usersDeferred = async { getUsers() }
val todoListDeferred = async { getTodoList() }
val users = usersDeferred.await()
val todoList = todoListDeferred.await()
} catch (e: Exception) {
println("Get users & todo list caught exception: $e")
}
getUsers
和getTodoList
是两个挂起函数。
如果他们两个之一发生异常会发生什么呢?app会直接crash,虽然异常会被catch到,会打印异常语句。
这个时候就需要coroutineScope
出场了。
try {
coroutineScope {
val usersDeferred = async { getUsers() }
val todoListDeferred = async { getTodoList() }
val users = usersDeferred.await()
val todoList = todoListDeferred.await()
}
} catch (e: Exception) {
println("Get users & todo list caught exception: $e")
}
这次发生异常之后会被catch到,并且app不会崩溃。
但是使用了coroutineScope
还是有问题。如果一个请求出现问题,可以返回空值,并且可以接续执行其他的协程如何处理呢。这就需要supervisorScope
出场了:
supervisorScope {
val usersDeferred = async { getUsers() }
val todoListDeferred = async { getTodoList() }
val users = try {
usersDeferred.await()
} catch (e: Exception) {
println("get users error")
null
}
val todoList = try {
todoListDeferred.await()
} catch (e: Exception) {
println("get todo list error")
emptyList<Int>()
}
}
同时每个await
都加一个try-catch语句块。这样就可以 保证在任意一个网络请求出问题的时候单独处理。
coroutineScope
和supervisorScope
最大的不同是coroutineScope
在子协程出问题的时候会中断执行。supervisorScope
在一个子协程出问题的时候不会取消其他的。
协程的取消
协程的取消是合作式的。千言万语汇成一句话就是:你让它取消,它会收到一个取消的信号。但是实际取消还是不取消取决于你。kotlinx.coroutines里的函数都是可以取消的,他们会在取消的时候抛出CancellationException
异常。
看这段代码:
Button(onClick = {
val handler =
CoroutineExceptionHandler { _, exception -> println("CoroutineExceptionHandler caught $exception") }
scope.launch {
val job = launch {
repeat(1_000) {
println("Coroutine running... $it")
delay(500L)
}
}
delay(1200L)
println("MAIN: I'm no waiting")
job.cancelAndJoin()
println("main: quited")
}
}) {
Text("Cancel coroutine", color = Color.White)
}
运行结果:
如果把while
循环里的delay
删掉呢,运行结果:
上下两个运行结果分析,如果没有delay
的话想要取消协程是取消不掉的。dalay
就是kotlinx.coroutine库里的函数,所以协程才可以取消。
那么,如果自己开发的协程里就不用delay这样的函数就没法取消了么?还是可以的,有两个办法:
- 检查
isActive
。 - 调用kotlinx.coroutine库的函数,比如:
delay
或者yield
。
使用isActive
直接看官方代码:
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) { // 取消循环执行
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
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.")
在循环里不断检查isActive
的值,如果被取消则停掉循环退出协程。运行结果:
也可以使用另外一个函数来实现这个功能:ensureActive()
。
while (true) { // 取消循环执行
ensureActive()
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
看下ensureActive()
的源码:
/**
* Ensures that current job is [active][Job.isActive].
* If the job is no longer active, throws [CancellationException].
* If the job was cancelled, thrown exception contains the original cancellation cause.
*
* This method is a drop-in replacement for the following code, but with more precise exception:
* ```
* if (!job.isActive) {
* throw CancellationException()
* }
* ```
*/
public fun Job.ensureActive(): Unit {
if (!isActive) throw getCancellationException()
}
使用yield
主要是用来让同一个调度器(dispatcher)下的其他协程先执行。对于本协程来说,调用它会挂起当前的执行,然后立刻恢复。可以用来处理重CPU、或者可能耗尽线程池的任务。
suspend fun doHeavyWork() {
withContext(Dispatchers.Default) {
repeat(1000) {
yield()
// do heavy work
}
}
}
当然在这里提到就是说它可以用来处理协程的取消:
Button(onClick = {
val handler =
CoroutineExceptionHandler { _, exception -> println("CoroutineExceptionHandler caught $exception") }
scope.launch {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (true) {
yield() //* 使用yield取消协程
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
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.")
}
}) {
Text("Cancel coroutine", color = Color.White)
}
Android开发相关
在Android开发使用协程的时候,会用到专门定制的scope和挂起函数
To be continued。。。