MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Kotlin协程取消与超时

2021-07-272.1k 阅读

Kotlin 协程取消机制概述

在 Kotlin 协程中,取消是一种重要的控制机制,它允许我们在协程执行过程中提前终止其执行。与传统线程不同,协程的取消并非强制立即停止,而是一种协作式的过程。这意味着协程需要自己检查取消信号并优雅地处理取消操作。

协程取消的协作性质是为了确保资源的正确释放和避免数据不一致等问题。例如,在进行文件写入操作的协程中,如果突然强制终止,可能会导致文件写入不完整,而协作式取消可以让协程在收到取消信号后,完成当前正在进行的写入块,然后关闭文件,确保数据的完整性。

取消的触发方式

  1. 通过协程作用域取消 最常见的取消协程方式是通过 CoroutineScopecancel() 方法。每个协程都在一个 CoroutineScope 内运行,通过调用该作用域的 cancel() 方法,可以向该作用域内的所有协程发送取消信号。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val scope = CoroutineScope(Job())
        val job = scope.launch {
            while (true) {
                println("协程正在运行")
                delay(1000)
            }
        }
        delay(3000)
        scope.cancel()
        job.join()
        println("协程已取消")
    }
    

    在上述代码中,我们创建了一个 CoroutineScope 并在其中启动了一个协程。这个协程会无限循环打印“协程正在运行”并每隔 1 秒延迟一次。3 秒后,我们调用 scope.cancel() 取消该作用域内的协程,然后使用 job.join() 等待协程真正结束,最后打印“协程已取消”。

  2. 通过 Job 对象取消 每个协程都返回一个 Job 对象,我们也可以直接调用这个 Job 对象的 cancel() 方法来取消对应的协程。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            while (true) {
                println("协程正在运行")
                delay(1000)
            }
        }
        delay(3000)
        job.cancel()
        job.join()
        println("协程已取消")
    }
    

    这里直接对 launch 启动的协程返回的 Job 对象调用 cancel() 方法,同样达到了取消协程的目的。

协程内部对取消的响应

  1. 检查取消状态 协程需要主动检查取消状态以响应取消信号。Kotlin 协程提供了 isActive 属性来检查协程是否处于活动状态(即未被取消)。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            while (isActive) {
                println("协程正在运行")
                delay(1000)
            }
            println("协程收到取消信号,正在处理")
        }
        delay(3000)
        job.cancel()
        job.join()
        println("协程已取消")
    }
    

    在这个例子中,协程通过 while (isActive) 循环来不断检查自身是否被取消。当 job.cancel() 被调用后,isActive 变为 false,循环结束,协程可以执行一些清理操作,这里打印“协程收到取消信号,正在处理”。

  2. 使用 suspendCancellableCoroutine suspendCancellableCoroutine 是一个特殊的构建器,它允许我们创建一个可取消的挂起函数。当协程被取消时,suspendCancellableCoroutine 内部可以通过 invokeOnCancellation 块来处理取消逻辑。

    import kotlinx.coroutines.*
    
    suspend fun performCancellableTask() = suspendCancellableCoroutine<Unit> { continuation ->
        val job = GlobalScope.launch {
            for (i in 1..10) {
                if (continuation.isCancelled) {
                    println("任务被取消,清理资源")
                    return@launch
                }
                println("任务进行中:$i")
                delay(1000)
            }
            continuation.resume(Unit)
        }
        continuation.invokeOnCancellation {
            job.cancel()
            println("外部取消,清理任务相关资源")
        }
    }
    
    fun main() = runBlocking {
        val job = launch {
            performCancellableTask()
            println("任务完成")
        }
        delay(3000)
        job.cancel()
        job.join()
        println("协程已取消")
    }
    

    performCancellableTask 函数中,我们通过 continuation.isCancelled 检查是否被取消,并在 invokeOnCancellation 块中处理外部取消时的资源清理。当主协程在 3 秒后取消 job 时,performCancellableTask 内部会正确响应取消并进行清理。

