Android进阶宝典 -- Kotlin协程分析(创建、取消、超时)

1,188 阅读8分钟

对于长期做过Java开发的伙伴,协程可能是一个比较陌生的概念,因为现阶段使用Java开发Android应用是无法使用协程的,所以在转到Kotlin开发之后,协程是一个必须要理解的概念,它能够解决传统Android开发中的一些痛点问题,而且现在Google官方已经将Kotlin作为第一开发语言,相信体验到Kotlin的魅力之后就会爱不释手了。

1 初识协程

其实对于协程,坊间众说纷纭,并没有一个统一的说法,而且去找各类博客都不一样,每个人都有每个人的理解,如果伙伴们们看到这篇文章,那么就请不要再去看其他资料啦,相信这会是全网比较官方的文档了,而且有助于伙伴们更好地理解协程。

首先我们先创建一个协程,如下:

/**
 * 这是一个顶层函数,用来测试使用
 * 类似于Java中的 static main 函数
 */
fun main(){

    //创建一个协程
    GlobalScope.launch {
        delay(1000)
        println("World!")
    }
    println("Hello,")
}

我们先不关心GlobalScope是什么,通过调用launch方法就创建了一个协程,然后我们看在协程里加了一个延迟函数,随后打印一行字符串。

所以当协程运行在主线程之后,其实相当于在主线程中“开辟一个子线程”,此时主线程会首先打印“Hello,”,然后等到“子线程”执行结束之后,在打印“World!”,如果按照上面的写法,会如我们所想的那样吗?

运行之后,我们发现只打印了一行,并没有打印出“World!”,

Hello,

这是什么原因呢?这里就涉及到了挂起与恢复,当执行到协程代码块中,调用delay函数之后,当前协程会被挂起,如果主线程执行完成那么就直接return,相当于这个进程已经结束了,其实就没有恢复的机会了,如果实际开发中,app主进程当然不会执行完成就退出了,这只是一个示例,所以需要加一个阻塞主线程的方法就可以了。

fun main(){

    //创建一个协程
    GlobalScope.launch {
        delay(1000)
        println("World!")
    }
    println("Hello,")
    // 阻塞2s主线程,保证JVM还存活
    Thread.sleep(2000)
}

我们在打印协程中线程名称时,发现是运行在DefaultDispatcher-worker-1线程中的,而不是main,所以官方中对于协程的定义是“轻量级的线程”,那么既然都叫做线程了,为什么不用Java的Thread呢?到底和Java的线程有什么区别,我们稍后会着重介绍。

1.1 协程的挂起与阻塞

