一文搞懂 Kotlin 协程基础 – wiki基地


一文搞懂 Kotlin 协程基础

随着软件系统变得越来越复杂,处理并发和异步操作成为了现代开发的常态。无论是移动应用需要进行网络请求而不阻塞主线程,还是后端服务需要同时处理大量客户端连接,高效、简洁地管理并发是关键。传统的解决方案,如多线程、回调函数(Callback)或者 Future/Promise,在简化异步编程方面各有其局限性,常常导致代码变得复杂、难以阅读和维护,甚至引发“回调地狱”(Callback Hell)或线程管理噩梦。

Kotlin 协程(Coroutines)正是在这样的背景下应运而生,它为 Kotlin 提供了一种轻量级、可协程化的(composable)方式来编写异步和非阻塞代码。协程的出现,极大地提高了 Kotlin 在处理并发任务时的优雅度和效率。

本文旨在深入浅出地讲解 Kotlin 协程的基础知识,帮助你“一文搞懂”协程的核心概念,从而自信地开始使用它。

第一章:为什么需要协程?传统异步编程的痛点

在深入协程之前,我们先回顾一下传统异步编程的一些常见问题:

  1. 线程管理复杂性:

    • 创建和销毁线程开销较大。
    • 线程数量受限,大量线程会导致上下文切换开销,降低性能。
    • 线程间的通信(如共享数据)需要加锁,容易引入死锁或竞态条件。
    • 手动管理线程生命周期容易出错。
  2. 回调地狱(Callback Hell):

    • 当一个异步操作完成后需要执行另一个异步操作时,通常会使用嵌套的回调函数。
    • 多层嵌套的回调函数使得代码可读性极差,逻辑难以跟踪。
    • 错误处理变得分散和复杂。
  3. 阻塞带来的问题:

    • 在UI线程上执行耗时操作(如网络请求、文件读写)会导致UI冻结,用户体验差。
    • 在服务器端,如果一个请求线程被阻塞(等待I/O),该线程就无法处理其他请求,浪费资源。
  4. Future/Promise 的局限性:

    • 虽然比纯回调有所改进,链式调用一定程度上缓解了回调地狱,但在处理复杂的流程(如条件分支、循环)时,仍然不够直观,不如同步代码。

Kotlin 协程提供了一种编写异步代码的新方式,它使得异步代码看起来和写同步、阻塞的代码一样简单直观,但实际上是非阻塞的。

第二章:什么是 Kotlin 协程?核心概念初探

协程,可以理解为是一种“轻量级的线程”。但这个比喻并不完全准确,协程与线程的最大区别在于它们的调度方式和资源消耗:

  • 线程: 由操作系统内核进行调度,抢占式多任务。创建和切换开销大,数量受系统资源限制。
  • 协程: 由用户空间的库(Kotlin 协程库)进行调度,协作式多任务。可以在不阻塞线程的情况下挂起(Suspend)和恢复(Resume),创建和切换开销极小,数量可以非常多。

协程运行在线程之上。一个线程可以运行一个或多个协程。当一个协程被挂起时,它所占用的线程可以去执行其他协程或其他任务,而不会被阻塞。当挂起的协程满足恢复条件时(例如网络请求返回数据),它可以被安排到 同一个另一个 线程上继续执行。

协程的核心思想是“可挂起的计算”(Suspendable Computation)。这意味着你可以编写一段代码,其中包含一个标记为suspend的函数调用,当执行到这个suspend函数时,当前的协程可以被挂起,而底层的线程可以去做其他事情。当suspend函数完成其工作(例如网络请求返回结果)后,协程可以从之前挂起的地方恢复执行。

这种挂起和恢复的能力,让我们可以用看似同步的顺序结构来表达异步逻辑,从而避免了回调的嵌套,极大地提高了代码的可读性和可维护性。

第三章:协程的关键构建块

要理解和使用协程,我们需要掌握几个核心概念和关键词:

3.1 suspend 函数