异常与取消的关系

  1. 未捕获异常导致取消 当协程内部抛出未捕获的异常时,该协程会自动被取消。同时,该协程所属的整个 CoroutineScope 内的其他协程也会被取消,除非它们被标记为 SupervisorJob

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val scope = CoroutineScope(Job())
        val job1 = scope.launch {
            try {
                throw RuntimeException("模拟异常")
            } catch (e: Exception) {
                println("job1 捕获到异常:$e")
            }
        }
        val job2 = scope.launch {
            println("job2 正在运行")
            delay(5000)
        }
        job1.join()
        job2.join()
        println("所有协程结束")
    }
    

    在这个例子中,job1 抛出 RuntimeException,由于未在 scope 中特殊处理,job2 也会被取消,尽管 job2 本身并未出现异常。

  2. 处理异常并继续执行 我们可以在协程内部捕获异常并决定是否继续执行。通过这种方式,可以避免异常导致整个作用域内协程的取消。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val scope = CoroutineScope(Job())
        val job1 = scope.launch {
            try {
                throw RuntimeException("模拟异常")
            } catch (e: Exception) {
                println("job1 捕获到异常:$e,继续执行")
            }
            println("job1 继续执行其他操作")
        }
        val job2 = scope.launch {
            println("job2 正在运行")
            delay(5000)
        }
        job1.join()
        job2.join()
        println("所有协程结束")
    }
    

    这里 job1 捕获了异常并打印信息后继续执行,job2 不受影响,整个作用域内的协程没有因为 job1 的异常而全部取消。

Kotlin 协程超时机制

  1. 使用 withTimeout 函数 withTimeout 函数用于在指定的时间内执行一个协程块,如果超时,会抛出 TimeoutCancellationException,从而取消协程。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        try {
            withTimeout(3000) {
                for (i in 1..10) {
                    println("计数:$i")
                    delay(1000)
                }
            }
        } catch (e: TimeoutCancellationException) {
            println("操作超时:$e")
        }
        println("主函数结束")
    }
    

    在上述代码中,withTimeout(3000) 表示该协程块必须在 3 秒内完成。由于 for 循环每次延迟 1 秒,执行到第 4 次循环时会超时,抛出 TimeoutCancellationException,我们在 catch 块中捕获并处理该异常。

  2. withTimeoutOrNull 函数 withTimeoutOrNullwithTimeout 类似,但如果超时,它不会抛出异常,而是返回 null

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val result = withTimeoutOrNull(3000) {
            for (i in 1..10) {
                println("计数:$i")
                delay(1000)
            }
            "操作完成"
        }
        if (result == null) {
            println("操作超时")
        } else {
            println("操作结果:$result")
        }
        println("主函数结束")
    }
    

    这里如果操作超时,resultnull,我们可以根据 result 是否为 null 来判断是否超时并进行相应处理。

  3. 在协程构建器中设置超时 除了 withTimeoutwithTimeoutOrNull,我们还可以在协程构建器(如 launchasync)中设置超时。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            try {
                delay(5000)
                println("任务完成")
            } catch (e: CancellationException) {
                println("任务被取消:$e")
            }
        }
        job.invokeOnCompletion {
            if (it is TimeoutCancellationException) {
                println("任务超时取消")
            }
        }
        delay(3000)
        job.cancelAndJoin(TimeoutCancellationException())
        println("主函数结束")
    }
    

    在这个例子中,我们启动一个协程,它会延迟 5 秒。3 秒后,我们通过 job.cancelAndJoin(TimeoutCancellationException()) 取消协程,并传入 TimeoutCancellationException 模拟超时取消。在 invokeOnCompletion 块中,我们可以判断是否因为超时导致取消并进行处理。

