深入 Ruby 30:Thread、Mutex、Queue 与 GVL
系列导航
导读 · 上一篇:29:配置、日志、信号与有序关闭 · 下一篇:31:Fiber 与 scheduler · 完整源码包
共享状态的风险从一次读改写开始
两个线程都给计数器加一,业务期望总数增加二。只要一次加一被拆成读取旧值、计算新值、写回结果,另一个线程就可能在中间改变状态。CRuby 的全局虚拟机锁(Global VM Lock,GVL)约束同一 Ractor 中 Ruby 线程的执行,并不把任意业务代码块变成事务。判断线程安全,要找出需要同时成立的约束,以及哪些路径能够改变这些约束。
Taskbook 若增加并发导入,最容易共享的是任务数组、已见标识集合和计数器。线程之间能访问同一个对象,不代表一次“查重后追加”不可分割。即使数组追加本身没有破坏内部结构,两个线程仍可能同时确认标识不存在,随后各自追加一条记录。对象内部有效和业务数据正确,是两个不同的验收条件。
Thread 文档规定了创建、等待和异常传播行为;Mutex 文档提供显式同步手段。实验先构造确定的冲突,再验证修复。这样测试不依赖操作系统恰好在几纳秒内切换线程。
用队列固定错误交错
从仓库根目录执行:
1 | |
脚本要求 Ruby 3.4,完整文件也放在随文附件。它为两个工作线程准备 ready 和 release 两个队列。每个线程先读取计数器,再报告已读,随后阻塞等待释放。主线程收齐两个已读事件后,才允许任一个线程写回。
核心操作是:
1 | |
初始值为零,两个线程读取的旧值都是零。随后两次写入都写一,因此最终值是一。这里故意加入队列等待,展示一种程序允许的交错;它不测真实业务中竞争的出现频率,也不声称所有 counter += 1 循环都必然丢更新。某个没有等待点的小循环在当前优化器下反复得到正确结果,不能取消业务同步要求。
事件轨迹保留线程编号、读写动作和当时数值。测试不仅检查最终一,还保存两次读取先于释放这一构造条件。日志中两个写事件的先后可以变化,测试不应把无关顺序固定下来。并发回归应锁住导致错误的必要偏序,给调度器保留其余自由度。
用 sleep 猜测“另一个线程应该已运行”会把正确性转成时间假设。机器负载、调试器和持续集成环境都能打破这个假设。队列的收发给出明确依赖:未收到就绪消息,就不开始下一阶段。相同做法可以用于缓存初始化、重复任务领取和连接池争用测试。
下图采用日志中的读写顺序,并按代码展开队列依赖。释放令牌与写回之间的细部交错仅作示意;实验固定的是两个线程都读到零之后才允许写入。
sequenceDiagram
participant T0 as 工作线程 0
participant T1 as 工作线程 1
participant Ready as ready 队列
participant Main as 主线程
participant Release as release 队列
T0->>T0: 读取 counter,old = 0
T0->>Ready: 推入就绪令牌
T1->>T1: 读取 counter,old = 0
T1->>Ready: 推入就绪令牌
Note over T0,T1: 两个线程各自等待 release.pop
Ready-->>Main: 两次 ready.pop 均返回
Main->>Release: 推入第一个释放令牌
Release-->>T0: release.pop 返回
Main->>Release: 推入第二个释放令牌
Release-->>T1: release.pop 返回
T0->>T0: 写回 old + 1,counter = 1
T1->>T1: 写回 old + 1,counter = 1
Note over T0,T1: 两次更新使用同一个旧值,最终为 1
同步的范围必须覆盖业务约束
修复实验让两个线程各执行一千次更新,每次都在同一个互斥锁中完成读取与写入:
1 | |
断言要求总数等于两千。锁若只包住赋值,而把旧值读取放在锁外,仍然允许覆盖另一个线程的更新。同步范围由约束决定;对于“标识唯一且列表包含新项”,检查唯一性和写入列表必须受同一个同步协议保护。
锁的身份同样重要。在方法内部每次创建一个新 Mutex,线程各锁各的对象,不会产生互斥。受保护状态与锁应有一致的生命周期,所有访问路径遵守同一约定。只修复导入入口,却允许统计线程绕过锁遍历正在变化的集合,也不能称为共享状态已经安全。
长时间 I/O 放进锁内会扩大串行区域。若先在锁外读文件,再在锁内完成短小的校验与提交,可以减少其他线程等待。但分离步骤的前提是锁外结果不依赖已经失效的共享状态;需要时在提交前重新检查。不能为了缩短临界区,把必须原子完成的两个动作再次拆开。
对于只有一个拥有者的状态,还可让其他线程把消息提交到队列,由拥有者顺序修改。这个方案把写入集中到一个位置,却需要明确消息顺序、失败反馈和关闭协议。队列只负责传送对象引用;发送后继续修改同一个可变消息,仍会产生共享问题。不可变数据或复制后的消息更容易推理。
CPU 与 I/O 要分开观察
实验还对固定整数计算做五轮串行与双线程计时,并用两条管道观察 I/O 线程。CPU 计算没有等待外部设备,同一 Ractor 内 Ruby 执行受 GVL 约束,不能依据创建了两个 Thread 就预期两核加速。线程创建和切换甚至会增加成本。具体倍率属于当前机器、解释器构建和输入,不写成 Ruby 的常数。
管道实验的主线程先确认两个工作线程都到达读操作附近,再给两条管道写入数据。两个读者都能完成,事件记录展示了多个任务等待与恢复的过程。它证明这条 I/O 路径可交错推进,没有测量网络吞吐量,也没有证明所有原生扩展都会在阻塞时释放 GVL。扩展是否释放锁,需要查其实现或做针对实验。
计时使用单调时钟,避免系统校时造成时间倒退。CPU 子任务通过 Thread#value 收集;工作线程抛出的异常会在取值时重新抛出,不能只看主线程成功退出。若使用 join,同样需要明确异常如何被调用方接收,日志输出不能替代失败传播。
I/O 并发也需要容量上限。一万个任务各开一个线程,可能把等待转移成文件描述符、连接数和线程栈压力。先按外部连接池与服务容量设置工作者数量,再测排队时长。CPU 与 I/O 混合时,单独测快路径往往低估共享锁和队列中的尾部等待。
关闭路径也是并发协议
工作线程尚未完成时,主线程结束会影响其他线程的生命周期。生产者、队列和消费者之间应规定谁关闭入口、谁发出结束消息、谁等待工作者退出。只在正常路径 join,异常分支遗留工作者,容易让后续测试受到前一次运行影响。
本实验所有管道均在已完成读取后关闭,线程结果全部取回。扩展为长期工作池时,建议把关闭动作放到显式生命周期对象中,并对重复关闭、任务异常和生产者提前退出分别测试。不要用随处可调用的 Thread#kill 代替协议;异步中断可能落在业务状态已经改变、外部操作尚未完成的位置。
锁也不能自动处理跨系统事务。内存中把任务标记完成后,写文件失败,重试时仍可能观察到不一致状态。此时需要调整提交顺序、使用原子文件替换或设计幂等操作,继续扩大 Mutex 无法提供远端事务语义。
本次输出与任务所有权
本次在 arm64 macOS 的 Ruby 3.4.11 上,错误交错得到一,同步更新得到两千,两条管道均读到 x。五次 CPU 串行样本约二十二至二十九毫秒,线程样本约二十三至二十九毫秒;如此短的测量存在噪声,没有给出稳定的双线程提速证据。原始日志保留全部样本,不能挑一次较快的线程结果作为结论。
修复计数器还可以采用每个线程维护局部计数,结束后主线程求和。这样每一步不再访问共享计数器,代价是中途统计无法直接获得最终总数。若业务只在批次结束显示汇总,这个策略比每次都抢锁简单;若要求实时准确进度,则仍需定义快照或消息协议。数据需求决定同步次数,不应先选择锁再寻找用途。
传入工作线程的数据也要明确是否借用。只读输入可以在创建线程前固定;工作线程需要修改时,可返回独立结果,由主线程统一提交。避免同时保留“任何线程都可修改任务数组”和“某个线程负责校验数组”两套不兼容约定。实验中的两个队列只是控制轨迹,业务对象的所有权仍由程序设计承担。
练习与验收
把计数器改成“已见标识集合加任务数组”,用两个相同标识构造重复导入;先证明错误稳定出现,再把检查和追加放入同一临界区。额外断言唯一标识数量等于任务数量,避免只验证某一个容器。
再让一个工作线程在拿到任务后抛出异常,检查调用方能否通过结果收集收到该异常,并确认其他线程不会无限等待结束信号。练习完成的标准是错误可重现、修复后断言通过、进程能够自行退出。
| 观察到的问题 | 需要检查的边界 | 可执行处理 |
|---|---|---|
| 丢失更新或重复插入 | 读、判断、写是否整体同步 | 同一个 Mutex 覆盖约束 |
| CPU 线程没有提速 | 同一 Ractor 的执行与额外调度成本 | 保留串行基线再选并行模型 |
| 等待任务无法结束 | 生产者关闭、结果收集与异常路径 | 显式结束消息及等待回收 |
