Kotlin学习系列之:协程的取消和超时

​ 通过前面的三篇文章,我们已经讨论了协程的创建。有的时候,我们在启动了一个协程之后,并不需要该协程执行完毕,这个时候我们可以取消该协程的执行。比如在Android开发中,我们打开了一个页面,我们在进入页面的时候启动了一个协程来发起了网络请求,但是用户立马就关闭了页面,这个时候我们就可以取消这个协程的执行,因为我们已经不需要它的执行结果了。

  1. 我们先来回顾一下CoroutineScope.launch{}的方法签名:

    public fun CoroutineScope.launch(
        context: CoroutineContext = EmptyCoroutineContext,
        start: CoroutineStart = CoroutineStart.DEFAULT,
        block: suspend CoroutineScope.() -> Unit): Job
    

    可以看到,它有一个Job类型的返回值,它有对应的cancel()方法来取消执行的协程:

    fun main() = runBlocking {
    
        val job = launch {
            repeat(200) {
                println("hello : $it")
                delay(500)
            }
        }
    
        delay(1100)
        println("world")
    
        job.cancel()
        job.join()
    
        println("welcome")
    }
    

    运行结果为:

    hello : 0
    hello : 1
    hello : 2
    world
    welcome

    在delay 1100毫秒之后,由于在runBlocking协程(姑且称之)中调用了job.cancel()之后,launch协程(姑且称之)中原本会repeat 200次的执行,如今只计数到了2,说明的的确确被取消了。cancel()一般会和join()方法一起使用,因为cancel可能不会立马取消对应的协程(下面我们会提到,协程能够被取消,是需要一定条件的),所以会需要join()来协调两个协程。故而有个更加简便的方法:Job.cancelAndJoin(),可以用来替换上面的两行代码。

    public suspend fun Job.cancelAndJoin() {
        cancel()
        return join()
    }
    
  2. 协程能够被取消的前提条件

    只有协程代码是可取消的,cancel()才能起作用。

    Coroutine cancellation is cooperative. A coroutine code has to cooperate to be cancellable.

    这是官方的描述。我们来直接看一段代码:

    fun main() = runBlocking {
        
        val job = launch(context = Dispatchers.Default) {
    
            println("Current Thread : ${Thread.currentThread()}")
            var nextActionTime = System.currentTimeMillis()
            var i = 0
    
            while (i < 20) {
                if (System.currentTimeMillis() >= nextActionTime) {
                    println("Job: ${i++}")
                    nextActionTime += 500
                }
            }
        }
    
        delay(1300)
        println("hello")
    
        job.cancelAndJoin()
    
        println("welcome")
    }
    

    这段代码我们要注意两点:

    • 调用launch方法时,我们给其形参context多传递了一个Dispatcheres.Default参数。在这里我只告诉大家,这样使得launch启动的协程代码运行在一个新的子线程中,而不是和runBlocking协程一样(它是运行在主线程中)。(下一篇我们再来详细阐述这个地方)

    • 理解一下launch协程中的循环计算代码:

      第一次循环:i=0,同时 if条件肯定满足,输出”Job:0“,nextActionTime += 500

      第二次循环:由于nextActionTime在第一次循环中加了500,而且if中两行代码的执行时间肯定远远 不足500毫秒

      第…次循环:…

      直到等足了500毫秒,才第二次进入if条件,使用i++,nextActionTime += 500

      最终当i=20时,循环条件不满足,退出循环,至此launch协程代码执行完毕。

      在空等500毫秒中,实际上可以看做是死循环了500毫秒,并且一直占用着cpu。

    我们来看运行结果:

    Kotlin学习系列之:协程的取消和超时_第1张图片

    按照我们本来的认知,在delay 1300毫秒之后,由于我们调用了cancelAndJoin方法,应该会取消launch子协程的运行才对(换句话说i最大值为2,而不会加到20才退出)。也就是说,取消没有成功。现在,我们再回过头来,理解”只有协程代码是可取消的,cancel()才能起作用“。那也就是说,这个示例中的launch协程的代码是不可取消的。那么什么样的代码才可以视为可取消的呢

    • kotlinx.coroutines包下的所有挂起函数都是可取消的。这些挂起函数会检查协程的取消状态,当取消时就会抛出CancellationException异常
    • 如果协程正在处于某个计算过程当中,并且没有检查取消状态,那么它就是无法被取消的

    很显然,我们上面示例中的代码就是计算过程中,所以它是无法被取消的。那么有没有什么方式使得这样的计算代码也变为可取消的呢?

    • 可以周期性地调用一个挂起函数,因为该挂起函数会取检查取消状态。
    • 显式地去检查取消状态

    下面我们就对刚刚的代码做一下改进:

    fun main() = runBlocking {
        val job = launch(Dispatchers.Default) {
    
            println("Current Thread : ${Thread.currentThread()}")
            var nextActionTime = System.currentTimeMillis()
            var i = 0
    
            while (isActive) {
                if (System.currentTimeMillis() >= nextActionTime) {
                    println("Job: ${i++}")
                    nextActionTime += 500
                }
            }
        }
    
        delay(1300)
        println("hello")
    
        job.cancelAndJoin()
    
        println("welcome")
    }
    

    输出结果:

    Current Thread : Thread[DefaultDispatcher-worker-1,5,main]
    Job: 0
    Job: 1
    Job: 2
    hello
    welcome

    这样我们就能成功的取消了计算过程中的协程。

    最后,我们对协程取消条件做一下总结:从某种角度上讲,是否能够取消是主动的;外部调用了cancel方法后,相当于是发起了一条取消信号;被取消协程内部如果自身检测到自身状态的变化,比如isActive的判断以及所有的kotlinx.coroutines包下挂起函数,都会检测协程自身的状态变化,如果检测到通知被取消,就会抛出一个CancellationException的异常。

  3. 下面看一波这样的示例代码:

    fun main() = runBlocking {
        val job = launch {
            try {
                repeat(200) {
                    println("job: I am sleeping $it")
                    delay(500)
                }
            }catch (e: CancellationException){
                println("canceled")
            }
            finally {
                println("finally块")
            }
        }
    
        delay(1300)
        println("hello")
    
        job.cancelAndJoin()
        println("welcome")
    
    }
    

    job: I am sleeping 0
    job: I am sleeping 1
    job: I am sleeping 2
    hello
    canceled
    finally块
    welcome

    这块可以说明两个问题:

    • 就是前面提到的CancellationException
    • 我们可以在finally代码块中对于一些资源的关闭和回收
  4. 现在有一个问题:对于大多数资源的关闭和回收(比如关闭文件、取消job等),都是瞬间的动作,都不会是阻塞的行为。可能在极少数情况下,关闭和回收的操作是阻塞的,是需要调用挂起函数的,但是在finally中,如果协程已经被取消,那么此时对于挂起函数的调用,都会抛出一个CancellationException的异常。那么这种情况下,我们又该如何去处理:

    fun main() = runBlocking {
        val job = launch {
            try {
                repeat(200) {
                    println("job: I am sleeping $it")
                    delay(500)
                }
            } finally {
                withContext(NonCancellable){
                    println("finally块")
                    delay(1000)
                    println("after delay in finally block.")
                }
            }
        }
    
        delay(1300)
        println("hello")
    
        job.cancelAndJoin()
        println("welcome")
    
    }
    
    /*
    Calls the specified suspending block with a given coroutine context, suspends until it completes, and returns the result.
    */
    public suspend fun <T> withContext(
        context: CoroutineContext,
        block: suspend CoroutineScope.() -> T
    ): T 
    
    public object NonCancellable : AbstractCoroutineContextElement(Job), Job {
      .....
    }
    
    • withContext: 在给定的协程上下文下调用指定的挂起代码块,会一直挂起,直到结果返回,后面在介绍协程在Android开发的应用时,会时常看到它的身影。

    • NonCancellable:它是一个object对象,并且它是不会被取消的,它的状态一直是active的。

      A non-cancelable job that is always [active][Job.isActive]. It is designed for [withContext] function
      * to prevent cancellation of code blocks that need to be executed without cancellation.
      
  5. CancellationException。既然当协程处于取消状态时,对于挂起函数的调用,会导致该异常的抛出,那么我们为什么没有在输出终端见到它的身影呢?因为kotlin的协程是这样规定的:

    That is because inside a cancelled coroutine CancellationException is considered to be a normal reason for coroutine completion.

    也就是说,CancellationException这个异常是被视为正常现象的取消。

  6. 父子协程的取消。

    前面我们已经讨论了协程的取消自身的种种,那么如果父协程取消,对子协程有什么影响呢?同样地,子协程的取消,会对父协程有什么影响呢?

    /* Jobs can be arranged into parent-child hierarchies where cancellation
    * of a parent leads to immediate cancellation of all its [children]. Failure or cancellation of a child
    * with an exception other than [CancellationException] immediately cancels its parent. This way, a parent
    * can [cancel] its own children (including all their children recursively) without cancelling itself.
    *
    */
    

    这一段是Job这个接口的文档注释,我截取了一部分出来。我们一起来看下这段文档说明:

    Job可以被组织在父子层次结构下,当父协程被取消后,会导致它的子协程立即被取消。一个子协程失败或取消的异常(除了CancellationException),它也会立即导致父协程的取消。

    下面我们就通过代码来证明这一点:

    a. 父协程取消对于子协程的影响:

    fun main() = runBlocking {
    
        val parentJob = launch {
            launch {
                println("child Job: before delay")
                delay(2000)
                println("child Job: after delay")
            }
    
            println("parent Job: before delay")
            delay(1000)
            println("parent Job: after delay")
        }
    
        delay(500)
        println("hello")
    
    }
    

    这是没调用cancel的代码,输出结果如下:

    parent Job: before delay
    child Job: before delay
    hello
    parent Job: after delay
    child Job: after delay

    做一下变动:

    fun main() = runBlocking {
    
        val parentJob = launch {
            launch {
                println("child Job: before delay")
                delay(2000)
                println("child Job: after delay")
            }
    
            println("parent Job: before delay")
            delay(1000)
            println("parent Job: after delay")
        }
    
        delay(500)
        parentJob.cancelAndJoin()
        println("hello")
    
    }
    

    我们在delay(500)之后添加一行:parentJob.cancelAndJoin(),再看输出结果:

    parent Job: before delay
    child Job: before delay
    hello

    可以看到,我们一旦取消父协程对应的Job之后,子协程的执行也被取消了,那么也就验证父协程的取消对于子协程的影响。

    b. 子协程正常的CancellationException取消:

    fun main() = runBlocking {
    
        val parentJob = launch {
            val childJob = launch {
                println("child Job: before delay")
                delay(2000)
                println("child Job: after delay")
    
            }
    
            println("parent Job: before delay")
            delay(1000)
            childJob.cancelAndJoin()
            println("parent Job: after delay")
        }
    
        delay(500)
        println("hello")
    
    }
    

    输出结果为:

    parent Job: before delay
    child Job: before delay
    hello
    parent Job: after delay

    可以看到,如果子协程是正常的取消(即CancellationException),那么对于父协程是没有影响的。

    c. 子协程的非CancellationException取消

    fun main() = runBlocking {
    
        val parentJob = launch {
            val childJob = launch {
                println("child Job: before delay")
                delay(800)
                throw RuntimeException("cause to cancel child job")
            }
    
            println("parent Job: before delay")
            delay(1000)
            childJob.cancelAndJoin()
            println("parent Job: after delay")
        }
    
        delay(500)
        println("hello")
    
    }
    

    输出结果:

    parent Job: before delay
    child Job: before delay
    hello
    Exception in thread “main” java.lang.RuntimeException: cause to cancel child job

    这样非CancellationException导致的子协程地取消,也会导致父协程的取消。

  7. 提问:A协程有两个子协程B、C,如果B由于非CancellationException导致被取消,那么C会受到影响吗?

    这个也不难得出答案,B的非CancellationException导致的取消,自然会导致父协程A被取消,那么C作为A的子协程也会被取消。

  8. 说明:以上的讨论是返回Job的协程且不考虑SupervisorJob的存在,后面还会学习到返回Deferred的协程以及SupervisorJob(它和我们在Android开发中使用协程息息相关)。

  9. 协程的超时取消。

    如果用于执行某个任务的协程,我们设定,如果它超过某个时间后,还未完成,那么我们就需要取消该协程。我们可以使用withTimeout轻松实现这一功能:

    fun main() = runBlocking {
    
       val result =  withTimeout(1900) {
            repeat(3) {
                println("hello: $it")
                delay(400)
            }
          "hello world"
        }
    		println(result)
    }
    

    这种情况下没有超时,输出结果为:

    hello: 0
    hello: 1
    hello: 2

    “hello world”

    我们修改一下超时时间为1100,这时的输出结果为:

    hello: 0
    hello: 1
    hello: 2
    Exception in thread “main” kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1100 ms

    这样就把超时转换成了普通的异常,我们可以对异常进行捕获:

    fun main() = runBlocking {
    
        try {
           val result =   withTimeout(1100) {
                repeat(3) {
                    println("hello: $it")
                    delay(400)
                }
                "hello world"
            }
            println(result)
        } catch (e: TimeoutCancellationException) {
            println("超时取消")
        }
    }
    

    hello: 0
    hello: 1
    hello: 2
    超时取消

    与之类似地还有withTimeoutOrNull:

    fun main() = runBlocking {
    
        val result = withTimeoutOrNull(1900) {
            repeat(3) {
                println("hello: $it")
                delay(400)
            }
    
            "hello world"
        }
    
        println("the result is : $result")
    }
    

    输出结果为:

    hello: 0
    hello: 1
    hello: 2
    the result is : hello world

    再次修改超时时间:

    fun main() = runBlocking {
    
        val result = withTimeoutOrNull(1100) {
            repeat(3) {
                println("hello: $it")
                delay(400)
            }
    
            "hello world"
        }
    
        println("the result is : $result")
    }
    

    运行结果如下:

    hello: 0
    hello: 1
    hello: 2
    the result is : null

    可以看到,withTimeoutOrNull与withTimeout的区别在于,当发生超时取消后,withTimeoutOrNull的返回为null,而withTimeout会抛出一个TimeoutCancellationException。

    public suspend fun <T> withTimeoutOrNull(timeMillis: Long, block: suspend CoroutineScope.() -> T): T? {
        if (timeMillis <= 0L) return null
    
        var coroutine: TimeoutCoroutine<T?, T?>? = null
        try {
            return suspendCoroutineUninterceptedOrReturn { uCont ->
                val timeoutCoroutine = TimeoutCoroutine(timeMillis, uCont)
                coroutine = timeoutCoroutine
                setupTimeout<T?, T?>(timeoutCoroutine, block)
            }
        } catch (e: TimeoutCancellationException) {
            // Return null if it's our exception, otherwise propagate it upstream (e.g. in case of nested withTimeouts)
            if (e.coroutine === coroutine) {
                return null
            }
            throw e
        }
    }
    

    之所以有这样的区别,我们可以从withTimeoutOrNul的源码中得出答案:它对TimeoutCancellationException进行了捕获。

你可能感兴趣的:(Kotlin系列教程)