深入理解 Kotlin 协程 (一):万物始于异步与回调

0 阅读7分钟

什么是异步?

我们先来看看另一个概念——同步 (Synchronous)。同步指的是程序按照指令的执行顺序逐条执行。例如下图中,指令执行顺序是 A -> B -> C

flowchart LR
    S(Start) --> A[A] --> B[B] --> C[C] --> E(End)

异步 (Asynchronous) 的情况则是反过来。例如下图中,执行顺序可能为:A -> C -> B -> D,也可能是:A -> B -> C -> D,或者是 A -> B -> D -> C

flowchart LR 
    Start(Start) --> A 
    A --> B & C 
    B -.-> D 
    D -.-> C 
    C --> End(End) 
    
    A[A] 
    B[B] 
    C[C] 
    D[D]

区分两者的关键在于:执行任务时,是否阻塞了当前流程。

前置模拟代码

为了能够直接运行后续的示例代码,我们先给出模拟代码:

// 模拟业务实体和空对象占位符
data class UserProfile(val name: String, val role: String)

val EMPTY_USER_PROFILE = UserProfile("Empty", "None")

// 模拟耗时的网络请求
fun fetchFromServer(userId: String): UserProfile {
    if (userId.isBlank()) throw IllegalArgumentException("userId 不能为空")
    println("[${Thread.currentThread().name}] 开始从网络获取 $userId 的信息...")
    Thread.sleep(1000)
    return UserProfile("用户_$userId", "Android Developer")
}

// 模拟内存缓存
object MemoryCache {
    private val cache = mutableMapOf<String, UserProfile>()
    fun get(userId: String) = cache[userId]
    fun put(userId: String, profile: UserProfile) {
        cache[userId] = profile
    }
}

// 统一的展示与错误处理
fun showUserProfile(profile: UserProfile) = println("展示用户信息: $profile")
fun showError(t: Throwable) = println("出现错误: ${t.message}")

异步与回调

异步任务执行后,我们通常会通过回调来通知调用者,返回执行结果。例如:

fun main() {
    val callback: (Int) -> Unit = {
        println("The random number is: $it")
    }
    generateRandom(callback)
}

fun generateRandom(callback: (Int) -> Unit) {
    thread {
        val randomNumber = (0..100).random()
        Thread.sleep(2000)
        callback(randomNumber)
    }
}

运行结果为:The random number is: 5

然而当上述的回调不断嵌套时,就会出现人尽皆知的“回调地狱 (Callback Hell)”。

fun main() {
    validateUser { isValid ->
        println("User is valid: $isValid")
        getUserInfo(isValid) { userInfo ->
            println("User info: $userInfo")
            generateRandom { randomNumber ->
                println("Random number for user: $randomNumber")
                saveResult(randomNumber) { success ->
                    println("Saved result success: $success")
                }
            }
        }
    }
}

// 模拟验证用户
fun validateUser(callback: (Boolean) -> Unit) {
    thread {
        println("Thread: Validating user...")
        Thread.sleep(1000)
        callback(true)
    }
}

// 模拟获取用户信息
fun getUserInfo(isValid: Boolean, callback: (String) -> Unit) {
    thread {
        if (!isValid) {
            callback("Unknown user info")
            return@thread
        }
        println("Thread: Getting info...")
        Thread.sleep(1000)
        callback("Android Developer")
    }
}

// 生成用户随机数
fun generateRandom(callback: (Int) -> Unit) {
    thread {
        println("Thread: Generating random...")
        Thread.sleep(1000)
        val randomNumber = (0..100).random()
        callback(randomNumber)
    }
}

// 模拟保存结果
fun saveResult(number: Int, callback: (Boolean) -> Unit) {
    thread {
        println("Thread: Saving result $number...")
        Thread.sleep(1000)
        callback(true)
    }
}

因此,对于复杂的异步事件交互,我们常常会引入 EventBus 框架或是生产–消费者模型来破解上述的回调地狱,并解除代码之间的强耦合,一环不再扣着一环,一处的改动将不会影响到后续的流程。

