Ruby GIL 全局解释锁详解

Ruby GIL 全局解释锁详解:多线程、Ractor 并发正确使用姿势

很多人学 Ruby 多线程时,都会被 GIL 这个"看不见的锁"绊住脚:明明写了多个线程,CPU 多核却跑不满,计算密集任务跑起来甚至比单线程还慢。这不是你的代码写得差,而是 Ruby 的全局解释器锁在背后悄悄掌控着一切。今天我们就把 GIL 的底层逻辑、传统多线程的适用边界、Ractor 的真正用法一次性讲透,帮你在 Ruby 里写出真正高效的并发代码。

一、GIL 到底是什么?为什么 Ruby 要留着它?

GIL 全称 Global Interpreter Lock,是 CRuby(也就是我们日常用的标准 Ruby 解释器)内置的一把全局互斥锁。它有一个铁律:‌同一时刻,同一个 Ruby 进程里,永远只有一个线程能执行 Ruby 字节码‌。哪怕你的机器是 16 核、32 核,只要在单进程里,多线程也没法把多个核心同时用起来跑 Ruby 代码。

很多人会疑惑:为什么 Ruby 要设计这么一个"反常识"的机制?这背后其实是历史和安全的双重选择。Ruby 底层的 C 语言实现里,几乎所有内置数据结构------数组、哈希、字符串,甚至对象内存分配,都不是天然线程安全的。如果没有 GIL 保护,多线程同时修改同一个数组时,底层的"取长度、检查容量、写入元素、更新长度"这几步操作就会被打断,轻则数据错乱,重则直接崩溃。GIL 相当于给整个 Ruby 解释器的内部状态加了一层大保护罩,不用给每一个底层操作单独加细粒度锁,就从根源上避免了绝大多数底层竞态条件,大幅降低了解释器的实现复杂度,也让日常写 Ruby 代码时,基础操作默认是安全的。

Ruby 的 GIL 不是完全"焊死"的,它内置了一个隐藏的定时器线程,一旦有多个线程在等待执行,定时器就会定期给当前持有 GIL 的线程设置中断标记,强制它释放锁,让其他线程有机会抢到执行权。但即便有这个调度机制,本质上还是"同一时间只有一个线程跑 Ruby 代码",没法实现真正的多核并行。

二、别再乱用多线程了:传统 Thread 的正确打开方式

很多新手踩的第一个坑:上来就用多线程跑计算密集任务,期待速度翻倍,结果发现总耗时和单线程几乎没区别,甚至因为线程切换开销还变慢了。这不是多线程没用,而是你用错了场景。

传统的 Ruby Thread 依托操作系统原生线程,虽然受 GIL 限制没法并行跑计算,但只要遇到 I/O 操作------比如网络请求、文件读写、数据库查询,线程就会主动释放 GIL,让出执行权。这时候其他线程就可以立刻开始执行,不用傻傻等当前 I/O 完成。所以 Ruby 原生多线程的天生主场,是‌I/O 密集型任务‌,而不是计算密集型任务。

举个最典型的爬虫场景,串行请求 10 个网页,每个网页耗时 1 秒,总耗时就要 10 秒。用多线程改造后,10 个请求几乎同时发起,总耗时只需要 1 秒左右,性能直接翻 10 倍:

ruby

require 'net/http'

urls = 10.times.map { |i| "https://example.com/page/#{i}" }

串行版本总耗时约 10 秒

urls.each { |url| Net::HTTP.get(URI(url)) }

多线程版本总耗时约 1 秒

threads = urls.map do |url|

Thread.new(url) do |u|

Net::HTTP.get(URI(u))

puts "完成请求: #{u}"

end

end

threads.each(&:join)

但这里有个关键提醒:哪怕有 GIL 保护,涉及多个线程同时修改同一个共享变量时,依然会出现竞态条件。因为 GIL 会在方法调用、I/O 操作的间隙主动释放,这时候多个线程就可能同时切入修改同一份数据。比如下面这段代码,你期待最终结果是 1000000,但实际跑出来大概率是一个小于预期的随机数:

ruby

count = 0

100.times.map do

Thread.new { 10000.times { count += 1 } }

end.each(&:join)

puts count # 几乎不可能输出 1000000

这种场景下,必须用 Ruby 内置的 Mutex 互斥锁来保护临界区代码,确保同一时间只有一个线程能修改共享资源,彻底避免数据错乱:

ruby

