第一章:协程初探——从“轻量级线程”说起

说实话,我第一次接触协程这个概念时,脑子里冒出的第一个念头就是:“这不就是线程的包装吗?”

后来踩了不少坑,才真正理解——协程和线程,压根儿就不是一个层面的东西。今天咱们就从最基础的地方聊起,把协程的来龙去脉理清楚。

1.1 什么是协程?

协程,英文叫 Coroutine。拆开来看,“Co”是协作,“Routine”是例程。合起来就是“协作式例程”。

什么意思呢?

你想想看,线程是由操作系统调度的,它什么时候执行、什么时候暂停,你控制不了。但协程不一样——协程的切换是程序自己控制的,说白了就是“我主动让出 CPU,而不是被操作系统抢走”。

举个例子:

// 这是一个简单的协程
fun main() = runBlocking {
    launch {
        println("协程开始工作")
        delay(1000)  // 挂起,但不阻塞线程
        println("协程恢复执行")
    }
    println("主线程继续做其他事")
}

你看,delayThread.sleep 最大的区别是什么?delay 只是挂起了协程,线程本身并没有被阻塞。这意味着,同一个线程可以同时运行成千上万个协程。

核心理解:协程是运行在线程上的“轻量级任务”。一个线程可以承载成千上万个协程,而协程的切换几乎零开销。

1.2 协程与线程的区别

我记得刚带团队时,有个同事问我:“既然线程也能做异步,为什么还要用协程?”

这个问题问得好。咱们直接上对比:

对比维度 线程 协程
调度方式 操作系统抢占式调度 程序自身协作式调度
创建开销 较大(约 1MB 栈空间) 极小(约几 KB)
切换成本 涉及内核态切换,成本高 用户态切换,几乎零成本
并发数量 几百个就吃力 轻松上万
阻塞行为 阻塞线程 挂起协程,不阻塞线程

说白了,线程是“重量级选手”,协程是“轻量级选手”。

我在项目中遇到过这样一个场景:一个直播 App 需要同时拉取 500 个房间的在线人数。如果用线程,光创建线程的开销就能把手机搞卡。换成协程后,一个线程就搞定了所有请求,流畅度提升非常明显。

个人习惯:我一般这样区分——如果你需要并行计算(利用多核 CPU),用线程;如果你需要高并发 I/O(网络请求、数据库读写),用协程。

1.3 Kotlin 协程的核心概念

好,现在咱们来聊聊 Kotlin 协程的三个核心概念:Job、CoroutineScope、CoroutineContext。

这三个东西,你刚开始可能会觉得抽象。别急,咱们一个一个拆。

1.3.1 Job —— 协程的“身份证”

Job 代表一个协程任务。你可以用它来:

  • 检查协程是否完成
  • 取消协程
  • 等待协程执行完毕
val job = launch {
    repeat(100) { i ->
        println("任务执行中: $i")
        delay(500)
    }
}

// 5 秒后取消任务
delay(5000)
job.cancel()  // 取消协程
job.join()    // 等待协程真正结束

这里有个坑,我曾经踩过:cancel() 只是发出取消信号,协程并不会立刻停止。它需要在下一次挂起点(比如 delaywithContext)时才会响应取消。

避坑指南:我曾经在项目里直接调了 cancel() 就以为万事大吉,结果协程里的资源一直没释放。后来才发现,必须配合 join() 或者使用 cancelAndJoin() 才能确保协程完全结束。

1.3.2 CoroutineScope —— 协程的“容器”

CoroutineScope 定义了协程的“生命周期”。

你想想看,每个协程都需要一个“老板”来管理它。这个老板就是 CoroutineScope。当老板被销毁时,它手下的所有协程都会被自动取消。

class MyActivity : AppCompatActivity() {
    // 创建一个与 Activity 生命周期绑定的作用域
    private val scope = MainScope()

    fun loadData() {
        scope.launch {
            // 在这里启动协程
            val data = fetchData()
            updateUI(data)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()  // 销毁所有协程,防止内存泄漏
    }
}

我个人习惯在 Android 项目里用 lifecycleScopeviewModelScope,它们已经帮我们绑定了生命周期,省心不少。

1.3.3 CoroutineContext —— 协程的“配置清单”

CoroutineContext 是协程的上下文,它包含了协程运行所需的各种配置信息。说白了,就是告诉协程“在哪个线程跑”、“出错了怎么办”、“叫什么名字”等等。

它主要由以下几个元素组成:

  • Dispatcher:指定协程在哪个线程上执行
  • Job:协程任务本身
  • CoroutineName:给协程起个名字,方便调试
  • CoroutineExceptionHandler:异常处理器
// 创建一个自定义的上下文
val customContext = Dispatchers.IO + 
                   CoroutineName("MyCoroutine") + 
                   CoroutineExceptionHandler { _, e -> 
                       println("出错了: ${e.message}")
                   }

scope.launch(customContext) {
    // 这个协程在 IO 线程执行,名字叫 MyCoroutine
    // 如果抛出异常,会被上面的处理器捕获
}

这里有个细节:+ 运算符用于组合多个上下文元素。如果出现冲突(比如两个 Dispatcher),右边的会覆盖左边的。

小技巧:调试协程问题时,我习惯给每个协程加上 CoroutineName。这样在日志里一眼就能看出是哪个协程出了问题,排查效率高很多。

1.4 三者之间的关系

咱们用一张图来理解:

  • CoroutineScope 是“老板”,管理协程的生命周期
  • Job 是“员工”,代表具体的协程任务
  • CoroutineContext 是“工作手册”,告诉员工怎么干活

当你调用 scope.launch { ... } 时,实际上发生了三件事:

  1. 创建一个新的 Job
  2. 将 Job 与 scope 的上下文合并
  3. 在新上下文中启动协程

嗯,到这里,协程的三大核心概念你应该有个基本认识了。下一章咱们会深入聊 launchasync 的区别,以及如何在实际项目中用好它们。

记住一句话:协程不是银弹,但用好了,它能让你的异步代码像同步代码一样优雅。