suspend 是 Kotlin 协程中最核心的关键词之一。

  • 定义:suspend 关键字修饰的函数称为挂起函数
  • 作用: 挂起函数可以在执行过程中暂停(挂起)而不阻塞其所在的线程,并在稍后恢复执行。
  • 约束: 挂起函数只能在协程或者另一个挂起函数中调用。

示例:

“`kotlin
import kotlinx.coroutines.*

// 这是一个普通的挂起函数
suspend fun performTask() {
println(“Task started on ${Thread.currentThread().name}”)
// delay 是一个库提供的挂起函数,它会挂起当前协程,而不是阻塞线程
delay(1000) // 模拟耗时操作,挂起1秒
println(“Task finished on ${Thread.currentThread().name}”)
}

fun main() = runBlocking { // runBlocking 是一个协程构建器,用于启动一个阻塞当前线程的协程
println(“Main function started on ${Thread.currentThread().name}”)
performTask() // 在 runBlocking 协程中调用挂起函数
println(“Main function finished on ${Thread.currentThread().name}”)
}
“`

输出示例:

Main function started on main
Task started on main
Task finished on main
Main function finished on main

注意观察输出中的线程名,performTask 在挂起前后可能(在这个简单的例子中是同一个)使用同一个线程,但在 delay 期间,主线程并没有被阻塞,runBlocking 内部的机制允许它在等待 delay 结束时做其他事情(尽管在这个例子中没有其他事情可做)。delay 的关键在于它挂起了协程,而不是阻塞了线程。

3.2 CoroutineScope(协程作用域)

  • 定义: CoroutineScope 定义了协程的生命周期。它允许你启动新的协程(使用 launchasync)并管理它们的生命周期。
  • 作用: CoroutineScope 实现了结构化并发(Structured Concurrency)。这意味着在一个 CoroutineScope 中启动的协程(子协程)的生命周期会与其父协程或作用域的生命周期关联。当作用域被取消时,所有在其内部启动的协程也会被取消。
  • 重要性: 避免协程泄漏,确保所有后台任务在其相关组件(如 Activity, ViewModel, Service)销毁时能够被正确取消。

常见的获取 CoroutineScope 的方式:

  • GlobalScope:一个全局的协程作用域,它的生命周期只与整个应用的生命周期绑定。不推荐在大多数情况下使用 GlobalScope.launch { ... },因为它启动的协程无法被结构化地管理和取消,容易导致协程泄漏。
  • 通过 CoroutineScope(coroutineContext) 手动创建。
  • 许多库提供了预定义的作用域,例如 Android 中的 lifecycleScope(在 Activity/Fragment 中)和 viewModelScope(在 ViewModel 中)。
  • 协程构建器 (launch, async, runBlocking) 本身也会创建一个内部的 CoroutineScope

