换个思路理解:非阻塞挂起

0 阅读7分钟

一、从一个打印问题说起

两个执行单元,一个负责打印偶数,一个负责打印奇数,要求按顺序输出:

偶数: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,展示了基于线程的交替执行。

核心代码如下:

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,在两个协程之间交接执行机会:

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)
    }
}

这里有一个值得注意的细节:

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 交接执行机会。

真正的区别发生在等待时。

左侧是线程版本,右侧是协程版本:

阻塞与挂起对比流程图

一句话概括:

线程阻塞 = 线程被占用;协程挂起 = 线程可以被复用。

五、日志验证:挂起不等于切换线程

很多初学者有一个误解:

协程是轻量级线程,所以一定运行在不同线程上。

运行日志可以直接打破这个误解:

[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 ThreadKotlin Coroutine
信号量Java SemaphoreKotlin Semaphore
等待许可阻塞线程挂起协程
模拟耗时Thread.sleepdelay
等待时线程状态被占用可以执行其他协程
当前实验线程配置2 条工作线程 + 1 条 main 线程1 条 main 线程承载 3 个协程
信号量职责通知 + 互斥通知 + 互斥
内存可见性Semaphore 的 happens-before 保证Semaphore 的内存一致性保证

这里的线程数量是当前实验的配置,不代表所有线程实现都需要三条线程。

另外,协程也不是天然不会阻塞线程。

例如:

launch {
    Thread.sleep(1500)
}

虽然代码运行在协程中,但 Thread.sleep() 仍然会阻塞底层线程。

而:

launch {
    delay(1500)
}

才是真正的挂起等待。

这也是为什么在实际 Android 项目中,我们需要关注 API 是否具有 Main-safe 特性,而不能仅仅因为调用方使用了协程,就认为底层操作一定是非阻塞的。

八、总结

两个示例都在演示执行权如何交接,但等待方式根本不同。

Sample_02(线程版):

Semaphore.acquire() 和 Thread.sleep() 会阻塞工作线程。等待期间线程被占用,无法继续执行其他任务。

Sample_01(协程版):

Semaphore.acquire() 和 delay() 会挂起协程。等待期间底层线程可以继续运行其他协程。

理解并发方案,抓住两个维度就够了:

  1. 通信机制:执行单元通过什么方式交接?Semaphore、wait/notify、Channel,还是 Mutex?
  2. 等待对象:等待发生时,被暂停的是线程,还是协程?

工具可以不同,但第二个维度才是区分阻塞与非阻塞的关键。

协程不是让等待消失了,而是让等待不再必须占用线程。

九、最好动手写一遍

实现奇偶数交替打印的方式其实有很多种,核心逻辑万变不离其宗——无非是互斥与通知。

建议大家不妨亲自去动手试一试下面这些不同的实现方案,感受它们的异同:

  • Java 传统派:synchronized + wait() / notifyAll()、ReentrantLock + Condition
  • Kotlin 协程派:Mutex(互斥锁)、Channel(信道单向通信)、或者用演员模型(Actor)

动手敲一遍,比看十遍文章更能深刻理解“阻塞”与“挂起”的本质。

参考代码