一、从一个打印问题说起
两个执行单元,一个负责打印偶数,一个负责打印奇数,要求按顺序输出:
text
偶数:0
奇数:1
偶数:2
奇数:3
......
表面上看,这是一个打印问题。
但它真正要解决的是执行单元之间的通信:当前一方完成后,如何通知另一方继续?还没轮到自己时,又应该如何等待?
Java 线程可以使用 synchronized 配合 wait/notify、Lock/Condition 或 Semaphore 来协调。
Kotlin 协程则可以使用 Mutex、Channel 或协程版 Semaphore。
本文先看项目中的线程版本,再看协程版本,重点观察一个问题:
等待时,线程和协程分别发生了什么?
二、线程版本:等待会阻塞线程
项目中的 Sample_02.kt 虽然是 Kotlin 源文件,但使用的是 Java 的 Thread 和 java.util.concurrent.Semaphore,展示了基于线程的交替执行。
核心代码如下:
kotlin
var count = 0
val semEven: Semaphore = Semaphore(1) // 偶数线程先执行
val semOdd: Semaphore = Semaphore(0) // 奇数线程初始阻塞
val t1 = Thread(Runnable {
while (count <= MAX_COUNT) {
semEven.acquire() // 阻塞线程,直到获得许可
log("even --> count: $count")
count++
Thread.sleep(500) // 线程阻塞睡眠
semOdd.release() // 释放许可,唤醒奇数线程
}
})
val t2 = Thread(Runnable {
while (count <= MAX_COUNT) {
semOdd.acquire() // 阻塞线程,直到获得许可
log("odd --> count: $count")
count++
Thread.sleep(500) // 线程阻塞睡眠
semEven.release() // 释放许可,唤醒偶数线程
}
})
偶数信号量初始有一个许可,所以 t1 先执行。
它打印并递增 count,然后通过 semOdd.release() 通知 t2。奇数线程拿到许可后执行,再释放 semEven,把执行机会交还。
这里的等待是阻塞式的:
Semaphore.acquire()拿不到许可时,线程停在调用处。Thread.sleep(500)时,线程暂停。- 等待期间,这两条工作线程不能去执行其他任务。
补充:Semaphore 在这里同时承担了两个职责。
- 通知:告诉对方轮到自己了。
- 互斥:通过两个信号量交替交接,保证同一时刻只有一方操作
count。
三、协程版本:等待会挂起协程
Sample_01.kt 使用 kotlinx.coroutines.sync.Semaphore,在两个协程之间交接执行机会:
kotlin
val semEven = Semaphore(1) // 初始为 1,让偶数协程先跑
val semOdd = Semaphore(1, acquiredPermits = 1) // 创建后立即消耗 1 个许可,等效于初始为 0
var count = 0
val job1 = launch { // 偶数协程
while (count <= maxCount) {
semEven.acquire() // 挂起协程,等待许可
log("even --> count: $count")
count++
delay(1500) // 挂起协程,不阻塞线程
semOdd.release() // 唤醒奇数协程
}
}
val job2 = launch { // 奇数协程
while (count <= maxCount) {
semOdd.acquire()
log("odd --> count: $count")
count++
delay(1500)
semEven.release()
}
}
launch { // 后台定时协程
while (true) {
log("this Thread can work")
delay(500)
}
}
这里有一个值得注意的细节:
kotlin
Semaphore(1, acquiredPermits = 1)
这是 kotlinx.coroutines.sync.Semaphore 的构造函数。
第二个参数表示创建时预先消耗的许可数。
因此,Semaphore(1, acquiredPermits = 1) 相当于创建一个最大许可数为 1 的信号量,但创建时就已经用掉了一个许可,等效于初始可用许可为零,也就是 Java 版的 Semaphore(0)。
协程版的关键区别:
Semaphore.acquire()没有许可时,协程挂起,而不是让承载它的线程阻塞。delay(1500)也是挂起协程 ,不会像Thread.sleep(1500)那样让线程停住。
这就是非阻塞挂起的核心:
等待中的协程暂停了,线程仍然可以运行其他任务。
四、核心对比:阻塞 vs 挂起
两个版本的业务逻辑没有本质区别,都是通过 Semaphore 交接执行机会。
真正的区别发生在等待时。
左侧是线程版本,右侧是协程版本:

