一、问题起手:一个“看起来完全没问题”的代码
很多人在协程里第一次写到下面这样的代码时,都会觉得这没什么问题:
- 用了
ReentrantLock - 做了余额判断
- 甚至切到 单线程 dispatcher
但真实运行结果却可能是:
currentBalance = -500
更诡异的是:
- 多线程:直接抛异常
- 单线程:不报错,但结果错
这就引出了本文的核心问题:
为什么 Java 的锁,在协程里看起来“完全不可靠”?
二、synchronized 与协程的根本冲突
2.1 synchronized 的语义:线程绑定的 JVM monitor
在 JVM 层面,synchronized 的语义非常明确:
-
锁的拥有者是 Thread
-
进入同步块 → 当前线程获取 monitor
-
退出同步块 → 必须由同一个线程释放
-
JVM 假设:
持锁期间,线程不会“消失”
2.2 协程的语义:可挂起、可恢复、可迁移
而协程的 suspend 意味着:
-
当前逻辑执行可以 挂起
-
线程会被 释放
-
恢复时:
- 可能是同一个线程
- 也可能是另一个线程
也就是说:
协程绑定的是“逻辑执行流”, 而不是某个固定线程。
2.3 语义正面冲突
如果允许下面的代码存在:
synchronized(lock) {
delay(1000)
}
那么 JVM 将面对一个无法成立的事实:
- monitor 是线程 A 拿的
- delay 后代码可能在线程 B 执行
- 线程 B 却在“同步块”中运行
这会直接破坏 JVM 对 monitor 的基本假设。
👉 因此 Kotlin 的选择是:
在编译期直接禁止: synchronized / @Synchronized 中不能出现挂起点
三、为什么 ReentrantLock 却能“正常编译”?
3.1 语言级 vs 库级
synchronized是 语言级特性ReentrantLock是 普通 Java 类
lock.lock()
delay(1000)
lock.unlock()
在 Kotlin 编译器看来,这只是普通方法调用:
“我不知道你这是锁, 我也不知道你有什么语义。”
所以:
- 编译期 不拦
- 风险 全部留给运行期
四、同一段代码,ReentrantLock 的两种结局
我们统一看下面这段代码:
4.1 多线程调度:直接抛异常
如果在多线程 dispatcher 下运行:
这个启动协程去线程调度和在方法上线程调度 本质是一样的.
你很快会看到:
IllegalMonitorStateException
原因非常直接:
lock.lock()在 线程 Adelay()挂起后释放线程- 协程恢复时可能在 线程 B
lock.unlock()却由线程 B 执行
而 JVM 的规则是:
❌ 只能由拿锁的线程释放锁
👉 这是 JVM 级非法行为,直接抛异常。
4.2 单线程调度:不报错,但结果是 -500
很多人会尝试“绕过”这个问题:
val dispatcher = newSingleThreadContext("single")
在这种情况下:
lock.lock()/unlock()确实发生在同一个 Thread 实例- 不会抛异常
但结果却可能是:
currentBalance = -500
4.3 为什么单线程下也会算错?
因为问题已经不是“线程安全”,而是:
挂起把“时间”引进了临界区
真实执行时间线(单线程):
balance = 1000
启动 3 个协程
- 协程 A:判断通过 → delay
- 1 秒后:扣 500 → balance = 500
- 协程 B:判断通过 → delay
- 1 秒后:扣 500 → balance = 0
- 协程 C:判断在 delay 前已成立 → 再扣 500
最终结果:
balance = -500
⚠️ 注意:
- 没有线程并发
- 锁没有失效
- 只是判断结果在 suspend 后已经过期
4.4 小结:两种死法
| 场景 | 表现 | 根因 |
|---|---|---|
| 多线程 + Lock | 抛异常 | 锁 owner 是 Thread |
| 单线程 + Lock | 结果错误 | 时间竞态 |
| 任意线程 + Mutex | 语义成立 | 锁绑定协程 |
五、为什么 Mutex 在协程里是“语义正确”的
Mutex 的关键区别在于:
- 它锁的不是线程
- 而是 协程执行权
也就是说:
ReentrantLock → owner = Thread
Mutex → owner = Coroutine
因此:
- 挂起不会破坏锁语义
- 恢复后仍是同一个逻辑执行者
六、总结
ReentrantLock 的世界观是: ❌ 锁的 owner 必须是同一个线程
而协程的世界观是: ✅ 只要是“同一个协程”,逻辑就是连续的
当线程锁遇到可迁移执行的协程时, 两套宇宙法则发生了正面冲突。
synchronized→ JVM 线程模型 → 编译期禁止 suspendReentrantLock→ 库级 API → 运行期风险自负Mutex→ 协程原生同步 → 唯一语义正确的选择