mutex = Thread::Mutex.new

count = 0

100.times.map do

Thread.new do

10000.times do

mutex.lock

count += 1

mutex.unlock

end

end

end.each(&:join)

puts count # 稳定输出 1000000

总结一下传统 Thread 的使用铁则:

✅ 优先用在网络请求、文件读写这类 I/O 密集场景,能获得数倍性能提升

✅ 任何共享资源的修改,必须用 Mutex 包裹,不要赌 GIL 能帮你保证安全

❌ 绝对不要用多线程跑 CPU 密集的计算任务,完全没有并行收益

三、绕过 GIL 的真正并行:Ractor 不是银弹,是新范式

Ruby 3.0 正式引入的 Ractor,彻底打破了 GIL 的限制。很多人以为 Ractor 只是"升级版线程",其实它的底层逻辑完全不一样:每个 Ractor 都拥有自己独立的对象堆、独立的 GIL,多个 Ractor 可以在多核 CPU 上真正并行执行,互不干扰。

Ractor 从设计之初就从根源上解决了线程安全问题:不同 Ractor 之间默认完全隔离,不能直接共享普通对象,所有数据交互只能通过消息传递完成。没有共享内存,自然就不会出现传统多线程里的竞态条件,你完全不用写 Mutex 锁,就能写出天然线程安全的并行代码。

创建一个基础的 Ractor 非常简单,代码块里的逻辑会在独立的隔离环境中运行,执行完成后可以把结果 yield 出来,主线程通过 take 方法获取返回值:

ruby

ractor = Ractor.new do

这里的代码完全运行在独立的隔离环境中

1000000.times.sum

end

result = ractor.take

puts "计算结果: #{result}"

Ractor 之间的消息传递有两种标准模式,完全不用碰共享内存:

Pull 模式‌:子 Ractor 用 Ractor.yield 向外发送数据,主线程用 ractor.take 拉取结果,适合子任务返回计算结果的场景

Push 模式‌:主线程用 ractor.send 向子 Ractor 推送数据,子 Ractor 用 Ractor.receive 接收消息,适合给子任务分发输入参数的场景

下面是一个典型的并行计算场景,用 4 个 Ractor 拆分计算任务,充分利用 4 核 CPU,总耗时几乎是单线程的 1/4:

ruby

创建 4 个并行的计算 Ractor

ractors = 4.times.map do |i|

Ractor.new(i) do |index|

每个 Ractor 负责自己分片的计算任务

start = index * 250000

total = 250000.times(start).sum

"Ractor #{index} 计算结果: #{total}"

end

end

收集所有 Ractor 的返回结果

ractors.each { |r| puts r.take }

但 Ractor 也有自己的使用边界,这些坑一定要避开:

不要在 Ractor 里意外传入可变对象,普通数组、哈希这类可变对象默认是不能跨 Ractor 传递的,强行传递会直接报错

不要创建几十上百个 Ractor,每个 Ractor 都有独立的解释器实例,创建和调度开销远大于普通线程,一般 Ractor 数量和 CPU 核心数匹配是最优选择

不要在 Ractor 里直接复用主线程的数据库连接,每个 Ractor 需要单独初始化自己的连接,否则会出现连接状态错乱

主线程退出时,所有子 Ractor 会被强制终止,一定要确保关键任务的结果已经被主线程 take 接收

四、最终选型指南:你的场景该用谁?

最后给大家一张清晰的决策表,再也不用纠结并发方案的选择:

表格

场景类型 首选方案 核心收益

网络爬虫、批量接口请求、文件批量读写 传统 Thread + Mutex 轻量、语法简单,I/O 等待时自动切换,开发成本最低

复杂数学计算、大数据分片处理、图片批量生成 Ractor 真正多核并行,计算性能随核心数线性提升,天然线程安全

Rails 常规 Web 请求 不用手动写并发,交给 Puma 多进程 + 内部线程池 成熟稳定,不用自己处理并发细节

轻量异步 I/O 任务 Fiber 调度器 比 Thread 开销更低,适合高并发 I/O 场景

Ruby 的并发从来不是"非线程即 Ractor"的单选题,理解 GIL 的底层逻辑,分清不同并发模型的适用边界,才能写出既高效又稳定的代码。不用盲目追求"真正并行",I/O 密集场景用传统多线程就足够高效;需要榨干多核性能的计算场景,再拿出 Ractor 这个大杀器,这才是 Ruby 并发的正确使用姿势。