五、协程取消与结构化并发:Job取消与父子协程

从 sleep 与 delay 的本质差异出发,理解协程取消机制与结构化并发的层级管理思想

Job cancel 结构化并发 CoroutineScope 协程取消

目录导航(点击跳转)

一、sleep 与 delay:阻塞与挂起的本质区别

1模拟耗时操作:sleep 比 delay 更准确

在协程中模拟耗时操作时,Thread.sleep()delay() 更加准确。原因在于两者的行为本质不同:

  • Thread.sleep() —— 确确实实阻塞了线程,在那期间线程什么都做不了,无法执行任何其他任务。
  • delay() —— 只是将函数挂起,并没有阻塞线程。线程被释放出来,可以去执行其他协程中的代码。

用代码对比来看:

// === sleep 版本:线程被阻塞 ===
fun main() = runBlocking {
    launch(Dispatchers.IO) {
        println("A - 线程: ${Thread.currentThread().name}")
        Thread.sleep(1000)   // 线程实实在在被占用 1 秒
        println("B - 线程: ${Thread.currentThread().name}")
    }
    launch(Dispatchers.IO) {
        println("C - 线程: ${Thread.currentThread().name}")
    }
}

// 输出顺序:A → B → C
// 因为 sleep 占着线程不放,第二个协程必须等第一个完成
// === delay 版本:线程被释放 ===
fun main() = runBlocking {
    launch(Dispatchers.IO) {
        println("A - 线程: ${Thread.currentThread().name}")
        delay(1000)          // 协程挂起,线程还给线程池
        println("B - 线程: ${Thread.currentThread().name}")
    }
    launch(Dispatchers.IO) {
        println("C - 线程: ${Thread.currentThread().name}")
    }
}

// 输出顺序:A → C → B(等待1秒后)
// delay 挂起后线程空闲,第二个协程立即复用该线程

Thread.sleep()

  • 阻塞线程,线程无法做其他事
  • 模拟耗时操作更真实
  • 浪费线程资源
  • 不配合协程取消(无法响应 cancel)

delay()

  • 挂起协程,线程可复用
  • 模拟非阻塞等待
  • 高效利用线程资源
  • 可被取消(抛出 CancellationException)

如果你的目的是真实模拟一个阻塞式耗时任务(如网络 I/O 的底层 socket 阻塞),用 sleep() 更准确。如果你想模拟协程友好的挂起操作,用 delay()。两者模拟的场景不同,选择取决于你关注的点。

2为什么 delay 能取消而 sleep 不能

delay() 是挂起函数,它在内部检查协程的取消状态。当协程被取消时,delay() 会立即抛出 CancellationException 结束协程。sleep() 纯粹阻塞线程,不参与协程的取消协作机制。

// delay 响应取消
val job = CoroutineScope(Dispatchers.Default).launch {
    println("开始等待")
    delay(5000)               // 协程在此挂起
    println("等待结束")        // 如果被取消,这行不会执行
}
delay(100)
job.cancel()                  // delay 立即响应取消
// 只输出 "开始等待"
// sleep 不响应取消
val job = CoroutineScope(Dispatchers.Default).launch {
    println("开始等待")
    Thread.sleep(5000)        // 线程被阻塞 5 秒
    println("等待结束")        // 即使 cancel 了也会执行
}
delay(100)
job.cancel()                  // sleep 期间 cancel 不生效
// 输出 "开始等待" 和 "等待结束"(取消被忽略)

在协程中使用 Thread.sleep() 会破坏协程的取消协作机制。协程的取消是协作式的 —— 需要被取消方主动检查取消状态。而 sleep() 阻塞了线程,连检查的机会都没有。

二、CoroutineScope 的两大职责

3职责一:提供上下文信息

CoroutineScope 是协程的"运行环境",它提供协程执行所需的上下文信息CoroutineContext)。最典型的就是通过 ContinuationInterceptor 来做线程管理:

// CoroutineScope 携带了 Dispatcher 信息
val scope = CoroutineScope(Dispatchers.IO)

scope.launch {
    // 这里的代码运行在 IO 线程池
    // 因为 scope 的上下文中有 Dispatchers.IO 这个拦截器
    println("当前线程: ${Thread.currentThread().name}")
}

// launch 的源码签名:
// public fun CoroutineScope.launch(
//     context: CoroutineContext = EmptyCoroutineContext,
//     start: CoroutineStart = CoroutineStart.DEFAULT,
//     block: suspend CoroutineScope.() -> Unit
// ): Job
CoroutineScope
提供
CoroutineContext
包含
ContinuationInterceptor
(线程调度)
4职责二:提供取消能力

CoroutineScope 的另一个重要功能是提供取消能力。在实际开发中,很多并发任务可能在中途就不再被需要了。

典型场景:做一个网络请求,请求结束后把结果展示在界面上。但用户在请求还未完成时就关闭了界面,那么网络请求以及后续的 UI 更新就不被需要了。

用户打开界面
发起网络请求
用户关闭界面
(请求还未完成)
需取消请求
+ UI 更新

传统做法是从内存泄露角度去解决:在 onDestroy() 中手动清理引用、解除回调绑定,防止 Activity 泄露。RxJava 处理得很好 —— RxJava 是通过切断整个业务链条(dispose 订阅链),从根本上阻止后续回调的执行。

协程的做法更自然:直接取消整个 scope,该 scope 下所有协程全部终止。

// 传统方式:手动管理生命周期
class MyActivity : Activity() {
    private var disposable: Disposable? = null

    fun loadData() {
        disposable = api.fetchData()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe { data -> updateUI(data) }
    }