超时与取消的协同工作

  1. 超时导致取消 超时本质上就是一种特殊的取消情况。当使用 withTimeout 或在协程构建器中设置超时取消时,一旦超时,协程会收到取消信号并按照取消机制进行处理。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        try {
            withTimeout(3000) {
                while (true) {
                    println("协程正在运行")
                    delay(1000)
                }
            }
        } catch (e: TimeoutCancellationException) {
            println("操作超时:$e")
        }
        println("主函数结束")
    }
    

    这里协程在 3 秒超时后,会抛出 TimeoutCancellationException,这与普通的取消信号一样,协程内部可以通过检查取消状态等方式来正确处理。

  2. 取消与超时的优先级 如果在设置了超时的协程中手动取消协程,手动取消会优先于超时。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            try {
                withTimeout(5000) {
                    while (true) {
                        println("协程正在运行")
                        delay(1000)
                    }
                }
            } catch (e: TimeoutCancellationException) {
                println("操作超时:$e")
            } catch (e: CancellationException) {
                println("协程手动取消:$e")
            }
        }
        delay(3000)
        job.cancel()
        job.join()
        println("主函数结束")
    }
    

    在这个例子中,虽然设置了 5 秒的超时,但在 3 秒时手动取消了协程,此时 catch (e: CancellationException) 块会捕获到手动取消的异常,而不会触发超时异常。

复杂场景下的取消与超时处理

  1. 嵌套协程的取消与超时 在嵌套协程的场景中,取消与超时的处理会更加复杂。外层协程的取消或超时会影响内层协程,但内层协程也可以独立处理自身的取消和超时情况。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val outerJob = launch {
            try {
                withTimeout(5000) {
                    launch {
                        try {
                            withTimeout(3000) {
                                while (true) {
                                    println("内层协程正在运行")
                                    delay(1000)
                                }
                            }
                        } catch (e: TimeoutCancellationException) {
                            println("内层协程超时:$e")
                        }
                    }
                    while (true) {
                        println("外层协程正在运行")
                        delay(1000)
                    }
                }
            } catch (e: TimeoutCancellationException) {
                println("外层协程超时:$e")
            }
        }
        delay(4000)
        outerJob.cancel()
        outerJob.join()
        println("主函数结束")
    }
    

    这里外层协程设置了 5 秒超时,内层协程设置了 3 秒超时。4 秒时手动取消外层协程,外层协程捕获手动取消异常,内层协程在手动取消前可能已经超时并捕获超时异常。

  2. 多个协程并发时的取消与超时 当多个协程并发运行时,一个协程的取消或超时可能会影响其他协程,具体取决于协程之间的关系和共享资源等情况。

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val scope = CoroutineScope(Job())
        val job1 = scope.launch {
            try {
                withTimeout(3000) {
                    while (true) {
                        println("job1 正在运行")
                        delay(1000)
                    }
                }
            } catch (e: TimeoutCancellationException) {
                println("job1 超时:$e")
            }
        }
        val job2 = scope.launch {
            while (true) {
                println("job2 正在运行")
                delay(1000)
            }
        }
        job1.join()
        job2.cancel()
        job2.join()
        println("所有协程结束")
    }
    

    在这个例子中,job1 设置了 3 秒超时,job2 没有设置超时。当 job1 超时时,job2 由于与 job1 处于同一 CoroutineScope,在 job1.join() 之后手动取消 job2,确保所有协程正确结束。

  3. 资源管理与取消超时的结合 在实际应用中,协程往往会涉及到资源的获取和释放,如数据库连接、文件句柄等。取消和超时操作必须与资源管理紧密结合,以确保资源的正确释放。

    import kotlinx.coroutines.*
    import java.io.File
    
    suspend fun writeToFile(file: File, content: String) = withTimeout(5000) {
        file.bufferedWriter().use { writer ->
            for (char in content) {
                if (isActive) {
                    writer.write(char)
                    delay(100)
                } else {
                    break
                }
            }
        }
    }
    
    fun main() = runBlocking {
        val file = File("test.txt")
        val job = launch {
            try {
                writeToFile(file, "Hello, World!")
            } catch (e: TimeoutCancellationException) {
                println("写入文件超时:$e")
            }
        }
        delay(2000)
        job.cancel()
        job.join()
        println("主函数结束")
    }
    

    writeToFile 函数中,我们使用 withTimeout 确保写入操作在 5 秒内完成。同时,通过 isActive 检查协程是否被取消,在取消时及时中断写入操作。在主函数中,2 秒后取消协程,确保文件写入操作能够正确处理取消和超时情况,并且 FilebufferedWriter 会通过 use 块正确关闭,避免资源泄漏。