异步结果的两种路径

因为异步调用不是立马返回的,所以结果返回通常会有两条路径:

  • 结果已准备好,立即返回。
  • 结果还未准备,执行异步任务,等准备好后,通过回调传给调用方。
// 场景:异步加载用户资料
fun asyncUserProfile(
    userId: String,
    callback: (UserProfile) -> Unit
): UserProfile? {
    return when (val profile = MemoryCache.get(userId)) {
        null -> {
            // 路径A:缓存未命中,开启异步网络请求
            thread {
                fetchFromServer(userId)
                    .also { MemoryCache.put(userId, it) } // 存入缓存
                    .also(callback) // 回调通知调用者
            }
            null // 立即返回null,表示数据需要等待
        }

        else -> profile // 路径B:缓存命中,立即返回数据
    }
}

fun main() {
    // 调用示例
    val userProfile = asyncUserProfile("user123") {
        showUserProfile(it) // 1. 异步请求的回调
    }
    if (userProfile != null) {
        showUserProfile(userProfile) // 2. 直接同步返回
    }
}

两种路径的差异在于,如果结果立即返回,那么会在当前线程执行;如果是异步回调,则不清楚所处的线程。例如在上述代码中,回调是在后台的 thread 中直接执行的。在实际开发中,方法内部可能会借助消息机制(如 Android 中的 Looper、Handler),将回调代码放到特定的目标线程(通常是主线程)来执行。

在实际开发中,我们通常不会这么设计 API,因为这样反而增加了程序的复杂度(调用方需要同时处理两种情况)。大家更希望两种情况的结果都以回调返回。不过,如果能够借助编译器来简化这段逻辑的书写,倒是一种很不错的思路。

Kotlin 协程的挂起函数本质上就采用了这种思路: 如果挂起,就会返回挂起标记,外部会进行判断:如果是挂起标记,则等待其回调;不是挂起标记,则获取需要的结果接着向下执行。

异常处理的痛点

对于异步逻辑来说,在外部使用 try...catch 语句并不能捕获异步流程的异常,因为异步逻辑是回调形式。所以,我们必须在回调中进行捕获,将捕获到的异常,通过回调传出。

fun asyncUserProfile(
    userId: String,
    onSuccess: (UserProfile) -> Unit,
    onError: (Throwable) -> Unit,
) {
    if (userId.isBlank()) {
        throw IllegalArgumentException("启动前置校验失败:userId 不能为空")
    }

    thread {
        try {
            fetchFromServer(userId).also(onSuccess)
        } catch (e: Exception) {
            onError(e)
        }
    }
}

在调用处,我们可以对异步异常进行处理。除此之外,对于启动异步任务时抛出的外层异常,我们还需再次进行捕获。完整的异常捕获和处理如下所示:

fun main() {
    try {
        val userId = "" // 模拟一个会导致异常的空 ID
        asyncUserProfile(
            userId = userId,
            onSuccess = { profile -> showUserProfile(profile) },
            onError = { error -> showError(error) }
        )
    } catch (e: Exception) {
        showError(e)
    }
}

仔细观察这段异常捕获逻辑,你就会发现异步编程中很难受的一点:外层函数启动时的异常会进入到外层的 catch,而异步线程中发生的网络异常,需要专门捕获并通过 onError 回调传出。

想象一下真实场景中,你的异常处理逻辑被分为了好几块,并且同一个错误的处理函数要被调用两次,这不仅可读性低,还很容易漏掉 catch。

如果我们能把这两处异常的处理进行合并,将异步逻辑“同步化”,那么异常处理会变得很容易,直接使用 try...catch 即可包裹住所有的执行流、捕获所有异常。而这,正是 Kotlin 协程要解决的核心痛点之一。

取消响应

除了异常,我们还希望异步任务在我们想要停止的时候,能够立即被收回。

以获取用户信息为例,将其改造为可取消的获取:

