系列导航

导读 · 上一篇:29:配置、日志、信号与有序关闭 · 下一篇:31:Fiber 与 scheduler · 完整源码包

共享状态的风险从一次读改写开始

两个线程都给计数器加一,业务期望总数增加二。只要一次加一被拆成读取旧值、计算新值、写回结果,另一个线程就可能在中间改变状态。CRuby 的全局虚拟机锁(Global VM Lock,GVL)约束同一 Ractor 中 Ruby 线程的执行,并不把任意业务代码块变成事务。判断线程安全,要找出需要同时成立的约束,以及哪些路径能够改变这些约束。

Taskbook 若增加并发导入,最容易共享的是任务数组、已见标识集合和计数器。线程之间能访问同一个对象,不代表一次“查重后追加”不可分割。即使数组追加本身没有破坏内部结构,两个线程仍可能同时确认标识不存在,随后各自追加一条记录。对象内部有效和业务数据正确,是两个不同的验收条件。

Thread 文档规定了创建、等待和异常传播行为;Mutex 文档提供显式同步手段。实验先构造确定的冲突,再验证修复。这样测试不依赖操作系统恰好在几纳秒内切换线程。

用队列固定错误交错

从仓库根目录执行:

1
ruby examples/ruby/labs/30/run.rb

脚本要求 Ruby 3.4,完整文件也放在随文附件。它为两个工作线程准备 ready 和 release 两个队列。每个线程先读取计数器,再报告已读,随后阻塞等待释放。主线程收齐两个已读事件后,才允许任一个线程写回。

核心操作是:

1
2
3
4
old = counter
ready << true
release.pop
counter = old + 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.synchronize { counter += 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 的执行与额外调度成本 保留串行基线再选并行模型
等待任务无法结束 生产者关闭、结果收集与异常路径 显式结束消息及等待回收

参考资料

实验附件

下载完整实验。原始运行输出保存在仓库 `writing-plans/ruby/evidence/30/run.log`。使用 Ruby 3.4.11 从仓库根目录执行正文命令;附件与仓库实验内容一致。