这个时候就发现产生了嵌套!?
确实,如果只是使用launch{...}
,它本身的能力是有限的, 但是协程中却提供了一个很有帮助的函数:withContext()
,这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行,看下下面的一个代码:
fun main() {
val coroutineScope = CoroutineScope(Dispatchers.Unconfined)
coroutineScope.launch(Dispatchers.Unconfined) { // 在未指定线程中开启协程
val text = withContext(Dispatchers.IO) { // 开启一个子协程,并指定在I/O线程,去做一个耗时操作
delay(200L) // 1 做一个200MS的耗时操作
“World”
}
print(text)
}
print("Hello, ")
Thread.sleep(300L) // 让主线程睡300MS,防止主线程关闭
}
// 打印结果:
Hello, World
如果说 withContext(){..}
里面的代码很长,或者需要复用,我们需要将他们抽成一个方法,可以写成这样:
…
coroutineScope.launch(Dispatchers.Unconfined) {
val text = withContext(Dispatchers.IO) {
getText()
}
}
…
suspend fun getText(): String {
delay(200L)
return “World”
}
我们抽出了一个方法,并在前面加上了 suspend
关键字。
这个关键字是Kotlin协程中最为核心鹅关键字,它的中文意思是 [暂停] 或者 [挂起],它所代表的意义其实就是将当前协程任务在线程中挂起: 线程在执行到遇到挂起的代码时,可以绕过去,不用等到这些代码执行完毕才能执行后面的代码,而是你走你的,我走我的,你占的道反正不在我这。
而 launch{..}
里面的代码块,其实就是默认被 suspend
修饰了,所以抽出来的方法,我们也额外的手动加上。
===========================================================================
挂起是什么?请看下面的代码(还是上面那个例子):
coroutineScope.launch(Dispatchers.Unconfined) {
val text = withContext(Dispatchers.IO) {
getText()
}
print(text)
}
上面可以看成做了两个事情:
-
开启一个(Unconfined即当前线程所在的)协程
-
在这个协程中 开启一个(IO线程)子协程做耗时任务
这个时候,我们就成 coroutineScope.launch{..}
挂起了。
我们以线程的角度来考虑这段代码,请看下图:
假设在CPU单核运算,只有一条线来走时间片轮换,那我们假设每个时间只有一个线程在执行代码。
那么在走到 withContext(Dispatchers.IO){..}
时,它其实就是开启了一个IO线程。并将代码块中的代码放入到这个线程里面去做。
那这不就是切线程吗?为啥美其名曰挂起?
是因为,这个协程的API在完成了I/O线程的任务之后,不需要我们手动切回到原来的线程(可以对比一下RxJava在子线程做完网络请求后我们手动切回主线程),自动的帮我们切回了原来的线程。
我上面的代码,在 withContext(){..}
I/O线程做完延时操作后,自然而然的回到了 Unconfined的线程去做print。
这个恢复到原来线程的操作在Kotlin里叫 resume
。它本身一点儿也不稀奇,因为我们在RxJava中就已经知道通过Handler来切换线程了, 只是Kotlin包装到我们意识不到原来已经切换回来的程度。
总结成一句话:挂起就是: 切换线程 + 恢复到原来线程
我们用一个 suspend
来修饰挂起函数。suspend的意义是什么?它能实现挂起吗?
答案是否定的,单单给一个普通函数加上一个 suspend
,没有任何效果,可以看下下面代码:
我们在函数中调用了 print(),发现 suspend置灰了,这说明只是用 suspend是无法让函数挂起的。
那CoroutineScope.launch(){}
代码块能让语句挂起吗?答案也是否定的。
那让函数挂起的语句是什么?
答案是 withContext(){..}
,它是真正的创建了线程的代码,所以当出现了这个语句,协程才能真正的挂起。
当然,除了 withContext() ,还有别的能让协程挂起,比如 delay()
,因为他本身也是个操作线程的语句,包括别的。
总结下suspend
的作用:
-
提醒调用者,该函数会切换线程(也就是挂起)
-
静态代码扫描,检测该函数是否有线程操作
-
只有在协程中调用
withContext(){..}
类的代码, 该协程才会被挂起
当我们了解了挂起的本质是切换线程,那我们就明白了非阻塞的含义是什么了。
在Java开发中,我们通过调用 Executor
来开一个子线程,子线程会阻塞主线程的代码吗?
答案是不会的,因为当我们只是开一个子线程出来,主线程还是会继续走,等时间片不在主线程时,子线程再去走,但对于主线程来说,它里面的代码并没有因为子线程的存在而产生阻塞。
↑所以这个情况,也是非阻塞的。
而Koltin中的【非阻塞式挂起】,其实和Java一样,它只是通过协程达到了这个效果:代码明明看起来是阻塞的,但是运行却是不阻塞的。 如下所示:
coroutineScope.launch(Dispatchers.Main) {
val avatar = async { api.getAvatar(user) } // 获取用户头像
val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
val merged = suspendingMerge(avatar, logo) // 合并结果
show(merged) // 更新 UI
}
再往前想一想 runBlocking(){..}
为啥线程阻塞了?因为它本身就没有切换线程,它是直接在本线程上做耗时任务。
嘿嘿,是不是有点恍然大悟了?它本身就是噱头。
=============================================================================
官方的例子举得很好,接下来就都用官方的代码了~
4.1.1 协程的取消
协程任务是可以取消的
// 省略一些CoroutineScope的代码
val job : Job = launch {
repeat(1000) { i ->
println(“job: I’m sleeping $i …”)
delay(500L)
}
}
delay(1300L) // 延迟一段时间
println(“main: I’m tired of waiting!”)
job.cancel() // 取消该作业
job.join() // 等待作业执行结束
println(“main: Now I can quit.”)
// 执行结果:
job: I’m sleeping 0 …
job: I’m sleeping 1 …
job: I’m sleeping 2 …
main: I’m tired of waiting!
main: Now I can quit.
我们可以看到 CoroutineScope
提供了一个 Job
类型的句柄,我们可以调用其 Job.cancel()
、Job.join()
来停止和加入该协程。
这两个方法也可以合并成一个 Job.cancelAndJoin()
来使用
4.1.2 协程是不能随时取消的
和线程一样,我们在调用了 Thread.cancel()
后,线程不会马上取消。
协程也一样,当协程在进行计算任务任务时,如果你直接cancel,是不会取消任务的:
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU
// 每秒打印消息两次
if (System.currentTimeMillis() >= nextPrintTime) {
println(“job: I’m sleeping ${i++} …”)
nextPrintTime += 500L
}
}
}
delay(1300L) // 等待一段时间
println(“main: I’m tired of waiting!”)
job.cancelAndJoin() // 取消一个作业并且等待它结束
println(“main: Now I can quit.”)
// 打印结果:
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.
我们需要像 Thread那样,去调用isInterrupted()
来检查。
协程提供了一个变量 isActive
来判断当前协程是否取消了,如果取消了,就不用再走下去了:
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) { // 可以被取消的计算循环
// 每秒打印消息两次
if (System.currentTimeMillis() >= nextPrintTime) {
println(“job: I’m sleeping ${i++} …”)
nextPrintTime += 500L
}
}
}
delay(1300L) // 等待一段时间
println(“main: I’m tired of waiting!”)
job.cancelAndJoin() // 取消该作业并等待它结束
println(“main: Now I can quit.”)
// 打印结果:
job: I’m sleeping 0 …
job: I’m sleeping 1 …
job: I’m sleeping 2 …
main: I’m tired of waiting!
main: Now I can quit.
4.1.3 在取消时释放内存
我们是可以感知得到Job被取消的,当Job被取消的时候,它会抛出一个 CancellationException
,我们把它当成结束回调,在里面做释放资源:
val job = launch {
try {
repeat(1000) { i ->
println(“job: I’m sleeping $i …”)
delay(500L)
}
} finally {
println(“job: I’m running finally”) // 此时任务取消,释放资源
}
}
delay(1300L) // 延迟一段时间
println(“main: I’m tired of waiting!”)
job.cancelAndJoin() // 取消该作业并且等待它结束
println(“main: Now I can quit.”)
4.1.4 假如你的释放资源也是耗时的怎么办
这是一个比较少见的情况,在 finally
块中调用了耗时操作,此时协程又被取消,那相当于你finally的耗时操作执行不了
协程提供了 withContext(NonCancellable){...}
来帮助协程延迟取消:
val job = launch {
try {
repeat(1000) { i ->
println(“job: I’m sleeping $i …”)
delay(500L)
}
} finally {
withContext(NonCancellable) {
println(“job: I’m running finally”)
delay(1000L)
println(“job: And I’ve just delayed for 1 sec because I’m non-cancellable”)
}
}
}
delay(1300L) // 延迟一段时间
println(“main: I’m tired of waiting!”)
job.cancelAndJoin() // 取消该作业并等待它结束
println(“main: Now I can quit.”)
在实践中绝大多数取消一个协程的理由是它有可能超时。
当你手动追踪一个相关 Job 的引用并启动了一个单独的协程在延迟后取消追踪,这里已经准备好使用 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 …
Exception in thread “main” kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms
发现抛出了 TimeoutCancellationException
异常,它是 CancellationException
的子类。
之所以后者没有产生堆栈信息,是因为协程认为我们取消它,这是一个正常的结束。
如果遇到特殊的场景,比如我必须要知道这个任务在规定时间内还没有做完,,可以使用类似 withTimeout
的 withTimeoutOrNull
函数,并把这些会超时的代码包装在 try {...} catch (e: TimeoutCancellationException) {...}
代码块中,而 withTimeoutOrNull
通过返回 null 来进行超时操作,从而替代抛出一个异常:
val result = withTimeoutOrNull(1300L) {
repeat(1000) { i ->
println(“I’m sleeping $i …”)
delay(500L)
}
“Done” // 在它运行得到结果之前取消它
}
println(“Result is $result”)
// 打印结果:
I’m sleeping 0 …
I’m sleeping 1 …
I’m sleeping 2 …
Result is null
就不会抛出异常,而是返回一个null。个人认为这样的做法比catch异常要温柔一点。
===================================================================================
coroutineScope{..}
会创建一个挂起函数,所以调用它的函数必须被 suspend
修饰。
官方文档是这么描述的:
This function is designed for parallel decomposition of work.
When any child coroutine in this scope fails, this scope fails and all the rest of the children are cancelled
This function returns as soon as the given block and all its children coroutines are completed.
翻译一下就是
这个方法是为了并行工作而设计的
当这个作用域内的任何一个子协程失败,那么这个作用域失败,并且其所有剩余的子协程都失败
当这个作用域所有的子协程都成功完成任务了,那么这个作用域将会返回(结果)
5.1 coroutineScope 和 CoroutineScope的区别
因为 coroutineScope
的描述,所以可以从结构化和非结构化入手,coroutineScope是结构化的,CoroutineScope是非结构化的。
结构化更利于并行程序的执行。
举个例子,我们在主线程中,点击一个Button,这个button会去做耗时操作,然后返回结果,使用 CoroutineScope是这样的:
…
btn.setOnClickListener( () -> {
CoroutineScope(Dispatchers.Main).launch {
int result = downloadUserData()
Toast.makeText(applicationContext, "Result : " + result, Toast.LENGTH_LONG).show()
});
…
private suspend int downloadUserData() {
int result = 0;
// 这里我们使用一个CoroutineScope并切换线程
CoroutineScope(Dispatchers.IO).launch {
for (int i = 0; i < 2000; i++) {
kotlinx.coroutines.delay(400);
result++;
}
}
return result;
}
}
Toast打印出来的result是0
这是切了线程后,并没有等到该协程执行完返回,直接执行了Toast的展示。
这很明显不是我们想要的结果。
这时,我们换成 coroutineScope {}
来试试:
private suspend int downloadUserData() {
int result = 0;
coroutineScope {
for (int i = 0; i < 20000; i++) {
kotlinx.coroutines.delay(400);
result++;
}
}
return result;
}
Toast的打印result是20000,使我们想要的结果。
这就是因为 coroutineScope它等待到代码块执行完,才返回结果。
5.2 coroutineScope 和 runBlocking的区别
猛地一看,coroutineScope{}
会等到里面所有的代码执行完,这不跟 runBlocking{}
的阻塞等待 一样吗?
他们最大的区别就是,当他们代码块的函数被挂起时,当前的线程会怎么做。
-
coroutineScope{} 函数被挂起时,线程会继续往之后走
-
runBlocking{} 函数被挂起时,线程会等待执行完这个函数里面所有的代码才会往之后走
看个代码:
fun main() {
CoroutineScope(Dispatchers.Unconfined).launch { // 开启一个主协程
val result = downloadUserData()
println(result)
}
println(“OUT”) // 在协程之外打印东西
Thread.sleep(12000L) // 保证程序不结束
}
suspend fun downloadUserData(): Int {
var res = 0
coroutineScope { // 使用coroutineScope来开启一个子协程
for (i in 0…10) {
delay(100L)
res++
}
}
return res
}
// 打印结果:
OUT
11
也就是说,当 coroutineScope
挂起时,线程会继续往下走,但由于他没执行完,所以 println(result)
没有执行,继续往下走,会执行println("OUT")
,所以先打印 OUT , 再打印计算结果。
我们换成runBlocking试试:
suspend fun downloadUserData(): Int {
var res = 0
runBlocking {
for (i in 0…10) {
delay(100L)
res++
}
}
return res
}
// 答应结果:
11
OUT
显然,runBlocking会不会把控制权还给线程,(虽然他挂起来了),但还是会一直执行,直到代码块走完
==========================================================================
终于来到了关键的地方了,在前面的学习中,我好像只学习了每个关键字、语句块的概念,但是我并没有让他们实践,并行执行,因为学习协程的最终目的是高效进行高并发操作。
我们先来看一下两个耗时操作:
suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // 假设我们在这里做了一些有用的事
return 13
}
suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // 假设我们在这里也做了一些有用的事
return 29
}
接下来,我们的程序要将两者的最终结果相加,我们可能会这样写:
val time = measureTimeMillis {
val one = doSomethingUsefulOne()
val two = doSomethingUsefulTwo()
println(“The answer is ${one + two}”)
}
println(“Completed in $time ms”)
// 打印结果:
The answer is 42
Completed in 2017 ms
通过耗时来看,我们发现程序它的结果像是串行执行的,没有起到并行的结果。
doSomethingUsefulOne()
和 doSomethingUsefulTwo()
并没有产生依赖,并且我们为了更快的得到结果,我们需要将两个方法并行执行。
async
和 launch
类似,它可以用来启动一个单独的协程。不同之处在于 launch
返回的是Job
类型,而 async
返回的是一个 Deferred
类,这是一个轻量级的非阻塞future,也就是会在稍后提供给我们结果。我们可以使用 .await()
在一个延期的值上得到它的最终结果, Deferred
继承自Job,所以我们也可以通过 Deferred来结束一个协程。
val time = measureTimeMillis {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println(“The answer is ${one.await() + two.await()}”) // 使用wait来等待结果
}
println(“Completed in $time ms”)
// 打印结果:
The answer is 42
Completed in 1017 ms
显然,性能快了一倍。
CoroutineStart
可以用来设置启动属性,他有下面四种属性:
- DEFAULT
默认,立即根据Context来安排协程的执行。
- ATOMIC
自动。这与默认值类似,但是协程在开始执行之前不能取消。
- UNDISPATCHED
立即执行协程,一直到它在当前线程的第一个挂起点
当resume时,会根据 CortuineDispatcher来找到对应的线程
- LAZY
不会马上执行,只有在需要的时候执行。
使用这些修饰的协程,需要手动调用 Job.start()
来开启协程
对于 async来说,懒启动也就是等到调用await()
的时候,才去执行计算:
val time = measureTimeMillis {
val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
// 执行一些计算
one.start() // 启动第一个
two.start() // 启动第二个
println(“The answer is ${one.await() + two.await()}”)
}
println(“Completed in $time ms”)
// 打印结果:
The answer is 42
Completed in 1017 ms
=====================================================================
既然我们知道了协程是一种编程思想,它是为了帮助主程序更流畅的执行下去,而他的实现是切换线程。
那么我们知道了其实它的使用场景:
-
I/O操作
-
耗时计算
在看到很多篇文章的时候,可能动不动就会来一句话:因为协程是微线程,所以使用协程切换会比线程节省很多开销。
这句话是很不严谨,甚至是错误的。
考虑到我们使用协程的场景,99%都是需要异步的去处理一些东西,所以这必须要切换线程。
那 【协程的挂起】 其实就是 【线程的切换和恢复】,很明显,这肯定是有开销的。
最后
如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。
欢迎大家一起交流讨论啊~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!
c(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
// 执行一些计算
one.start() // 启动第一个
two.start() // 启动第二个
println(“The answer is ${one.await() + two.await()}”)
}
println(“Completed in $time ms”)
// 打印结果:
The answer is 42
Completed in 1017 ms
=====================================================================
既然我们知道了协程是一种编程思想,它是为了帮助主程序更流畅的执行下去,而他的实现是切换线程。
那么我们知道了其实它的使用场景:
-
I/O操作
-
耗时计算
在看到很多篇文章的时候,可能动不动就会来一句话:因为协程是微线程,所以使用协程切换会比线程节省很多开销。
这句话是很不严谨,甚至是错误的。
考虑到我们使用协程的场景,99%都是需要异步的去处理一些东西,所以这必须要切换线程。
那 【协程的挂起】 其实就是 【线程的切换和恢复】,很明显,这肯定是有开销的。
最后
如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。
[外链图片转存中…(img-gtA36iXo-1715717042534)]
欢迎大家一起交流讨论啊~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!