协程进阶:组合 06. 并发环境下的异常传播

本篇解析 example-compose-06.kt。探讨并发任务中的错误级联。

1. 核心现象:一人失败,全家取消

coroutineScope 启动的并发任务中,只要其中一个子协程抛出异常,所有其他的兄弟协程都会被取消

执行流程 (example-compose-06):

  1. 启动 async#1,它模拟一个非常耗时的计算。
  2. 启动 async#2,它立即抛出 ArithmeticException
  3. 结果async#1finally 块会立即被执行,因为它被 async#2 引起的级联取消信号中断了。

2. 开发者总结

这种机制虽然听起来很严格,但它是“结构化并发”的核心精髓。它防止了一个小任务的失败导致整个系统资源(如线程、网络连接)的浪费。在 Android 页面加载多个组件数据时,这种设计能确保一旦主要逻辑报错,整个界面的其他耗时异步加载也会立即停止,从而保持系统的健壮性。

协程进阶:上下文 06. Job 的父子关系

本篇解析 example-context-06.kt。探讨协程如何建立和断开层级联系。

1. 默认继承

在同一个作用域启动的子协程,默认会继承父协程的 Job

2. 独立运行:launch(Job())

如果在启动子协程时传入了一个新的 Job() 对象,这个子协程就会脱离原来的家族体系,成为一个独立的根协程。

  • 特性:当父协程被取消时,这个独立的子协程不会被取消。

3. 开发者感悟

这种机制提供了极大的灵活性。如果你需要启动一个生命周期不随当前 Activity 结束而结束的任务(例如一个全局的同步服务),你可以使用这种方式来断开父子关联。

协程进阶:异常 06. 取消异常的透明性 (Cancellation Transparency)

本篇解析 example-exceptions-06.kt。探讨协程在取消过程中的异常“透明”特性。

1. 核心概念:透明且未包装

在 Kotlin 协程中,取消异常(CancellationException)是透明的。

特点:

  • 不被包装:当一个协程被取消时,它会抛出 CancellationException。这个异常不会被包装 in 其他异常中,而是以原始形式抛出。
  • 重新抛出:如果你捕获了 CancellationException,通常建议将其重新抛出。这有助于协程库正确地传播取消信号,并完成结构化并发的清理工作。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
val job = GlobalScope.launch(handler) {
val inner = launch {
launch {
launch { throw IOException() } // 1. 原始异常发生
}
}
try {
inner.join()
} catch (e: CancellationException) {
println("Rethrowing CancellationException...")
throw e // 2. 重新抛出取消信号
}
}
  • 执行逻辑
    1. 最深层的子协程抛出 IOException
    2. 这导致整个 inner 协程树被取消。
    3. 父协程在 inner.join() 处捕获到 CancellationException
    4. 即使父协程重新抛出了 CancellationException,最外层的 handler 最终捕获到的依然是那个引发一切的原始异常:IOException

3. 开发者感悟

“取消透明性”保证了异常源头不会被掩盖。在复杂的嵌套协程中,你不需要担心中间层级的 try-catch 会让真实的报错信息丢失。只要遵循“捕获后重新抛出”的原则,协程库就能确保原始的错误根源最终能到达你的全局异常处理器中。

Flow 基础:06. 异步流的取消

本篇解析 example-flow-06.kt。探讨 Flow 如何响应取消信号。

1. 核心机制:协作取消

Flow 的取消遵循协程的协作取消原则。

代码解析

1
2
3
4
5
6
fun main() = runBlocking {
withTimeoutOrNull(250) { // 在 250ms 后超时取消
simple().collect { value -> println(value) }
}
println("Done")
}
  • simple() 每 100ms 发射一次数据。
  • 当 250ms 到达时,withTimeoutOrNull 会取消 collect 所在的协程。
  • 由于 simple() 内部包含 delay(可取消的挂起函数),Flow 收集会立即停止并退出。

2. 开发者感悟

Flow 的取消非常直观:取消收集者所在的协程,Flow 就会停止产出。这在 Android 界面销毁(如 onDestroy)时自动停止数据流更新非常有用。

3. 注意点

如果你的 flow { ... } 内部全都是 CPU 密集型的计算且没有调用任何挂起函数(如 delayyield),你需要像处理普通协程一样,手动检查 currentCoroutineContext().isActive 状态。

协程进阶:06. 不可取消的任务块

本篇解析 example-cancel-06.kt,介绍如何在协程取消后运行挂起逻辑。

1. 核心问题