一句话概括:
线程阻塞 = 线程被占用;协程挂起 = 线程可以被复用。
五、日志验证:挂起不等于切换线程
很多初学者有一个误解:
协程是轻量级线程,所以一定运行在不同线程上。
运行日志可以直接打破这个误解:
text
[main] even --> count: 0
[main] this Thread can work
[main] this Thread can work
[main] this Thread can work
[main] odd --> count: 1
[main] this Thread can work
...
所有输出都是 [main]。
为什么?
因为 launch 没有指定调度器,子协程继承 runBlocking 的上下文,全部在 main 线程上执行。
偶数协程调用 delay(1500) 后挂起,main 线程转头去运行每隔 500ms 打印一次的后台协程。
1.5 秒过后,偶数协程恢复,main 线程又回来继续执行它。
下面这张时间线展示了同一个 main 线程如何在协程之间切换,以及它和 Thread.sleep 的区别:

上图展示了两个不同的执行过程:
Thread.sleep(1500):main 线程在整个等待期间都被占用。delay(1500):协程挂起,main 线程可以继续执行后台协程。
这里还有一个容易混淆的概念:
协程挂起不等于线程切换。
挂起描述的是协程的执行状态,线程切换描述的是代码实际运行在哪条线程上。这是两个不同的维度。
六、交替执行:等待时究竟发生了什么
再回到最初的打印问题。
偶数方打印完成后,通知奇数方;奇数方打印完成后,再通知偶数方。
整个业务流程是一样的:

真正产生区别的是没有获得执行许可的时候:
- 线程版本:线程阻塞,等待许可。
- 协程版本:协程挂起,等待许可。
下面这张图进一步展示了等待期间的线程资源状态:

这里需要强调:
协程并没有消除等待,也没有缩短任务本身的耗时。
它只是让等待不再必须占用线程。
七、两个版本的核心差异
把前面的实验放在一起,可以得到下面这张对照表:
| 维度 | Sample_02(线程版) | Sample_01(协程版) |
|---|---|---|
| 执行单元 | Java Thread | Kotlin Coroutine |
| 信号量 | Java Semaphore | Kotlin Semaphore |
| 等待许可 | 阻塞线程 | 挂起协程 |
| 模拟耗时 | Thread.sleep | delay |
| 等待时线程状态 | 被占用 | 可以执行其他协程 |
| 当前实验线程配置 | 2 条工作线程 + 1 条 main 线程 | 1 条 main 线程承载 3 个协程 |
| 信号量职责 | 通知 + 互斥 | 通知 + 互斥 |
| 内存可见性 | Semaphore 的 happens-before 保证 | Semaphore 的内存一致性保证 |
这里的线程数量是当前实验的配置,不代表所有线程实现都需要三条线程。
另外,协程也不是天然不会阻塞线程。
例如:
kotlin
launch {
Thread.sleep(1500)
}
虽然代码运行在协程中,但 Thread.sleep() 仍然会阻塞底层线程。
而:
kotlin
launch {
delay(1500)
}
才是真正的挂起等待。
这也是为什么在实际 Android 项目中,我们需要关注 API 是否具有 Main-safe 特性,而不能仅仅因为调用方使用了协程,就认为底层操作一定是非阻塞的。
八、总结
两个示例都在演示执行权如何交接,但等待方式根本不同。
Sample_02(线程版):
Semaphore.acquire() 和 Thread.sleep() 会阻塞工作线程。等待期间线程被占用,无法继续执行其他任务。
Sample_01(协程版):
Semaphore.acquire() 和 delay() 会挂起协程。等待期间底层线程可以继续运行其他协程。
理解并发方案,抓住两个维度就够了:
- 通信机制:执行单元通过什么方式交接?Semaphore、wait/notify、Channel,还是 Mutex?
- 等待对象:等待发生时,被暂停的是线程,还是协程?
工具可以不同,但第二个维度才是区分阻塞与非阻塞的关键。
协程不是让等待消失了,而是让等待不再必须占用线程。
九、最好动手写一遍
实现奇偶数交替打印的方式其实有很多种,核心逻辑万变不离其宗------无非是互斥 与通知。
建议大家不妨亲自去动手试一试下面这些不同的实现方案,感受它们的异同:
- Java 传统派 :
synchronized+wait() / notifyAll()、ReentrantLock+Condition - Kotlin 协程派 :
Mutex(互斥锁)、Channel(信道单向通信)、或者用演员模型(Actor)
动手敲一遍,比看十遍文章更能深刻理解"阻塞"与"挂起"的本质。