Kotlin 协程新手指南 —— 协程上下文与调度器

一、CoroutineContext:协程的"身份证"

1.1 什么是 CoroutineContext?

每个协程都有一个 CoroutineContext(协程上下文) ,它是一组配置信息的集合,定义了协程的行为。可以理解成协程的"身份证"。

CoroutineContext 主要包含四类元素:

元素 作用
Job 控制协程的生命周期(启动、取消、等待)
CoroutineDispatcher 决定协程在哪个线程/线程池上执行
CoroutineName 协程的名字,调试用
CoroutineExceptionHandler 处理未捕获的异常

💡 注意 :协程上下文是一个"索引集合",每个元素都有唯一的键(Key),可以通过键来获取对应的元素。

1.2 使用 + 组合上下文

CoroutineContext 支持用 + 操作符合并,右边的元素会覆盖左边同类型的元素:

kotlin 复制代码
val ctx1 = Dispatchers.IO + CoroutineName("NetworkTask")
val ctx2 = ctx1 + Dispatchers.Default  // 覆盖了 Dispatcher

1.3 协程上下文的继承规则

协程启动时,新协程的上下文 = 默认值 + 父协程上下文 + 显式参数

重要原则:

  • Job 总是新创建的 (每个协程有自己的 Job),但新 Job 会被加入父 Job 的子列表中。
  • 其他元素(Dispatcher、Name、ExceptionHandler)默认从父协程继承
  • 如果显式指定了某个元素,则覆盖继承的同名元素。
kotlin 复制代码
val parentScope = CoroutineScope(Dispatchers.Main + CoroutineName("Parent"))

parentScope.launch { // 子协程上下文:Main + Parent(Job 是新创建的)
    launch(Dispatchers.IO) { // 孙协程:IO + Parent(Dispatcher 被覆盖)
    }
}

二、Dispatchers:调度器深度解析

调度器决定协程在哪个线程或线程池上执行。Kotlin 提供了四个内置调度器。

2.1 Dispatchers.Main:主线程

kotlin 复制代码
viewModelScope.launch(Dispatchers.Main) {
    binding.tvResult.text = "Hello"   // Android 主线程(UI 线程)
}

适用场景

  • 更新 UI
  • 调用 Android 框架的方法(大部分都要在主线程)
  • 运行轻量、快速的任务

⚠️ 注意:如果在单元测试中使用 Dispatchers.Main,必须先调用 Dispatchers.setMain(Dispatchers.Unconfined)

2.2 Dispatchers.IO:IO 密集型任务

kotlin 复制代码
withContext(Dispatchers.IO) {
    val data = api.fetchData()      // 网络请求
    fileWriter.write(data)          // 文件 IO
}

适用场景

  • 网络请求
  • 数据库读写(Room、SQLite)
  • 文件 IO
  • 任何阻塞 IO 操作

核心特性

  • 默认线程池大小 = max(64, CPU 核数)
  • 适合会阻塞的 IO 操作(这些操作会让线程闲着等数据)
  • 当一个线程被阻塞时,IO 调度器会动态扩展线程池

2.3 Dispatchers.Default:CPU 密集型任务

kotlin 复制代码
withContext(Dispatchers.Default) {
    val sorted = bigList.sortedBy { it.score }   // 列表排序
    val parsed = JSONObject(jsonStr)             // JSON 解析
}

适用场景

  • 列表排序
  • JSON 解析
  • 图片处理(Bitmap 操作)
  • 复杂计算
  • 任何"占满 CPU"的任务

核心特性

  • 默认线程池大小 = CPU 核数(最少 2)
  • 因为 CPU 任务"满载跑",再多线程也没用,反而增加上下文切换开销

2.4 IO vs Default -- 到底怎么选?

⚠️ 注意:面试高频题,记住口诀: "等的活用 IO,算的活用 Default"

特征 IO Default
任务特点 阻塞等待(IO) 消耗 CPU(计算)
线程数 多(可到 64+) 少(CPU 核数)
如果选错了 CPU 密集型在 IO 也会跑,但可能因线程切换增加开销 IO 密集型在 Default 会因线程数不足造成排队

底层冷知识 :Kotlin 1.6+ 之后,Dispatchers.IODispatchers.Default 共享同一个线程池,只是最大并发数不同。因此在大多数情况下,切换开销很低。但语义上仍应按照原则选择,让代码意图清晰。

2.5 Dispatchers.Unconfined -- 不受约束(少用)

  • 启动时在调用者线程运行,遇到第一个挂起点后,在哪个线程恢复就继续在哪个线程运行。
  • 风险:容易造成线程切换混乱,通常用于测试或极特殊的库代码。