实际应用案例分析

  1. 网络请求中的取消与超时 在 Android 开发中,使用 Kotlin 协程进行网络请求时,取消和超时是非常重要的功能。例如,当用户在网络请求过程中切换页面,我们需要取消正在进行的请求以节省资源。

    import kotlinx.coroutines.*
    import okhttp3.*
    import java.io.IOException
    
    val client = OkHttpClient()
    
    suspend fun makeNetworkRequest(url: String): String = withTimeout(10000) {
        val request = Request.Builder()
           .url(url)
           .build()
        try {
            client.newCall(request).execute().use { response ->
                if (!response.isSuccessful) throw IOException("Unexpected code $response")
                response.body?.string() ?: ""
            }
        } catch (e: IOException) {
            println("网络请求异常:$e")
            ""
        }
    }
    
    fun main() = runBlocking {
        val job = launch {
            try {
                val result = makeNetworkRequest("https://example.com/api/data")
                println("网络请求结果:$result")
            } catch (e: TimeoutCancellationException) {
                println("网络请求超时:$e")
            }
        }
        delay(5000)
        job.cancel()
        job.join()
        println("主函数结束")
    }
    

    在这个例子中,makeNetworkRequest 函数使用 withTimeout 设置了 10 秒的超时。在主函数中,5 秒后取消协程,模拟用户在请求过程中进行了其他操作导致需要取消请求的场景。

  2. 数据处理任务中的取消与超时 假设我们有一个数据处理任务,需要对大量数据进行计算,并且希望在一定时间内完成,或者在用户手动取消时能够及时停止。

    import kotlinx.coroutines.*
    import java.util.concurrent.atomic.AtomicInteger
    
    suspend fun processData(data: List<Int>) = withTimeout(5000) {
        val sum = AtomicInteger(0)
        for (num in data) {
            if (isActive) {
                sum.addAndGet(num)
                delay(100)
            } else {
                break
            }
        }
        sum.get()
    }
    
    fun main() = runBlocking {
        val largeData = (1..1000).toList()
        val job = launch {
            try {
                val result = processData(largeData)
                println("数据处理结果:$result")
            } catch (e: TimeoutCancellationException) {
                println("数据处理超时:$e")
            }
        }
        delay(3000)
        job.cancel()
        job.join()
        println("主函数结束")
    }
    

    这里 processData 函数对一个整数列表进行求和操作,设置了 5 秒的超时。主函数中模拟在 3 秒后用户手动取消任务,processData 函数会根据取消信号和超时情况正确处理。

  3. 并发任务调度中的取消与超时 在一些需要并发执行多个任务并进行调度的场景中,取消和超时也起着关键作用。例如,我们有多个文件需要同时读取并处理,但希望如果某个文件读取超时或整个任务被取消时,能够正确处理。

    import kotlinx.coroutines.*
    import java.io.File
    
    suspend fun readFile(file: File) = withTimeout(3000) {
        file.readText()
    }
    
    fun main() = runBlocking {
        val files = listOf(File("file1.txt"), File("file2.txt"), File("file3.txt"))
        val job = launch {
            val results = files.map { file ->
                async {
                    try {
                        readFile(file)
                    } catch (e: TimeoutCancellationException) {
                        println("读取文件 ${file.name} 超时:$e")
                        ""
                    }
                }
            }
            results.forEach { it.await() }
        }
        delay(5000)
        job.cancel()
        job.join()
        println("主函数结束")
    }
    

    在这个例子中,readFile 函数对单个文件进行读取并设置了 3 秒超时。主函数中并发读取多个文件,5 秒后取消整个任务,确保在超时或取消时能够正确处理每个文件的读取任务。

通过以上对 Kotlin 协程取消与超时的详细介绍和各种场景下的代码示例,我们可以更好地掌握这两个重要特性在实际开发中的应用,从而编写出更健壮、高效的异步代码。无论是在 Android 开发、服务器端编程还是其他领域,合理运用协程的取消与超时机制都能提升程序的性能和用户体验。同时,在复杂场景下,我们需要仔细考虑取消和超时对资源管理、任务调度等方面的影响,确保程序的正确性和稳定性。在实际项目中,根据具体需求灵活选择取消和超时的触发方式以及协程内部的处理逻辑,将有助于我们充分发挥 Kotlin 协程的优势。