前面我们提到了当执行delay函数时,协程会被挂起,那么协程中碰到什么样的场景会被挂起呢,我们先看下delay函数的源码。

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        if (timeMillis < Long.MAX_VALUE) {
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

在这里,我们看到一个方法关键字suspend,中文含义就是挂起,所以伙伴们记住一点,当协程中执行suspend方法时就会被挂起。那么挂起和阻塞有什么区别呢?因为从用户视角来看现象是一致的。

thread {
    Thread.sleep(1000)
    Log.e("TAG","World!")
}
Log.e("TAG","Hello,")
Thread.sleep(2000)

其实从系统维度来看,两者的差别还是很大的,像通过Thread.sleep,Thread.wait这种方式是会阻塞线程,直到被唤醒才会继续往下执行,这个过程是通过系统来调度的,会释放CPU资源让其他线程占用。

而协程的挂起是不会释放CPU资源的,也就是说协程的挂起更像是由程序主动发起的,不再交由系统调度,所以我们可以这样理解,当协程挂起时,会释放底层的线程去干其他事情,这样能够最大限度地利用线程干更多的事,有利于提高效率。

所以我们可以通过一个例子,来验证我们的结论:启动10w个线程和协程。

val startTime = System.currentTimeMillis()
repeat(100000){
    thread {
        Thread.sleep(100)
    }
}
Log.e("TAG","start 10w thread cost ${System.currentTimeMillis() - startTime}")

2023-06-21 23:17:17.273 16989-16989/com.lay.nowinandroid E/TAG: start 10w thread cost 27279
val startTime = System.currentTimeMillis()
repeat(100000){
    GlobalScope.launch {
        delay(1000)
    }
}
Log.e("TAG","start 10w coroutines cost ${System.currentTimeMillis() - startTime}")

2023-06-21 23:18:53.189 26327-26327/com.lay.nowinandroid E/TAG: start 10w coroutines cost 1088

想必伙伴们已经看到了这个巨大的差距了吧,当启动10w个线程时,每启动一次都会阻塞100ms,此时系统CPU会进行调度,因为在子线程中所以对于主线程并不会造成太大的影响,但是一共用了27279ms完成;

当启动10w线程时,每次创建一次协程都会挂起协程1000ms,一共花费1088ms,其实通过这个例子就可以说明,当挂起协程时,确实能够提高程序的运行效率。

1.2 协程作用域的构建

因为我们知道,协程是不会阻塞线程的,线程则是会阻塞线程,那么如何通过协程的方式来阻塞线程呢?其实Kotlin中提供了相应的api,例如runBlocking、coroutineScope两种作用域构建器,可以实现对线程的阻塞,那么两种构建器有什么特性呢,我们详细介绍一下。

首先我们先明白一个概念,协程作用域其实就是提供协程创建的环境,所有的协程都必须要在协程作用域中创建,而且每个协程作用域都会有自己的上下文,每个协程也有自己的上下文,这个在后续文章中会介绍。

1.2.1 runBlocking协程作用域

其实在Kotlin中提供了runBlocking来包装主线程,那么此时主线程就会一直阻塞到runBlocking中的协程全部执行完毕,其实就是类似于于Thead.sleep方法。

runBlocking {
    Log.e("TAG","进入到主协程代码块")
    delay(2000)
    Log.e("TAG","进入到主协程代码块finish")
}
Log.e("TAG","进入到主协程代码块执行完毕")

2023-06-21 23:53:19.821 32099-32099/com.lay.nowinandroid E/TAG: 进入到主协程代码块
2023-06-21 23:53:21.854 32099-32099/com.lay.nowinandroid E/TAG: 进入到主协程代码块finish
2023-06-21 23:53:21.855 32099-32099/com.lay.nowinandroid E/TAG: 进入到主协程代码块执行完毕

但是我们再看一个例子,既然runBlocking协程构建器总是会阻塞到内部每个协程都执行完成,我们再创建一个协程,看下效果。

runBlocking {
    Log.e("TAG","进入到主协程代码块")
    GlobalScope.launch {
        delay(1000)
        Log.e("TAG","启动一个新的协程finish")
    }
    Log.e("TAG","进入到主协程代码块finish")
}
Log.e("TAG","进入到主协程代码块执行完毕")

我们通过GlobalScope创建一个协程,理想状况下我们认为其内部协程执行完成之后,才会打印“进入到主协程代码块执行完毕”,实际上并不是这样的。

2023-06-21 23:55:42.509 32476-32476/com.lay.nowinandroid E/TAG: 进入到主协程代码块
2023-06-21 23:55:42.529 32476-32476/com.lay.nowinandroid E/TAG: 进入到主协程代码块finish
2023-06-21 23:55:42.530 32476-32476/com.lay.nowinandroid E/TAG: 进入到主协程代码块执行完毕
2023-06-21 23:55:43.576 32476-32510/com.lay.nowinandroid E/TAG: 启动一个新的协程finish

因为通过GlobalScope创建的协程,其生命周期与当前应用程序的生命周期绑定,当前应用程序的生命周期结束之后,其协程作用域就退出了,因此它并不会保活当前父协程,即runBlocking协程作用域,所以就像我们上面看到的那样,虽然作为runBlocking协程作用域的子协程,但并没有等到其执行完成再退出。

所以我们纠正一下前面的说法,对于runBlocking协程作用域来说,并不是只要创建一个协程就能一直阻塞到其执行完成,而是在runBlocking作用域下创建的协程才能一直阻塞到其执行完毕

这句话比较拗口,其实我们通过一个例子来说明:

runBlocking {
    Log.e("TAG","进入到主协程代码块")
    this.launch {
        delay(1000)
        Log.e("TAG","启动一个新的协程finish")
    }
    Log.e("TAG","进入到主协程代码块finish")
}
Log.e("TAG","进入到主协程代码块执行完毕")

此时是通过runBlocking的作用域上下文创建了一个协程,这个时候执行结果我们看到是一直阻塞到协程执行完成之后。

2023-06-22 00:05:27.253 2501-2501/com.lay.nowinandroid E/TAG: 进入到主协程代码块
2023-06-22 00:05:27.256 2501-2501/com.lay.nowinandroid E/TAG: 进入到主协程代码块finish
2023-06-22 00:05:28.300 2501-2501/com.lay.nowinandroid E/TAG: 启动一个新的协程finish
2023-06-22 00:05:28.301 2501-2501/com.lay.nowinandroid E/TAG: 进入到主协程代码块执行完毕

所以一开始的例子中,我们通过GlobalScope创建的一个协程并不在runBlocking的上下文作用域中创建的,所以就不会阻塞到协程执行完成,如果一定要等到其执行完成,可以通过join函数来完成等待。

runBlocking {
    Log.e("TAG","进入到主协程代码块")
    val job = GlobalScope.launch {
        delay(1000)
        Log.e("TAG","启动一个新的协程finish")
    }
    //手动阻塞
    job.join()
    Log.e("TAG","进入到主协程代码块finish")
}
Log.e("TAG","进入到主协程代码块执行完毕")

但是如果我们通过手动保持所有启动的协程引用,调用join方法很容易会出错,所以在面对结构化的并发问题时,我们需要采用的就是利用runBlocking的协程作用域创建线程,利用其特性来完成并发。

所以如果在某个业务场景当中,要求多个异步任务都执行完成之后,才可以继续往后执行,传统Java开发中,可能需要处理多个异步任务,然后再拿到统一的结果,需要开启多个线程。而在Kotlin中则只需要通过runBlocking配合协程阻塞,就能够实现具体的效果。

var a = ""
var b = ""
var c = ""

runBlocking {
    Log.e("TAG", "进入到主协程代码块")
    launch {
        delay(2000)
        a = "这是异步任务A的结果"
    }
    launch {
        delay(1500)
        b = "这是异步任务B的结果"
    }
    launch {
        delay(300)
        c = "这是异步任务C的结果"
    }
    Log.e("TAG", "进入到主协程代码块finish")
}
Log.e("TAG", "结果:$a $b $c")

2023-06-22 00:16:54.810 4519-4519/com.lay.nowinandroid E/TAG: 进入到主协程代码块
2023-06-22 00:16:54.814 4519-4519/com.lay.nowinandroid E/TAG: 进入到主协程代码块finish
2023-06-22 00:16:56.861 4519-4519/com.lay.nowinandroid E/TAG: 结果:这是异步任务A的结果 这是异步任务B的结果 这是异步任务C的结果

如果是Java开发的伙伴,可以把Java的实现方式贴在评论区。

1.2.2 coroutineScope协程作用域

像在runBlocking作用域下的协程,其实是没有执行顺序的,它只会在所有的协程执行完成之后,继续主线程的执行;如果在某个业务场景中需要某个协程先执行完,然后再执行其他的协程,那么可以使用coroutineScope来创建一个协程。

coroutineScope创建的协程作用域,会等到其所有的子协程都执行完成之后才会结束,继续后面的协程执行。

var a = 0
var b = 0
runBlocking {

    coroutineScope {
        delay(1000)
        a = 20
        Log.e("TAG", "协程B完成")
    }

    launch {
        delay(300)
        b = a + 20
        Log.e("TAG", "协程A完成")
    }

    Log.e("TAG", "A-----B__--")
}
Log.e("TAG", "主线程继续执行 $a $b")

2023-06-22 00:33:41.816 7299-7299/com.lay.nowinandroid E/TAG: 协程B完成
2023-06-22 00:33:41.818 7299-7299/com.lay.nowinandroid E/TAG: A-----B__--
2023-06-22 00:33:42.161 7299-7299/com.lay.nowinandroid E/TAG: 协程A完成
2023-06-22 00:33:42.162 7299-7299/com.lay.nowinandroid E/TAG: 主线程继续执行 20 40

通过上面的例子我们看到,正常情况下,调用delay会将协程挂起,然后释放底层线程去干其他的事,正常情况下会先打印出“A-----B__--”,但是并没有,而是完全阻塞等到coroutineScope作用域内部子协程完全执行完成之后才会执行后续的。

当然在coroutineScope内部,还是会按照正常的挂起函数执行顺序执行,例如:

var a = 0
var b = 0
runBlocking {

    GlobalScope.launch {
        delay(2000)
        Log.e("TAG","GlobalScope 协程 执行完成")
    }

    coroutineScope {
        launch {
            delay(200)
            Log.e("TAG","coroutineScope 协程1 执行完成")
        }
        Log.e("TAG","------1-------")
        delay(1000)
        a = 20
        Log.e("TAG", "协程B完成")
    }

    launch {
        delay(300)
        b = a + 20
        Log.e("TAG", "协程A完成")
    }

    Log.e("TAG", "A-----B__--")
}
Log.e("TAG", "主线程继续执行 $a $b")

感兴趣的伙伴,可以把这段代码执行的结果发在评论区,看下对于协程作用域的理解是否已经完全掌握了。

回到coroutineScope这上面来,通过源码我们发现coroutineScope是一个挂起函数,也就是当执行到coroutineScope代码块时,会将当前协程挂起也就是runBlocking这个主协程会被挂起,从而释放线程去做其他的事情,这个时候runBlocking内部其实是没法做事的,只能等到恢复之后(coroutineScope内部协程执行完毕),才能继续执行后面的代码,但是又因为runBlocking的特性阻塞线程,所以runBlocking外部的主线程也无法执行,从而继续阻塞着。

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

1.3 协程的取消与超时

1.3.1 协程的取消

当我们使用一个协程时,可能并不会等到其完全执行结束自动退出,中间需要通过人为的干预取消协程,例如两个协程在执行异步任务,只要有一个任务返回了结果,那么另一个协程就需要取消。

fun test1() = runBlocking<Unit> {
    val job = launch {
        repeat(1000){
            delay(200)
            Log.e("TAG","now is $it")
        }
    }
    //3s后任务取消
    delay(2000)
    Log.e("TAG","2s后,取消协程")
    job.cancel()
}

正常情况下,test1方法会等到1000次的打印任务完成之后退出,但是我们手动添加一个退出任务,就是在2s后取消协程,调用cancel方法。

2023-06-22 11:39:27.396 10451-10451/com.lay.nowinandroid E/TAG: now is 0
2023-06-22 11:39:27.632 10451-10451/com.lay.nowinandroid E/TAG: now is 1
2023-06-22 11:39:27.853 10451-10451/com.lay.nowinandroid E/TAG: now is 2
2023-06-22 11:39:28.082 10451-10451/com.lay.nowinandroid E/TAG: now is 3
2023-06-22 11:39:28.323 10451-10451/com.lay.nowinandroid E/TAG: now is 4
2023-06-22 11:39:28.564 10451-10451/com.lay.nowinandroid E/TAG: now is 5
2023-06-22 11:39:28.806 10451-10451/com.lay.nowinandroid E/TAG: now is 6
2023-06-22 11:39:29.048 10451-10451/com.lay.nowinandroid E/TAG: now is 7
2023-06-22 11:39:29.192 10451-10451/com.lay.nowinandroid E/TAG: 2s后,取消协程

通过日志我们发现,确实是取消了协程。但是取消其实会有副作用的,通过源码我们可以看到:

public fun cancel(cause: CancellationException? = null)

当子协程取消之后,会抛出一个CancellationException异常,此时父协程会接收到这个异常并决定是否需要处理异常,所以我们一般都会对异常比较敏感,明明是一个正常的取消操作,偏偏是通过抛异常的行为来进行协程取消,那么协程一定会被取消吗?

答案是不一定,在某些场景下,例如CPU的密集型任务时仅仅调用cancel是不会被立即取消的,看下面的例子

fun test2() = runBlocking {
    val job = launch {
        for (i in 0..100) {
            Log.e("TAG", "now is $i")
        }
    }
    delay(5)
    Log.e("TAG", "准备取消......")
    job.cancelAndJoin()
    Log.e("TAG", "结束任务")
}

在子协程中一直在执行循环任务,此时一直在占用CPU,而父协程内只是挂起了5ms,只为保证子协程内部任务开启,这时我们发现只有当循环任务完成之后,才被取消,但这时取消其实已经没有意义了。

所以划重点,协程的取消是协作的,什么是协作呢?就是在协程中,所有的挂起函数都是可以被取消的,伙伴们看好,是只有“挂起函数”才可以被取消!,它会在挂起时检查协程是否被取消,如果取消那么会抛出CancellationException异常,那么当协程在执行CPU密集型任务时,是没有检查取消的(因为没有挂起的这个契机,除非加上delay等挂起函数),所以协程便不能被取消

fun test1() = runBlocking<Unit> {
    val job = launch {
        repeat(1000) {
            try {
                delay(200)
            }catch (e:Exception){
                Log.e("TAG","exp-->$e")
            }
            Log.e("TAG", "now is $it")
        }
    }
    //3s后任务取消
    delay(2000)
    Log.e("TAG", "2s后,取消协程")
    job.cancel()
}

既然只有挂起函数,才会主动检查协程是否被取消,那么我们试一下当执行delay挂起函数时,catch一下看是否能够捕获到异常;

2023-06-22 13:05:25.864 23519-23519/com.lay.nowinandroid E/TAG: now is 6
2023-06-22 13:05:26.084 23519-23519/com.lay.nowinandroid E/TAG: now is 7
2023-06-22 13:05:26.245 23519-23519/com.lay.nowinandroid E/TAG: 2s后,取消协程
2023-06-22 13:05:26.249 23519-23519/com.lay.nowinandroid E/TAG: exp-->kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@72b055a
2023-06-22 13:05:26.249 23519-23519/com.lay.nowinandroid E/TAG: now is 8
2023-06-22 13:05:26.249 23519-23519/com.lay.nowinandroid E/TAG: exp-->kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@72b055a
2023-06-22 13:05:26.249 23519-23519/com.lay.nowinandroid E/TAG: now is 9

果然,当我们执行cancel函数之后,再次执行delay挂起函数时,捕获到了JobCancellationException异常, 因为我们在子协程中捕获了异常,父协程无法接收异常,所以取消操作就没有生效,这和我们之前的结论是一致的。

所以当执行计算工作,或者CPU密集型任务时,如果没有挂起函数时如果想要取消协程,其实是有2种方案可用。

  • 定期使用挂起函数检查协程是否被取消,常见的为yield函数
fun test2() = runBlocking {
    val job = launch{
        for (i in 0..100) {
            yield()
            Log.e("TAG", "now is $i $isActive")
        }
    }
    delay(5)
    Log.e("TAG", "准备取消......")
    job.cancel()
    Log.e("TAG", "结束任务")
}

因为yield函数属于挂起函数,当进行CPU密集型任务时,每次都会执行一次yield,检查当前协程的状态是否被取消,如果取消那么将不再执行任务,其实和其他普通的挂起函数一样,yield同样也是为了释放底层线程干其他的事情。

  • 使用isActive或者ensureActive判断当前协程是否活跃

当协程被取消之后,isActive就会变为false,此时进入到Cancelling取消中的状态,直到协程取消进入到Cancelled状态。

fun test2() = runBlocking {
    val job = launch(Dispatchers.Default) {
        for (i in 0..1000) {
            if (isActive) {
                Log.e("TAG", "now is $i")
            } else {
                break
            }
        }
    }
    delay(5)
    Log.e("TAG", "准备取消......")
    job.cancelAndJoin()
    Log.e("TAG", "结束任务")
}

这里我把量级加到了1000,因为计算速度太快可能无法等到协程取消,而且声明了协程作用域上下文为Dispatchers.Default,这里为啥需要生命为此,后续再介绍。

2023-06-22 13:53:22.162 31488-31519/com.lay.nowinandroid E/TAG: now is 844
2023-06-22 13:53:22.163 31488-31519/com.lay.nowinandroid E/TAG: now is 845
2023-06-22 13:53:22.163 31488-31519/com.lay.nowinandroid E/TAG: now is 846
2023-06-22 13:53:22.164 31488-31488/com.lay.nowinandroid E/TAG: 结束任务

最终在执行到846次循环时,协程被取消了。

fun test2() = runBlocking {
    val job = launch(Dispatchers.Default) {
        for (i in 0..1000) {
            ensureActive()
            Log.e("TAG", "now is $i")
        }
    }
    delay(5)
    Log.e("TAG", "准备取消......")
    job.cancelAndJoin()
    Log.e("TAG", "结束任务")
}

除此之外,还可以通过调用ensureActive来判断当前协程的状态,如果不是活跃状态,那么就会直接抛CancellationException异常,这就有点类似于挂起函数的作用了。

public fun Job.ensureActive(): Unit {
    if (!isActive) throw getCancellationException()
}

1.3.2 协程取消时的资源释放

当我们取消协程时,一般都会做一些资源释放工作,此时可以放在finally中进行,例如:

fun test1() = runBlocking<Unit> {
    val job = launch {
        try {
            repeat(1000) {
                delay(200)
                Log.e("TAG", "now is $it")
            }
        } finally {
            Log.e("TAG", "do release")
        }
    }
    //3s后任务取消
    delay(2000)
    Log.e("TAG", "2s后,取消协程")
    job.cancel()
}
2023-06-22 14:11:36.137 2857-2857/com.lay.nowinandroid E/TAG: now is 7
2023-06-22 14:11:36.368 2857-2857/com.lay.nowinandroid E/TAG: now is 8
2023-06-22 14:11:36.369 2857-2857/com.lay.nowinandroid E/TAG: 2s后,取消协程
2023-06-22 14:11:36.372 2857-2857/com.lay.nowinandroid E/TAG: do release

此时在finally中完成释放工作时,协程已经是Canceled的状态,此时再次调用挂起函数,就会抛CancellationException,例如下面的代码:

fun test1() = runBlocking<Unit> {
    val job = launch {
        try {
            repeat(1000) {
                delay(200)
                Log.e("TAG", "now is $it")
            }
        } finally {
            Log.e("TAG", "do release")
            delay(200)
            Log.e("TAG","suspend again")
        }
    }
    //3s后任务取消
    delay(2000)
    Log.e("TAG", "2s后,取消协程")
    job.cancel()
}

当运行后,“suspend again”不会被执行打印,因为已经发生了异常,这点要特别注意,如果需要在finally中使用挂起函数,也就是要挂起一个被取消的协程,那么可以使用withContext配合NonCancellable上下文。

fun test1() = runBlocking<Unit> {
    val job = launch {
        try {
            repeat(1000) {
                delay(200)
                Log.e("TAG", "now is $it")
            }
        } finally {
            Log.e("TAG", "do release")
            withContext(NonCancellable) {
                delay(200)
                Log.e("TAG", "suspend again")
            }
        }
    }
    //3s后任务取消
    delay(2000)
    Log.e("TAG", "2s后,取消协程")
    job.cancel()
}

1.3.2 协程超时

其实超时在业务场景中是很常见的,当进行网络请求时受到网络波动,导致结果返回超时,如果我们使用OkHttp等网络框架时,也会设置一次请求的超时时间,而在协程当中,我们同样可以设置withTimeout超时函数来取消某个协程。

fun testTimeout() = runBlocking {

    try {
        //设置2s超时时间
        withTimeout(2000) {
            delay(2500)
            Log.e("TAG", "拿到了服务端返回的结果")
        }
    }catch (e:Exception){
        Log.e("TAG","exp --> $e")
    }

    Log.e("TAG","finish")
}

这里我们设置了2s的超时时间,我们模拟的拿到服务端结果为2.5s,那么此时已经超时,我们打印结果发现抛出了TimeoutCancellationException异常。

2023-06-22 14:32:05.284 6570-6570/com.lay.nowinandroid E/TAG: exp --> kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 2000 ms

通过源码我们可以看到,TimeoutCancellationException是CancellationException的子类,也就是说当抛出超时异常之后,当前协程也一起被取消了。

public class TimeoutCancellationException internal constructor(
    message: String,
    @JvmField @Transient internal val coroutine: Job?
) : CancellationException(message), CopyableThrowable<TimeoutCancellationException> {
    /**
     * Creates a timeout exception with the given message.
     * This constructor is needed for exception stack-traces recovery.
     */
    internal constructor(message: String) : this(message, null)

    // message is never null in fact
    override fun createCopy(): TimeoutCancellationException =
        TimeoutCancellationException(message ?: "", coroutine).also { it.initCause(this) }
}

除此之外,还可以通过withTimeoutOrNull函数,默认返回一个超时之后的值就是null。同前面对于协程取消时资源释放问题,当协程超时时,也可以放在finally中进行资源释放。

首先我们可以先看官方的一个例子:

fun testFinally() = runBlocking {
    Log.e("TAG","start ---- ")
    repeat(10000) {
        launch {
            val resource = withTimeout(60) {
                delay(50)
                Resource()
            }
            resource.close()
        }
    }
}
Log.e("TAG","count $count")
var count = 0

class Resource {
    init {
        count++
    }

    fun close() {
        count--
    }
}

每当创建一个Resource对象,count都会+1,每次调用close回收资源,count都会-1,如此一来,只要创建Resource时没有超时,那么一定为0;一旦在某个时间创建Resource超时,那么Resource就不为0了,因为协程被取消,导致close函数没有被及时调用。

所以为了避免这种情况发生,正确的释放资源的姿势应该是放在finally当中,这时候如果发生超时,那么一定会调用close函数。

fun testFinally() = runBlocking {
    Log.e("TAG", "start ---- ")
    repeat(10000) {
        launch {
            var resource: Resource? = null
            try {
                withTimeout(60) {
                    delay(50)
                    resource = Resource()
                }
            } finally {
                resource?.close()
            }
        }
    }
}
Log.e("TAG", "count $count")

因为我现在用的是mac,配置还不错,导致一直尝试复现第一种写法没有成功,一直是0,有精力的伙伴可以尝试复现一下。

对于协程,可能很多伙伴们在开发当中都用到过,但对于某些知识点,例如不同协程构建器构建的协程内部执行规则,对于有些伙伴可能就是知识盲区,然而通过翻阅大部分的资料效率并不高,我这里也是为大家整理出来方便伙伴们学习掌握。