    override fun onDestroy() {
        disposable?.dispose()  // 手动切断链条
        super.onDestroy()
    }
}
// 协程方式:lifecycleScope 自动管理
class MyActivity : Activity() {
    fun loadData() {
        lifecycleScope.launch {
            val data = withContext(Dispatchers.IO) {
                api.fetchData()      // 网络请求
            }
            updateUI(data)           // 更新 UI
        }
    }
    // onDestroy 时 lifecycleScope 自动取消,无需手动处理
}

lifecycleScope 早就处理好了一切,不需要你在 onDestroy() 中手动执行取消操作。当 Lifecycle 进入 DESTROYED 状态时,scope 内所有协程自动取消。

三、Job:协程的取消手柄

5launch 返回 Job,Job 可以取消协程

每个协程启动后会返回一个 Job 类型的返回值,你可以通过 job.cancel() 来取消这个协程。

来看 launch 的源码:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

注意源码中的关键逻辑:如果 startCoroutineStart.LAZY,则创建 LazyStandaloneCoroutine(需要手动 start);否则创建 StandaloneCoroutine(active=true,自动启动)。最终返回的 coroutine 就是 Job 类型。

使用 job.cancel() 取消协程:

val job = lifecycleScope.launch {
    println("A")
    delay(1000)
    println("B")
}
job.cancel()

// 控制台只能看到 "A"
// 因为打印 A 之后协程被挂起,job.cancel() 被执行
// delay 检测到取消,抛出 CancellationException,协程结束
1
launch 启动协程,返回 Job
2
协程执行 println("A")
3
delay(1000) 挂起
4
job.cancel() 取消,协程终止
6block 参数中的隐式 receiver

注意到 block 参数的类型是 suspend CoroutineScope.() -> Unit,这是一个带有接收者类型的函数类型:

  • suspend —— 表示这是一个挂起函数类型
  • CoroutineScope.() —— 接收者类型为 CoroutineScope
  • 这意味着在 launch { } 大括号内部,有一个 CoroutineScope 类型的隐式 this

这就是 Kotlin 的语法糖:函数类型的参数作为最后一个参数时,可以写在括号外面。而 CoroutineScope. 前缀表示大括号里有一个隐式的 receiver。

// 这两种写法等价:
lifecycleScope.launch(Dispatchers.IO) {
    // 这里的 this 是 CoroutineScope
    // 所以可以直接写 launch { } —— 相当于 this.launch { }
    val data = api.fetchData()
}

// 展开理解:
lifecycleScope.launch(
    context = Dispatchers.IO,
    block = suspend CoroutineScope.() -> Unit {
        // this 指向当前协程的 CoroutineScope
    }
)

在协程中启动协程直接写 launch { } 就行。里面的 CoroutineScope 是和这个协程一一对应的,它受到外面的 lifecycleScope 管理。这就是结构化并发的基础 —— 每个协程都有自己的 scope,子协程的 scope 隶属于父协程。

四、结构化并发:父子协程的层级管理

7什么是结构化并发

结构化并发(Structured Concurrency)的核心思想:由一个作用域来管理它内部的所有协程。取消父协程时,所有子协程也会被取消。

看下面这个代码:

var job: Job? = null
val jobScope = CoroutineScope(Dispatchers.IO).launch {
    job = launch {
        http1()        // 子协程
    }
    http2()            // 父协程自身的操作
}

// 只取消子协程
job?.cancel()          // 只会取消 http1()

// 取消整个作用域
jobScope.cancel()      // 取消 http1() + http2() 所有操作
  • jobScope (父协程)
    • http2() —— 父协程自身任务
    • job (子协程) —— http1()

当调用 job.cancel() 时,只取消 http1();但当调用 jobScope.cancel() 时,取消的是其中的所有操作——包括 http2() 和子协程 http1()

取消子协程 (job.cancel)

  • 只影响 http1()
  • http2() 继续执行
  • 作用范围:单个子协程

取消作用域 (jobScope.cancel)

  • http1() 和 http2() 全部取消
  • 所有子协程级联取消
  • 作用范围:整个协程树
8父子关系与层级管理

外面的 scope 管理着里面的协程,我们称:

  • 里面的协程是外面协程的子协程
  • 外面的协程是里面协程的父协程

cancel 不仅仅会取消它自己启动的所有协程,而且会级联取消它所有的子协程。协程的结构化并发不仅仅是一对多的关系,更是一层对多层的关系。

// 多层嵌套的结构化并发
val rootScope = CoroutineScope(Dispatchers.Main).launch {

    launch {
        // 子协程 A
        launch {
            // 孙子协程 A1
        }
        launch {
            // 孙子协程 A2
        }
    }

    launch {
        // 子协程 B
        launch {
            // 孙子协程 B1
        }
    }
}

// rootScope.cancel() 会取消整棵树:
// root → 子A + 子B → 孙A1 + 孙A2 + 孙B1 (全部取消)
  • rootScope (根协程)
    • 子协程 A
      • 孙协程 A1
      • 孙协程 A2
    • 子协程 B
      • 孙协程 B1

这就是结构化并发的核心优势:取消一个 scope,整棵协程树都会被清理干净。不用像传统多线程那样手动管理每个线程的生命周期,也不会有"僵尸线程"残留。

五、核心思想总结

核心要点

一句话总结

协程的取消是协作式的、结构化的 —— delay 挂起但不阻塞线程所以能响应取消,CoroutineScope 通过 Job 树实现父子协程的级联管理,一个 cancel 清理整棵协程树。