学习 Kotlin Coroutines 基础 – wiki基地


深入浅出:Kotlin Coroutines 协程基础详解

在现代软件开发中,处理并发和异步操作是常见的挑战。无论是进行网络请求、访问数据库、处理大量数据,还是构建响应式UI,我们都需要避免阻塞主线程,确保应用的流畅性和响应性。传统的解决方案如多线程编程虽然有效,但也带来了线程管理的复杂性、资源消耗、同步问题以及臭名昭著的“回调地狱”。

Kotlin Coroutines(协程)正是为了解决这些问题而诞生的。它们提供了一种更轻量、更灵活、更易于理解的方式来编写异步和非阻塞代码。本文将带你深入了解 Kotlin Coroutines 的基础知识,包括它的核心概念、关键组件以及如何在实践中使用它们。

为什么需要 Coroutines?传统方式的困境

在我们 diving into Coroutines 之前,先回顾一下传统处理并发的方式及其局限性:

  1. Callbacks (回调): 这是早期处理异步操作的常见方式,尤其在 JavaScript 和早期的 Android 开发中。例如,一个网络请求完成后通过回调函数通知结果。当存在多个相互依赖的异步操作时,这会迅速导致“回调地狱”(Callback Hell),代码层层嵌套,难以阅读、维护和调试。错误处理也变得复杂。

  2. Threads (线程): Java/Kotlin 等语言提供了多线程支持。我们可以创建一个新的线程来执行耗时操作,避免阻塞主线程。然而:

    • 创建和管理开销大: 创建线程需要操作系统资源,数量过多会消耗大量内存和CPU时间。
    • 同步复杂: 多个线程访问共享资源时需要加锁(synchronizedLock等),容易引发死锁、活锁、竞态条件等问题。
    • 调试困难: 线程切换是不确定的,难以复现 bug。
    • 取消困难: 中断一个正在执行的线程并不容易且不安全。
  3. Futures / Promises: 提供了更结构化的异步编程方式,可以将异步操作的结果封装在一个对象中,并通过链式调用处理结果或错误。但这仍然需要处理回调或阻塞等待。

  4. Reactive Streams (响应式流,如 RxJava): 提供强大的数据流处理能力,通过操作符组合异步操作。它非常强大,但也带来了新的编程范式,学习曲线较陡峭,且对于简单的异步任务可能会显得过于复杂。

Coroutines 旨在提供一种既能避免回调地狱,又能比线程更轻量、更易于管理的并发解决方案。

什么是 Kotlin Coroutines?

简单来说,Kotlin Coroutines 可以被视为“轻量级线程”。它们不是操作系统级别的线程,而是由 Kotlin 运行时或特定库(如 kotlinx.coroutines)在用户空间管理的。

核心概念:挂起 (Suspending)

Coroutines 的关键在于 suspend 函数。当一个 suspend 函数被调用时,它可以在执行到某个耗时操作(如网络请求、文件读写)时“挂起”(暂停)自身的执行,而不阻塞当前线程。当耗时操作完成后,协程可以在同一个线程或不同的线程上“恢复”执行,从挂起的地方继续。

这就像是你在读书时被打断了,你可以在书里夹一个书签,去做其他事情(不阻塞你所在的房间),事情办完后,你回来找到书签,从上次读到的地方继续。整个过程,你(协程)暂停了,但你所在的房间(线程)可以被其他人使用。

通过挂起和恢复,协程可以将异步代码写成看起来像同步的、顺序执行的代码,大大提高了可读性和可维护性。

Coroutines 的核心组件和概念