示例:

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking { // runBlocking 创建一个 CoroutineScope
println(“runBlocking scope started”)

// launch 在 runBlocking 作用域中启动一个子协程
val job1 = launch {
    println("Child Coroutine 1 started")
    delay(2000)
    println("Child Coroutine 1 finished")
}

// launch 在 runBlocking 作用域中启动另一个子协程
val job2 = launch {
    println("Child Coroutine 2 started")
    delay(1000)
    println("Child Coroutine 2 finished")
}

println("Waiting for children to complete...")

// runBlocking 作用域会等待其所有子协程完成
//job1.join() // 可以显式等待,但 runBlocking 默认就会等
//job2.join()

println("runBlocking scope finished")

}
“`

输出示例:

runBlocking scope started
Child Coroutine 1 started
Child Coroutine 2 started
Child Coroutine 2 finished
Child Coroutine 1 finished
Waiting for children to complete...
runBlocking scope finished

可以看到,runBlocking 协程作为父协程,会等待 job1job2 这两个子协程都执行完毕后才结束。这就是结构化并发的一个体现。

3.3 CoroutineContext(协程上下文)

  • 定义: CoroutineContext 是一个元素的集合,这些元素共同定义了协程的行为。
  • 作用: 它包含了协程的各种元数据,例如:
    • Job:控制协程的生命周期,处理取消和父子关系。
    • Dispatcher:决定协程在哪个线程或线程池上执行。
    • CoroutineName:协程的名称,用于调试。
    • CoroutineExceptionHandler:处理协程中未捕获的异常。
  • 继承性: 新创建的协程会继承其父协程的上下文,但可以通过传入新的上下文元素来覆盖。

示例:

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
// CoroutineContext 包含了 Job (由 runBlocking 提供) 和 Dispatcher (默认是主线程或 common pool)
println(“My Job in runBlocking: ${coroutineContext[Job]}”)
println(“My Dispatcher in runBlocking: ${coroutineContext[CoroutineDispatcher]}”) // 或 coroutineContext.dispatcher

val job = launch(Dispatchers.Default + CoroutineName("MyCoroutine")) {
    // 这个协程的上下文是 runBlocking 的上下文 + Dispatchers.Default + CoroutineName("MyCoroutine")
    println("Inside launched coroutine:")
    println("My Job: ${coroutineContext[Job]}")
    println("My Dispatcher: ${coroutineContext[CoroutineDispatcher]}")
    println("My Name: ${coroutineContext[CoroutineName]}")
}

job.join()

}
“`

输出示例:

My Job in runBlocking: StandaloneCoroutine{Active}@...
My Dispatcher in runBlocking: Dispatchers.Main or kotlinx.coroutines.internal.LimitedDispatcher@... (具体的取决于环境)
Inside launched coroutine:
My Job: StandaloneCoroutine{Active}@...
My Dispatcher: Dispatchers.Default
My Name: CoroutineName(MyCoroutine)

这个例子展示了如何通过 + 操作符组合上下文元素,以及如何在协程内部访问其上下文。

3.4 Job

  • 定义: Job 是协程上下文中的一个元素,它代表着一个正在运行的协程。
  • 作用: Job 对象可以用来管理协程的生命周期,主要操作包括:
    • start(): 启动协程(如果它是延迟启动的)。
    • join(): 挂起当前协程,直到 Job 完成。
    • cancel(): 取消 Job 的执行。
    • isActive: 检查 Job 是否处于活动状态。
    • 监听 Job 的状态变化。

示例:

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println(“Coroutine: $i …”)
delay(100) // 挂起,提供取消的机会
}
}

delay(500) // 等待一段时间
println("Main: I'm tired of waiting!")
job.cancel() // 取消协程
job.join() // 等待协程取消完成
println("Main: Now I can quit.")

}
“`

输出示例:

Coroutine: 0 ...
Coroutine: 1 ...
Coroutine: 2 ...
Coroutine: 3 ...
Coroutine: 4 ...
Main: I'm tired of waiting!
Main: Now I can quit.

注意,协程的取消是协作式的。这意味着协程需要主动配合取消操作。像 delayyield 等库提供的挂起函数是可取消的挂起函数,它们内部会检查协程的取消状态。如果你在协程中执行计算密集型任务或者阻塞调用,而没有定期检查 isActive 或调用可取消的挂起函数,协程将无法被取消。

3.5 Dispatcher(调度器)

  • 定义: Dispatcher 决定了协程在哪个线程或线程池上执行。它是 CoroutineContext 的一个重要元素。
  • 作用: 将协程的任务分派到合适的线程上执行,确保不会阻塞UI线程,或者在进行大量I/O操作时使用合适的线程池。

Kotlin 协程库提供了几种标准的调度器:

  • Dispatchers.Main:专为UI框架设计(如 Android 的主线程)。协程在这个调度器上启动时,会在UI线程上执行。需要引入特定平台的依赖(如 kotlinx-coroutines-android)。
  • Dispatchers.IO:用于执行阻塞的 I/O 操作(如网络请求、文件读写)。它使用一个按需创建、容量较大的线程池。
  • Dispatchers.Default:用于执行CPU密集型任务。它使用一个共享的后台线程池,线程数默认为CPU核心数。
  • Dispatchers.Unconfined:非受限调度器。协程在哪个线程启动,就在哪个线程执行,直到第一个挂起点。挂起恢复后,会在负责恢复的线程上继续执行。不推荐在大多数场景下使用,因为它可能导致协程在意外的线程上执行。
  • 可以通过 newSingleThreadDispatcher("MyThread")newFixedThreadPoolContext(4, "MyPool") 创建自定义调度器(使用后需要 close() 释放资源)。

示例:

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
// 在 Default 调度器上启动一个协程
launch(Dispatchers.Default) {
println(“Default Coroutine started on ${Thread.currentThread().name}”)
delay(100)
println(“Default Coroutine finished on ${Thread.currentThread().name}”)
}

// 在 IO 调度器上启动一个协程
launch(Dispatchers.IO) {
    println("IO Coroutine started on ${Thread.currentThread().name}")
    delay(100) // delay 是可取消的挂起函数,不会阻塞 IO 线程池
    println("IO Coroutine finished on ${Thread.currentThread().name}")
}

// runBlocking 默认在当前线程(主线程)上运行
println("runBlocking Coroutine started on ${Thread.currentThread().name}")
delay(200)
println("runBlocking Coroutine finished on ${Thread.currentThread().name}")

}
“`