kotlin 复制代码
// 不推荐在业务代码中使用
launch(Dispatchers.Unconfined) {
    println("Before suspend: ${Thread.currentThread().name}") // main
    delay(100)
    println("After suspend: ${Thread.currentThread().name}")  // kotlinx.coroutines.DefaultExecutor
}

2.6 Dispatchers.Main.immediate -- 性能优化

Dispatchers.Main.immediateDispatchers.Main 的性能优化版本。区别在于:

  • Dispatchers.Main:无论当前在哪个线程,都会总是通过消息队列调度到主线程执行(有一次 dispatch 开销)
  • Dispatchers.Main.immediate:如果当前已经在主线程,就直接执行,不再经过调度器;否则才通过消息队列调度

换句话说:immediate 会检查当前线程,如果"已经在家",就直接干活,省去了排队上楼的步骤。

使用场景 :在主线程上调用挂起函数时,避免多余的 dispatch 开销。如果你不确定当前在哪个线程,Dispatchers.Main.immediate 是比 Dispatchers.Main 更好的默认选择。

  • 行为:如果当前已经在主线程,则立即执行代码块,不再经过调度器;否则就通过消息队列调度。
  • 优点:避免不必要的 dispatch 开销,尤其适合从主线程调用自己的场景。
kotlin 复制代码
// 假设当前已在主线程
withContext(Dispatchers.Main.immediate) {
    // 这里会立即执行,不会走调度队列
}

2.7 自定义调度器

你可以基于现有线程池或创建新的线程池来生成调度器:

kotlin 复制代码
// 限定并发数为 1 的 IO 调度器(串行处理)
val singleIo = Dispatchers.IO.limitedParallelism(1)

// 从 Java 线程池创建
val myDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
// ⚠️ 记得关闭:myDispatcher.close()

⚠️ 重要 :自定义线程池用完一定要 myDispatcher.close(),否则线程不会释放。

三、withContext:切换上下文的核心

3.1 withContext 的本质

withContext 是一个挂起函数,它把一段代码切换到指定的上下文执行:

kotlin 复制代码
suspend fun fetchAndParse(): Result {
    // 假设当前在 Main
    val raw = withContext(Dispatchers.IO) {
        api.fetchRaw()      // ← 切换到 IO 线程执行
    }
    val parsed = withContext(Dispatchers.Default) {
        parseJson(raw)      // ← 切换到 Default 线程执行
    }
    return parsed           // ← 自动切回原来的上下文(Main)
}

特点

  • 切换到指定调度器执行 block
  • block 执行完,自动切回调用时的上下文
  • block 的返回值就是 withContext 的返回值
  • 跟基于回调的方案相比,没有额外开销

3.2 main-safety:让函数调用方不操心线程

Android 官方推崇的最佳实践之一:让每个挂起函数都是 main-safe(主线程安全)的

kotlin 复制代码
class Repository {
    // ❌ 这个函数从主线程调用会卡 UI
    suspend fun getDocs(): String {
        return URL("...").readText()  // 阻塞操作,但在主线程执行!
    }
    
    // ✅ main-safe 版本
    suspend fun getDocsMainSafe(): String = withContext(Dispatchers.IO) {
        URL("...").readText()  // 明确在 IO 线程执行
    }
}

核心思想:挂起函数应该自己负责将耗时操作切换到合适的线程,让调用方可以在主线程直接调用,不用担心阻塞 UI。

3.3 withContext 不阻塞,跟 runBlocking 完全不同

  • runBlocking阻塞当前线程,直到内部协程完成
  • withContext挂起 当前协程,不阻塞底层线程
kotlin 复制代码
// ❌ runBlocking 会阻塞线程
fun test() = runBlocking {
    delay(1000)
}

// ✅ withContext 只是挂起,不阻塞
suspend fun test2() = withContext(Dispatchers.IO) {
    delay(1000)
}

3.4 嵌套 withContext 的优化

withContext 是挂起函数,可以嵌套使用,但要注意性能:

kotlin 复制代码
// ❌ 多次切换,每次 withContext 都有调度开销
suspend fun process() {
    val a = withContext(Dispatchers.IO) { fetchA() }
    val b = withContext(Dispatchers.IO) { fetchB() }
    val c = withContext(Dispatchers.IO) { fetchC() }
}

// ✅ 用 withContext 包一层,只在开始切一次
suspend fun process() = withContext(Dispatchers.IO) {
    val a = fetchA()   // 已经在 IO,不需要再切
    val b = fetchB()
    val c = fetchC()
}

不过,上面的 示例也没那么糟糕,因为 Dispatchers.IODispatchers.Default 共享同一个线程池后端,实际调度开销很小。但从代码可读性来说,第二种写法更清晰。

四、Job:上下文中的生命周期句柄

Job 不仅是 launch 的返回值,也是 CoroutineContext 的核心元素之一,它在上下文里扮演"生命周期控制器"的角色。