要使用 Coroutines,我们需要了解几个核心组件:

  1. suspend 函数:

    • 定义:用 suspend 关键字修饰的函数。
    • 作用:表示该函数可能在执行过程中挂起(暂停)并在稍后恢复
    • 调用限制:suspend 函数只能在另一个 suspend 函数中或者在一个协程构建器(Coroutine Builder)内部调用。
    • 例子:
      “`kotlin
      suspend fun fetchData(): String {
      println(“开始 fetching data…”)
      delay(2000) // 一个挂起函数,模拟耗时操作,不会阻塞线程
      println(“数据 fetched.”)
      return “Some Data”
      }

      suspend fun processData() {
      val data = fetchData() // 在另一个suspend函数中调用fetchData
      println(“处理数据: $data”)
      }
      ``
      *
      delay()函数是kotlinx.coroutines库提供的一个挂起函数,它会暂停当前协程的执行一段指定的时间,但不会阻塞底层的线程。这是与Thread.sleep()` 的根本区别。

  2. Coroutine Builders (协程构建器):

    • 作用:用于启动一个新的协程。它们通常是定义在 CoroutineScope 上的扩展函数。
    • 常见的构建器:

      • launch: 启动一个新协程,不返回结果。通常用于执行“防火不回头”的任务(fire and forget)。它返回一个 Job 对象,可用于取消或等待协程完成。
      • async: 启动一个新协程,并返回一个 Deferred<T> 对象。Deferred 继承自 Job,并额外提供了 await() 方法来获取协程的计算结果。await() 是一个挂起函数,它会挂起当前协程直到 async 块中的任务完成并返回结果。async 通常用于并行执行多个任务并等待所有结果。
      • runBlocking: 启动一个新协程,并阻塞当前线程直到协程完成。它主要用于将阻塞代码连接到非阻塞协程世界,通常用于 main 函数或测试中。在实际的异步代码中应尽量避免使用 runBlocking,因为它会阻塞线程。
    • 例子:
      “`kotlin
      import kotlinx.coroutines.*

      fun main() = runBlocking { // 这是一个Coroutine Builder,阻塞main线程
      println(“主线程开始: ${Thread.currentThread().name}”)

      // 使用 launch 启动一个协程
      val job: Job = launch {
          println("launch 协程开始: ${Thread.currentThread().name}")
          delay(1000)
          println("launch 协程结束: ${Thread.currentThread().name}")
      }
      
      // 使用 async 启动一个协程,并获取结果
      val deferred: Deferred<String> = async {
          println("async 协程开始: ${Thread.currentThread().name}")
          delay(1500)
          println("async 协程结束: ${Thread.currentThread().name}")
          return@async "Async Result" // 使用 return@async 返回结果
      }
      
      println("主线程等待 launch 协程完成")
      job.join() // 等待 launch 协程完成 (挂起当前协程)
      println("launch 协程已完成")
      
      println("主线程等待 async 协程结果")
      val result = deferred.await() // 等待 async 协程完成并获取结果 (挂起当前协程)
      println("async 结果: $result")
      
      println("主线程结束: ${Thread.currentThread().name}")
      

      }
      *输出示例 (线程名可能不同)*:
      主线程开始: main
      launch 协程开始: main
      async 协程开始: main
      主线程等待 launch 协程完成
      launch 协程结束: main
      launch 协程已完成
      主线程等待 async 协程结果
      async 协程结束: main
      async 结果: Async Result
      主线程结束: main
      ``
      注意到
      launchasync默认可能运行在同一个线程 (maininrunBlocking),但它们内部的delay` 使协程挂起,线程可以去做其他事情。

  3. CoroutineScope (协程作用域):

    • 作用:定义了协程的生命周期和结构化并发。launchasync 等协程构建器是 CoroutineScope 的扩展函数。
    • 重要性:每个协程都应该在一个 CoroutineScope 中启动。Scope 负责管理其启动的所有子协程。当 Scope 被取消时,所有由它启动的子协程也会被取消。这极大地简化了协程的生命周期管理,防止协程泄露。
    • GlobalScope: 是一个顶层作用域,它的生命周期与应用程序的生命周期绑定。通常不推荐在应用代码中直接使用 GlobalScope.launch,因为由它启动的协程不会受结构化并发的约束,难以管理其生命周期和取消,容易导致资源泄露。
    • 结构化并发 (Structured Concurrency): 这是 Coroutines 的一个重要设计原则。在一个父协程(或一个 Scope)中启动的子协程,其生命周期与父协程(或 Scope)绑定。父协程会等待所有子协程完成(除非使用 supervisorScope)。如果父协程被取消,其所有子协程也会被取消。如果子协程失败,它会向上抛出异常,导致父协程和同级协程被取消。
    • 创建 Scope:

      • UI 组件/特定生命周期对象: 可以为具有特定生命周期的组件(如 Android Activity/ViewModel)定义一个 Scope,在其销毁时取消 Scope。
      • coroutineScope builder: 这是一个挂起函数,用于创建一个子作用域。它会等待其内部所有协程完成,并且会传播子协程的错误。适用于需要等待一组子任务完成的场景。
      • supervisorScope builder: 也是一个挂起函数,创建一个子作用域。与 coroutineScope 不同的是,它不会因为子协程的失败而取消所有同级子协程和父协程,只会取消失败的子协程自身。适用于需要独立处理子任务失败的场景(如UI中加载多个互不依赖的数据项)。
    • 例子 (使用 coroutineScope 实现结构化并发):
      “`kotlin
      import kotlinx.coroutines.*

      suspend fun performTwoTasksConcurrently() = coroutineScope { // 创建一个子作用域
      val task1 = async {
      println(“Task 1 started: ${Thread.currentThread().name}”)
      delay(1000)
      println(“Task 1 finished”)
      “Result 1”
      }

      val task2 = async {
          println("Task 2 started: ${Thread.currentThread().name}")
          delay(1500)
          println("Task 2 finished")
          "Result 2"
      }
      
      // coroutineScope 会等待 task1 和 task2 都完成
      val result1 = task1.await()
      val result2 = task2.await()
      
      println("All tasks finished. Results: $result1, $result2")
      

      }

      fun main() = runBlocking {
      println(“Starting concurrent tasks…”)
      performTwoTasksConcurrently() // 在 runBlocking 的作用域中调用
      println(“Concurrent tasks completed.”)
      }
      ``
      在这个例子中,
      coroutineScope确保了performTwoTasksConcurrently函数只有在task1task2都完成后才会返回。如果其中任何一个async协程抛出异常,整个coroutineScope会被取消,异常会向上抛给调用者 (runBlocking`)。

  4. CoroutineContext (协程上下文):

    • 作用:协程上下文是协程行为的配置集,它是一个元素的集合。
    • 关键元素:
      • Job: 协程的生命周期句柄,用于控制和查询协程的状态(活动、取消、完成)。
      • CoroutineDispatcher: 决定协程在哪个线程或线程池上执行。
      • CoroutineName: 用于调试,给协程一个名字。
      • CoroutineExceptionHandler: 用于处理未捕获的异常(仅对 launch 启动的协程有效,对 async 需要通过 await() 捕获)。
    • 组合上下文:可以使用 + 操作符组合不同的上下文元素。
    • 修改上下文:可以使用 withContext 函数在协程内部切换上下文(特别是切换 Dispatcher),并在代码块执行完毕后恢复原上下文。

    • 例子:
      “`kotlin
      import kotlinx.coroutines.*
      import kotlin.coroutines.CoroutineContext

      fun main() = runBlocking {
      // 默认上下文 (继承自父协程或 Scope)
      launch {
      println(“Default Context: I’m working in ${Thread.currentThread().name}”)
      }

      // 指定 Dispatcher.Default
      launch(Dispatchers.Default) {
          println("Default Dispatcher: I'm working in ${Thread.currentThread().name}")
      }
      
      // 指定 Dispatcher.IO
       launch(Dispatchers.IO) {
          println("IO Dispatcher: I'm working in ${Thread.currentThread().name}")
      }
      
      // 指定 CoroutineName
      launch(CoroutineName("MyNamedCoroutine")) {
           println("Named Coroutine: I'm working in ${Thread.currentThread().name}")
           println("My name is ${coroutineContext[CoroutineName]?.name}")
      }
      
      // 组合上下文
      val myContext: CoroutineContext = Dispatchers.Default + CoroutineName("CombinedContext")
      launch(myContext) {
           println("Combined Context: I'm working in ${Thread.currentThread().name}")
           println("My name is ${coroutineContext[CoroutineName]?.name}")
      }
      
      // 使用 withContext 切换 Dispatcher
      println("Before withContext: ${Thread.currentThread().name}")
      withContext(Dispatchers.IO) {
           println("Inside withContext(IO): ${Thread.currentThread().name}")
           // 在这里执行IO密集型操作
           delay(500)
      }
      println("After withContext: ${Thread.currentThread().name}")
      
       // 等待所有协程完成
      Unit // runBlocking 会等待内部所有协程,但显式 join 更好理解或控制
      

      }
      *输出示例 (线程名可能不同,顺序不定)*:
      Default Context: I’m working in main
      Default Dispatcher: I’m working in DefaultDispatcher-worker-1
      IO Dispatcher: I’m working in DefaultDispatcher-worker-2
      Named Coroutine: I’m working in DefaultDispatcher-worker-3
      My name is MyNamedCoroutine
      Combined Context: I’m working in DefaultDispatcher-worker-4
      My name is CombinedContext
      Before withContext: main
      Inside withContext(IO): DefaultDispatcher-worker-5
      After withContext: main
      ``
      这展示了如何在启动协程时指定上下文,以及如何在协程内部使用
      withContext` 临时切换 Dispatcher。

  5. CoroutineDispatcher (协程调度器):

    • 作用:决定协程在哪个线程或线程池上执行。
    • 主要类型:

      • Dispatchers.Default: 默认调度器,用于 CPU 密集型任务。它使用一个共享的后台线程池,线程数量默认等于 CPU 核数。
      • Dispatchers.IO: 用于 IO 密集型任务,如网络请求、文件读写、数据库操作。它使用一个独立的线程池,线程数量按需创建,上限较高。
      • Dispatchers.Main: 在支持 UI 的平台(如 Android)上可用,表示主线程/UI 线程。必须在相应的平台模块中引入(例如 kotlinx-coroutines-android)。用于更新 UI。
      • Dispatchers.Unconfined: 不限于任何特定线程。它在调用它的线程上启动协程,但协程挂起后恢复时,可能会在任意线程上继续执行(取决于挂起函数在哪里恢复)。适用于不消耗 CPU 时间、也不进行 IO 的小型任务,或需要严格控制线程亲和力的场景。新手应谨慎使用
    • 最佳实践:

      • CPU 密集型任务 (calculations, sorting) -> Dispatchers.Default
      • IO 密集型任务 (network requests, database calls, file operations) -> Dispatchers.IO
      • 更新 UI -> Dispatchers.Main
      • 使用 withContext 在需要时切换 Dispatcher,而不是启动新协程。这更高效,因为 withContext 不会创建新的 Job,只是切换上下文。
    • 例子 (结合 withContext):
      “`kotlin
      import kotlinx.coroutines.*

      // 假设这是在 Android 应用的 ViewModel 中 (拥有一个 CoroutineScope)
      class MyViewModel {
      private val viewModelScope = CoroutineScope(Dispatchers.Main) // UI主线程Scope

      fun loadDataAndDisplay() {
          viewModelScope.launch { // 启动在主线程
              try {
                  println("UI Thread before data loading: ${Thread.currentThread().name}")
                  val data = fetchDataFromNetwork() // 调用挂起函数,内部会切换Dispatcher
      
                  println("UI Thread after data loading: ${Thread.currentThread().name}")
                  updateUI(data) // 更新 UI
              } catch (e: Exception) {
                  handleError(e) // 处理错误
              }
          }
      }
      
      private suspend fun fetchDataFromNetwork(): String = withContext(Dispatchers.IO) {
          // 这个代码块将在 IO 线程执行
          println("Fetching data on: ${Thread.currentThread().name}")
          delay(2000) // 模拟网络延迟
          println("Data fetched.")
          "Network Data Loaded"
      }
      
      private fun updateUI(data: String) {
          // 这个函数需要在主线程执行
           println("Updating UI on: ${Thread.currentThread().name} with $data")
           // 实际的UI更新代码
      }
      
      private fun handleError(e: Exception) {
           println("Error handling on: ${Thread.currentThread().name}. Error: ${e.message}")
           // 实际的错误处理代码,可能更新UI
      }
      
      fun onCleared() {
          viewModelScope.cancel() // 当 ViewModel 销毁时取消 Scope
      }
      

      }

      // 模拟 Android 环境下的 Dispatchers.Main
      // 在 JVM 环境运行需要手动设置或模拟
      fun main() = runBlocking(Dispatchers.Main) { // 在JVM上模拟 Main 调度器,例如使用TestCoroutineDispatcher
      // 为了简化,这里假设 runBlocking 就是 Main
      println(“Starting ViewModel example on: ${Thread.currentThread().name}”)
      val viewModel = MyViewModel()
      viewModel.loadDataAndDisplay()
      delay(3000) // 给协程足够时间运行
      viewModel.onCleared()
      println(“ViewModel example finished.”)
      }

      // 注意:在真实的 Android 应用中,Dispatchers.Main 会自动配置好。
      // 上面的 main 函数仅为演示概念,实际运行时可能需要更复杂的Main Dispatcher模拟。
      ``
      这个例子清晰地展示了如何在 UI 协程(
      launchviewModelScope,其 Dispatcher 是Main)中调用一个需要切换到IO线程执行的挂起函数 (fetchDataFromNetwork),然后再自动切回Main线程 (withContext` 块结束后)。

  6. Job 和 Cancellation (取消):

    • Job:每个由 launchasync 启动的协程都会返回一个 Job 对象(async 返回 Deferred,它是 Job 的子类)。Job 代表了协程的生命周期,并提供了取消协程的方法。
    • 生命周期状态:New, Active, Completing, Cancelled, Completed
    • 取消:调用 job.cancel() 方法可以取消一个协程。
    • 协作式取消 (Cooperative Cancellation): Coroutines 的取消是协作式的。这意味着协程本身必须主动检查自己是否被取消,并在检测到取消时停止工作。大多数 kotlinx.coroutines 提供的挂起函数(如 delay, withContext, await, IO操作等)都是可取消的,它们会在挂起时检查协程的 Job 是否被取消,如果被取消,它们会立即抛出 CancellationException
    • 如果你的协程在执行一个长时间运行的、不调用任何挂起函数的 CPU 密集型循环,它将不会自动响应取消。你需要定期检查 isActive 属性或调用 yield() 函数来使协程变得可取消。
    • join(): 是一个挂起函数,用于等待一个协程完成(无论成功还是失败)。如果协程被取消,join() 会抛出 CancellationException

    • 例子 (取消协程):
      “`kotlin
      import kotlinx.coroutines.*

      fun main() = runBlocking {
      println(“Starting a long running job…”)
      val job = launch {
      try {
      repeat(1000) { i ->
      println(“Job: I’m working $i …”)
      delay(500) // 这是一个可取消的挂起函数
      // 或者使用 yield() 检查取消并让出执行
      // yield()
      }
      } catch (e: CancellationException) {
      println(“Job: Caught CancellationException”)
      } finally {
      // 清理资源
      println(“Job: Cleaning up resources”)
      }
      }

      delay(1300) // 等待一段时间
      println("Main: I'm tired of waiting!")
      job.cancel() // 取消 Job
      job.join() // 等待 Job 完成取消 (或者说进入结束状态)
      println("Main: Job has been cancelled and joined.")
      

      }
      *输出示例*:
      Starting a long running job…
      Job: I’m working 0 …
      Job: I’m working 1 …
      Job: I’m working 2 …
      Main: I’m tired of waiting!
      Job: Caught CancellationException
      Job: Cleaning up resources
      Main: Job has been cancelled and joined.
      ``
      在这个例子中,
      delay(500)函数使得协程在每次循环时都检查是否被取消。当main协程调用job.cancel()后,下一个delay调用会检测到取消,抛出CancellationException,然后执行finally` 块进行清理。

  7. Exception Handling (异常处理):

    • launch 启动的协程中,未捕获的异常会通过 CoroutineExceptionHandler 或默认的机制(可能导致应用崩溃)处理。异常不会自动传播给父协程(但会取消父协程及其兄弟协程,这是结构化并发的一部分)。
    • async 启动的协程中,异常会被“存储”在返回的 Deferred 对象中,直到调用 await() 时才会被重新抛出。这意味着你需要在调用 await() 的地方使用 try/catch 来捕获异常。

    • 例子:
      “`kotlin
      import kotlinx.coroutines.*

      fun main() = runBlocking {
      // 异常处理器
      val handler = CoroutineExceptionHandler { _, exception ->
      println(“Caught exception with handler: $exception”)
      }

      // launch 中的异常处理
      val job = launch(handler) { // 将 handler 添加到上下文
          println("launch job started")
          throw RuntimeException("Exception from launch")
      }
      job.join() // 等待 job 完成 (这里是因异常结束)
      println("launch job finished")
      
      // async 中的异常处理
      println("\nasync job started")
      val deferred = async {
          throw RuntimeException("Exception from async")
          "Result" // 这行不会执行
      }
      
      try {
          val result = deferred.await() // 在 await() 时异常被抛出
          println("async result: $result") // 这行不会执行
      } catch (e: Exception) {
          println("Caught exception from async await(): $e")
      }
       println("async job finished")
      

      }
      *输出示例*:
      launch job started
      Caught exception with handler: java.lang.RuntimeException: Exception from launch
      launch job finished

      async job started
      Caught exception from async await(): java.lang.RuntimeException: Exception from async
      async job finished
      ``
      这个例子清楚地展示了
      launchasync` 在异常处理行为上的不同。