输出示例(线程名可能不同):

runBlocking Coroutine started on main
Default Coroutine started on DefaultDispatcher-worker-1
IO Coroutine started on DefaultDispatcher-worker-2
IO Coroutine finished on DefaultDispatcher-worker-2
Default Coroutine finished on DefaultDispatcher-worker-1
runBlocking Coroutine finished on main

可以看到,不同的协程被分派到了不同的线程池中的线程上执行。runBlocking 运行在主线程,而 launchDefaultIO 的协程则运行在对应的后台线程。

3.6 协程构建器 (launchasync)

协程构建器用于启动新的协程。最常用的有两个:

  • launch:

    • 用途: 启动一个不关心返回值的协程。用于执行“动作”或“副作用”。
    • 返回: 返回一个 Job 对象,可用于管理协程的生命周期(取消、等待完成)。
    • 异常处理: 如果协程内部发生未捕获的异常,会传播给父协程或通过 CoroutineExceptionHandler 处理。
  • async:

    • 用途: 启动一个需要返回值的协程。用于执行“计算”。
    • 返回: 返回一个 Deferred<T> 对象,它是 Job 的一个子类,并且有一个 await() 挂起函数,用于获取协程计算的结果。
    • 异常处理: 异常会在调用 await() 时抛出。

示例:launch 用于并行执行动作

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“Starting tasks…”)

// 启动第一个任务 (无需返回值)
val job1 = launch {
    delay(1000)
    println("Task 1 done")
}

// 启动第二个任务 (无需返回值)
val job2 = launch {
    delay(1500)
    println("Task 2 done")
}

// 等待所有任务完成
job1.join()
job2.join()

println("All tasks finished.")

}
“`

示例:async 用于并行执行计算并获取结果

“`kotlin
import kotlinx.coroutines.
import kotlin.system.

suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

fun main() = runBlocking {
val time = measureTimeMillis {
// 使用 async 并行执行两个耗时计算
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }

    // 使用 await() 获取结果,await() 是挂起函数
    // 如果 async 还没完成,await() 会挂起当前协程等待结果
    println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms") // 总时间应该接近 1000ms,因为是并行执行

}
“`

输出示例:

The answer is 42
Completed in 10xx ms (x取决于具体执行时间)

对比不使用 async 顺序调用 doSomethingUsefulOne()doSomethingUsefulTwo() 的情况,总时间会接近 2000ms。这体现了 async 结合 await 实现并发计算的能力。

3.7 runBlocking

  • 用途: runBlocking 是一个特殊的协程构建器,主要用于连接阻塞世界和协程世界。它启动一个新的协程并阻塞当前线程,直到协程及其所有子协程完成。
  • 场景: 主要用于 main 函数或测试中,不应在协程内部或生产环境中用于阻塞UI线程或后端请求处理线程。
  • 返回: 返回协程执行的结果(如果是表达式主体)。

示例:

“`kotlin
import kotlinx.coroutines.*

