Kotlin-协程(四)理解Job和协程的生命周期

1,346 阅读8分钟

Job 其实就是协程的句柄。从某种程度上讲,当我们用 launch 和 async 创建一个协程以后,同时也会创建一个对应的 Job 对象。另外,Job 也是我们理解协程生命周期、结构化并发的关键知识点。通过 Job 暴露的 API,我们还可以让不同的协程之间互相配合,从而实现更加复杂的功能。

一、Job 生命周期

1、job的激活(isActive)、取消(isCancelled)与完成(isCompleted)

在上节课我们学习 launchasync 的时候,我们知道它们两个返回值类型分别是 JobDeferred。源码如下:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {   //job
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}
public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> {  //Deferred
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyDeferredCoroutine(newContext, block) else
        DeferredCoroutine<T>(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

而如果你去看 Deferred 的源代码,你会发现,它其实也是继承自 Job 的。对应的,它只是多了一个泛型参数 T,还多了一个返回类型为Tawait() 方法。所以,不管是 launch 还是 async,它们本质上都会返回一个 Job 对象。源码如下:

//继承job
public interface Deferred<out T> : kotlinx.coroutines.Job {   
    public abstract val onAwait: kotlinx.coroutines.selects.SelectClause1<T>

    public abstract suspend fun await(): T  //返回泛型T

    @kotlinx.coroutines.ExperimentalCoroutinesApi public abstract fun getCompleted(): T   //返回泛型T

    @kotlinx.coroutines.ExperimentalCoroutinesApi public abstract fun getCompletionExceptionOrNull(): kotlin.Throwable?
}

通过 Job 对象,我们主要可以做两件事情:

  • 使用 Job 监测协程的生命周期状态;

  • 使用 Job 操控协程。

看一个具体的例子:

fun main() {
    runBlocking{
        val job = launch(start = CoroutineStart.LAZY){  //CoroutineStart.LAZY调用start才能开启协程
            delay(1000)
        }
        job.log(0)
        job.start()  //开启协程
        job.log(1)
        delay(500)
        job.cancel()  //取消协程
        job.log(2)
        job.invokeOnCompletion {  //协程完成或者取消会回调一次
            job.log(4)
        }
        delay(1500)
        job.log(3)
    }
}

//打印协程的时间
fun Job.log(index:Int) {
    print(
"""

---------start $index--------
Time=${LocalDateTime.now()}                    //时间
ThreadName= ${Thread.currentThread().name}     //线程名称
isActive = $isActive                           //是否激活
isCancelled = $isCancelled                     //是否被取消
isCompleted = $isCompleted                     //是否完成
---------end $index--------

"""
    )
}

输出的日志:

---------start 0--------
Time=2023-02-22T16:20:43.022617100
ThreadName= main @coroutine#1
isActive = false
isCancelled = false
isCompleted = false
---------end 0--------



---------start 1--------
Time=2023-02-22T16:20:43.023616900
ThreadName= main @coroutine#1
isActive = true
isCancelled = false
isCompleted = false
---------end 1--------



---------start 2--------
Time=2023-02-22T16:20:43.543217500
ThreadName= main @coroutine#1
isActive = false
isCancelled = true
isCompleted = false
---------end 2--------



---------start 4--------
Time=2023-02-22T16:20:43.586217500
ThreadName= main @coroutine#2
isActive = false
isCancelled = true
isCompleted = true
---------end 4--------



---------start 3--------
Time=2023-02-22T16:20:45.053775500
ThreadName= main @coroutine#1
isActive = false
isCancelled = true
isCompleted = true
---------end 3--------

添加CoroutineStart.LAZY参数,Job只有调用start才会开始(激活)协程;invokeOnCompletion会在协程完成或者异常或者取消的情况下完成一次回调。

从协程的三个状态Api可以看出,调用start后,协程isActive = true,调用cancelisCancelled = true,最后完成回调isCompleted = true

2、job的挂起(join)

job.join() 其实是一个“挂起函数”,它的作用就是:挂起当前的程序执行流程,等待 job 当中的协程任务执行完毕,然后再恢复当前的程序执行流程。

看下面的例子,如果不加线程的sleep,job协程肯定不会输出job end的,如下:

runBlocking {
    println("out start")
    val job = GlobalScope.launch {
        println("job start")
        delay(1000)
        println("job end")
    }
    println("out mid")
    job.invokeOnCompletion {
        println("job completion")
    }
    println("out end")
    // Thread.sleep(2000)   //不加线程的sleep
}

//输出日志
out start
out mid
job start
out end

但如果我们在外部协程执行完线程退出前把job挂起,会怎么样呢?

runBlocking {
    println("out start")
    val job = GlobalScope.launch {
        println("job start")
        println("job start thread:${Thread.currentThread().name}")
        delay(1000)
        println("job end")
        println("job end thread:${Thread.currentThread().name}")
    }
    println("out mid")
    println("job join thread:${Thread.currentThread().name}")
    job.join()      //改变在这里:调用job.join将job协程挂起
    job.invokeOnCompletion {
        println("job completion")
    }
    println("out end")
}

//输出日志
out start
out mid
job join thread:main @coroutine#1   //被挂起前为主线程
job start
job start thread:DefaultDispatcher-worker-1 @coroutine#2   //被挂起到子线程执行
job end
job end thread:DefaultDispatcher-worker-1 @coroutine#2     //在子线程执行完成
job completion
out end

被挂起的job协程,会在其他线程执行完后恢复外部协程,这就是协程挂起的魅力。join的源码如下:

public suspend fun join()

也是一个suspend挂起函数。

二、Deferred

Deferred 是继承自 Job 的一个接口,它并没有在 Job 的基础上扩展出很多其他功能,最重要的就是 await() 这个方法。让我们来看一个简单的例子:

runBlocking {
   println("out start")
   val deferred = async {
       println("deferred start")
       delay(1000)
       "Hello World"    //返回Hello World
   }
   val result = deferred.await()      //将deferred挂起,并获取协程的返回值
   println("out result:$result")
   println("out end")
}

//打印日志
out start
deferred start
out result:Hello World    //返回值:Hello World
out end

await() 这个方法是一个挂起函数,这也就意味着,这个方法拥有挂起和恢复的能力。如果当前的 Deferred 任务还没执行完毕,那么,await() 就会挂起当前的协程执行流程,等待 Deferred 任务执行完毕,再恢复执行后面剩下的代码

看到这里,也许你会觉得奇怪,挂起函数不是非阻塞的吗?怎么这里又出现了阻塞?注意,这里其实只是看起来像是阻塞了,但它实际上是将剩下的代码存了起来,留在后面才执行了。也就是嵌套的协程,如果有协程是阻塞的,那么其他协程都会被阻塞。

e30c901b79682faaa00fd0d978521b37.gif

Deferred 除了比 Job 多了一个 await() 挂起函数,还多了二个实验性的API,分别是getCompletedgetCompletionExceptionOrNull,这二个API必须在协程完成后才能调用,不然就会抛异常。

三、Job 与结构化并发

结构化并发是 Kotlin 协程的第二大优势。那么,到底什么是结构化并发呢?其 实,这是一个非常大的话题,三言两语真的很难讲清楚。简单来说,结构化并发就是:带有结构和层级的并发。看下面的例子:

runBlocking {
    var parentJob: Job? = null
    var job1: Job? = null
    var job2: Job? = null
    var job3: Job? = null

    printMsg("parentJob start")
    parentJob = launch {
        job1 = launch {
            printMsg("job1 start")
            delay(1000)
            printMsg("job1 end")
        }
        job2 = launch {
            printMsg("job2 start")
            delay(3000)
            printMsg("job2 end")
        }
        job3 = launch {
            printMsg("job3 start")
            delay(5000)
            printMsg("job3 end")
        }
    }

    delay(500)

    //遍历子协程
    parentJob.children.forEachIndexed { index, job ->
        when (index) {
            0 -> println("job1-->job is job1? ${job1 === job}")
            1 -> println("job2-->job is job2? ${job2 === job}")
            2 -> println("job3-->job is job3? ${job3 === job}")
        }
    }
    //将parentJob协程挂起
    parentJob.join()
    printMsg("parentJob end")
}

//输出日志
2023-02-23T17:38:15.710147700 main @coroutine#1 parentJob start
2023-02-23T17:38:15.731157500 main @coroutine#3 job1 start
2023-02-23T17:38:15.731157500 main @coroutine#4 job2 start
2023-02-23T17:38:15.731157500 main @coroutine#5 job3 start
job1-->job is job1? true
job2-->job is job2? true
job3-->job is job3? true
2023-02-23T17:38:16.738585700 main @coroutine#3 job1 end
2023-02-23T17:38:18.742618400 main @coroutine#4 job2 end
2023-02-23T17:38:20.732363600 main @coroutine#5 job3 end
2023-02-23T17:38:20.733357900 main @coroutine#1 parentJob end

在上面的代码中,我们一共定义了 4 个 JobparentJob 是最外层的 launch 返回的对象,而在这个 launch 的内部,还额外嵌套了三个 launch,它们的 Job 对象分别赋值给了 job1job2job3

接着,我们对parentJob.children进行了遍历,然后逐一对比了它们与 job1job2job3 的引用是否相等(===代表了引用相等,即是否是同一个对象)。

通过这样的方式,我们可以确定,job1job2job3 其实就是 parentJobchildren。也就是 说,我们使用 launch 创建出来的协程,是存在父子关系的。

如果你去看 Job 的源代码,你会发现它还有两个 API 是用来描述父子关系的。源码如下:

public interface Job : CoroutineContext.Element {

//...省略部分代码...
public val children: Sequence<Job>

@InternalCoroutinesApi
public fun attachChild(child: ChildJob): ChildHandle

}

另外还有ChildJobParentJobChildHandle接口,源码如下:

public interface ChildJob : Job {
    @InternalCoroutinesApi
    public fun parentCancelled(parentJob: ParentJob)
}

public interface ParentJob : Job {
    @InternalCoroutinesApi
    public fun getChildJobCancellationCause(): CancellationException
}

public interface ChildHandle : DisposableHandle {
    @InternalCoroutinesApi
    public val parent: Job?

    @InternalCoroutinesApi
    public fun childCancelled(cause: Throwable): Boolean
}

可以看到,每个 Job 对象,都会有一个 children 属性,它的类型是 Sequence,它是一个惰性的集合,我们可以对它进行遍历。而 attachChild() 则是一个协程内部的 API,用于绑定ChildJob 的。

另外注意了,我们调用的是 parentJobjoin() 方法,它会等待其内部的 job1、job2、job3 全部执行完毕后才会恢复执行。换句话说,只有当 job1、job2、job3 全部执行完毕,parentJob 才算是执行完毕。

如果我们取消parentJobchildJob会怎么样呢?看下面的代码:

runBlocking {
    var parentJob: Job? = null
    var job1: Job? = null
    var job2: Job? = null
    var job3: Job? = null

    printMsg("parentJob start")
    parentJob = launch {
        job1 = launch {
            printMsg("job1 start")
            delay(1000)
            printMsg("job1 end")
        }
        job2 = launch {
            printMsg("job2 start")
            delay(3000)
            printMsg("job2 end")
        }
        job3 = launch {
            printMsg("job3 start")
            delay(5000)
            printMsg("job3 end")
        }
    }

    //遍历子协程
    parentJob.children.forEachIndexed { index, job ->
        when (index) {
            0 -> println("job1-->job is job1? ${job1 === job}")
            1 -> println("job2-->job is job2? ${job2 === job}")
            2 -> println("job3-->job is job3? ${job3 === job}")
        }
    }

    delay(500)

    //将parentJob协程取消
    parentJob.cancel()   //变化在这里
    printMsg("parentJob end")
}

//输出日志
2023-02-23T18:03:27.189871100 main @coroutine#1 parentJob start
2023-02-23T18:03:27.216872500 main @coroutine#3 job1 start
2023-02-23T18:03:27.216872500 main @coroutine#4 job2 start
2023-02-23T18:03:27.216872500 main @coroutine#5 job3 start
2023-02-23T18:03:27.710896100 main @coroutine#1 parentJob end

即使我们调用的只是 parentJobcancel() 方法,并没有取消 job1、job2、job3,但是它们内部的协程任务也全都被取消了。

四、思考与实战

1、实战示例

在内部定义了三个挂起函数 getResult1()、getResult2()、getResult3(),它们各自都会耗时 1000 毫秒,而且它们之间的运行结果也互不相干。代码逻辑也很简单,也是我们平时在工作中会经常遇到的业务场景。请思考下面的代码应该如何优化?

runBlocking {
    //耗时一秒多的挂起函数
    suspend fun getResult1(): String {
        delay(1000)
        return "result1"
    }

    //耗时一秒多的挂起函数
    suspend fun getResult2(): String {
        delay(1000)
        return "result2"
    }

    //耗时一秒多的挂起函数
    suspend fun getResult3(): String {
        delay(1000)
        return "result3"
    }

    val resultList = mutableListOf<String>()
    //统计时间
    val time = measureTimeMillis {
        resultList.add(getResult1())
        resultList.add(getResult2())
        resultList.add(getResult3())
    }

    printMsg("time $time")
    printMsg("list $resultList")
}

//打印日志
2023-02-23T18:14:48.430632600 main @coroutine#1 time 3041
2023-02-23T18:14:48.431634500 main @coroutine#1 list [result1, result2, result3]

当我们直接调用这三个挂起函数,并且拿到结果以后,整个过程大约需要消耗 3000 毫秒,也就是这几个函数耗时的总和。对于这样的情况,我们其实完全可以使用 async 来优化:

runBlocking {
    //耗时一秒多的挂起函数
    suspend fun getResult1(): String {
        delay(1000)
        return "result1"
    }

    //耗时一秒多的挂起函数
    suspend fun getResult2(): String {
        delay(1000)
        return "result2"
    }

    //耗时一秒多的挂起函数
    suspend fun getResult3(): String {
        delay(1000)
        return "result3"
    }

    val resultList = mutableListOf<String>()
    //统计时间
    val time = measureTimeMillis {
        val deferred1 = async { getResult1() }
        val deferred2 = async { getResult2() }
        val deferred3 = async { getResult3() }

        resultList.add(deferred1.await())
        resultList.add(deferred2.await())
        resultList.add(deferred3.await())
    }

    printMsg("time $time")
    printMsg("list $resultList")
}

//输出日志
2023-02-23T18:21:33.587125300 main @coroutine#1 time 1025
2023-02-23T18:21:33.588134300 main @coroutine#1 list [result1, result2, result3]

时间大概缩短了三分之二,优化的核心逻辑时:不能每一次都调用await挂起函数,耗时主要就是在挂起的过程中,通过并发执行和获取结果。

所以,当我们总是拿 launchasync 来做对比的时候,就会不自觉地认为 async 是用来替代launch 的。但实际上,async 最常见的使用场景是:与挂起函数结合,优化并发

2、示例剖析

如果上面的示例,我们通过async{...}.await()withContext的子线程来写是否也能优化并发呢? 看下面的代码(部分代码省略):

//async{...}.await()
val resultList = mutableListOf<String>()
//统计时间
val time = measureTimeMillis {
    val result1 = async {
        printMsg("result1")
        getResult1()
    }.await()     //直接调用await挂起函数
    val result2 = async {
        printMsg("result2")
        getResult2()
    }.await()     //直接调用await挂起函数
    val result3 = async {
        printMsg("result3")
        getResult3()
    }.await()     //直接调用await挂起函数

    resultList.add(result1)
    resultList.add(result2)
    resultList.add(result3)
}

//输出日志
2023-02-24T09:27:38.472654700 main @coroutine#2 result1    //同一个线程不同的协程
2023-02-24T09:27:39.491072500 main @coroutine#3 result2    //同一个线程不同的协程
2023-02-24T09:27:40.496829600 main @coroutine#4 result3    //同一个线程不同的协程
2023-02-24T09:27:41.506078100 main @coroutine#1 time 3064    //还是有3秒多
2023-02-24T09:27:41.506078100 main @coroutine#1 list [result1, result2, result3]
//withContext子线程
val resultList = mutableListOf<String>()
//统计时间
val time = measureTimeMillis {
    val result1 = withContext(Dispatchers.IO) {  //用withContext的子线程,挂起函数
        printMsg("result1")   //打印线程信息和返回结果
        getResult1()
    }
    val result2 = withContext(Dispatchers.IO) {  //用withContext的子线程,挂起函数
        printMsg("result2")   //打印线程信息和返回结果
        getResult2()
    }
    val result3 = withContext(Dispatchers.IO) {  //用withContext的子线程,挂起函数
        printMsg("result3")   //打印线程信息和返回结果
        getResult3()
    }

    resultList.add(result1)
    resultList.add(result2)
    resultList.add(result3)
}

//输出日志
2023-02-24T09:24:01.081278100 DefaultDispatcher-worker-1 @coroutine#1 result1   //同一个线程同一个协程
2023-02-24T09:24:02.102147200 DefaultDispatcher-worker-1 @coroutine#1 result2   //同一个线程同一个协程
2023-02-24T09:24:03.111196700 DefaultDispatcher-worker-1 @coroutine#1 result3   //同一个线程同一个协程
2023-02-24T09:24:04.115182600 main @coroutine#1 time 3066        //还是有3秒多
2023-02-24T09:24:04.115182600 main @coroutine#1 list [result1, result2, result3]

因为都是单个协程执行时就挂起并等待输出结果,所以不管是不同线程还是不同的协程,耗时都没有减少,我们看下正确的示例代码输出(部分代码省略):

val resultList = mutableListOf<String>()
//统计时间
val time = measureTimeMillis {
    val deferred1 = async {
        printMsg("result1")
        getResult1()
    }
    val deferred2 = async {
        printMsg("result2")
        getResult2()
    }
    val deferred3 = async {
        printMsg("result3")
        getResult3()
    }

    resultList.add(deferred1.await())  //获取结果时才挂起
    resultList.add(deferred2.await())  //获取结果时才挂起
    resultList.add(deferred3.await())  //获取结果时才挂起
}

//输出日志
2023-02-24T10:50:08.786657300 main @coroutine#2 result1   //同一个线程不同的协程
2023-02-24T10:50:08.790653200 main @coroutine#3 result2   //同一个线程不同的协程
2023-02-24T10:50:08.791644200 main @coroutine#4 result3   //同一个线程不同的协程
2023-02-24T10:50:09.800200700 main @coroutine#1 time 1052   //时间缩短三分之二
2023-02-24T10:50:09.800200700 main @coroutine#1 list [result1, result2, result3]

所以协程的结构化并发一定要注意协程挂起的时机。注意结构化并发的处理。

参考内容

16 | Job:协程也有生命周期吗?

Android 上的 Kotlin 协程

个人学习笔记