kotlin 复制代码
val job: Job = viewModelScope.launch {
    delay(1000)
    println("Done")
}

// 状态查询
job.isActive      // 是否活跃中
job.isCompleted   // 是否已完成
job.isCancelled   // 是否被取消

// 生命周期操作
job.cancel()                    // 取消协程
job.cancelAndJoin()             // 取消并等待完成
job.join()                      // 挂起等待完成(不取消)
job.invokeOnCompletion { e -> } // 完成时回调

// 从上下文中获取当前协程的 Job
val currentJob = coroutineContext[Job]

Job 之间通过父子关系形成层级,这是结构化并发的基础------父 Job 取消会级联取消所有子 Job,父 Job 会等所有子 Job 完成才结束。

Job 的层级关系、取消传播,以及 Job vs SupervisorJob 的区别,是结构化并发的核心内容。

记住:Job 是协程在上下文里的"生命周期句柄",每个协程都有自己独立的一个

五、协程取消(Cancellation)------ 协作式机制

5.1 协作式取消的含义

协程的取消是协作式 的:job.cancel() 只是设置了一个"取消标志",协程本身需要主动检查这个标志才能停止。如果协程内部没有检查点,取消操作就无法生效。

kotlin 复制代码
val job = scope.launch {
    var i = 0
    while (i < 1_000_000) {   // ⚠️ 没有挂起点,也没有检查 isActive
        i++
    }
}
delay(100)
job.cancel()  // 无效!协程不会停止

5.2 让协程可取消的两种方法

方法一:使用挂起函数(自动响应)

大多数挂起函数(delaywithContextchannel.receive 等)会在挂起点自动检查取消状态,发现已取消会抛出 CancellationException

kotlin 复制代码
launch {
    repeat(1000) {
        delay(500)   // ✅ 每次 delay 都会检查取消
        println("Running")
    }
}

方法二:手动检查 isActive 或调用 ensureActive()

kotlin 复制代码
launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000 && isActive) {   // 手动检查标志
        i++
    }
    // 或者使用 ensureActive()
    ensureActive()
}

5.3 在 finally 中清理资源

协程被取消时,finally 块仍然会执行,所以可以安全地释放资源。

kotlin 复制代码
val job = scope.launch {
    val stream = openFile()
    try {
        process(stream)
    } finally {
        stream.close()   // ✅ 即使被取消也会关闭
    }
}

5.4 在 finally 里不能调用挂起函数

协程取消后,内部挂起函数通常无法正常执行(会直接抛出 CancellationException)。如果必须在清理时调用挂起函数,需要使用 NonCancellable 上下文。

kotlin 复制代码
val job = scope.launch {
    try {
        work()
    } finally {
        withContext(NonCancellable) {
            // 这个挂起函数会忽略取消状态,确保执行完成
            saveLogs()
        }
    }
}

5.5 中断阻塞代码:runInterruptible

对于传统的阻塞代码(如 Thread.sleep()InputStream.read()),协程无法通过协作式取消来打断。runInterruptible 可以将这些阻塞代码包装成可中断的。

kotlin 复制代码
suspend fun readBlocking() = withContext(Dispatchers.IO) {
    runInterruptible {
        // 当协程被取消时,此行会抛出 InterruptedException 并转为 CancellationException
        Thread.sleep(10000)
    }
}

注意:runInterruptible 依赖于底层对中断的响应。某些阻塞操作(如不检查中断标志的死循环)可能仍然无法中断。

5.6 CancellationException 是特殊的异常

  • CancellationException 是用来实现取消的正常控制流,不应该被当作"业务异常"处理。
  • 通常情况下,你不需要捕获 CancellationException;如果捕获了,应该重新抛出它,避免破坏取消机制。
kotlin 复制代码
try {
    doWork()
} catch (e: CancellationException) {
    // 如果是取消导致的异常,通常应该重新抛出
    throw e
} catch (e: Exception) {
    // 处理其他异常
}

5.7 Prompt Cancellation -- 取消时不返回值

withTimeout 或手动取消后,协程框架会尽快终止,但不会返回任何值(实际上会抛出 CancellationException)。如果你的代码中有资源分配,务必使用 try-finallyuse 来保证释放。

六、超时(Timeout)

6.1 withTimeout -- 超时抛异常

kotlin 复制代码
try {
    val result = withTimeout(1000) {
        longRunningOperation()
    }
    println(result)
} catch (e: TimeoutCancellationException) {
    println("操作超时")
}
  • 超时后,withTimeout 内部协程会被取消(同样是协作式的)。
  • 超时异常是 CancellationException 的子类。

6.2 withTimeoutOrNull -- 超时返回 null

kotlin 复制代码
val result = withTimeoutOrNull(1000) {
    longRunningOperation()
}
if (result == null) {
    println("超时或失败")
} else {
    println(result)
}
  • 更优雅,适合不需要抛异常的场景。