finally 块中,如果你需要调用一个挂起函数(例如 delay 或关闭网络连接),由于协程已经是“已取消”状态,挂起函数会直接抛出异常,导致清理逻辑中断。

2. 解决方案:withContext(NonCancellable)

使用 withContext(NonCancellable) 可以创建一个临时作用域,该作用域会忽略当前的取消信号,确保内部逻辑完整执行。

代码解析

1
2
3
4
5
6
7
finally {
withContext(NonCancellable) {
println("I am running finally")
delay(1000L) // 这里的延迟会生效,不会被取消
println("Cleanup done after 1 second")
}
}

3. 开发者感悟

NonCancellable 只应该用于 finally 块中的资源释放或记录日志。不要滥用它来执行长期的业务逻辑,否则会破坏协程的响应性。

Kotlin 协程基础 06:协程的轻量级特性

本篇文档解析 example-basic-06.kt,探讨为什么协程被称为“轻量级线程”。

1. 核心概念:协程 vs 线程

在传统的线程模型中,如果你创建 50,000 个线程,由于内存和系统调用开销,程序会迅速 OOM (OutOfMemoryError) 或崩溃。而协程可以轻松应对这种规模的任务。

代码解析

1
2
3
4
5
6
7
8
fun main() = runBlocking {
repeat(50_000) {
launch {
delay(5000L)
print(".")
}
}
}
  • 海量并发repeat(50_000) 表示我们要启动五万个协程。
  • 非线性等待:每个协程在 delay 时都会交出控制权,但不会占用操作系统线程。

2. 深入理解:为什么不崩溃?

  1. 内存占用极小:协程本质上是运行在 JVM 堆内存中的一些对象。一个线程通常需要约 1MB 的栈内存,而一个协程只需几十到几百个字节。
  2. **调度器 (Dispatcher)**:协程运行在线程池之上。50,000 个协程实际上由几十个实际的线程(通常等于 CPU 核心数)来“轮流轮换”处理。
  3. 挂起 (Suspend) 的本质:当协程 delay 时,它只是被调度器标记为“待办任务”,而不是让线程“死等”。线程会立即转而处理其他就绪的协程。

3. 开发者总结

协程极大地提高了并发处理效率,它不再受限于操作系统的线程数量,让我们能够以接近 1:1 的模型处理成千上万个异步任务(如高并发服务器请求、大规模 UI 数据流处理等)。

6. N 字形变换

题目

将一个给定字符串 s 根据给定的行数 numRows ,以从上往下、从左到右进行 Z 字形排列。

比如输入字符串为 "PAYPALISHIRING" 行数为 3 时,排列如下:

1
2
3
P   A   H   N
A P L S I I G
Y I R

之后,你的输出需要从左往右逐行读取,产生出一个新的字符串,比如:"PAHNAPLSIIGYIR"

请你实现这个将字符串进行指定行数变换的函数:

1
string convert(string s, int numRows);

示例 1:

1
2
输入:s = "PAYPALISHIRING", numRows = 3
输出:"PAHNAPLSIIGYIR"

示例 2:

1
2
3
4
5
6
7
输入:s = "PAYPALISHIRING", numRows = 4
输出:"PINALSIGYAHRPI"
解释:
P I N
A L S I G
Y A H R
P I

示例 3:

1
2
输入:s = "A", numRows = 1
输出:"A"

提示:

  • 1 <= s.length <= 1000
  • s 由英文字母(小写和大写)、',''.' 组成
  • 1 <= numRows <= 1000

简介

1
2
3
4
重点不在字形,重点在变换是什么变换
关键词:
一维函数变二维函数
往复变化函数

正文

我们思考,为什么这个算法难写
分析 N字形 的离散函数,我们面临着一个唯一的 x 对应着 1- N 个 y
x 变化的时候,我们首先要算该 x 对应了几个 y
这根本不符合函数的定义
函数是指一个集合中的每个元素都有且仅有一个映射到另一个集合中的元素,这种关系被称为函数映射

推出我们需要把一个一维度线性离线函数 s = f(n) 变成二维离散函数 s = g(x, y)
注意,这里好玩的是 y 和 x 其实是数列
即 s = g(x(n), y(n))
我们开始推导