fun asyncUserProfileCancellable(
    userId: String,
    onSuccess: (UserProfile) -> Unit,
    onError: (Throwable) -> Unit
): Thread = thread {
    try {
        fetchFromServerCancellable(userId).also(onSuccess)
    } catch (e: Exception) {
        onError(e)
    }
}

fun fetchFromServerCancellable(userId: String): UserProfile {
    println("[${Thread.currentThread().name}] 开始加载可取消的数据...")

    for (i in 1..5) {
        // 关键点:在耗时循环中,检查线程中断状态
        if (Thread.interrupted()) {
            throw InterruptedException("Profile fetch cancelled. 任务被主动取消。")
        }

        Thread.sleep(200)
        println("数据加载进度: ${i * 20}%")
    }

    return UserProfile("用户_$userId", "Android Developer")
}

如果我们要取消此次用户信息的获取,只需调用线程的 interrupt() 函数即可。可以看到,线程的取消需要线程内部进行配合支持。如果线程内部不配合(比如不检查中断标志),我们只好等它自己结束。

当然,你可以调用 stop() 函数来强制停止线程,但这种粗暴的停止方式早被废弃了。

原因:

  1. 造成资源泄露:线程被停止时,来不及释放持有的系统资源。
  2. 数据不一致问题:终止线程会立即释放其持有的所有锁,可能会让其他线程读到错误的“脏数据”。

复杂分支与多任务协同

如果要一次执行多个异步任务,并整合他们的结果,我们就需要用到一些同步工具,例如 CountDownLatch

// 定义倒计时门栓
val uids = listOf("user1", "user2", "user3")
val countDownLatch = CountDownLatch(uids.size)
// ConcurrentHashMap 中的 value 不能为空
val map = uids.associateWithTo(ConcurrentHashMap<String, UserProfile>()) { EMPTY_USER_PROFILE }

uids.forEach { uid ->
    asyncUserProfile(
        userId = uid,
        onSuccess = {
            map[uid] = it
            countDownLatch.countDown()
        }, onError = {
            showError(it)
            countDownLatch.countDown()
        }
    )
}

countDownLatch.await() // 阻塞等待所有回调完成
val userProfiles = map.values
userProfiles.forEach {
    showUserProfile(it)
}

countDownLatch.await() 这行代码会阻塞当前线程,等待所有异步请求的回调都执行完,这样我们就可以拿到所有的请求结果了。但这类并发工具应该谨慎使用,因为很容易因忘记调用 countDown(),导致 await() 无休止地阻塞线程。

常见的异步设计思路演进

上述遇到的异步痛点:结果获取、异常处理、取消响应、多任务循环,早有许多解决手段。核心思想都是通过整合异步回调流程和主流程,让代码看起来像同步调用,从而降低异步逻辑复杂度。

1. Future

Future 是 JDK 1.5 引入的接口,使用其 get() 方法能够同步阻塞式地获取异步结果。

fun userProfileFuture(userId: String): Future<UserProfile> {
    val ioExecutor = Executors.newCachedThreadPool()
    return ioExecutor.submit(Callable {
        fetchFromServer(userId)
    })
}

对于之前需要使用 CountDownLatch 才能实现的循环逻辑,现在只需这样:

val uids = listOf("user1", "user2", "user3")
val userProfiles = uids.map {
    userProfileFuture(it)
}.map {
    it.get()
}
userProfiles.forEach {
    showUserProfile(it)
}

可以看到代码逻辑更加清晰了,结果的获取顺序也能保持一致。但这样还是会阻塞当前执行流程,我们需要同步等待结果,这违反了异步不阻塞当前流程的初衷。

2. CompletableFuture

为了解决阻塞问题,JDK 1.8 新增了 CompletableFuture,它实现了 Future 接口,并提供了强大的链式调用 API。

fun userProfileCompletableFuture(userId: String): CompletableFuture<UserProfile> =
    CompletableFuture.supplyAsync {
        fetchFromServer(userId)
    }


fun main() {
    val uids = listOf("user1", "user2", "user3")
    uids.map {
        userProfileCompletableFuture(it)
    }.let { futureList ->
        CompletableFuture.allOf(*futureList.toTypedArray()).thenApply {
            futureList.map { it.get() }
        }
    }.thenAccept { profiles ->
        profiles.forEach(::showUserProfile)
    }

    Thread.sleep(3000)
}

