Kotlin 协程,重点就在「协」这个字上

很多人学习 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 等工具,让协程之间可以在不阻塞线程的情况下传递数据。

所以,可以把下面这句话作为理解协程的入口:

线程主要提供执行资源,协程提供可挂起的任务模型,结构化并发负责组织任务之间的协作。

如果想用一句更简洁、更容易记住的话来概括,也可以说:

线程关注执行,协程关注协作。

它不是严格定义,而是一种理解视角。 。

相关推荐
好的佩奇1 小时前
Dart 之模式
android·flutter·dart
淡淡的香烟3 小时前
Flutter实现多语言切换
android·flutter·1024程序员节
AIsoft_86895 小时前
手机视频转文字工具清单:哪些APP支持录屏识别
android·音视频
_祝你今天愉快8 小时前
Paint Cavans高级绘制:[一]Paint画笔高级应用
android
@zulnger9 小时前
安卓进行稳定性测试
android·appium·1024程序员节
不吃凉粉9 小时前
android 副屏连接
android
OxYGC11 小时前
手机为什么能“自动跳过“开屏广告?李跳跳、GKD 与 iOS 代理软件原理盘点
android·ios·开屏广告·广告屏蔽
Android-Flutter18 小时前
Compose 管理状态 详解
android·compose
缓慢更新20 小时前
审批的加签与会签:多人意见怎么合
android·java·数据库·低代码·rxjava