26、协程与协程的协程:协程的嵌套与递归、协程的递归遍历、协程的树形结构管理、协程的复杂依赖关系处理

协程嵌套,说白了就是「协程里面开协程」。这听起来简单,但实际用起来坑不少。我最早接触这个场景,是在做一个多级评论列表的加载——每个评论下面还有子评论,子评论下面还有子评论。嗯,这就是典型的树形结构。

你想想看,如果每个节点都开一个协程去加载数据,那这些协程之间是什么关系?是并行还是串行?父协程要不要等子协程?子协程失败了父协程怎么办?这些问题,就是本章要聊的核心。

协程的嵌套:结构化并发的基石

Kotlin 协程的嵌套,不是简单的「在 launch 里再 launch 一个」。它背后有一套完整的结构化并发规则。我习惯把协程嵌套分成两种:父子嵌套兄弟嵌套

嵌套类型 典型写法 行为特征
父子嵌套 coroutineScope { launch { ... } } 父协程等待所有子协程完成;子协程异常会取消父协程
兄弟嵌套 coroutineScope { launch { ... }; launch { ... } } 兄弟协程并行执行;任一失败,整个 scope 取消

这里有个关键点:父子嵌套的取消传播是单向的。父协程取消,子协程一定取消。但子协程取消,父协程不一定取消——除非子协程抛出了未捕获的异常。

核心原则:结构化并发要求每个协程都有明确的「父-子」关系,且生命周期绑定。不要用 GlobalScope 去创建「孤儿协程」。

协程的递归:自己调用自己

递归在协程里怎么玩?说白了就是「协程函数调用自身」。但这里有个陷阱——每次递归调用都会创建一个新的协程上下文。如果递归深度太大,栈会爆掉吗?

我遇到过这样一个场景:需要递归遍历一个深度未知的文件夹结构,每个文件夹都开一个协程去读取子文件。结果递归到第 50 层时,直接 OOM 了。

为什么会这样?因为每次递归都创建了一个新的协程,这些协程的上下文对象堆积在内存里。解决方案其实很简单:用挂起函数代替递归协程

// ❌ 错误示范:每次递归都创建新协程
suspend fun processFolder(path: String) = withContext(Dispatchers.IO) {
    val files = File(path).listFiles() ?: return@withContext
    for (file in files) {
        if (file.isDirectory) {
            launch { processFolder(file.path) }  // 递归创建协程
        }
    }
}

// ✅ 正确做法:用挂起函数递归,不创建新协程
suspend fun processFolderSafe(path: String) = withContext(Dispatchers.IO) {
    val files = File(path).listFiles() ?: return@withContext
    for (file in files) {
        if (file.isDirectory) {
            processFolderSafe(file.path)  // 直接挂起递归
        }
    }
}

我的经验:递归协程的深度建议控制在 20 层以内。如果超过这个深度,考虑改用迭代 + 栈结构。我曾经在项目里用 ArrayDeque 模拟递归,效果很好。

协程的递归遍历:树形结构怎么走?

树形结构的遍历,在协程世界里有个经典场景:多级评论的异步加载。每个节点需要从网络或数据库加载数据,而且子节点的加载依赖父节点的结果。

我习惯把这种遍历分成两种模式:

  • 深度优先:先加载完一个分支的所有子节点,再加载下一个分支。适合「依赖链」明确的场景。
  • 广度优先:先加载所有同层节点,再加载下一层。适合「层级统计」场景。
// 深度优先的递归遍历
suspend fun dfsLoadComments(commentId: String): List<Comment> = withContext(Dispatchers.IO) {
    val children = api.loadChildren(commentId)  // 挂起加载子评论
    val result = mutableListOf<Comment>()
    for (child in children) {
        result.add(child)
        result.addAll(dfsLoadComments(child.id))  // 递归加载孙子评论
    }
    result
}

// 广度优先的层级遍历
suspend fun bfsLoadComments(rootId: String): List<Comment> = withContext(Dispatchers.IO) {
    val queue = ArrayDeque<String>()
    queue.add(rootId)
    val result = mutableListOf<Comment>()
    while (queue.isNotEmpty()) {
        val currentId = queue.removeFirst()
        val children = api.loadChildren(currentId)
        result.addAll(children)
        queue.addAll(children.map { it.id })
    }
    result
}