Coroutines 的优势总结

通过上面的介绍,我们可以总结 Coroutines 的优势:

  1. 轻量级和高效: 相比线程,创建和管理协程的开销非常小,可以在单个线程中运行成千上万个协程。
  2. 简化异步编程: 使用 suspend 函数,可以将复杂的异步流程写成顺序式的代码,避免回调地狱,提高代码的可读性和可维护性。
  3. 结构化并发: 通过 CoroutineScope 和父子协程关系,提供了强大的生命周期管理和错误传播机制,防止协程泄露。
  4. 协作式取消: 提供了一种优雅的方式来取消正在进行的异步操作。
  5. 灵活的调度: 通过 DispatcherswithContext 可以方便地控制协程在哪个线程上执行,以及在需要时轻松切换线程。

进阶之路

本文只涵盖了 Kotlin Coroutines 的基础知识。在你掌握了这些概念后,可以进一步学习:

  • Flows: 处理异步数据流,解决背压问题,常用于处理连续产生的数据(如数据库更新、传感器数据)。
  • Channels: 实现协程之间的通信,类似阻塞队列。
  • Select Expression: 从多个挂起操作中选择第一个完成的那个。
  • Coroutine Testing: 如何方便地测试协程代码。
  • 更深入的异常处理和监控: CoroutineExceptionHandler 的更高级用法,SupervisorJob 等。

总结

Kotlin Coroutines 是现代 Kotlin 异步编程的强大工具。通过理解 suspend 函数、协程构建器 (launch, async, runBlocking)、CoroutineScopeCoroutineContext (Dispatchers, Job)、以及取消和异常处理机制,你就可以开始编写更简洁、高效且易于维护的并发代码。结构化并发是 Coroutines 设计的核心,遵循它能帮助你写出健壮的应用。从基础开始,通过实践不断深入,你将能充分发挥 Coroutines 的潜力。

希望这篇详细的文章对你学习 Kotlin Coroutines 的基础有所帮助!祝你编程愉快!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部