fun main() { // 普通的阻塞式 main 函数
println(“Before runBlocking”)

runBlocking { // 启动一个协程,并阻塞 main 线程
    println("Inside runBlocking")
    delay(1000) // 在协程中挂起
    println("Inside runBlocking after delay")
}

println("After runBlocking")

}
“`

输出示例:

Before runBlocking
Inside runBlocking
Inside runBlocking after delay
After runBlocking

可以看到,main 函数在调用 runBlocking 后被阻塞了大约1秒,直到 runBlocking 内部的协程执行完毕。

第四章:结构化并发(Structured Concurrency)

结构化并发是协程设计的一个核心原则,由 CoroutineScopeJob 共同实现。它的核心思想是将协程组织成一个父子层级结构,并确保:

  1. 生命周期关联: 父协程(或 CoroutineScope)的取消会传播到其所有子协程。
  2. 完成等待: 父协程会等待其所有子协程完成。

示例:取消的传播

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val scope = CoroutineScope(Job()) // 创建一个独立的作用域

val job = scope.launch { // 在作用域中启动一个父协程
    launch { // 父协程中启动一个子协程1
        repeat(5) { i ->
            try {
                println("Child 1: $i")
                delay(500) // 挂起点,检查取消
            } finally {
                // finally 块可以用来清理资源
                if (currentCoroutineContext().isActive) {
                    println("Child 1: Still active?") // 不应该打印
                } else {
                     println("Child 1: Cleaning up...")
                }
            }
        }
    }
    launch { // 父协程中启动一个子协程2
        repeat(5) { i ->
            println("Child 2: $i")
            delay(300) // 挂起点,检查取消
        }
    }
}

delay(1000) // 等待一段时间让子协程执行
println("Main: Cancelling scope...")
scope.cancel() // 取消作用域
job.join() // 等待父协程(和其所有子协程)完成取消

println("Main: Scope is cancelled.")

}
“`

输出示例(可能因时序略有差异):

Child 2: 0
Child 1: 0
Child 2: 1
Child 1: 1
Child 2: 2
Main: Cancelling scope...
Child 2: Cleaning up...
Child 1: Cleaning up...
Main: Scope is cancelled.

从输出可以看到,当父作用域(scope)被取消时,其内部的子协程也接收到了取消信号,并执行了清理逻辑。这就是结构化并发带来的好处:你只需要管理父 Job 或 Scope 的生命周期,子协程的生命周期会自动与之关联。

第五章:一个综合示例:并行获取数据

假设我们需要从两个不同的API获取数据,然后将它们组合起来。使用协程和 async 可以很方便地实现并行请求。

“`kotlin
import kotlinx.coroutines.
import kotlin.system.

// 模拟从 API 1 获取数据,这是一个挂起函数
suspend fun fetchDataFromApi1(): String {
println(“Fetching data from API 1…”)
delay(1200) // 模拟网络延迟
println(“Finished fetching from API 1”)
return “Data from API 1”
}

// 模拟从 API 2 获取数据,这是一个挂起函数
suspend fun fetchDataFromApi2(): String {
println(“Fetching data from API 2…”)
delay(800) // 模拟网络延迟
println(“Finished fetching from API 2”)
return “Data from API 2”
}

fun main() = runBlocking {
val time = measureTimeMillis {
println(“Starting parallel data fetch…”)

    // 在 IO 调度器上使用 async 并行启动两个数据获取任务
    val data1Deferred = async(Dispatchers.IO) { fetchDataFromApi1() }
    val data2Deferred = async(Dispatchers.IO) { fetchDataFromApi2() }

    // 等待两个任务都完成,并获取结果
    val data1 = data1Deferred.await()
    val data2 = data2Deferred.await()

    println("Combining results: $data1 and $data2")
}
println("Total time taken: $time ms")

}
“`

