Ruby 多线程、GVL(GIL)与 Mutex 完全指南
Ruby 的多线程机制一直是开发者最容易产生误解的知识点之一。核心争议点围绕着 CRuby 的全局虚拟机锁(GVL,行业常俗称 GIL)展开:很多人以为有了 GVL 多线程就天然线程安全,也有人以为 GVL 让多线程完全没用。尤其是在 Ruby 3.x、Ruby 4.0 引入 Ractor 与 YJIT/ZJIT 之后,GVL 的语义发生了变化,很多经典的认知和示例也需要同步更新。
今天我们就从底层机制、版本差异、常见误区到工程实践,把 CRuby GVL、Ractor 并发、JRuby 多线程、Mutex 同步一次性讲透。
核心结论先行:
- CRuby(MRI Ruby)存在 GVL(全局虚拟机锁) ,Ruby 4.0 依然保留,但 GVL 是按 Ractor 持有的;同一 Ractor 内同一时刻只有一条线程执行 Ruby 字节码,不同 Ractor 中的线程可以真正并行执行。
- JRuby 没有 GVL,可以实现真正的多核并行多线程。
- 无论有没有 GVL,多线程并发修改共享可变变量都存在竞态条件,都需要 Mutex 做同步保护。
- 新版 Ruby 中极简竞态示例难以复现,不代表线程安全,只是触发概率变低。
一、CRuby 的 GVL:全局虚拟机锁
1. 基本概念
GVL 的全称是 Global VM Lock(全局虚拟机锁),行业内常沿用其他语言的叫法俗称 GIL(全局解释器锁)。在 Ruby 引入 Ractor 并发模型之后,GVL 的语义已经发生了重要变化:
- GVL 按 Ractor 持有:GVL 不再是整个进程唯一的全局锁,而是每个 Ractor 各自持有一把。同一 Ractor 内的线程需要争抢 GVL,同一时刻只能有一条线程执行 Ruby 字节码;但不同 Ractor 中的线程互不阻塞,可以在多核 CPU 上真正并行执行。
- 与 VM Lock 的区分 :GVL 并不是虚拟机唯一的底层锁。Ruby 还有更全局的 VM Lock,用于保护跨 Ractor 的临界区操作;而常规的 Ruby 线程执行,主要受各自 Ractor 的 GVL 约束。
GVL 的核心作用是保护虚拟机内部的核心数据结构,避免并发操作导致解释器崩溃。对于只使用传统 Thread 而不使用 Ractor 的程序,所有线程都运行在同一个默认 Ractor 中,行为和旧版本一致,仍然受单把 GVL 的约束。
2. GVL 的释放时机
GVL 不是一直占着不放的,以下场景线程会主动释放锁:
- IO 阻塞:网络请求、文件读写、数据库操作等系统调用;
sleep休眠;Thread.pass主动让出执行权;- 线程执行达到时间片阈值(约 100ms),虚拟机主动触发抢占切换。
由此衍生出两种典型场景的性能差异:
- IO 密集型任务:多线程可以获得并发收益。IO 等待期间 GVL 被释放,其他线程可以继续执行,整体吞吐量提升。
- CPU 密集型任务:单 Ractor 内的多线程无法真正并行,只能并发轮转。多个线程争抢 GVL,反而可能因为切换开销比单线程更慢,无法利用多核 CPU;如果想要利用多核,可以使用多 Ractor 或者多进程方案。
二、最大误区:GVL 不保护你的业务共享变量
这是多线程领域最经典的认知误区:很多人觉得"有 GVL 保护,多线程修改共享变量天然安全,不用加锁"。
这个结论是完全错误的。GVL 只保护虚拟机内部的底层状态,不保护你业务代码中的共享对象和变量。
1. 为什么经典示例在新版 Ruby 总是"正确"?
最经典的多线程累加示例:
ruby
count = 0
threads = 10.times.map do
Thread.new do
1000.times { count += 1 }
end
end
threads.each(&:join)
puts count
在 Ruby 2.6 及更早版本中,这段代码经常返回小于 10000 的结果;但在 Ruby 3.x / 4.0(开启 YJIT/ZJIT)中,反复运行几乎每次都是 10000。甚至把自增拆分为简单方法调用,也依然很难复现计数丢失。
原因不是竞态消失了,而是触发概率很低:
- 单次
count += 1执行时间极短,而 Ruby 的线程抢占是基于约 100ms 的时间片阈值。千次级别的简单循环,线程往往能在一个时间片内完成全部迭代,很难刚好在某次迭代的「读取-计算-写回」中间触发抢占。 - 简单的自定义方法会被 YJIT 内联(inline)优化,进一步减少了中间的调度安全点,让切换更难发生在读写之间。
- 即使在循环末尾加上
sleep 0,也只是在一次自增完整执行完毕之后才释放 GVL,不会打断自增逻辑本身。
补充说明:如果循环体量足够大、执行时间足够长,线程依然会被时间片机制强制抢占,理论上依然可能出现计数丢失。只是对于短循环的极简场景,落在时间片间隙中的概率极低,难以稳定复现。
2. 稳定复现竞态的正确写法
要稳定复现「读-写之间发生线程切换」,最可靠的方式是在读取共享变量之后、写回之前,主动插入线程调度点,强制释放 GVL 让出执行权。
ruby
count = 0
threads = 10.times.map do
Thread.new do
1000.times do
tmp = count # 第一步:读取共享变量
Thread.pass # 关键:读取后主动让出 GVL,触发线程切换
count = tmp + 1 # 第二步:拿着旧值写回,产生覆盖丢失
end
end
end
threads.each(&:join)
puts count
# 多次运行结果稳定小于 10000,可稳定复现竞态
3. 原理解析
Thread.pass 会主动释放 GVL,让调度器切换到其他线程执行,整个过程精准制造了竞态窗口:
- 线程 A 读取
count的当前值,存入局部变量tmp; - 执行
Thread.pass,线程 A 主动让出 GVL,暂停执行; - 线程 B 获得 GVL,读取同一个
count值,完成 +1 计算并写回; - 线程 A 重新获得 GVL 恢复执行,手里的
tmp依然是旧值,计算后写回,直接覆盖线程 B 的修改。
最终结果就是一次 +1 操作凭空丢失,这就是典型的竞态条件(Race Condition)。
4. 关键结论
count += 1 在逻辑上依然是「读-改-写」三步操作,不是原子操作 。
新版 Ruby 中 JIT 优化和抢占机制的变化,只是让极简场景下的竞态变得难以触发,绝不代表代码是线程安全的。生产环境中业务逻辑远比重纯累加复杂,方法调用、对象分配、IO 操作处处都是调度安全点,线程切换随时可能发生在共享变量的读写之间。
一句话说透:
GVL 保证单 Ractor 内的 Ruby 代码不能并行执行 ,但不保证不会交错执行;只要存在交错执行,共享可变状态就存在竞态风险。
三、JRuby 有没有 GVL?
JRuby 没有 GVL。
JRuby 运行在 JVM 之上,直接复用 JVM 的原生线程模型,Ruby 线程会一对一映射到操作系统线程,可以在多核 CPU 上真正并行执行 Ruby 代码。
CRuby vs JRuby 多线程对比
| 特性 | CRuby(MRI,单 Ractor) | JRuby |
|---|---|---|
| GVL | 有 | 无 |
| CPU 密集型 | 并发轮转,无法利用多核 | 真正并行,可利用多核 |
| IO 密集型 | 可并发,有收益 | 可并发,性能更好 |
| 共享变量竞态 | 存在,难触发 | 存在,更易触发 |
四、Mutex:互斥锁解决竞态问题
Thread::Mutex 是 Ruby 标准库提供的互斥锁,用来保护临界区代码,保证同一时刻只有一个线程进入临界区,是解决共享变量竞态的标准方案。
无论 CRuby 还是 JRuby,Mutex 的用法和行为完全一致。
1. 基础 API
Mutex.new:创建一个新的互斥锁;#lock:获取锁,获取不到则阻塞等待;#unlock:释放锁;#synchronize { ... }:最推荐用法,自动获取锁,代码块执行完毕(或抛出异常)后自动释放锁,避免死锁;#try_lock:尝试获取锁,不阻塞,成功返回 true,失败返回 false。
2. 修复多线程累加示例
ruby
count = 0
mutex = Thread::Mutex.new
threads = 10.times.map do
Thread.new do
1000.times do
mutex.synchronize do
count += 1
end
end
end
end
threads.each(&:join)
puts count # 始终为 10000,结果正确
3. 重要特性:默认不可重入
Ruby 标准的 Mutex 是非可重入锁 。同一个线程如果重复调用 lock,会直接造成死锁:
ruby
mutex = Mutex.new
mutex.lock
mutex.lock # 死锁,程序卡住
如果需要可重入锁(比如递归方法中加锁),使用 Thread::RecursiveMutex:
ruby
mutex = Thread::RecursiveMutex.new
mutex.lock
mutex.lock # 不会死锁,同一个线程可重入
mutex.unlock
mutex.unlock
4. 最佳实践
- 优先使用
synchronize代码块,不要手动 lock/unlock,避免异常导致锁不释放; - 尽量缩小锁的粒度,只把读写共享变量的临界区放入
synchronize,减少锁竞争; - 避免在锁内执行 IO 操作,会长时间占用锁,降低并发性能。
五、条件变量 ConditionVariable
多线程协作除了互斥,还经常需要"等待-唤醒"场景,比如生产者消费者模型。Thread::ConditionVariable 就是配套 Mutex 使用的条件变量。
标准生产者消费者示例
条件变量的正确使用范式是搭配 while 循环检查条件 ,而不是直接 wait。这一方面可以避免生产者先发信号、消费者后等待导致的信号丢失,另一方面也可以应对系统的虚假唤醒(spurious wakeup)。
ruby
mutex = Thread::Mutex.new
cv = Thread::ConditionVariable.new
queue = []
# 生产者
producer = Thread.new do
5.times do |i|
mutex.synchronize do
queue << i
cv.signal # 唤醒一个等待的消费者线程
end
sleep 0.1
end
end
# 消费者
consumer = Thread.new do
5.times do
mutex.synchronize do
while queue.empty? # 关键:循环检查条件,避免错过信号或虚假唤醒
cv.wait(mutex) # 释放锁并阻塞等待,被唤醒后重新获取锁,再次检查条件
end
puts "消费:#{queue.shift}"
end
end
end
producer.join
consumer.join
六、高频踩坑汇总
-
误区:有 GVL 就不用加锁
GVL 只保护虚拟机内部状态,不保护业务层面的共享变量。并发读写共享对象依然存在竞态,必须加锁。
-
误区:极简示例结果正确就是线程安全
Ruby 3.x/4.0 中短循环竞态难以触发,但不代表不存在。业务代码逻辑复杂,切换点更多,不能靠本地测试几次就判定线程安全。
-
误区:JRuby 无 GVL 就不用锁
真正并行的环境下竞态更容易发生,反而更需要严格的同步机制。
-
误区:Mutex 是可重入锁
默认
Mutex不可重入,同一个线程重复加锁会死锁;需要可重入请使用RecursiveMutex。 -
误区:CPU 密集任务开多线程能提速
单 Ractor 下 CRuby 受 GVL 限制,CPU 密集场景多线程不会提速,反而可能变慢。这类场景优先用多 Ractor 或者多进程,或者切换到 JRuby。
-
误区:条件变量
wait不需要检查条件直接调用
wait可能错过信号或者遭遇虚假唤醒,必须使用while循环在等待前后校验条件。
七、并发方案选型建议
- IO 密集型场景(网络请求、文件读写、数据库操作):CRuby 多线程是性价比很高的选择,代码轻量,IO 等待期间可以并发执行。
- CPU 密集型场景:CRuby 环境优先使用多 Ractor 或者多进程(每个执行单元有独立的 GVL,可利用多核);如果可以切换运行时,JRuby 多线程是更优雅的方案。
结语
Ruby 的多线程机制不是简单的"有 GVL 所以没用",也不是"有 GVL 所以天然安全"。GVL 的存在让单 Ractor 内的多线程在 CPU 密集场景受限,但在 IO 密集场景依然有很高的价值;而它带来的线程安全错觉,才是最多开发者踩坑的地方。
理解 GVL 的现代语义、竞态的触发条件,以及正确使用 Mutex 和条件变量做同步,才能写出真正健壮、高效的 Ruby 并发代码。最后记住一句话:
GVL 是虚拟机的锁,不是你的业务锁;只要共享可变状态,就永远不要假设线程安全。