其中 thenAccept 回调只会在获取到结果后才执行。与 Future 不同的是,这里的 get() 调用处于 CompletableFuture 提供的异步环境中,因此不会阻塞主流程。

CompletableFuture 很好用,但它还是没能解决根本问题:大量的嵌套和链式调用,使得结果的获取脱离了主流程。

3. 响应式编程 (RxJava)

响应式编程主要关注的是数据流的变换和流转,更注重描述数据输入和输出之间的关系。

在 RxJava 中,我们能够轻松实现上述逻辑:

Observable.fromIterable(uids)
    .map { fetchFromServer(it) }
    .subscribeOn(Schedulers.io()) // 切到 IO 线程执行
    .observeOn(AndroidSchedulers.mainThread()) // 切回主线程更新UI
    .subscribe(
        { profile -> showUserProfile(profile) },
        { error -> showError(error) }
    )

RxJava 提供了丰富的数据变换操作符,并且可以随意切换线程调度器。因其过于强大,所以一度被开发者滥用,变为线程切换的工具。

4. Promise 与 async/await

CompletableFuture 其实实现了 CompletionStage 接口,它从定义和功能上来看,就像一个 Promise。

Promise 是 JS 语言中的异步任务模型,存在着挂起、完成、拒绝三种状态。当其处于完成状态时,可以调用 then 回调来获取结果;出现异常拒绝时,可以通过 catch 来捕获异常。

Promise.all(uids.map(id => userProfilePromise(id)))
  .then(userProfiles => console.log(userProfiles))
  .catch(e => console.error(e))

通过引入 async/await 关键字,上述代码可以进一步简化为:

async function main() {
    try {
      const userProfiles = await Promise.all(uids.map(id => userProfilePromise(id)))
      console.log(userProfiles)
    } catch (e) {
      console.error(e)
    } 
}

加上 await 这个语法糖后,我们就可以省略之前的 thencatch 调用,将代码完全转换为同步调用的形式

这个设计完美兼顾了异步任务的非阻塞执行同步语法的线性结构这两个需求,我们可以使用写同步代码的思维,来写异步非阻塞代码。

很多语言(如 JS、C#、Python)为了实现这个能力,专门引入了 asyncawait 这两个关键字,而 Kotlin 采用了更好的设计。

Kotlin 协程:终极魔法

Kotlin 协程(Coroutines)专为简化异步设计而生,它只用了一个 suspend 关键字就实现了相同的能力。

suspend 表示挂起点,而这个挂起点包含了异步调用回调两层含义。

我们在这个挂起点可以进行异步回调、添加调度器处理线程切换以及作为协程取消响应的检测位置等。

加上了 suspend 的函数即为挂起函数(suspend function),表示该函数支持同步形式的异步调用。注意:所有的挂起函数只能在其他挂起函数或是协程作用域中调用。

suspend fun userProfileSuspendable(userId: String) =
    suspendCoroutine { continuation ->
        thread {
            try {
                // 对应 Promise 的 resolve (onSuccess)
                continuation.resume(fetchFromServer(userId))
            } catch (e: Exception) {
                // 对应 Promise 的 reject (onError)
                continuation.resumeWithException(e)
            }
        }
    }

suspendCoroutine 函数的回调会提供一个 Continuation 实例,它在底层负责保存和恢复挂起状态。

调用示例:

suspend fun main() {
    try {
        // 直接等号赋值,没有回调
        val userProfile = userProfileSuspendable("user123")
        showUserProfile(userProfile)
    } catch (e: Exception) {
        // 异步线程里的异常,进入到了这里的 catch 块中
        showError(e)
    }
}

可以看出,suspend 关键字“分饰两角”:在声明函数类型时,它充当了 Promise 中的 async;在调用挂起函数时,它又静默充当了 Promise 中的 await

至此,异步逻辑就完美地“同步化”了。