kotlin协程——>基础、取消与超时
Kotlin使用挂起函数为异步操作,使用kotlinx.coroutines中的launch、async
1. 第⼀个协程程序
import kotlinx.coroutines.* fun main() { GlobalScope.launch { // 在后台启动⼀个新的协程并继续 delay(1000L) // ⾮阻塞的等待 1 秒钟(默认时间单位是毫秒) println("World!") // 在延迟后打印输出 } println("Hello,") // 协程已在等待时主线程还在继续 Thread.sleep(2000L) // 阻塞主线程 2 秒钟来保证 JVM 存活 }
代码运行的结果
Hello, World!
本质上,协程是轻量级的线程。它们在某些 CoroutineScope 上下⽂中与 launch 协程构建器 ⼀起启 动。这⾥我们在 GlobalScope 中启动了⼀个新的协程,这意味着新协程的⽣命周期只受整个应⽤程序 的⽣命周期限制。 可以将 GlobalScope.launch { …… } 替换为 thread { …… } ,并将 delay(……) 替换为 Thread.sleep(……) 达到同样⽬的。试试看(不要忘记导⼊ kotlin.concurrent.thread )。 — — — — — — — — — 协程基础 第⼀个协程程序 205 如果你⾸先将 GlobalScope.launch 替换为 thread ,编译器会报以下错误:
Error: Kotlin: Suspend functions are only allowed to be called from a coroutine or another suspend function
这是因为 delay 是⼀个特殊的 挂起函数 ,它不会造成线程阻塞,但是会 挂起 协程,并且只能在协程中 使⽤。
2. 桥接阻塞与⾮阻塞的世界
第⼀个⽰例在同⼀段代码中混⽤了 ⾮阻塞的 delay(……) 与 阻塞的 Thread.sleep(……) 。这容易 让我们记混哪个是阻塞的、哪个是⾮阻塞的。让我们显式使⽤ runBlocking 协程构建器来阻塞:
import kotlinx.coroutines.* fun main() { GlobalScope.launch { // 在后台启动⼀个新的协程并继续 delay(1000L) println("World!") } println("Hello,") // 主线程中的代码会⽴即执⾏ runBlocking { // 但是这个表达式阻塞了主线程 delay(2000L) // ……我们延迟 2 秒来保证 JVM 的存活 } }
结果是相似的,但是这些代码只使⽤了⾮阻塞的函数 delay。调⽤了 runBlocking 的主线程会⼀直 阻塞 直到 runBlocking 内部的协程执⾏完毕。
这个⽰例可以使⽤更合乎惯⽤法的⽅式重写,使⽤ runBlocking 来包装 main 函数的执⾏:
import kotlinx.coroutines.* fun main() = runBlocking<Unit> { // 开始执⾏主协程 GlobalScope.launch { // 在后台启动⼀个新的协程并继续 delay(1000L) println("World!") } println("Hello,") // 主协程在这⾥会⽴即执⾏ delay(2000L) // 延迟 2 秒来保证 JVM 存活 }
这⾥的 runBlocking { …… } 作为⽤来启动顶层主协程的适配器。我们显式指定了其返回 类型 Unit,因为在 Kotlin 中 main 函数必须返回 Unit 类型。
这也是为挂起函数编写单元测试的⼀种⽅式:
class MyTest { @Test fun testMySuspendingFunction() = runBlocking<Unit> { // 这⾥我们可以使⽤任何喜欢的断⾔⻛格来使⽤挂起函数 } }
延迟⼀段时间来等待另⼀个协程运⾏并不是⼀个好的选择。让我们显式(以⾮阻塞⽅式)等待所启动的 后台 Job 执⾏结束:
val job = GlobalScope.launch { // 启动⼀个新协程并保持对这个作业的引⽤ delay(1000L) println("World!") } println("Hello,") job.join() // 等待直到⼦协程执⾏结束
现在,结果仍然相同,但是主协程与后台作业的持续时间没有任何关系了。好多了。
3. 结构化的并发
协程的实际使⽤还有⼀些需要改进的地⽅。当我们使⽤ GlobalScope.launch 时,我们会创建⼀个 顶层协程。虽然它很轻量,但它运⾏时仍会消耗⼀些内存资源。如果我们忘记保持对新启动的协程的引 ⽤,它还会继续运⾏。如果协程中的代码挂起了会怎么样(例如,我们错误地延迟了太⻓时间),如果我们 启动了太多的协程并导致内存不⾜会怎么样?必须⼿动保持对所有已启动协程的引⽤并 join 之很容易 出错。 有⼀个更好的解决办法。我们可以在代码中使⽤结构化并发。我们可以在执⾏操作所在的指定作⽤域内 启动协程,⽽不是像通常使⽤线程(线程总是全局的)那样在 GlobalScope 中启动。 在我们的⽰例中,我们使⽤ runBlocking 协程构建器将 main 函数转换为协程。包括 runBlocking 在内的每个协程构建器都将 CoroutineScope 的实例添加到其代码块所在的作⽤域中。我们可以在这 个作⽤域中启动协程⽽⽆需显式 join 之,因为外部协程(⽰例中的 runBlocking )直到在其作⽤域 中启动的所有协程都执⾏完毕后才会结束。因此,可以将我们的⽰例简化为:
import kotlinx.coroutines.* fun main() = runBlocking { // this: CoroutineScope launch { // 在 runBlocking 作⽤域中启动⼀个新协程 delay(1000L) println("World!") } println("Hello,") }
4. 作⽤域构建器
除了由不同的构建器提供协程作⽤域之外,还可以使⽤ coroutineScope 构建器声明⾃⼰的作⽤域。它 会创建⼀个协程作⽤域并且在所有已启动⼦协程执⾏完毕之前不会结束。 runBlocking 与 coroutineScope 可能看起来很类似,因为它们都会等待其协程体以及所有⼦协程结 束。主要区别在于,runBlocking ⽅法会阻塞当前线程来等待,⽽ coroutineScope 只是挂起,会释放底 层线程⽤于其他⽤途。由于存在这点差异,runBlocking 是常规函数,⽽ coroutineScope 是挂起函数。 可以通过以下⽰例来演⽰:
import kotlinx.coroutines.* fun main() = runBlocking { // this: CoroutineScope launch { delay(200L) println("Task from runBlocking") } coroutineScope { // 创建⼀个协程作⽤域 launch { delay(500L) println("Task from nested launch") } delay(100L) println("Task from coroutine scope") // 这⼀⾏会在内嵌 launch 之前输出 } println("Coroutine scope is over") // 这⼀⾏在内嵌 launch 执⾏完毕后才输出 }
请注意,(当等待内嵌 launch 时)紧挨“Task from coroutine scope”消息之后,就会执⾏并输出“Task from runBlocking”⸺尽管 coroutineScope 尚未结束。
5. 提取函数重构
我们来将 launch { …… } 内部的代码块提取到独⽴的函数中。当你对这段代码执⾏“提取函数”重构 时,你会得到⼀个带有 suspend 修饰符的新函数。这是你的第⼀个挂起函数。在协程内部可以像普通 函数⼀样使⽤挂起函数,不过其额外特性是,同样可以使⽤其他挂起函数(如本例中的 delay )来挂 起协程的执⾏。
import kotlinx.coroutines.* fun main() = runBlocking { launch { doWorld() } println("Hello,") } // 这是你的第⼀个挂起函数 suspend fun doWorld() { delay(1000L) println("World!") }
但是如果提取出的函数包含⼀个在当前作⽤域中调⽤的协程构建器的话,该怎么办?在这种情况下,所 提取函数上只有 suspend 修饰符是不够的。为 CoroutineScope 写⼀个 doWorld 扩展⽅法是其 中⼀种解决⽅案,但这可能并⾮总是适⽤,因为它并没有使 API 更加清晰。惯⽤的解决⽅案是要么显式 将 CoroutineScope 作为包含该函数的类的⼀个字段,要么当外部类实现了 CoroutineScope 时 隐式取得。作为最后的⼿段,可以使⽤ CoroutineScope(coroutineContext),不过这种⽅法结构上不安 全,因为你不能再控制该⽅法执⾏的作⽤域。只有私有 API 才能使⽤这个构建器。
6.全局协程像守护线程
以下代码在 GlobalScope 中启动了⼀个⻓期运⾏的协程,该协程每秒输出“I'm sleeping”两次,之后在 主函数中延迟⼀段时间后返回。
GlobalScope.launch { repeat(1000) { i -> println("I'm sleeping $i ...") delay(500L) } } delay(1300L) // 在延迟后退出
你可以运⾏这个程序并看到它输出了以下三⾏后终⽌:
I'm sleeping 0 ... I'm sleeping 1 ... I'm sleeping 2 ...
在 GlobalScope 中启动的活动协程并不会使进程保活。它们就像守护线程
7.取消协程的执行
在⼀个⻓时间运⾏的应⽤程序中,你也许需要对你的后台协程进⾏细粒度的控制。⽐如说,⼀个⽤⼾也 许关闭了⼀个启动了协程的界⾯,那么现在协程的执⾏结果已经不再被需要了,这时,它应该是可以被 取消的。该 launch 函数返回了⼀个可以被⽤来取消运⾏中的协程的 Job:
val job = launch { repeat(1000) { i -> println("job: I'm sleeping $i ...") delay(500L) } } delay(1300L) // 延迟⼀段时间 println("main: I'm tired of waiting!") job.cancel() // 取消该作业 job.join() // 等待作业执⾏结束 println("main: Now I can quit.")
程序执⾏后的输出如下:
job: I'm sleeping 0 ... job: I'm sleeping 1 ... job: I'm sleeping 2 ... main: I'm tired of waiting! main: Now I can quit.
⼀旦 main 函数调⽤了 job.cancel ,我们在其它的协程中就看不到任何输出,因为它被取消了。这⾥ 也有⼀个可以使 Job 挂起的函数 cancelAndJoin 它合并了对 cancel 以及 join 的调⽤。
8.取消是协作的
协程的取消是 协作 的。⼀段协程代码必须协作才能被取消。所有 kotlinx.coroutines 中的挂起 函数都是 可被取消的 。它们检查协程的取消,并在取消时抛出 CancellationException。然⽽,如果协 程正在执⾏计算任务,并且没有检查取消的话,那么它是不能被取消的,就如如下⽰例代码所⽰:
val startTime = System.currentTimeMillis() val job = launch(Dispatchers.Default) { var nextPrintTime = startTime var i = 0 while (i < 5) { // ⼀个执⾏计算的循环,只是为了占⽤ CPU // 每秒打印消息两次 if (System.currentTimeMillis() >= nextPrintTime) { println("job: I'm sleeping ${i++} ...") nextPrintTime += 500L } } } delay(1300L) // 等待⼀段时间 println("main: I'm tired of waiting!") job.cancelAndJoin() // 取消⼀个作业并且等待它结束 println("main: Now I can quit.")
运⾏⽰例代码,并且我们可以看到它连续打印出了“I'm sleeping”,甚⾄在调⽤取消后,作业仍然执⾏了 五次循环迭代并运⾏到了它结束为⽌。
9.使计算代码可取消
我们有两种⽅法来使执⾏计算的代码可以被取消。第⼀种⽅法是定期调⽤挂起函数来检查取消。对于这 种⽬的 yield 是⼀个好的选择。另⼀种⽅法是显式的检查取消状态。让我们试试第⼆种⽅法。 将前⼀个⽰例中的 while (i < 5) 替换为 while (isActive) 并重新运⾏它。
val startTime = System.currentTimeMillis() val job = launch(Dispatchers.Default) { var nextPrintTime = startTime var i = 0 while (isActive) { // 可以被取消的计算循环 // 每秒打印消息两次 if (System.currentTimeMillis() >= nextPrintTime) { println("job: I'm sleeping ${i++} ...") nextPrintTime += 500L } } } delay(1300L) // 等待⼀段时间 println("main: I'm tired of waiting!") job.cancelAndJoin() // 取消该作业并等待它结束 println("main: Now I can quit.")
你可以看到,现在循环被取消了。isActive 是⼀个可以被使⽤在 CoroutineScope 中的扩展属性。
10. 在 finally 中释放资源
我们通常使⽤如下的⽅法处理在被取消时抛出 CancellationException 的可被取消的挂起函数。⽐如 说,try {……} finally {……} 表达式以及 Kotlin 的 use 函数⼀般在协程被取消的时候执⾏它们 的终结动作:
val job = launch { try { repeat(1000) { i -> println("job: I'm sleeping $i ...") delay(500L) } } finally { println("job: I'm running finally") } } delay(1300L) // 延迟⼀段时间 println("main: I'm tired of waiting!") job.cancelAndJoin() // 取消该作业并且等待它结束 println("main: Now I can quit.")
join 和 cancelAndJoin 等待了所有的终结动作执⾏完毕,所以运⾏⽰例得到了下⾯的输出:
job: I'm sleeping 0 ... job: I'm sleeping 1 ... job: I'm sleeping 2 ... main: I'm tired of waiting! job: I'm running finally main: Now I can quit.
11. 运⾏不能取消的代码块
在前⼀个例⼦中任何尝试在 finally 块中调⽤挂起函数的⾏为都会抛出 CancellationException,因 为这⾥持续运⾏的代码是可以被取消的。通常,这并不是⼀个问题,所有良好的关闭操作(关闭⼀个⽂ 件、取消⼀个作业、或是关闭任何⼀种通信通道)通常都是⾮阻塞的,并且不会调⽤任何挂起函数。然⽽, 在真实的案例中,当你需要挂起⼀个被取消的协程,你可以将相应的代码包装在 withContext(NonCancellable) {……} 中,并使⽤ withContext 函数以及 NonCancellable 上 下⽂,⻅如下⽰例所⽰:
val job = launch { try { repeat(1000) { i -> println("job: I'm sleeping $i ...") delay(500L) } } finally { withContext(NonCancellable) { println("job: I'm running finally") delay(1000L) println("job: And I've just delayed for 1 sec because I'm non-cancellable") } } } delay(1300L) // 延迟⼀段时间 println("main: I'm tired of waiting!") job.cancelAndJoin() // 取消该作业并等待它结束 println("main: Now I can quit.")
12. 超时
在实践中绝⼤多数取消⼀个协程的理由是它有可能超时。当你⼿动追踪⼀个相关 Job 的引⽤并启动了 ⼀个单独的协程在延迟后取消追踪,这⾥已经准备好使⽤ withTimeout 函数来做这件事。来看看⽰例代码:
withTimeout(1300L) { repeat(1000) { i -> println("I'm sleeping $i ...") delay(500L) } }
运⾏后得到如下输出:
I'm sleeping 0 ... I'm sleeping 1 ... I'm sleeping 2 ... Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms
withTimeout 抛出了 TimeoutCancellationException ,它是 CancellationException 的⼦类。 我们之前没有在控制台上看到堆栈跟踪信息的打印。这是因为在被取消的协程中 CancellationException 被认为是协程执⾏结束的正常原因。然⽽,在这个⽰例中我们在 main 函数中正确地使⽤了 withTimeout
由于取消只是⼀个例外,所有的资源都使⽤常⽤的⽅法来关闭。如果你需要做⼀些各类使⽤超时的特别 的额外操作,可以使⽤类似 withTimeout 的 withTimeoutOrNull 函数,并把这些会超时的代码包装在 try {...} catch (e: TimeoutCancellationException) {...} 代码块中,⽽ withTimeoutOrNull 通过返回 null 来进⾏超时操作,从⽽替代抛出⼀个异常:
val result = withTimeoutOrNull(1300L) { repeat(1000) { i -> println("I'm sleeping $i ...") delay(500L) } "Done" // 在它运⾏得到结果之前取消它 } println("Result is $result")
运⾏这段代码时不再抛出异常:
I'm sleeping 0 ... I'm sleeping 1 ... I'm sleeping 2 ... Result is null