输出示例:

Starting parallel data fetch...
Fetching data from API 1...
Fetching data from API 2...
Finished fetching from API 2
Finished fetching from API 1
Combining results: Data from API 1 and Data from API 2
Total time taken: 12xx ms (取决于具体执行时间,接近最长的那个延迟)

这个例子清晰地展示了:

  1. 如何使用 async 启动需要返回值的并行任务。
  2. 如何使用 await() 挂起当前协程,等待并行任务的结果。
  3. 如何使用 Dispatchers.IO 将I/O密集型任务分派到合适的线程池。
  4. 整个过程用 runBlocking 包裹,使其可以在 main 函数中运行并等待协程完成。
  5. 通过 measureTimeMillis 可以看到,总耗时接近两个任务中最长的一个,证明了任务是并行执行的。

第六章:常见误区与使用建议

  1. 不要滥用 GlobalScope GlobalScope 启动的协程生命周期不受控,容易导致内存泄漏和意外行为。总是优先使用结构化并发,通过 CoroutineScope 或已有的作用域(如 lifecycleScope, viewModelScope, runBlocking 内部)启动协程。
  2. suspend 不等于在后台线程运行: suspend 只是标记函数可以被挂起/恢复,它本身不指定在哪里执行。具体的执行线程由 Dispatcher 决定。一个协程可以在多个不同的线程之间跳转。
  3. 理解阻塞与挂起:
    • 阻塞(Blocking): 使当前线程停止执行,直到任务完成。会浪费线程资源。例如 Thread.sleep()
    • 挂起(Suspending): 暂停协程的执行,但不阻塞线程。线程可以去做其他工作。例如 delay()
  4. 协程取消是协作式的: 你的协程代码内部需要通过调用可取消的挂起函数(如 delay, yield, withContext 等)或定期检查 isActive 来响应取消信号。长时间运行的计算循环需要显式检查 isActive
  5. runBlocking 的适用场景: 仅用于 main 函数、单元测试或需要桥接阻塞代码和协程代码的特定场景。绝不应该在应用的UI层或处理并发请求的后端服务中使用 runBlocking,它会阻塞负责的线程。
  6. 正确选择 Dispatcher 根据任务类型选择合适的调度器。I/O 密集型用 Dispatchers.IO,CPU 密集型用 Dispatchers.Default,UI 更新用 Dispatchers.Main
  7. 异常处理:launch 中,未捕获的异常会传播;在 async 中,异常会在调用 await() 时抛出。了解协程的异常传播规则对于编写健壮的代码至关重要(基础篇不深入,但需知晓)。

总结

Kotlin 协程提供了一种强大而优雅的方式来处理异步和并发编程。通过掌握以下核心概念,你就能迈出协程之旅坚实的第一步:

  • suspend 函数: 可挂起而不阻塞线程的代码块。
  • CoroutineScope 定义协程的生命周期和结构化并发。
  • CoroutineContext 协程的配置信息,包含 JobDispatcher 等。
  • Job 协程的句柄,用于管理生命周期和取消。
  • Dispatcher 决定协程在哪执行(哪个线程/线程池)。
  • launchasync 启动协程的构建器,分别用于无需结果的动作和需要结果的计算。
  • 结构化并发: 利用 CoroutineScopeJob 实现父子协程的生命周期管理和取消传播。
  • 挂起 vs 阻塞: 协程的核心在于挂起而非阻塞。

协程的强大远不止于此,还有通道(Channels)、流(Flows)、选择(Select)等高级概念,以及详细的异常处理和测试策略。但理解并熟练运用本文介绍的基础知识,是你深入协程世界的基础。

现在,抛开复杂的回调和难管的线程吧,用 Kotlin 协程写出更清晰、更简洁、更高效的异步代码!

希望这篇长文能帮助你彻底搞懂 Kotlin 协程的基础!祝你编码愉快!


发表评论

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

滚动至顶部