发布于2026-06-29 阅读(0)
扫一扫,手机访问
Ruby 的并发与并行,以及那个让无数开发者又爱又恨的全局锁(GIL),是每个 Ruby 程序员进阶路上绕不开的话题。很多初学者会困惑:为什么我开了多线程,计算速度却没提升?为什么 IO 操作时又能“并行”?这篇文章就来把这些概念掰开揉碎,结合代码实例,把底层逻辑讲清楚。
并发和并行
开发中常遇到两个概念:并发和并行。几乎所有相关文章都会强调一点:并发不等于并行。怎么理解呢?
把这个场景迁移到 Web 开发中:
在 Ruby 里怎么模拟并发行为?来看代码:
1、顺序执行
模拟只有一个线程时的操作。
require 'benchmark' def f1 puts "sleep 3 seconds in f1n" sleep 3 end def f2 puts "sleep 2 seconds in f2n" sleep 2 end Benchmark.bm do |b| b.report do f1 f2 end end ## ## user system total real ## sleep 3 seconds in f1 ## sleep 2 seconds in f2 ## 0.000000 0.000000 0.000000 ( 5.009620)
用 sleep 模拟耗时操作,顺序执行时总耗时就是两个函数耗时之和。
2、并行执行
模拟多线程时的操作。
# 接上述代码
Benchmark.bm do |b|
b.report do
threads = []
threads << Thread.new { f1 }
threads << Thread.new { f2 }
threads.each(&:join)
end
end
##
## user system total real
## sleep 3 seconds in f1
## sleep 2 seconds in f2
## 0.000000 0.000000 0.000000 ( 3.005115)
多线程下耗时接近 f1 的耗时(3 秒),这正是我们期望的“并行”——两个线程同时睡眠,整体时间由最长的那个决定。Ruby 的多线程能够很好地应付 IO Block:当某个线程处于 IO 阻塞状态时,其他线程可以继续执行,从而大幅缩短整体处理时间。
Ruby 中的线程
上面的代码用到了 Ruby 内置的 Thread 类。Ruby 的线程是轻量级的,写起来也相当直观。
再看一个模拟并发的例子:
def thread_test
time = Time.now
threads = 3.times.map do
Thread.new do
sleep 3
end
end
puts "不用等3秒就可以看到我:#{Time.now - time}"
threads.map(&:join)
puts "现在需要等3秒才可以看到我:#{Time.now - time}"
end
test
## 不用等3秒就可以看到我:8.6e-05
## 现在需要等3秒才可以看到我:3.003699
Thread 的创建是非阻塞的,所以第一个 puts 会立即输出,随后通过 join 等待所有线程结束。每个线程 sleep 3 秒,在阻塞的场景下,多线程确实实现了类似并行的效果。
但,是不是有了多线程就能真正利用多核 CPU 并行计算了呢?
很遗憾,下面的例子会打破这个幻想:
require 'benchmark'
def multiple_threads
count = 0
threads = 4.times.map do
Thread.new do
2500000.times { count += 1}
end
end
threads.map(&:join)
end
def single_threads
time = Time.now
count = 0
Thread.new do
10000000.times { count += 1}
end.join
end
Benchmark.bm do |b|
b.report { multiple_threads }
b.report { single_threads }
end
## user system total real
## 0.600000 0.010000 0.610000 ( 0.607230)
## 0.610000 0.000000 0.610000 ( 0.623237)
即使是把同一个任务拆成 4 个线程“并行”执行,总耗时居然和单线程差不多少!为什么?
因为全局锁(GIL)的存在。
全局锁
我们常用的 MRI Ruby 实现中有一个 GIL(Global Interpreter Lock)。简单说,即便你开了多线程,每次也只能有一个线程真正执行 Ruby 代码。至于哪个线程能执行,由底层操作系统调度决定。即便你有多个 CPU,也只是为线程切换提供了更多选择,并不能同时执行多条 Ruby 指令。
上面的 count += 1 就是纯 Ruby 运算,受 GIL 限制,无法并行,所以时间不缩短,甚至因为线程切换还略有增加。
可是等等,之前 sleep 和网络请求的例子明明是“并行”的啊!
这正是 Ruby 设计的高明之处:所有阻塞操作(IO 操作)在 GIL 下是可以并行的。比如读写文件、网络请求,当某个线程陷入 IO 等待时,GIL 会被释放,其他线程就能趁机执行。这才是多线程的真正优势——不是加快计算,而是提高 IO 密集型任务的吞吐量。
下面用网络请求验证:
require 'benchmark'
require 'net/http'
# 模拟网络请求
def multiple_threads
uri = URI("http://www.baidu.com")
threads = 4.times.map do
Thread.new do
25.times { Net::HTTP.get(uri) }
end
end
threads.map(&:join)
end
def single_threads
uri = URI("http://www.baidu.com")
Thread.new do
100.times { Net::HTTP.get(uri) }
end.join
end
Benchmark.bm do |b|
b.report { multiple_threads }
b.report { single_threads }
end
user system total real
0.240000 0.110000 0.350000 ( 3.659640)
0.270000 0.120000 0.390000 ( 14.167703)
网络请求时 CPU 基本空闲,线程处于 IO 阻塞状态,这时 GIL 被释放,多个线程可以同时发起请求,耗时从 14 秒降到了 3.6 秒。
GIL 的思考
那么,有了 GIL 是不是就线程安全了呢?
当然不是。GIL 只保证一次只有一个线程执行 Ruby 代码,但 Ruby 解释器在执行过程中会在某些 “检查点” 切换到另一个线程。如果共享了类变量或全局变量,就可能踩坑。
哪些地方会触发 GIL 切换?
来看一个实际的竞争例子。先看一个 没有触发 GIL 切换 的代码:
@a = 1
r = []
10.times do |e|
Thread.new {
@c = 1
@c += @a
r << [e, @c]
}
end
r
## [[3, 2], [1, 2], [2, 2], [0, 2], [5, 2], [6, 2], [7, 2], [8, 2], [9, 2], [4, 2]]
这里每个线程的 @c 都能正确保持为 2,没有出现数据错乱。
但如果在线程里加入一个可能触发 GIL 切换的操作,比如 puts:
@a = 1
r = []
10.times do |e|
Thread.new {
@c = 1
puts @c
@c += @a
r << [e, @c]
}
end
r
## [[2, 2], [0, 2], [4, 3], [5, 4], [7, 5], [9, 6], [1, 7], [3, 8], [6, 9], [8, 10]]
这次,GIL 被触发切换,各个线程的调度顺序被打乱,共享变量 @c 出现了竞争,最终结果就不对了。
小结
Web 应用大多是 IO 密集型的,利用 Ruby 多进程+多线程模型能大幅提升系统吞吐量。根本原因在于:当某个线程处于 IO Block 状态时,其他线程可以继续执行,从而降低 IO 阻塞对整体的影响。但由于 GIL 的存在,MRI Ruby 并不能真正利用多线程进行并行计算——纯 CPU 运算在多线程下并不会更快。
另外,据说 JRuby 去除了 GIL,是真正意义上的多线程,既能应付 IO Block,也能充分利用多核 CPU 加快运算速度,有兴趣的话可以了解一下。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8