6.3 超时时的资源泄漏陷阱

超时本质上就是自动取消,因此可能带来资源泄漏。例如:

kotlin 复制代码
// ⚠️ 危险:如果超时发生在 allocate() 之后、返回之前,资源就无法释放
val resource = withTimeoutOrNull(100) {
    val r = allocateResource()   // 假设分配了一些资源
    delay(200)                   // 模拟工作
    r
}
// 超时时 resource 为 null,但 allocateResource() 分配的资源没有释放

解决方案 :在内部使用 usetry-finally

kotli 复制代码
withTimeoutOrNull(100) {
    allocateResource().use { r ->    // 无论是否超时,use 都会释放
        delay(200)
        // 使用 r
    }  // 自动调用 close()
}

七、综合实战:一个 main-safe 的 Repository

kotlin 复制代码
class UserRepository(
    private val api: ApiService,
    private val dao: UserDao
) {
    // main-safe:内部切换线程
    suspend fun getUser(id: String): User = withContext(Dispatchers.IO) {
        val cached = dao.getUser(id)
        if (cached != null) {
            return@withContext cached
        }
        val remote = api.fetchUser(id)
        dao.saveUser(remote)
        remote
    }

    suspend fun loadUserProfile(id: String): Profile? = withTimeoutOrNull(5000) {
        coroutineScope {
            val user = async { getUser(id) }
            val posts = async { api.getPosts(id) }
            Profile(user = user.await(), posts = posts.await())
        }
    }
}

// ViewModel 调用(主线程)
class UserViewModel(private val repo: UserRepository) : ViewModel() {
    fun load(id: String) {
        viewModelScope.launch(Dispatchers.Main) {
            showLoading()
            val profile = repo.loadUserProfile(id)  // main-safe + 超时保护
            if (profile != null) {
                showProfile(profile)
            } else {
                showTimeoutError()
            }
            hideLoading()
        }
    }
}

总结速查表

概念 一句话总结
CoroutineContext 协程的"身份证",包含 Job、调度器、名称等
上下文继承 Job 新创建,其他元素从父协程继承,显式传入覆盖
Dispatchers.Main UI 线程专用
Dispatchers.IO 网络/文件/数据库,适合等待型任务
Dispatchers.Default 排序/解析/计算,适合 CPU 密集型任务
Main.immediate 已在主线程则立即执行,避免调度开销
withContext 切换线程的挂起函数,执行完自动切回
main-safe 挂起函数内部切换耗时操作,让调用方放心在主线程调用
协作式取消 cancel() 只是设标志,协程需主动检查 isActive 或调用挂起函数
finally 里挂起 需用 withContext(NonCancellable) 包裹
runInterruptible 将阻塞代码变得可取消
CancellationException 取消专用异常,通常不应捕获或应重新抛出
withTimeout 超时抛异常(TimeoutCancellationException
withTimeoutOrNull 超时返回 null,更友好
超时资源泄漏 在超时块内使用 usetry-finally 确保释放

最容易踩的坑:

  1. ❌ 用 kotlin.runCatching 包协程代码,捕获了 CancellationException 破坏取消机制
  2. ❌ 在 finally 里调用挂起函数,没用 NonCancellable
  3. ❌ CPU 密集循环不检查 isActive / ensureActive,调了 cancel() 也不停
  4. ❌ 把 Dispatchers.IO 当万能调度器,CPU 任务也丢进去
  5. ❌ 误以为 withTimeout 超时会取消外层父协程(其实只取消它的内部子作用域

掌握了协程上下文与调度器,你就具备了编写健壮、高效、main-safe 的异步代码的能力。

相关推荐
潘潘潘1 小时前
Android JAVA Socket 知识梳理
android
00后程序员张2 小时前
Jenkins 自动上传 IPA 到 App Store 把发布步骤融入 CI/CD
android·ios·小程序·https·uni-app·iphone·webview
Gary Studio2 小时前
复杂 SoC(RK3568)PCB 布局的五步
android·linux·硬件
plainGeekDev2 小时前
HttpURLConnection → OkHttp + Kotlin
android·java·kotlin
QING6182 小时前
Kotlin 协程新手指南 —— 协程基础与挂起函数
android·kotlin·android jetpack
2601_961766642 小时前
【分享】分身空间 2.3.7[特殊字符]生活工作互不打扰
android·生活
百度搜知知学社2 小时前
抖音双模块架构:兼容全安卓版本并支持登录
android·架构·安卓·登录·兼容性·抖音
文阿花3 小时前
Echarts实现柱状3D扇形图
android·3d·echarts
故渊at3 小时前
第六板块:Android 安全与权限体系 | 第十九篇:SELinux 强制访问控制与沙箱机制
android·安全·访问控制·selinux·权限体系·沙箱机制