协程
本文对Kotlin中协程进行探索分析
Kotlin协程(一)
我最初听说的关于协程的概念就是协程是更轻量级的线程,或者可以说协程并不会阻塞当前线程。 举个下面的demo例子
kotlin
GlobalScope.launch(Dispatchers.Main) {
Log.d(TAG, "Starting time-consuming task in a background thread...")
withContext(Dispatchers.IO) {
delay(5000L)
Log.d(TAG, "Task finished!")
}
Log.d(TAG, "Main thread continues to execute...")
}
Log.d(TAG, "the code after launch is running")
打印出来的log顺序是
Log.d(TAG, "the code after launch is running")
Log.d(TAG, "Starting time-consuming task in a background thread...")
Log.d(TAG, "Task finished!")
Log.d(TAG, "Main thread continues to execute...")
显然我们通过GlobalScope.launch开启的协程并没有阻塞下面该行代码的运行
kotlin
Log.d(TAG, "the code after launch is running")
当然关于打印出的log顺序应该还有很多疑惑,疑惑是对下面三面的执行顺序
挂起与恢复
在探究之前我们需要先了解协程中挂起跟恢复这两个概念
挂起概念
挂起 (Suspending)指的是协程在执行过程中暂时停止其执行,但是它并不占用线程的时间资源。也就是说,协程在某个点上挂起时,它暂停当前协程的工作 ,但是让出线程的控制权,从而允许其他任务在该线程上继续执行。
通过挂起的概念我们看到挂起也就是协程把自己挂起来
,让线程继续执行后面的代码。 那么挂起的发动时机是什么呢?这就与挂起这一操作的具体过程有关了
挂起具体过程
- 挂起 :当你在协程内执行一个挂起函数(比如
delay()
、withContext()
等)时,协程会在执行这些函数时"挂起"自己。挂起是指协程的执行被暂停,而当前线程被释放出来,可以执行其他操作。- 恢复:在挂起函数的执行完成后,协程会从挂起的位置恢复执行,继续执行后面的代码。
挂起的具体触发时机是我们执行到了一个挂起函数,什么是挂起函数呢? 挂起函数在kt中就是用suspend修饰的函数,我们自己也可以定义挂起函数,但是需要注意的是如果挂起函数不运行在协程中,那么跟普通的函数没什么两样。 (恢复比较简单,看概念可懂)
当然不同的挂起函数执行的功能肯定不一样,我们要结合具体的功能加上挂起这一概念来分析代码执行的先后顺序。 下面我们用通俗的语言来整理一下这一过程
当我们开启一个协程的时候如果碰到了一个挂起函数这样一个标志会挂起自己(注意这里挂起的对象是整个协程),继续执行线程其他代码。下面就是协程自己的内部问题了,当挂起函数自己执行完成后会从挂起的位置恢复执行协程后面的代码。
当然如果你看到这里对打印的log顺序第一条疑惑的话那就符合预期了😤🤓 我们再回头看下代码
kotlin
//MainActivity.kt --onCreate()..
GlobalScope.launch(Dispatchers.Main) {
Log.d(TAG, "Starting time-consuming task in a background thread...")
withContext(Dispatchers.IO) {
delay(5000L)
Log.d(TAG, "Task finished!")
}
Log.d(TAG, "Main thread continues to execute...")
}
Log.d(TAG, "the code after launch is running")
这个背景就是在activity中的oncreate中执行的,也就是说本来就是主线程,如果说碰到挂起函数才挂起的话那么不应该是碰到withcontex之后才挂起吗?为啥Log.d(TAG, "the code after launch is running")
先执行呢?
这就与协程的启动流程相关了
协程的启动
说是协程的启动,其实也不太恰当,姑且先拿来一用 说到协程的启动,我们需要先知道什么是协程?
什么是协程
关于网上对协程的定义也是各有千秋,笔者更喜欢用描述性语言来描述协程
- 协程是运行在线程上的
- 协程是更轻量级的任务调度 ,任务调度这一概念更加细微,也就是允许我们在一个线程上开启多个任务(协程),而不是为每个任务开一个线程,协程通过协程内部通过调度器(
Dispatchers
)控制执行上下文,从而选择在哪个线程上运行,当然如果协程本质就是线程的调度会不会有点太大了?那不是rxjava干的活吗🤔,笔者认为怎么描述都可以,能干活就行
协程的开启
我们可以用很多种方式来开启协程,具体本文暂时不提,不同方式开启的协程生命周期也不同,这个后面将。
我们需要知道我们开启一个协程实际上是把我们的协程提交给调度器 ,调度器会把任务放在队列里面,这意味着它会立即准备好执行,但并不会立即开始执行。实际的执行时机取决于调度器的行为和当前线程的空闲情况。
举一些常见的具体例子
-
Dispatchers.Main
:将协程调度到主线程上。主线程的执行会按照顺序进行,但是主线程本身是有其他任务的(例如 UI 更新),所以协程体中的代码也会等待主线程空闲后执行。 -
Dispatchers.IO
和Dispatchers.Default
:这些调度器依赖于线程池,协程会等待线程池中的空闲线程。如果线程池中的所有线程都在忙碌,协程就会被推迟执行。
也就是说协程的具体执行时机不是确定的,但是调度器会尽量的帮我们调度好。简单点可以理解为有资源的时候会调度给我们的协程。
为什么协程不阻塞当前线程
所以在本demo中
kotlin
GlobalScope.launch(Dispatchers.Main) {
Log.d(TAG, "Starting time-consuming task in a background thread...")
withContext(Dispatchers.IO) {
delay(5000L)
Log.d(TAG, "Task finished!")
}
Log.d(TAG, "Main thread continues to execute...")
}
Log.d(TAG, "the code after launch is running")
我们通过GlobalScope.lanch
开启的协程只是把协程代码放在了主线程调度器中,并不是说立即执行,主线程而是会执行 Log.d(TAG, "the code after launch is running")
的代码,这也就具体解释了为什么协程不会阻塞当前的线程,当然协程远没有这里简单。
下面我们把目光投在协程内部的具体执行。
协程内部的执行与挂起函数有关,需要了解一下常见的挂起函数
常见的挂起函数
delay()
delay()
是一个常用的挂起函数,它会暂停当前协程指定的时间,但不会阻塞线程。
kotlin
suspend fun example() {
println("Start delay")
delay(1000L) // 挂起协程1秒钟
println("End delay")
}
withContext()
withContext()
是一个非常常见的挂起函数,它用于切换协程的执行上下文(线程池)。
kotlin
suspend fun example() {
println("Start")
withContext(Dispatchers.IO) {
// 在IO线程中执行任务
delay(1000L)
println("Executed in IO context")
}
println("End")
}
async()
和 await()
async()
是一个常用的挂起函数,用于启动并发的协程任务,它返回一个 Deferred
对象,可以用于获取异步结果。
kotlin
suspend fun example() {
val deferred: Deferred<Int> = GlobalScope.async {
delay(1000L)
return@async 42
}
val result = deferred.await() // 等待结果
println("Result: $result")
}
launch()
launch()
本身是一个启动协程的函数,它通常和 GlobalScope
一起使用,用于启动后台任务。虽然它本身并不是一个传统意义上的挂起函数
kotlin
GlobalScope.launch {
delay(1000L)
println("Task completed")
}
join()
join()
是一个挂起函数,它会挂起当前协程,直到另一个协程执行完成。这个函数常用于等待一个协程的结束。
kotlin
val job = GlobalScope.launch {
delay(1000L)
println("Task completed")
}
runBlocking {
job.join() // 等待 job 执行完成
println("Main thread resumes")
}
cancel()
cancel()
是一个挂起函数,用于取消协程的执行。当协程被取消时,它会抛出 CancellationException
,这会导致协程终止。
需要注意的是launch其实并不是一个挂起函数,要跟作用域一起使用,
协程执行到挂起函数的时候会把自身挂起,不影响当前线程的运行,但是当前协程会停止执行,摘掉挂起函数执行完毕会从上一个挂起点开始执行后面的内容(恢复操作)
cancelAndJoin()
:
cancelAndJoin()
会取消协程并等待该协程完成(即挂起直到协程结束)。这种方法适用于你需要在取消协程后等待它结束的场景
kotlin
val job = launch {
// 某些任务
}
job.cancelAndJoin() // 取消并等待协程完成
这个方法会确保协程完全结束之后,才继续执行后续代码。
log先后顺序解析
那么这个时候再回头看下最初的代码
kotlin
GlobalScope.launch(Dispatchers.Main) {
Log.d(TAG, "Starting time-consuming task in a background thread...")
withContext(Dispatchers.IO) {
delay(5000L)
Log.d(TAG, "Task finished!")
}
Log.d(TAG, "Main thread continues to execute...")
}
Log.d(TAG, "the code after launch is running")
通过launch开启了一个新的协程,任务会放在队列里,显然会先打印"the code after launch is running"
接着在协程内部打印the code after launch is running
执行到withContex
这个切换上下面的挂起函数,当前协程挂起,等待挂起函数执行完毕,所以会打印Task finished!
,接着协程恢复,打印Main thread continues to execute...
需要注意的是lanch里面的内容是属与协程的,虽说协程依附于线程,但是分析的时候需要单独抽象开。
kotlin
GlobalScope.launch(Dispatchers.Main) {
Log.d(TAG, "Starting time-consuming task in a background thread...")
Log.d(TAG, "---上述代码运行线程:${Thread.currentThread().name}---")
withContext(Dispatchers.IO) {
delay(5000L)
Log.d(TAG, "Task finished!")
Log.d(TAG, "---上述代码运行线程:${Thread.currentThread().name}---")
}
Log.d(TAG, "Main thread continues to execute...")
Log.d(TAG, "---上述代码运行线程:${Thread.currentThread().name}---")
}
Log.d(TAG, "the code after launch is running")
Log.d(TAG, "---上述代码运行线程:${Thread.currentThread().name}---")
运行结果显而易见
the code after launch is running
---上述代码运行线程:main---
Starting time-consuming task in a background thread...
---上述代码运行线程:main---
Task finished!
---上述代码运行线程:DefaultDispatcher-worker-1---
Main thread continues to execute...
---上述代码运行线程:main---
使用withContex(Dispatchers.IO)会切换协程的上下文,挂起函数执行之后会再回到main作用域
那么我如果使用withContex切换到main呢?当然无意义,而且也会挂起协程等待挂起函数执行完毕,也就是Main thread continues to execute...
会在task finished
之后打印 不是说delay不会实际阻塞吗?
???这不是混淆概念吗,不阻塞线程,又不是不挂起协程。
父子协程
协程允许多个子协程的并发,父子协程的管理也需要额外注意
父子协程的基本特性
-
父协程管理子协程的生命周期 :父协程会自动等待子协程完成(如果使用
join
等等待操作),或者父协程取消时,所有子协程也会被取消。 -
协程作用域:父协程可以在其作用域内启动子协程,子协程通常会继承父协程的作用域和上下文。
-
协程的取消:如果父协程被取消,所有子协程也会被取消,反之,子协程的取消不会影响父协程。
-
协程的异常传播:父协程可能会接收到子协程的异常,具体的行为取决于父协程如何处理子协程的异常
如果使用
runBlocking
开启协程 ,runBlocking
是会阻塞当前线程的。
Demo 1:基本关系
kotlin
launch(Dispatchers.Main) {
Log.d(TAG, "Parent coroutine starts")
launch(Dispatchers.IO) {
delay(1000L)
Log.d(TAG, "Child1 coroutine finished")
}
launch(Dispatchers.Main) {
delay(1000L)
Log.d(TAG, "Child2 coroutine finished")
}
delay(500L)
Log.d(TAG, "Parent coroutine continues")
}
child1
跟child2
两个子协程的顺序是一样的吗,由于调度器队列不同,顺序并不是固定的 多次运行程序会得到
- Parent coroutine starts
- Parent coroutine continues
- Child2 coroutine finished
- Child1 coroutine finished
或者
- Parent coroutine starts
- Parent coroutine continues
- Child1 coroutine finished
- Child2 coroutine finished
解释
启动子协程时,父协程并不会被阻塞。它们可以并发地运行,父协程会继续执行,而不需要等待子协程完成。
上面说执行到挂起函数协程会挂起
那么在两个协程中间执行挂起函数withContext则可以确定先后顺序了
kotlin
launch(Dispatchers.Main) {
Log.d(TAG, "Parent coroutine starts")
launch(Dispatchers.IO) {
delay(1000L)
Log.d(TAG, "Child1 coroutine finished")
}
withContext(Dispatchers.IO){
delay(1000L)
Log.d(TAG, "withContext at io task finish")
}
launch(Dispatchers.Main) {
delay(1000L)
Log.d(TAG, "Child2 coroutine finished")
}
delay(500L)
Log.d(TAG, "Parent coroutine continues")
}
由于父协程执行到withContext
挂起函数的时候会挂起等待挂起函数执行,所以child2会在后面执行,而且由于切换的context
是io
与child1
相同故二者可确定先后关系,最后打印顺序如下
Parent coroutine starts
Child1 coroutine finished
withContext at io task finish
Parent coroutine continues
Child2 coroutine finished
子协程可以继承父协程的上下文,但是协程之间是独立的,父协程挂起不会影响子协程自己的运行。(这里的child2
这时还没有启动)
Demo 2:父协程等待子协程完成
kotlin
launch {
Log.d(TAG, "Parent coroutine starts")
val job = launch {
delay(1000L)
Log.d(TAG, "Child1 coroutine finished")
}
//父协程等待子协程执行完毕
job.join()
Log.d(TAG, "Parent coroutine continues after child1 is done")
launch {
delay(1000L)
Log.d(TAG, "Child2 coroutine finished")
}
delay(500L)
Log.d(TAG, "Parent coroutine finished")
}
job.join()
:让父协程等待子协程完成后再继续执行。
运行结果
Parent coroutine starts
Child1 coroutine finished
Parent coroutine continues after child1 is done
Parent coroutine finished
Child2 coroutine finished
Demo 3:子协程取消
前文提到 协程的取消:如果父协程被取消,所有子协程也会被取消,反之,子协程的取消不会影响父协程。 这与挂起操作不同,挂起具有不阻塞性
kotlin
val parentJob = launch {
Log.d(TAG, "Parent coroutine starts")
val childJob = launch {
repeat(5) { i ->
delay(500L)
Log.d(TAG, "Child coroutine running: $i")
}
}
delay(700L)
Log.d(TAG, "Parent coroutine cancels the child")
childJob.cancelAndJoin() //确保取消掉了子协程并等待其完成
Log.d(TAG, "Child coroutine canceled")
}
parentJob.join()
显然子协程只能打印出0
Parent coroutine starts
Child coroutine running: 0
Parent coroutine cancels the child
Child coroutine canceled
但是如果我们在父协程中delay了1000L 可能会碰到这种情况
Parent coroutine starts
Child coroutine running: 0
Parent coroutine cancels the child
Child coroutine running: 1
Child coroutine canceled
打印1的顺序可能在Parent coroutine cancels the child
前或后
这是因为在子协程的 delay(500L)
后,虽然父协程已经发送了取消请求,但因为 delay
是一个挂起函数,子协程在 delay
的时候不会立即检测到取消请求 ,它会继续执行剩下的代码(打印 "Child coroutine running: 1"
)。
Demo 4 :父协程取消
kotlin
val parentJob = launch {
Log.d(TAG, "Parent coroutine starts")
launch {
repeat(5) { i ->
delay(500L)
Log.d(TAG, "Child coroutine running: $i")
}
}}
delay(2000L)
parentJob.cancelAndJoin()
Log.d(TAG, "Parent coroutine canceled")
这个比较简单,父协程取消后子协程会取消
Parent coroutine starts
Child coroutine running: 0
Child coroutine running: 1
Child coroutine running: 2
Parent coroutine canceled
Demo 5:子协程抛出异常,父协程如何处理
kotlin
fun main(): Unit = runBlocking {
launch {
println("Parent coroutine starts")
val childJob = launch {
println("Child coroutine starts")
throw Exception("Something went wrong in child coroutine")
}
try {
childJob.join()
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
println("Parent coroutine continues after exception")
}
}
- Parent coroutine starts
- Child coroutine starts
- Caught exception: StandaloneCoroutine is cancelling
- Parent coroutine continues after exception
- Exception in thread "main" java.lang.Exception: Something went wrong in child coroutine
- at com.example.myapplication.TestKt <math xmlns="http://www.w3.org/1998/Math/MathML"> m a i n main </math>main11childJob$1.invokeSuspend(Test.kt:15)
- at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
- at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
- ......
子协程抛出一个异常,父协程通过 try-catch
捕获该异常。childJob.join()
会阻塞父协程,直到子协程执行完毕。
如果父协程不try catch处理异常层层上抛最后jvm处理,程序崩溃。
Demo 6:子协程的作用域是由父协程管理的
kotlin
fun main() = runBlocking {
// 父协程
val parentJob = launch {
println("Parent coroutine starts")
// 子协程
launch {
println("Child coroutine starts")
delay(1000L)
println("Child coroutine ends")
}
delay(500L)
println("Parent coroutine ends")
}
parentJob.join() // 等待父协程完成
}
父协程管理子协程的生命周期。即使 parentJob
完成后,子协程仍会执行,直到其自身完成,子协程可以继承父协程的上下文和调度器。
况下父协程的生命周期通常会持续到所有子协程的任务都完成
Parent coroutine starts
Child coroutine starts
Parent coroutine ends
Child coroutine ends
kotlin
fun main() = runBlocking {
// 父协程
val parentJob = launch {
println("Parent coroutine starts")
// 子协程
launch {
for (i in 1..100) {
delay(1000L)
println("Child coroutine number: $i")
}
}
delay(500L)
println("Parent coroutine ends")
}
println("other things is doing")
}
- Parent coroutine starts
- Parent coroutine ends
- Child coroutine number: 1
- Child coroutine number: 2
- Child coroutine number: 3
- Child coroutine number: 4
- Child coroutine number: 5
- Child coroutine number: 6
- ......
协程的生命周期
Kotlin 协程的生命周期是指协程从启动到结束的整个过程,包括它的创建、执行、取消和销毁等阶段。协程的生命周期与其作用域、调度器以及是否被取消或暂停等因素密切相关。
在 Kotlin 中,协程的生命周期由以下几个关键点组成:
-
协程的创建
-
协程的执行
-
协程的暂停与恢复
-
协程的取消
-
协程的结束与清理
上述demo中我们看到很多开启协程的方式,下面是一些常见的开启方式
协程的创建
协程的创建可以通过多种方式进行,常见的启动方式有:
-
launch
:启动一个协程并返回一个Job
对象,用于管理协程。适合于不需要返回结果的场景。 -
async
:启动一个协程并返回一个Deferred
对象,用于获取协程的计算结果,适合需要返回结果的场景。 -
runBlocking
:创建一个协程并阻塞当前线程,通常用于顶层函数或测试中,适用于需要同步协程执行的场景。 -
GlobalScope.launch
:在全局作用域中启动协程,生命周期是应用程序的生命周期。
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
delay(1000L)
println("Task completed")
}
println("Main function is running")
job.join() // 等待子协程完成
}
launch
启动一个协程,该协程执行完毕后,协程对象(Job
)将被销毁。如果你不调用 join()
,主线程不会等待子协程完成。
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val deferred = async {
delay(1000L)
"Hello, Coroutine!"
}
println("Main function is running")
println(deferred.await()) // 获取协程的结果
}
与 launch
类似,async
启动一个协程,执行计算并返回一个 Deferred
对象。调用 await()
时,当前协程会等待结果。
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
println("Main function starts")
launch {
delay(1000L)
println("Task completed")
}
println("Main function ends")
}
runBlocking
会阻塞主线程,直到所有子协程完成为止。适用于需要同步执行协程的场景。
kotlin
import kotlinx.coroutines.*
fun main() {
GlobalScope.launch {
delay(1000L)
println("Task completed")
}
println("Main function is running")
Thread.sleep(2000L) // 等待协程完成
}
生命周期 :GlobalScope.launch
启动的协程与全局作用域绑定,生命周期与应用程序的生命周期一致。需要注意的是,这种方式启动的协程在应用程序结束时不会自动清理,因此要特别小心可能导致的内存泄漏问题。
协程的执行
一旦协程被启动,它会根据调度器(如 Dispatchers.Main
, Dispatchers.IO
, Dispatchers.Default
)在相应的线程上执行。协程的执行分为以下几种情况:
-
挂起函数 :协程可以通过
suspend
函数挂起自己,让出线程,允许其他任务执行。挂起函数不会阻塞线程,它们会暂停协程的执行并允许其他协程继续执行。-
挂起操作的例子:
-
delay()
:暂停当前协程一段时间,但不阻塞线程。 -
withContext()
:切换执行的调度器(线程),并挂起当前协程。 -
suspendCoroutine
:用于手动挂起协程。
-
-
kotlin
suspend fun doSomething() {
delay(1000L) // 挂起1秒钟
println("Task completed")
}
生命周期:协程的挂起和恢复不会影响协程的生命周期。当协程挂起时,它仍然活跃,直到它完成所有操作。
协程的取消
协程取消是协程生命周期中的一个重要部分。取消操作并不会强制停止协程,而是标记协程为取消状态,协程本身会在下一个挂起点检查是否取消,如果取消则会提前结束。
-
取消协程:
-
Job.cancel()
:取消协程,但不会立即停止协程执行。协程会在合适的挂起点检查是否取消,并且终止执行。 -
Job.cancelAndJoin()
:取消协程并等待协程完成取消操作。
-
kotlin
val job = launch {
repeat(1000) { i ->
delay(500L)
println("Job is working: $i")
}
}
delay(1500L)
job.cancel() // 取消协程
当协程被取消时,它的生命周期并不会立刻结束,直到协程在下一个挂起点检查到取消信号,并处理清理工作。
具体的机制如下(from deep seek)
-
取消信号的触发
当调用
job.cancel()
时,协程会收到取消请求,并标记为isActive = false
,但协程不会立即停止执行。 -
挂起点的检查
大多数标准挂起函数(如delay()
,yield()
,withContext
等) 会在执行时自动检查协程的取消状态。如果检测到取消,它们会抛出CancellationException
,从而终止协程并触发清理逻辑。 -
非挂起代码的取消
如果协程处于纯计算型代码(未调用任何挂起函数) ,即使被取消,它也会继续运行,直到遇到下一个挂起点或主动检查取消状态(例如通过
ensureActive()
或检查isActive
)。 -
清理工作
清理逻辑通常通过
try { ... } finally { ... }
或use
等资源管理结构实现。协程取消时抛出的CancellationException
会触发这些清理代码。
kotlin
val job = launch {
try {
repeat(1000) { i ->
println("Working $i")
delay(100) // 挂起点:会检查取消,协程在此处终止
}
} finally {
println("Cleanup") // 清理逻辑会执行
}
}
delay(250)
job.cancel() // 取消请求触发,协程在下一次 delay() 挂起时终止
强制立即取消的方法:
如果需要在非挂起代码中响应取消,可以手动检查状态:
kotlin
repeat(1000) { i ->
ensureActive() // 如果已取消,立即抛出 CancellationException
// 或检查 isActive
if (!isActive) throw CancellationException()
// 继续执行...
}
笔者目前还没遇到过这类问题,暂当看下而已
协程的结束与清理
当协程完成工作或者被取消时,它的生命周期会结束。协程会进行一些清理工作,释放资源等。可以通过 invokeOnCompletion
来设置协程完成时的回调。
invokeOnCompletion
:当协程完成时(无论是正常结束还是异常结束),都会触发这个回调。
kotlin
val job = launch {
delay(1000L)
println("Task completed")
}
job.invokeOnCompletion {
println("Job has finished")
}
协程完成后,Job
会被销毁,所有资源会被释放。若协程是由异常终止的,invokeOnCompletion
可以接收到异常信息。