很多人学习 Kotlin 协程,首先接触的是 launch、async、suspend 和 Dispatchers。
这些 API 当然重要。
但如果只关注 API,很容易把协程简单理解成"更轻量的线程",或者"更方便的异步编程工具"。
这样的理解并不完整。
从技术上说,协程是一种可以挂起和恢复的任务抽象;从实际编程的角度看,Kotlin 协程更值得关注的能力,是它如何通过挂起机制和结构化并发组织多个任务。
因此,我们可以借助"协"这个字建立一种直觉:
协程当然离不开线程,但它真正擅长表达的,是任务之间如何协作。
需要说明的是,这是一种帮助理解的教学视角,而不是对协程的严格定义。线程同样可以协作,Java 也有非阻塞并发工具;协程同样需要线程执行代码。
更准确地说:
线程主要提供执行资源,协程提供可挂起的任务模型;结构化并发则负责组织任务之间的协作关系。
一、传统 Java 并发,难的不只是创建线程
在传统 Java 并发编程中,我们经常需要处理这些问题:
- 多个线程同时修改数据,如何保证数据正确?
- 一个线程完成工作后,如何通知其他线程?
- 多个任务之间存在依赖,如何协调执行顺序?
- 一个任务失败或取消后,其他任务应该怎么办?
- 发起任务的页面或请求结束后,后台任务是否还应该继续?
Java 提供了丰富的并发工具。
synchronized、Lock、wait/notify、Future、BlockingQueue、CountDownLatch 和 ExecutorService,都可以解决不同场景下的问题。
现代 Java 还提供了 CompletableFuture、虚拟线程以及结构化并发等能力,并不是所有 Java 并发代码都依赖阻塞等待。
不过,从传统并发工具入手,仍然可以清楚地看到一个问题:
随着任务依赖关系越来越复杂,开发者需要手动协调的事情也会越来越多。
二、synchronized:多个线程如何安全访问共享数据?
先看一个最简单的场景。
两个线程同时修改一个共享变量:
java
int count = 0;
// 线程 A
count++;
// 线程 B
count++;
我们希望最终结果是 2。
但 count++ 并不是不可分割的操作。它通常包含读取、计算和写回等步骤。
多个线程交错执行时,可能读取到相同的旧值,从而发生更新丢失。因此,最终结果不一定是 2。
一种解决方式是使用 synchronized:
java
private final Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
synchronized 可以保证同一时刻只有一个线程进入同一把锁保护的临界区。
这样,多个线程对共享变量的修改就得到了协调。
不过,这里解决的只是一个局部问题:
如何安全地访问共享数据。
如果线程 A 需要等待线程 B 完成工作,或者多个任务必须按照特定顺序执行,仅有 synchronized 通常还不够。
三、Lock:更灵活地控制线程互斥
除了 synchronized,Java 还提供了 Lock 接口,最常见的实现是 ReentrantLock:
java
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
它和 synchronized 一样,可以保护共享数据,避免多个线程同时进入临界区。
那么,为什么还需要 Lock?
因为它提供了一些更灵活的控制能力。
1. 尝试获取锁
java
if (lock.tryLock()) {
try {
doWork();
} finally {
lock.unlock();
}
} else {
handleLockUnavailable();
}
如果锁当前不可用,tryLock() 可以立即返回。
开发者可以根据业务需要,决定拿不到锁时是重试、跳过,还是执行其他逻辑。
2. 可中断的锁等待
java
lock.lockInterruptibly();
try {
doWork();
} finally {
lock.unlock();
}
等待锁的线程可以响应中断,这有助于实现某些取消和退出流程。
3. 公平锁与非公平锁
ReentrantLock 支持配置公平性策略,让等待时间较长的线程有更高机会先获得锁。
不过,公平锁通常会带来额外开销。是否需要公平性,需要根据吞吐量和等待策略综合判断。
4. Lock 的边界在哪里?
Lock 主要解决线程之间的互斥访问,以及相关的锁获取和释放问题。
但如果业务要求任务 A 等待任务 B、C、D 全部完成后再继续,仅有 Lock 并不足以表达完整的任务关系。
开发者可能还需要组合:
- 条件变量;
CountDownLatch;Future;- 线程池;
- 超时和取消机制;
- 异常收集机制。
显式锁还必须正确释放。如果遗漏 unlock(),其他线程可能长期无法获得锁。
因此:
锁解决了局部的共享状态问题,却不等于整个异步任务的协作问题已经解决。
四、CountDownLatch:如何等待多个任务完成?
假设一个页面需要加载三个相互独立的数据源:
- 用户信息;
- 订单信息;
- 推荐信息。
我们希望三个任务全部结束后,再继续后续处理。
传统 Java 可以使用 CountDownLatch:
java
CountDownLatch latch = new CountDownLatch(3);
executor.execute(() -> {
try {
loadUser();
} finally {
latch.countDown();
}
});
executor.execute(() -> {
try {
loadOrders();
} finally {
latch.countDown();
}
});
executor.execute(() -> {
try {
loadRecommendations();
} finally {
latch.countDown();
}
});
latch.await();
continueWork();
CountDownLatch(3) 表示初始计数为 3。
每个任务结束时调用一次 countDown()。当计数归零后,等待在 await() 上的线程才能继续执行。
它解决了一个很实际的问题:
如何让一个线程等待多个任务结束?
但它也有明确的能力边界。
1. await() 会阻塞线程
如果计数尚未归零,await() 会阻塞调用它的线程。
在普通后台线程中,这可能意味着一个线程暂时不能执行其他工作;如果在 Android 主线程中调用,则可能影响 UI 响应,严重时可能触发 ANR。
2. CountDownLatch 不保护共享数据
CountDownLatch 的职责是协调等待和完成时机,不是提供互斥访问。
如果多个任务同时修改同一个可变对象,仍然需要使用锁、原子类或其他线程安全机制。
不过,它具有相应的内存可见性保证:任务在调用 countDown() 之前完成的操作,在另一个线程从 await() 成功返回后应当可见。
3. 计数归零不代表任务全部成功
即使 loadUser() 抛出异常,finally 中的 countDown() 仍然会执行。
这可以避免等待方永远阻塞,但也意味着:
计数归零只说明任务执行流程已经结束,不代表所有任务都成功了。
等待方不会自动获得任务的返回值和异常。
如果任务提交失败、根本没有启动,或者代码遗漏了 countDown(),等待方还可能一直等待。因此,实际项目通常还要设计:
- 异常收集;
- 返回值传递;
- 超时;
- 取消;
- 资源清理。
4. 它不管理整体任务生命周期
CountDownLatch 不知道这些任务为什么一起执行,也不会自动建立父子任务关系。
如果一个任务失败,它不会自动取消其他任务;如果页面已经销毁,它也不会自动停止与页面相关的任务。
它擅长解决的是:
"等到计数归零。"
但它不负责回答:
"这些任务属于谁?为什么一起执行?失败或取消之后怎么办?"
严格来说,CountDownLatch 与协程的 coroutineScope 并不是同一层级的工具。
前者是同步原语,后者表达的是任务的结构和生命周期。将它们放在一起比较,是为了展示两种不同的任务组织思路,而不是说 coroutineScope 是 CountDownLatch 的直接替代品。
五、Future:等待结果,也可能意味着阻塞线程
再看一个常见的 Java 并发场景:
java
Future<Data> future = executor.submit(() -> loadData());
Data data = future.get();
如果任务还没有完成,get() 会阻塞调用线程。
我们只是想等待一个任务的结果,却可能同时占住一个线程。
传统编程中,开发者经常需要在几种方案之间选择:
- 阻塞当前线程,等待任务完成;
- 注册回调,在任务结束后继续执行;
- 使用额外的同步工具协调多个任务;
- 使用
CompletableFuture构建异步任务链。
CompletableFuture 已经可以实现结果组合、异常处理和非阻塞式回调。现代 Java 的虚拟线程和结构化并发,也进一步改善了任务组织方式。
因此,问题并不是"Java 无法实现任务协作",也不能笼统地说"Java 的线程协作都是阻塞的"。
更准确的说法是:
Java 传统线程协作工具经常通过阻塞线程表达等待;协程则可以通过挂起协程表达等待。
不同模型都能组织任务,但它们表达任务依赖、取消、异常和生命周期关系的方式不同。
六、为什么线程等待可能导致 Android ANR?
Android 主线程负责处理输入事件、执行 UI 工作并参与界面绘制。
它必须及时响应,而不能长时间等待其他线程。
例如,后台线程持有一把锁:
java
synchronized (lock) {
loadData();
}
与此同时,主线程也尝试获取这把锁:
java
synchronized (lock) {
updateUI();
}
如果后台线程长时间持有锁,主线程就只能等待锁释放。
类似风险还包括:
- 在主线程调用
Future.get(); - 在主线程调用
Thread.join(); - 在主线程调用
CountDownLatch.await(); - 在主线程调用
BlockingQueue.take(); - 在主线程执行网络、文件等阻塞式 I/O;
- 主线程等待一把由后台线程长期持有的锁;
- 多个线程形成循环依赖,最终发生死锁。
这些问题的共同点是:
为了等待某个条件或任务完成,主线程停止了继续处理其他工作。
并不是所有等待都会导致 ANR,极短时间的等待未必能被用户感知。真正需要警惕的是,主线程因为阻塞调用、锁竞争或复杂依赖,无法及时处理系统要求它完成的工作。
这就引出了一个重要问题:
如果我们真正需要的是等待任务结果,是否一定要通过阻塞线程来实现?
七、Kotlin 协程:从线程阻塞到协程挂起
先看一个阻塞式调用:
kotlin
val result = future.get()
即使这段代码写在协程中,get() 依然可能阻塞执行它的线程。
把阻塞代码放进协程,不会自动把它变成非阻塞代码。
再看一个支持挂起的调用:
kotlin
viewModelScope.launch {
val result = repository.loadData()
uiState.value = result
}
假设 loadData() 使用了真正支持挂起的 API。
当结果尚未准备好时,当前协程可以挂起。执行它的线程不必停在那里等待,而可以继续执行其他任务。
等数据准备好后,协程再被恢复,并从挂起点之后继续执行。
这里的关键区别是:
线程阻塞,停下来的是线程;协程挂起,暂停的是协程本身。
线程是一种执行资源,协程则是可以挂起和恢复的任务。
协程挂起期间不必持续占用线程,因此大量处于等待状态的任务不一定需要一一对应大量线程。
八、同样的多任务协作,协程如何实现?
还是前面的场景:分别加载用户、订单和推荐信息。
使用 Kotlin 协程,可以这样组织:
kotlin
suspend fun loadPage(): PageData = coroutineScope {
val user = async {
loadUser()
}
val orders = async {
loadOrders()
}
val recommendations = async {
loadRecommendations()
}
PageData(
user = user.await(),
orders = orders.await(),
recommendations = recommendations.await()
)
}
三个 async 创建三个并发执行的子协程,await() 用来取得对应结果。
这里所说的"并发",表示多个任务在时间上可以交错推进,并不保证它们一定运行在三个不同的线程上。具体使用哪些线程,取决于协程上下文、调度器和底层操作。
1. 不需要手动维护计数器
使用 CountDownLatch 时,开发者需要初始化计数,并确保每个任务都在正确的时机调用 countDown()。
使用 coroutineScope 时,作用域会等待其中的子协程完成。
我们直接表达的是任务结构,而不是手动维护一个完成计数器。
2. 等待结果不必阻塞线程
Deferred.await() 是挂起函数。
如果结果尚未准备好,调用它的协程可以挂起,不需要像 Future.get() 那样阻塞当前线程。
不过,await() 本身不会自动切换线程,也无法修复子任务中的阻塞代码。
如果 loadUser() 内部直接在主线程执行阻塞操作,主线程仍然会被阻塞。
3. 任务具有明确的生命周期边界
三个 async 创建的子协程都属于同一个 coroutineScope。
作用域只有在所有子任务完成后才能正常返回。如果外部取消这个作用域,取消也会传播给其中的子协程。
这体现了结构化并发的重要原则:
任务不能脱离创建它的作用域随意存活。
九、协程也需要同步:Mutex
协程不会自动保证共享数据安全。
例如:
kotlin
var count = 0
coroutineScope {
repeat(1000) {
launch(Dispatchers.Default) {
count++
}
}
}
这些协程可能同时运行在不同线程上,而普通的 count++ 仍然不是原子操作。
最终结果可能小于 1000。
在协程环境中,可以使用 Mutex:
kotlin
private val mutex = Mutex()
private var count = 0
suspend fun increment() {
mutex.withLock {
count++
}
}
Mutex 用来协调多个协程对共享状态的访问。
如果锁不可用,尝试获取锁的协程可以挂起,而不是必须阻塞执行它的线程。
协程改善了等待方式,但不会自动解决共享状态的并发安全问题。
十、从 BlockingQueue 到 Channel:生产者和消费者如何协作?
除了锁和任务等待,生产者---消费者也是常见的协作场景。
Java 通常可以使用 BlockingQueue:
java
BlockingQueue<Data> queue = new ArrayBlockingQueue<>(10);
// 生产者线程
queue.put(loadData());
// 消费者线程
Data data = queue.take();
process(data);
BlockingQueue 是线程安全的,并且可以通过有界容量实现背压。
当队列已满时,put() 会等待可用空间;当队列为空时,take() 会等待新数据。
这里的等待是阻塞式的:
put()等待期间,生产者线程被阻塞;take()等待期间,消费者线程被阻塞;- 被阻塞的线程不能在这段时间执行其他任务。
当然,BlockingQueue 也提供了 offer()、poll() 等非阻塞或带超时的方法。
但如果需要在条件满足后自动继续执行,传统做法通常仍然需要阻塞线程、轮询,或者引入额外的异步调度机制。
使用 Channel 表达协程之间的数据传递
Kotlin 协程可以使用 Channel:
kotlin
val channel = Channel<Data>(capacity = 10)
val producer = launch {
repeat(100) {
channel.send(loadData())
}
channel.close()
}
val consumer = launch {
for (data in channel) {
process(data)
}
}
Channel 同样可以在生产者和消费者之间传递数据,并通过容量限制处理生产速度和消费速度不一致的问题。
关键区别在于:
- Channel 已满时,
send()可以挂起生产者协程; - Channel 为空时,
receive()可以挂起消费者协程; - 挂起期间,执行该协程的线程可以继续运行其他任务。
因此:
BlockingQueue 等待时通常阻塞线程,Channel 等待时可以挂起协程。
这不是说 Channel 完全不使用线程。数据的生产和消费仍然需要在线程上执行,只是等待数据或空间时不必一直占用线程。
Channel 也有非挂起操作
如果不希望等待,可以使用:
kotlin
val sendResult = channel.trySend(data)
val receiveResult = channel.tryReceive()
它们会立即返回结果,不会挂起协程。
这与 BlockingQueue 的 offer() 和 poll() 有些相似。
不同容量代表不同的协作方式
没有缓冲区的 Channel:
kotlin
val channel = Channel<Data>()
默认使用 rendezvous 模式。
生产者发送数据时,需要等待消费者准备接收。一次发送与一次接收直接完成协作。
有界 Channel:
kotlin
val channel = Channel<Data>(capacity = 10)
它允许生产者暂时领先消费者,但缓冲区满后,生产者会挂起,从而形成背压。
无限容量 Channel:
kotlin
val channel = Channel<Data>(capacity = Channel.UNLIMITED)
发送方通常不会因为容量不足而挂起,但如果消费者速度长期跟不上,数据可能不断堆积并消耗大量内存。
因此,Channel 并不是容量越大越好。
容量本身就是任务协作策略的一部分。
生命周期和结束信号
BlockingQueue 没有内置的"生产结束"语义。传统代码有时会放入一个特殊对象作为结束信号:
java
queue.put(POISON_PILL);
消费者取到这个对象后退出。
这种方式需要生产者和消费者提前约定协议,并正确处理多个消费者、异常和中断。
Channel 支持显式关闭:
kotlin
channel.close()
使用 for 循环接收数据时,Channel 关闭且剩余数据消费完毕后,循环会自然结束:
kotlin
for (data in channel) {
process(data)
}
Channel 还可以携带关闭原因。
不过,原始 Channel 不会因为创建在某个作用域中,就自动拥有完整的父子任务关系。
如果希望生产过程与协程作用域绑定,可以使用 produce 等协程构建器:
kotlin
val dataChannel: ReceiveChannel<Data> = produce {
repeat(100) {
send(loadData())
}
}
也可以在生产任务的 finally 中明确关闭 Channel。
Channel 不只是"非阻塞版 BlockingQueue"
二者都可以实现生产者---消费者模型,但它们属于不同的编程模型。
BlockingQueue 主要负责线程安全的数据队列和阻塞式等待,不会自动管理:
- 生产者属于哪个业务作用域;
- 消费者什么时候应该取消;
- 一个任务失败后其他任务怎么办;
- 页面销毁后整个数据流是否应该结束。
Channel 可以与协程作用域、取消机制及结构化并发组合,使数据传递与任务生命周期建立联系。
不过,这种联系仍然需要正确设计。随意创建一个全局 Channel,也可能导致资源泄漏、任务无法结束或数据无人消费。
因此,更准确的比较是:
BlockingQueue 主要在线程模型中协调数据传递,等待条件时通常阻塞线程;Channel 主要在协程模型中协调数据传递,等待条件时可以挂起协程。
十一、协程在多任务协作上的优势是什么?
可以从四个方面理解。
1. 从阻塞式等待,到挂起式等待
传统并发编程中,Future.get()、CountDownLatch.await()、BlockingQueue.take() 等操作可能阻塞调用线程。
协程可以通过挂起函数、await()、send() 和 receive() 表达等待,使线程在等待期间有机会执行其他任务。
这体现了协程的一个重要价值:
等待一个任务或条件,不一定需要持续占住一个线程。
但前提是调用链真正支持挂起,或者阻塞操作被合理地调度到了适当的线程池。
2. 从手动协调,到结构化组织
在传统 Java 代码中,我们可能需要组合:
ExecutorService;Future;CountDownLatch;BlockingQueue;- 取消机制;
- 异常容器;
- 超时机制。
协程通过 coroutineScope、Job、launch、async 和 Channel 等机制,可以在一个相对统一的模型中表达任务关系、数据传递、等待和取消。
这不是说 Java 无法实现相同能力,而是 Kotlin 协程对这些能力提供了更统一的组织方式。
3. 从等待任务完成,到管理任务生命周期
CountDownLatch 可以告诉我们什么时候计数归零,却不知道这些任务属于哪个页面、请求或业务流程。
结构化并发关心的是整个任务生命周期:
- 任务由谁启动?
- 谁负责等待它完成?
- 父任务取消后,子任务应该怎么办?
- 子任务失败后,其他任务是否继续?
- 页面销毁后,相关任务是否还应该存活?
例如,Android 中的 viewModelScope 会在 ViewModel 清理时取消其中的协程。
这种生命周期绑定,比单纯等待任务完成多了一层结构上的约束。
十二、不要把协程简单理解成轻量线程
"轻量线程"可以帮助初学者理解协程能够大量创建,但这个比喻也容易产生误导。
协程和线程不是同一层级的概念。
线程是操作系统或运行时提供的执行资源。协程则是由程序组织、可以挂起和恢复的任务。
多个协程可以共享少量线程,一个协程在不同阶段也可能由不同线程执行。
因此,使用协程时,不能只问:
这段代码运行在哪个线程?
还应该继续问:
- 这个协程属于哪个作用域?
- 它什么时候结束?
- 谁负责取消它?
- 它失败后会影响哪些任务?
- 它挂起时是否释放线程?
- 它调用的底层 API 是真正非阻塞,还是仍然会阻塞线程?
- 它是否访问了多个线程共享的可变状态?
- 生产者和消费者之间如何传递数据并处理背压?
这些问题比单纯记住几个调度器更加重要。
十三、总结
学习 Java 并发时,我们会遇到锁、线程池、Future、CountDownLatch 和 BlockingQueue 等工具。
它们分别解决:
- 共享数据互斥;
- 任务调度;
- 结果获取;
- 线程等待;
- 完成时机协调;
- 生产者和消费者之间的数据传递。
这些工具至今仍然重要,现代 Java 也在通过 CompletableFuture、虚拟线程和结构化并发不断改善任务组织方式。
因此,不能简单地认为 Java 的线程协作一定是阻塞的。
更准确的区别是:
传统线程协作通常以阻塞线程的方式表达等待,而协程可以通过挂起任务表达等待。
当任务关系变得复杂时,真正困难的往往不只是如何启动任务,而是如何回答下面这些问题:
- 这些任务属于谁?
- 谁等待它们完成?
- 一个任务失败后,其他任务怎么办?
- 外部流程取消后,内部任务是否继续?
- 结果、异常和资源应该如何统一处理?
- 生产者和消费者如何传递数据?
- 消费速度跟不上生产速度时,系统如何处理背压?
Kotlin 协程通过挂起机制,让等待不一定需要阻塞线程;通过结构化并发,让任务的生命周期、取消和失败传播具有明确边界;通过 Channel 等工具,让协程之间可以在不阻塞线程的情况下传递数据。
所以,可以把下面这句话作为理解协程的入口:
线程主要提供执行资源,协程提供可挂起的任务模型,结构化并发负责组织任务之间的协作。
如果想用一句更简洁、更容易记住的话来概括,也可以说:
线程关注执行,协程关注协作。
它不是严格定义,而是一种理解视角。 。