注意:广度优先遍历如果使用协程并行加载同层节点,要控制并发数。我曾经用 Semaphore 限制同时加载的节点数不超过 5 个,否则网络请求会打满线程池。

协程的树形结构管理

树形结构的管理,核心是「父子协程的生命周期绑定」。每个节点对应一个协程,父节点取消时,所有子节点自动取消。这听起来很美好,但实际项目中往往需要「部分取消」——比如用户只关闭了某个分支,其他分支保持打开。

我常用的方案是:每个节点维护自己的 Job,通过 Job 的 children 属性来管理子协程

class TreeNode(val id: String, val scope: CoroutineScope) {
    private val children = mutableMapOf<String, TreeNode>()
    private var nodeJob: Job? = null

    fun addChild(childId: String): TreeNode {
        val childNode = TreeNode(childId, scope)
        children[childId] = childNode
        return childNode
    }

    fun removeChild(childId: String) {
        children[childId]?.cancel()  // 取消子节点及其所有后代
        children.remove(childId)
    }

    fun cancel() {
        nodeJob?.cancel()
        children.values.forEach { it.cancel() }
    }

    fun launchNode(block: suspend CoroutineScope.() -> Unit) {
        nodeJob = scope.launch { block() }
    }
}

这个结构的好处是:你可以精确控制「取消到哪一层」。比如用户点击「收起评论」,你只需要取消当前节点的子协程,父节点和其他兄弟节点不受影响。

避坑指南:我曾经在树形结构里用 SupervisorJob,结果子协程失败了父协程不取消,导致「僵尸节点」一直占用内存。后来我改成每个节点独立 Job,手动管理取消链,才解决问题。

协程的复杂依赖关系处理

复杂依赖关系,说白了就是「A 等 B,B 等 C,C 又等 A」这种场景。在协程里,这种依赖关系可以用 async/await 或者 Channel 来处理。

我遇到过最复杂的依赖关系,是一个「多级表单校验」场景:

  • 字段 A 的校验依赖字段 B 的结果
  • 字段 B 的校验依赖字段 C 的结果
  • 字段 C 的校验又依赖字段 A 的结果(循环依赖)

这种循环依赖,说白了就是设计有问题。但在遗留系统里,你不得不处理它。我的方案是:用 Channel 做事件驱动,打破循环

// 用 Channel 处理循环依赖
suspend fun resolveCircularDependency() = coroutineScope {
    val channel = Channel<String>(Channel.UNLIMITED)

    launch {
        val resultA = computeA(channel.receive())  // 等待 B 的结果
        channel.send("A done: $resultA")
    }

    launch {
        val resultB = computeB(channel.receive())  // 等待 C 的结果
        channel.send("B done: $resultB")
    }

    launch {
        val resultC = computeC(channel.receive())  // 等待 A 的结果
        channel.send("C done: $resultC")
    }
}

这个方案的核心思路是:把「依赖」变成「事件」。每个节点不直接等待另一个节点,而是通过 Channel 广播自己的结果。谁需要这个结果,谁就去 Channel 里取。

我的建议:复杂依赖关系尽量用「有向无环图」来建模。如果发现循环依赖,优先重构业务逻辑,而不是用技术手段去绕。实在绕不过去,再用 Channel 或 StateFlow 做事件驱动。

知识体系总览

下面这张图,是我对本章知识体系的总结。你可以把它当作一个「思维导图」来用:

协程嵌套与递归 父子嵌套 生命周期绑定 取消传播 递归遍历 深度优先 广度优先 树形结构管理 节点 Job 管理 部分取消 复杂依赖关系 async/await Channel 事件驱动 核心:结构化并发 + 生命周期管理 + 事件驱动

这张图把本章的四个核心知识点串起来了。你从中心出发,沿着四条分支往下看,就能理清协程嵌套、递归遍历、树形管理和依赖关系之间的逻辑关系。

最后说一句:协程的嵌套和递归,本质上是对「异步树」的管理。你只要记住「每个协程都有爹,爹管儿子,儿子管孙子」这个朴素道理,大部分问题都能迎刃而解。


公众号:蓝海资料掘金营,微信deep3321