协程上下文:CoroutineContext、调度器、作用域与结构化并发

协程上下文,说白了就是协程运行时的「环境配置包」。我刚开始学 Kotlin 协程时,总觉得这玩意儿有点抽象。后来在项目里踩了几次坑,才真正理解它有多重要。

今天咱们就把 CoroutineContext、Dispatchers、协程作用域和结构化并发这几个概念串起来讲。你会发现,它们其实是一套完整的设计哲学。

一、CoroutineContext:协程的「身份证」

每个协程都携带一个 CoroutineContext。它像是一个不可变的 Map,里面存着协程运行所需的各种元素。常见的元素有:

  • Job:控制协程的生命周期
  • CoroutineDispatcher:决定协程跑在哪个线程
  • CoroutineName:给协程起个名字,方便调试
  • CoroutineExceptionHandler:处理未捕获的异常

我个人习惯用 + 运算符来组合这些元素,就像搭积木一样:

val context = Job() + Dispatchers.IO + CoroutineName("MyCoroutine")
launch(context) {
    // 协程体
}

这里要注意,+ 右边的元素会覆盖左边的同名元素。比如你写了两个 Dispatchers,后面的会生效。

小技巧:调试时给协程起个名字,能省不少事。我在排查线上问题时,经常靠 CoroutineName 快速定位是哪个协程出了问题。

二、Dispatchers:协程的「交通工具」

调度器决定了协程跑在哪个线程池上。Kotlin 内置了四种调度器:

调度器 用途 线程数
Dispatchers.Main UI 操作(Android、Swing 等) 1(主线程)
Dispatchers.IO 网络请求、文件读写 64 或更多
Dispatchers.Default CPU 密集型计算 等于 CPU 核心数
Dispatchers.Unconfined 不指定线程(极少用) 调用者线程

嗯,这里有个坑。我曾经在项目里用 Dispatchers.Default 做网络请求,结果发现并发量上不去。后来才意识到,Default 的线程数受 CPU 核心数限制,而 IO 操作应该用 Dispatchers.IO,它的线程池更大。

// 错误示范:用 Default 做网络请求
launch(Dispatchers.Default) {
    val result = api.fetchData() // 阻塞等待,浪费 CPU 线程
}

// 正确做法:用 IO 调度器
launch(Dispatchers.IO) {
    val result = api.fetchData() // 合理利用 IO 线程池
}
避坑指南:我曾经在 Android 项目里用 withContext(Dispatchers.IO) 更新 UI,结果直接崩溃。记住:UI 操作必须切回 Main 调度器。

三、协程作用域:协程的「活动范围」

协程不能凭空启动,它必须在一个作用域内。作用域定义了协程的生命周期边界。常见的几个作用域:

  • GlobalScope:全局作用域,进程级别。不推荐日常使用,容易造成泄漏。
  • lifecycleScope:Android 中绑定 Activity/Fragment 生命周期。
  • viewModelScope:Android 中绑定 ViewModel 生命周期。
  • 自定义 CoroutineScope:最灵活的方式。

为什么要有作用域?你想想看,如果每个协程都像野马一样不受控制,那内存泄漏、任务泄漏就是家常便饭。作用域就是给协程套上缰绳。

class MyViewModel : ViewModel() {
    // 自定义作用域,绑定到 ViewModel 生命周期
    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    
    fun loadData() {
        scope.launch {
            // 协程体
        }
    }
    
    override fun onCleared() {
        super.onCleared()
        scope.cancel() // 页面销毁时取消所有协程
    }
}
核心原则:每个协程都应该有一个明确的作用域。不要用 GlobalScope,除非你真的知道自己在做什么。

四、结构化并发:协程的「组织纪律」

结构化并发是 Kotlin 协程最优雅的设计之一。它的核心思想是:父协程管理子协程的生命周期

举个例子:

scope.launch { // 父协程
    launch { // 子协程 1
        delay(1000)
        println("子协程 1 完成")
    }
    launch { // 子协程 2
        delay(2000)
        println("子协程 2 完成")
    }
    println("父协程等待子协程...")
}

这里有个关键点:父协程会等待所有子协程完成,然后自己才结束。如果父协程被取消,所有子协程也会被递归取消。这就是结构化并发的威力——一个取消,全部取消

我记得有一次在重构代码时,发现某个页面退出后后台还在跑网络请求。排查了半天,发现是用了 GlobalScope.launch。改成结构化并发后,问题迎刃而解。

实战建议:尽量使用 coroutineScopesupervisorScope 来创建作用域。前者在子协程失败时会取消所有兄弟协程,后者则不会。根据业务场景选择合适的策略。

五、知识体系总览

下面这张图总结了本章的核心概念和它们之间的关系:

CoroutineContext Job Dispatcher CoroutineName ExceptionHandler CoroutineScope 结构化并发 父协程管理子协程生命周期 · 取消传播 · 异常传播

从图中可以看出,CoroutineContext 是协程的配置中心,它包含 Job、Dispatcher 等元素。而 CoroutineScope 则负责管理协程的生命周期,结构化并发则是作用域的具体实现机制。

六、实战中的选择策略

在实际项目中,我一般遵循这几个原则:

  1. UI 层:使用 Android 自带的 lifecycleScope 或 viewModelScope,自动绑定生命周期。
  2. 业务层:自定义 CoroutineScope,传入 SupervisorJob + Dispatchers.IO,手动管理取消。
  3. 工具类:使用 coroutineScope { } 创建临时作用域,确保任务完成后自动清理。
  4. 全局任务:尽量避免。如果确实需要(比如日志上报),用 GlobalScope 但要格外小心。
一句话总结:CoroutineContext 是协程的配置,Dispatcher 是协程的线程策略,作用域是协程的边界,结构化并发是协程的组织方式。四者合一,构成了 Kotlin 协程的完整生态。

好了,关于协程上下文的核心内容就讲到这里。记住,理解这些概念最好的方式就是动手写代码。下次遇到协程泄漏或线程问题,不妨先从这几个角度排查一下。


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