Ruby 3.2 引入的 IO::Buffer 为原生内存操作提供了比 String 更直接的接口,同时内置了合作式锁机制。其中 lock_shared 共享锁专门解决多线程并发读的问题,它允许多个线程同时持有读锁,而写线程需要独占锁。理解这一点后,性能测试的重点就变成:共享锁是否真的减少了锁竞争,以及多线程读的吞吐是否随线程数增加而扩展。

共享锁与独占锁的差异
IO::Buffer#lock 默认获取的是独占锁,同一时刻只允许一个线程进入临界区。如果多个线程只是读取缓冲区内容,不需要修改数据,使用独占锁会让读线程之间互相阻塞,无法发挥多核优势。lock_shared 则实现了共享读锁语义:只要没有线程持有独占写锁,多个线程就可以同时获得共享锁并进入临界区读取数据。
这种设计类似于操作系统中的读写锁。读操作可以并发,写操作必须排他。对于缓存数据、只读配置、共享字节流等场景,共享锁能显著减少锁竞争。需要注意的是,IO::Buffer 的锁是合作式的,并不强制阻止未加锁的访问。只有所有线程都遵守加锁约定时,数据安全性才有保证。
下面这段代码展示了共享锁与独占锁的基本用法。使用块形式可以确保离开作用域时自动解锁,避免因异常导致锁无法释放。
require 'io/buffer'
buf = IO::Buffer.new(4096)
buf.set_string('hello ruby io buffer')
# 共享读锁:多个线程可以同时进入
threads = 4.times.map do
Thread.new do
buf.lock_shared do
data = buf.get_string(0, 5)
puts data
end
end
end
threads.each(&:join)
# 独占写锁:同一时刻只有一个线程能进入
buf.lock do
buf.set_string('exclusive write', 0)
end
设计多线程读性能测试
性能测试的目标是量化 lock_shared 在多线程环境下的吞吐表现。一个合理的指标是每秒完成读取操作的次数,也就是吞吐量。测试需要对比三种模式:无锁直接读取、共享锁读取和独占锁读取。无锁读取可以作为理论性能上限,但它不具备线程安全性;独占锁读取则是互斥方案的基线。
测试参数需要仔细控制。缓冲区大小建议使用较大的值,例如 16 MiB,这样可以减少缓存命中带来的干扰。每个线程循环若干次,每次从缓冲区不同偏移读取固定大小的切片。线程数量从 1 开始逐渐增加到 CPU 核心数的两倍,观察吞吐变化趋势。计时使用 Process.clock_gettime(Process::CLOCK_MONOTONIC),它提供单调时钟,不受系统时间调整影响。
另一个关键点是在线程启动时使用一个简单的屏障同步,让所有线程尽量同时开始工作。否则线程创建和调度的时间差会污染测试结果。测试还应该进行预热,先执行少量迭代让 JIT 和缓存进入稳定状态,再开始正式计时。
基准代码实现与结果解读
下面的基准脚本实现了共享锁和独占锁两种模式的测试函数。每个线程在循环中根据迭代次数计算偏移,然后进入锁保护区域读取数据。计时从释放屏障后开始,到所有线程完成工作结束。
require 'io/buffer'
BUF_SIZE = 16 * 1024 * 1024
SLICE = 4096
def bench_shared(buf, thread_count, iterations)
barrier = Queue.new
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
workers = thread_count.times.map do
Thread.new do
barrier.pop
iterations.times do |i|
off = (i * SLICE) % (BUF_SIZE - SLICE)
buf.lock_shared do
buf.get_string(off, SLICE)
end
end
end
end
thread_count.times { barrier << true }
workers.each(&:join)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
total_ops = thread_count * iterations
total_ops / elapsed
end
def bench_exclusive(buf, thread_count, iterations)
barrier = Queue.new
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
workers = thread_count.times.map do
Thread.new do
barrier.pop
iterations.times do |i|
off = (i * SLICE) % (BUF_SIZE - SLICE)
buf.lock do
buf.get_string(off, SLICE)
end
end
end
end
thread_count.times { barrier << true }
workers.each(&:join)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
total_ops = thread_count * iterations
total_ops / elapsed
end
buf = IO::Buffer.new(BUF_SIZE)
buf.set_string('x' * BUF_SIZE, 0)
[1, 2, 4, 8].each do |n|
shared = bench_shared(buf, n, 2000)
exclusive = bench_exclusive(buf, n, 2000)
puts "threads=#{n} shared=#{shared.round} ops/s exclusive=#{exclusive.round} ops/s"
end
从典型结果来看,线程数为 1 时两种模式差距不大,因为不存在锁竞争。随着线程数增加到 2 或 4,共享锁的吞吐会明显高于独占锁,因为多个读线程可以并行工作。线程数超过 CPU 核心数后,共享锁的提升幅度趋于平缓,甚至可能因线程切换开销而略降。独占锁则因为每次只有一个线程能读取,吞吐往往不会随线程数增加而增长,反而可能因竞争调度导致下降。
测试中的性能陷阱
基准测试中最常见的干扰来自 Ruby 的垃圾回收。每次 get_string 都会创建新的字符串对象,大量循环会产生 GC 压力,导致某些线程暂停。为了避免这一点,可以在基准过程中暂时禁用 GC,或者使用返回引用类 API 减少分配。另一种做法是增加预热,并在多次运行后取中位数。
伪共享问题同样值得关注。多个线程如果频繁访问同一个缓存行内的相邻偏移,CPU 缓存一致性协议会引入额外开销。对于 IO::Buffer 来说,共享锁机构本身的元数据可能位于同一个缓存行,频繁加解锁会放大这种效应。测试时应确保每次读取的偏移错开,并且不要让所有线程都争抢同一个锁字。
最后,不要忽略锁粒度。如果临界区内只做一次小规模的 get_string,加锁和释放的开销可能比读取本身还高。此时共享锁的优势会被成本吃掉。合理做法是在共享锁内完成足够多次读取或较耗时操作,让锁开销占比降低。这也是为什么建议使用较大 SLICE 或多步计算的原因。
lock_shared 的实际使用建议
读多写少的场景最适合使用 lock_shared。例如多个工作线程共享一个只读映射表或预加载的二进制数据时,共享锁可以保证写线程不会同时修改缓冲区,同时读线程之间不互相阻塞。写操作必须使用独占锁,并且写锁会等待所有共享锁释放,因此不会出现写写并发。
还需要注意的是,lock_shared 只保护 IO::Buffer 自身的数据访问,不会保护从缓冲区读取后创建的 Ruby 对象。如果读取后还要修改这些对象,就需要额外的同步机制。此外,共享锁不适合持有太久,任何耗时操作都应尽量放到锁外执行,只有真正读取缓冲区的那一小段代码才需要加锁保护。
总之,IO::Buffer 的 lock_shared 为多线程读提供了轻量级的同步选项。通过合理的基准测试方法,可以清晰看到它在读并发场景中的性能优势,也可以帮助确定线程数量、缓冲区大小和锁粒度等参数的合理范围。只要避免 GC 干扰和伪共享等常见陷阱,就能得到稳定可重复的结果。

