深入 Ruby 32:Ractor 与对象隔离
系列导航
导读 · 上一篇:31:Fiber 与 scheduler · 下一篇:33:进程、超时与取消 · 完整源码包
Ractor 先改变对象所有权
Ractor 为并行执行设置对象访问边界。把原来共享可变数组的 Thread 代码直接换成 Ractor,往往先遇到对象不可访问,而非立刻得到加速。这些限制决定了数据怎样进入任务、计算结果怎样离开,以及发送方何时还能使用原对象。
本章固定 CRuby 3.4.11。Ractor 在此版本仍会给出实验性提示,不能把后续版本接口或第三方库的兼容结论直接套用。实验覆盖普通复制、可共享对象和 move,保留正常结果与预期错误;没有把创建成功当成整个生态已经兼容的证据。
官方 API与设计说明给出共享和隔离规则。实践中应先分类对象,再选择传递方式,最后测量并行收益。忽略分类会使对象在消息边界被隐式复制,或让后续访问直接失败。
外层冻结没有冻结对象图
从仓库根目录执行:
1 | |
第一个反例只冻结数组:
1 | |
数组不能追加或替换元素,但其中字符串仍然可变。另一个执行单元如果得到相同字符串引用,就可能改变数组观察到的内容。外层对象的 frozen 状态只能描述该对象自身,无法替代整个可达对象图的判断。
使用 Ractor.make_shareable 处理由普通容器和字符串组成的数据后,实验检查最外层对象可共享,内层字符串也已冻结。这个操作会影响传入对象图,不能在不知道调用者是否还需要修改时随意执行。需要保留可变输入,可考虑先制作独立副本;复制是否支持具体对象类型仍需验证。
在 Taskbook 中,任务含标题字符串和标签数组。只冻结任务 Hash,不足以保证标题和每个标签不可变。若选择共享任务快照,需要覆盖这些嵌套节点;若任务设计需要后续变更,传递副本或转移所有权通常更清楚。快照与可变模型应有明确边界,避免一个公共方法偶然冻结了调用者的数据。
实验用显式 .dup 创建字符串,避免字符串字面量冻结设置改变前提。写可重复反例时,运行参数和文件编译选项也是输入的一部分;依赖默认可变性的例子,升级版本后可能不再测量同一个问题。
默认发送复制普通可变数据
实验创建一个接收者,等待消息后修改其中字符串,再返回结果:
1 | |
验收要求 received 为 changed,同时 original 仍为 original。两个值同时检查,才能证明接收者拿到可独立修改的内容;只检查结果 changed,会漏掉发送方也被改变的实现错误。
这种复制不是所有 Ruby 对象的通用序列化保证。简单容器可用于教学,但带本地资源、扩展内部状态或运行上下文的对象,需要查具体限制。打开文件、连接和线程不能凭“也是对象”就假定能按相同方式搬运。消息尽量使用结构清楚的小数据,减少隐含状态。
复制还有时间和内存成本。把整个任务集发送给每个工作者,数据复制可能比计算更贵。按批次发送,或只传任务标识及必要字段,往往比一开始调整 Ractor 数量更有价值。批次过小又会增加消息频率,因此粒度要用端到端测量决定。
业务确认也不应停在 send 返回。发送成功说明消息提交到对应通道,不等于接收者完成计算,更不等于结果已经持久化。实验通过 take 收集结果后才作判定;涉及文件或远端服务时,还需要额外提交确认及失败状态。
move 将后续访问权交给接收者
send(value, move: true) 表达发送方放弃对不可共享对象的继续使用。实验把包含字符串的数组移交给接收者,随后故意访问原数组:
1 | |
接收者返回大写字符串,发送方访问抛出 MovedError,两项都通过才说明测试覆盖了移交结果与访问限制。把原变量设成 nil 只能改变一个变量绑定,不能证明所有别名都已停止使用;move 的运行时限制比调用约定更直接。
move 适合流水线中明确只允许下游继续处理的缓冲区。调用者需要在接口上读到这个所有权变化,否则一个普通-looking 的发送函数会使其后日志或重试访问失败。尤其是异常分支:发送之后才遇到其他错误,不能假定原对象仍可用于重新提交。
移交也不等于跨进程复制或永久隔离。Ractor 仍属于同一个进程,进程崩溃会影响整体;本地扩展的兼容性、资源使用和应用关闭仍需单独处理。对象访问规则只解决这一模型定义的共享问题,没有自动提供进程级容错。
可共享数据可以保留身份
对深度可共享数据,实验把数组作为新 Ractor 的参数,接收端返回 object_id,主 Ractor 检查两端身份相同。与普通数组复制实验对照,可以区分“值相等但对象独立”和“同一个可共享对象被访问”。
相同身份只适用于本次仍存活对象的进程内观察,不是跨进程业务标识。对象回收后 identity 也不适合拿来做长期主键。使用它是为了验证消息策略,任务标识仍应由业务字段承担。
Ractor 新块还有闭包边界。普通 Thread 块自然捕获外层局部变量的写法,迁移后可能不符合 Ractor 隔离要求。把输入显式放入参数或消息,既有助于满足限制,也让测试能清点每个任务依赖。全局可变常量、隐式单例缓存和延迟初始化同样需要审查。
这一要求会影响第三方库。主 Ractor 中能正常调用的方法,在另一个 Ractor 中不一定可用;类存在、require 成功、最简单 API 返回正确,均不能证明整条请求路径兼容。应使用真实需要的代码路径建立回归,并保留错误类别,避免捕获所有异常后返回空结果。
并行收益必须扣除通信成本
一个适合 Ractor 的初步候选是输入可分割、计算量较大、结果较小、工作者之间很少共享状态的任务。整数转换或复杂解析可能满足部分条件,但是否值得采用仍取决于实际实现。并行数量超过硬件和内存容量时,更多工作者可能降低吞吐。
本章故意不提供通用提速倍率。共享、复制、移交实验首先验证语义,尚未测量业务吞吐。性能报告应把启动、输入准备、消息传输、计算和结果合并分别说明,并有串行同结果基线。只计计算循环而忽略发送大数组,会高估整体收益。
关闭时还需要处理通道生命周期。接收者等待下一条消息而发送方提前结束,可以形成永久等待。固定数量任务、显式结束消息或关闭通道都有各自契约;测试应验证异常任务后其他工作者也能退出,而非只在全成功时回收。
本次可观察结果
本次运行保留了 Ruby 3.4.11 的实验性警告。外层冻结数组不可共享,make_shareable 后可共享;复制接收者改成 changed,发送者仍为 original;move 后访问原数组得到 Ractor::MovedError,共享快照两端身份一致。每个结果均由断言检查,不以警告存在与否决定成功。
消息 API 还有入站和出站方向。实验用 send/receive 提交输入,以 take 收取执行块最终返回值。理解方向能避免把工作者等待输入与主进程等待结果混淆;两端若都等待对方先发送,就会形成协议死锁,与是否有 GVL 无关。
多个工作者返回结果时,完成顺序也未必等于任务顺序。若导出必须保持原输入顺序,消息应携带业务序号,结果合并按序号排列。不要拿调度器当前碰巧的完成顺序作为持久化格式。附带序号的消息还能帮助把某个失败结果对应回原任务,并支持明确的重试选择。
共享常量的具体限制应按当前版本重新检查,常量名称本身不代表对象不可变。
练习与验收
把输入改为含嵌套标签数组的任务,逐层调用 freeze 并观察 shareable?。记录究竟哪一层仍可变,最后以 make_shareable 建立可共享快照。随后检查原始可变任务是否仍符合调用者预期,避免只顾通过 shareable 断言。
再把复制传递改为 move,在发送前保存一个别名。发送后同时测试原变量与别名的访问,并通过接收结果确认内容正确。练习应解释所有权变化,而非把预期异常全部吞掉。
| 数据需求 | 传递策略 | 必须验证 |
|---|---|---|
| 两端各自修改 | 普通可复制对象消息 | 原值未变,接收值正确 |
| 只允许下游修改 | move | 发送方访问失败 |
| 重复读取同一快照 | 可共享对象图 | 嵌套节点与身份边界 |