1
2
3
4
5
6
7
8
9
10
11
12
x(n) = x(n-1) + 1
函数 x(n) 很简单,我们怎么表达 y(n) 呢
我们发现 y(n) 是一个往复的等差数列
对于本题有
y(n) = y(n - 1) + step(n)
step 是一个往复函数
用 flag 代表往复函数的方向
| -1 当 y(n - 1) 到达最大值或者最小值时
flag = | 1 其他
step(n) = flag * step(n-1) 当 y(n - 1) 到达最大值或者最小值时
| -step(n-1) 其他
step(n) = | step(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
fun convert(s: String, numRows: Int): String {
推出 (numRows < 2) return s
val rows: MutableList<StringBuilder> = ArrayList()
for (i in 0 until numRows) rows.add(StringBuilder())
var i = 0
var step = -1
for (c in s.toCharArray()) {
rows[i].append(c)
if (i == 0 || i == numRows - 1) step = -step
i += step
}
val res = StringBuilder()
for (row in rows) res.append(row)
return res.toString()
}
}

协程进阶:同步 07. Actor 模式

本篇解析 example-sync-07.kt。探讨如何利用“消息传递”而非“共享状态”来解决并发冲突。

1. 核心概念:什么是 Actor?

Actor 是由一个状态(State)、一个行为(Behavior)以及一个信箱(Channel)组合而成的实体。

工作原理:

  • 私有状态:状态被封装在 Actor 内部,外部无法直接修改。
  • 消息驱动:外部只能通过向 Actor 的 Channel 发送消息来请求修改或查询状态。
  • 串行处理:Actor 内部会按顺序、一个个地处理接收到的消息,从而天然地避免了并发冲突。

2. 代码解析 (Counter Actor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 定义消息类型
sealed class CounterMsg
object IncCounter : CounterMsg()
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()

// 启动 Actor
fun CoroutineScope.counterActor() = actor<CounterMsg> {
var counter = 0 // Actor 的私有状态
for (msg in channel) { // 串行处理每一条消息
when (msg) {
is IncCounter -> counter++
is GetCounter -> msg.response.complete(counter)
}
}
}

3. 开发者总结

“不要通过共享内存来通信,而要通过通信来共享内存。” Actor 模式完美地实践了这一哲学。它比锁(Mutex)更高级,因为它将状态管理逻辑彻底解耦到了一个独立的后台任务中。在处理复杂的、多来源的状态更新(如 Android 应用中的全局设置同步、长连接状态维护)时,Actor 模式是目前业界公认的最优雅方案。

协程进阶:通道 07. 扇入模式 (Fan-in)

本篇解析 example-channel-07.kt。学习如何让多个生产者向同一个通道汇总数据。

1. 核心概念:多发一收

扇入模式指:多个生产者并发地向同一个通道发送数据,一个消费者负责统一处理。

特点:

  • 并发汇聚:不同来源的数据被汇聚到单一的流中,方便统一管理。
  • 天然公平:Channel 的调度保证了每个发送者都有机会把数据塞进通道,不会造成某个发送者长期饥饿。

2. 代码解析

1
2
3
4
val channel = Channel<String>()
launch { sendString(channel, "foo", 200L) } // 生产者 1
launch { sendString(channel, "BAR!", 500L) } // 生产者 2
repeat(6) { println(channel.receive()) } // 单一消费者

3. 开发者感悟

扇入模式在 Android 中非常适合处理多源数据汇聚。例如:你的 App 可能同时从网络轮询、本地传感器和 Socket 长连接获取数据。你可以开启 3 个协程作为生产者,它们都把数据塞进同一个 Channel,然后由主界面的 ViewModel 作为消费者进行统一分发和 UI 更新。

协程进阶:上下文 07. 父子协程的等待机制

本篇解析 example-context-07.kt。探讨父协程如何管理子协程的结束。

1. 核心规则:父协程的责任

父协程总是会等待所有的子协程完成后才结束。

特点:

  • 隐式等待:你不需要显式地对每个子协程调用 join()。父协程会感知到其作用域内所有活跃的 Job。
  • 结构化并发:这种机制确保了在父任务(如一个网络请求流)宣告完成时,它发起的细节任务也已经全部处理完毕。

2. 代码解析

1
2
3
4
5
6
7
8
9
10
val request = launch {
repeat(3) { i ->
launch {
delay((i + 1) * 200L)
println("Coroutine $i is done")
}
}
println("request: I'm done")
}
request.join() // 这行会等待 request 及其所有 3 个子协程全部完成

3. 开发者感悟

这是协程设计中非常优雅的一点。它消除了“孤儿协程”的风险。在 Android 中,如果你在 ViewModel 启动了一个任务,这个任务又分发了几个子任务,你只需要关注顶层任务的状态,底层任务的同步由协程库自动完成。