Ruby Box 深度指南:Ruby 进程内的类与模块隔离机制
【免费下载链接】rubyThe Ruby Programming Language项目地址: https://gitcode.com/GitHub_Trending/ru/ruby
Ruby Box 是 Ruby 语言运行时提供的一套进程内隔离方案,允许在同一个 Ruby 进程中创建相互隔离的"盒子"空间,用于隔离应用代码、第三方库与猴子补丁(monkey patch),是解决"依赖地狱"与全局污染问题的底层基础设施。本文将以 doc/language/box.md 为核心,结合 box.c、internal/box.h、test/ruby/test_box.rb 等源码与测试,完整讲解 Ruby Box 的启用方式、API 用法、隔离语义、实现原理与已知限制,读完即可上手用RUBY_BOX=1体验这一实验性特性。
Ruby Box 是什么
Ruby Box 的设计目标非常明确:在单个 Ruby 进程内提供分离的空间(separated spaces),以隔离应用代码、库和猴子补丁。
传统 Ruby 进程中,所有require进来的库共享同一份全局命名空间(Object的常量表、类的方法表、全局变量等)。一旦某个库对String、Array等内建类做了猴子补丁,整个进程都会受影响,不同版本的同一库也无法共存。Ruby Box 通过在运行时层面对类/模块的定义空间、方法解析、常量解析、全局变量进行按"盒"隔离,让不同盒子内可以各自持有互不可见的定义。
Ruby::Box类是 Ruby Box 的入口类(entrypoint),它在 C 层被定义为Module的子类:
// box.c rb_cBox = rb_define_class_under(mRuby, "Box", rb_cModule);也就是说,Ruby::Box的实例本身是一种Module,可以像模块一样通过::访问其常量。
启用 Ruby Box
Ruby Box 默认是关闭的,必须在 Ruby 进程启动时设置环境变量:
RUBY_BOX=1 ruby main.rb关键约束如下:
- 唯一合法值是
1:源码 box.c 中的Init_enable_box函数只认strlen(env) == 1 && env[0] == '1'这一种情况,其他任何值(包括未设置)都表示禁用 Ruby Box。 - 必须在进程启动前设置:文档明确说明"在 Ruby 程序启动之后再设置无效",因为该判定发生在运行时初始化(
Init_enable_box)阶段,ruby_box_enabled一旦确定就不会再改变。 - 启用后可通过
Ruby::Box.enabled?查询状态(对应源码rb_box_s_getenabled,见 box.c),关闭状态下返回false;未启用时调用Ruby::Box.new会抛出RuntimeError:"Ruby Box is disabled. Set RUBY_BOX=1 environment variable to use Ruby::Box."(见 box.c)。
测试 test/ruby/test_box.rb 对默认关闭与启用两种情况都有验证:
def test_box_availability_in_default assert_separately(['RUBY_BOX'=>nil], __FILE__, __LINE__, "#{<<~"begin;"}\n#{<<~'end;'}", ignore_stderr: true) begin; assert_nil ENV['RUBY_BOX'] assert_not_predicate Ruby::Box, :enabled? end; end def test_box_availability_when_enabled assert_separately([ENV_ENABLE_BOX], __FILE__, __LINE__, "#{<<~"begin;"}\n#{<<~'end;'}", ignore_stderr: true) begin; assert_equal '1', ENV['RUBY_BOX'] assert_predicate Ruby::Box, :enabled? end; end实验性警告
启用 Ruby Box 后,Ruby 启动时会打印一条实验性警告(对应 box.c 中通过rb_category_warn(RB_WARN_CATEGORY_EXPERIMENTAL, ...)输出的 "Ruby::Box is experimental, and the behavior may change in the future!")。文档建议使用-W:no-experimental选项将其隐藏:
RUBY_BOX=1 ruby -W:no-experimental main.rb测试文件 test/ruby/test_box.rb 中同样校验了警告文本的匹配模式。
基本用法:创建盒子并加载代码
创建盒子与加载文件的最小用法:
box = Ruby::Box.new box.require('something') # 或 require_relative、loadrequire、require_relative、load都可用(对应 box.c 中的rb_box_load/rb_box_require/rb_box_require_relative,它们分别透传到rb_load_entrypoint、rb_require_string、rb_require_relative_entrypoint)。被加载的文件(无论是.rb还是.so/.dll/.bundle原生扩展)都在该盒子中加载,其递归依赖的文件也会被加载进同一个盒子。
以文档中的示例说明隔离效果。假设有something.rb:
# something.rb X = 1 class Something def self.x = X def x = ::X end在主程序中:
X = 2 p X # 2 p ::X # 2 p box::Something.x # 1 p box::X # 1可以看到:
- 盒内定义的常量
X = 1与主程序(main box)中的X = 2互不干扰; - 通过
box::Something、box::X可以从盒子外部访问盒内定义; - 实例方法同样遵循盒内定义执行:
s = box::Something.new p s.x # 1测试 test/ruby/test_box.rb 验证了require的隔离性:@box.require(...)之后@box::BOX_A可访问,而全局的BOX_A仍抛NameError。
同一库的多个版本共存
由于不同盒子互相隔离,同一库的不同版本可以在一个进程内并存。测试test_require_rb_2versiobox(test/ruby/test_box.rb)演示了这一点:
@box.require(File.join(__dir__, 'box', 'a.1_2_0')) assert_equal "1.2.0", @box::BOX_A::VERSION n2 = Ruby::Box.new n2.require(File.join(__dir__, 'box', 'a.1_1_0')) assert_equal "1.1.0", n2::BOX_A::VERSION # 之前盒子的版本不受影响 assert_equal "1.2.0", @box::BOX_A::VERSION盒子嵌套
盒子内还可以再创建盒子。测试辅助文件 test/ruby/box/box.rb 展示了"box in box"的场景,对应测试test_box_in_box(test/ruby/test_box.rb):
BOX1 = Ruby::Box.new BOX1.require_relative('a.1_1_0') def yay BOX1::BOX_B::yay end yay三种盒子类型
Ruby Box 将进程内的空间划分为三类(文档"Ruby Box types"一节,配合源码 internal/box.h 中的BOX_*宏可以印证):
| 类型 | 说明 | 标识 |
|---|---|---|
| Master box(母版盒) | 所有盒子的"母版拷贝"来源,没有任何代码在其中运行,仅作为创建其他盒子的模板 | !is_root && !is_user |
| Root box(根盒) | 进程中唯一的盒子,所有内建类/模块都在根盒中定义并运行 | is_root |
| User boxes(用户盒) | 运行用户程序及其加载的库。用户主程序在main box中执行,-r选项指定的文件在 main box 中被 require | is_user |
层次结构示意:
[master] | |----[root] | |----[main] | |----[user box 1] | |----[user box 2] ...细节要点:
- main box是 Ruby 引导(bootstrap)结束时自动创建的一个用户盒。用户通过命令行指定的主脚本在 main box 中执行;
-r命令行选项指定的文件在 main box 中被 require。 - optional box:调用
Ruby::Box.new创建的是可选的用户盒(非 main),技术上与 main box 等价。对应 box.c 中box_entry_initialize设置box->is_user = true; box->is_optional = true;,而 main box 由box_value_initialize(false, true, false)创建(box.c)。 - master box只在 Ruby Box 启用时才被创建为可见对象(box.c);禁用时
box_id固定为 1,box_object为Qnil。 - 从 Ruby 侧可用
Ruby::Box.root、Ruby::Box.main获取 root/main box 对象,Ruby::Box.new的实例上还可调用master?、root?、main?判断身份(box.c)。
隔离语义详解
盒内类与模块的定义与访问
在一个盒子box中新定义的类和模块,通过box::A从盒子外部访问;在盒子内部,A与::A均可直接引用。
内建类的重开与猴子补丁隔离
这是 Ruby Box 最有价值的场景。在盒子中,内建类/模块是可见且可以重开的,可以用class/module子句修改定义。修改后的定义只在当前盒子内可见,其他盒子中的内建类不受影响。文档示例(foo.rb):
# foo.rb class String BLANK_PATTERN = /\A\s*\z/ def blank? self.match?(BLANK_PATTERN) end end module Foo def self.foo = "foo" def self.foo_is_blank? foo.blank? end end Foo.foo.blank? #=> false "foo".blank? #=> false# main.rb box = Ruby::Box.new box.require_relative('foo') box::Foo.foo_is_blank? #=> false (#blank? 在 box 中调用) "foo".blank? # NoMethodError String::BLANK_PATTERN # NameErrormain box 与box是不同盒子,main 中的猴子补丁在box中同样不可见——隔离是双向的。
测试 test/ruby/test_box.rb 验证了盒内新增方法对全局不可见:
def test_methods_added_in_box_are_invisible_globally setup_box @box.require_relative('box/string_ext') assert_equal "yay", @box::Bar.yay assert_raise(NoMethodError){ String.new.yay } end配套的 test/ruby/box/string_ext.rb 定义了String#yay并在盒内自检调用成功。
"内建"类/模块的定义
在盒子的语境下,"内建"(builtin)类/模块指:
- 在用户脚本中无需任何
require即可访问的类/模块; - 在任何用户程序运行之前已定义好的类/模块。
内建类在所有盒子中都可加载,但在 root box 中运行("Builtin classes and modules are loaded in all boxes, and run in the root box")。这意味着内建方法内部互相调用时走的是 root box 中的定义(详见下文"讨论"一节对Hash#map/Hash#each的分析)。
例外:非内建但默认启用的类/模块
以下类/模块默认可用,但并不属于内建类,在每个盒子中独立加载:
RubyGemsErrorHighlightDidYouMeanSyntaxSuggest
这些属于 default gems。如果用户盒中的代码调用 RubyGems,调用的是盒子自己内部的 RubyGems,而不是 root box 的那一份。这一点在源码 box.c 中可以看到:新建盒子时若box_gem_flags->gem为真,会通过rb_vm_call_cfunc_in_box在盒子内执行rb_define_gem_modules并调用rb_load_gem_prelude,从而为每个盒子独立装载 gem 基础设施;internal/box.h 中的rb_box_gem_flags_t正是记录这四个开关位。
通过盒子对象引用内建类
box::String是合法的引用,且String与box::String是同一个对象:
String == box::String #=> true String.object_id == box::String.object_id #=> true但需要注意,box::String这种引用返回的是当前盒子中的String,其定义解析基于当前盒子,而不是基于box。文档示例(foo.rb中为String添加self.foo):
# foo.rb class String def self.foo = "foo" end # main.rb box = Ruby::Box.new box.require_relative('foo') box::String.foo # NoMethodError原因在于:String是内建类,其"prime classext"(主类扩展结构)属于 root box,盒内对String的修改发生在盒子的独立 classext 副本上,通过box::String拿到的仍然是当前盒子视角的String本体,因此看不到foo这个在box中追加的类方法。
类实例变量、类变量与常量
内建类在不同盒子之间可以持有不同的类实例变量、类变量和常量集合。文档示例(foo.rb):
# foo.rb class Array @v = "foo" @@v = "_foo_" V = "FOO" end Array.instance_variable_get(:@v) #=> "foo" Array.class_variable_get(:@@v) #=> "_foo_" Array.const_get(:V) #=> "FOO"# main.rb box = Ruby::Box.new box.require_relative('foo') Array.instance_variable_get(:@v) #=> nil Array.class_variable_get(:@@v) # NameError Array.const_get(:V) # NameError测试test_class_variables_in_root_are_invisible_in_other_boxes(test/ruby/test_box.rb)从另一个方向验证:root box 中定义的类变量@@x在新建盒子中执行@@x += 1会抛出NameError: uninitialized class variable。
全局变量
盒子内的全局变量修改同样被隔离,只在所属盒子内生效。文档示例(foo.rb):
# foo.rb $foo = "foo" $VERBOSE = nil puts "This appears: '#{$foo}'"# main.rb p $foo #=> nil p $VERBOSE #=> false box = Ruby::Box.new box.require_relative('foo') # 输出 "This appears: 'foo'" p $foo #=> nil p $VERBOSE #=> false从源码看,每个rb_box_t都持有独立的gvar_tbl(全局变量表)哈希(见 internal/box.h 与 box.c 中的box->gvar_tbl = rb_hash_new()),这正是全局变量按盒隔离的数据基础。
顶层常量
通常顶层常量是Object的常量。在盒子中,顶层常量是盒子内Object的常量,且盒子对象box的常量与Object的常量严格相等。文档示例(foo.rb):
# foo.rb FOO = 100 FOO #=> 100 Object::FOO #=> 100# main.rb box = Ruby::Box.new box.require_relative('foo') box::FOO #=> 100 FOO # NameError Object::FOO # NameError在实现上,box_initialize会为每个新建盒子把Object的常量表(const table)设置到盒子对象上(box.c):
object_classext = RCLASS_EXT_WRITABLE_IN_BOX(rb_cObject, box); RCLASS_SET_CONST_TBL(box_value, RCLASSEXT_CONST_TBL(object_classext), true);顶层方法
顶层方法是Object的私有实例方法,且按盒子隔离。文档示例(foo.rb):
# foo.rb def yay = "foo" class Foo def self.say = yay end Foo.say #=> "foo" yay #=> "foo"# main.rb box = Ruby::Box.new box.require_relative('foo') box::Foo.say #=> "foo" yay # NoMethodError目前没有任何方式把盒子中的顶层方法暴露给其他盒子(相关设计讨论见文末)。测试test_continuous_top_level_method_in_a_box(test/ruby/test_box.rb)验证了全局视角foo不可见;而test_top_level_methods_in_box则被标记为pend(TODO:修复 loading/current box 检测),说明顶层方法在盒内连续加载场景下仍有待完善。
作用域规则:文件级(file scope)
Ruby Box 的作用域是文件级:一个.rb文件运行在单个盒子中。一旦文件在盒子box中加载,该文件中定义/创建的所有方法、Proc 都运行在box中。
这一规则由 VM 层的"当前盒"机制保证:vm.c中的current_box_on_cfp从当前控制帧(control frame)的cme->def->box(方法定义归属的盒子)或环境块的VM_ENV_BOX取出盒子(见 vm.c)。因此方法调用链、Proc 执行时都能一致地回到定义它的盒子中解析方法、常量与全局变量。
测试 test/ruby/test_box.rb 用一组用例验证了"Proc 携带定义它的盒子"这一语义:
box1.require("#{here}/box/proc_callee") proc_v = box1::Foo.callee # 在 box1 中创建的 Proc assert_raise(NameError) { Target } assert box1::Target assert_equal "fooooo", proc_v.call # 引用的是 box1 中的 Target实用工具方法
以下方法可用于试验与测试 Ruby Box(对应 box.c 中Init_Box注册的 API):
| 方法 | 行为 | 源码位置 |
|---|---|---|
Ruby::Box.new | 创建一个新的可选用户盒 | box.c |
Ruby::Box.current | 返回当前盒;未启用时返回nil | box.c |
Ruby::Box.enabled? | 返回是否以RUBY_BOX=1启动 | box.c |
Ruby::Box.root | 返回 root box | box.c |
Ruby::Box.main | 返回 main box | box.c |
Ruby::Box.master | 返回 master box | box.c |
Ruby::Box#eval | 在接收者盒子中求值 Ruby 代码字符串,等价于用文件调用#load | box.c |
Ruby::Box#require/#require_relative/#load | 在盒子中加载文件(支持.rb与原生扩展) | box.c |
Ruby::Box#load_path | 返回盒子本地的$LOAD_PATH | box.c |
Ruby::Box#inspect | 展示盒子的 id 与类型标签,如#<Ruby::Box:3,user,optional> | box.c |
box.master?/box.root?/box.main? | 查询盒子身份 | box.c |
Ruby::Box#eval的实现(box.c)先编译字符串为 iseq,再以目标盒作为执行上下文运行:
static VALUE rb_box_eval(VALUE box_value, VALUE str) { const rb_iseq_t *iseq; const rb_box_t *box; StringValue(str); iseq = rb_iseq_compile_iseq(str, rb_str_new_cstr("eval")); box = (const rb_box_t *)rb_get_box_t(box_value); return rb_iseq_eval(iseq, box); }inspect的输出格式(box.c)会依次追加master、root、user、main、optional等标签,例如#<Ruby::Box:3,user,optional>;Ruby::Box.current.inspect在 main box 中会显示main(测试test_raising_errors_in_require中对此有断言,见 test/ruby/test_box.rb)。
实现细节与底层原理
ISeq 内联方法/常量缓存
由于一个.rb文件整体运行在单个盒子中,方法/常量解析在盒内自洽。这意味着ISeq 的内联缓存(inline cache)在盒子的场景下依然有效——如果内联缓存失效或串盒,那就是 bug。这是盒子机制能够保持性能的重要前提。
方法调用全局缓存(gccct)与性能代价
C 层的rb_funcall()引用全局调用缓存表(gccct,global cc cache table),且缓存键的计算包含当前盒子。因此启用 Ruby Box 后,rb_funcall()调用会因缓存键复杂化而带来性能损耗。这是文档明确记录的已知代价。
当前盒(current box)与加载盒(loading box)
- 当前盒:正在执行的代码所在的盒子,
Ruby::Box.current返回的就是它。 - 加载盒:内部管理的、用于决定新 require/load 文件归属的盒子。例如调用
box.require("foo")时,box就是加载盒。
vm.c分别实现了二者的推导:rb_vm_current_box直接取当前控制帧(vm.c),而rb_vm_loading_box需要沿控制帧栈向上寻找第一个非 master 盒的加载者(vm.c)。
classext 写时复制(CoW)与性能优化方向
每个盒子对被修改的内建类持有自己的rb_classext_t(类扩展结构)副本,这依赖"classext 写时复制"机制,rb_box_t中的classext_cow_classes表记录了发生 CoW 的类集合(internal/box.h)。文档"讨论"一节指出了当前实现的优化空间:
rb_classext_t中包含cc_tbl(方法调用缓存表)、callable_m_tbl(已解析的互补方法表)与cvc_tbl(类变量缓存表)三类缓存数据;- 仅仅调用方法或引用类变量就会改写这三张表,从而频繁触发 classext CoW,次数远超预期;
- 若能把这三种表移出
rb_classext_t,rb_classext_t的复制次数将大幅减少。
原生扩展的盒子本地副本
启用 Ruby Box 后加载原生扩展(.so/.dll/.bundle)时,实现会先在进程私有的临时目录(_ruby_box_前缀、0700 权限、随机命名的目录,见 box.c)中生成扩展的盒子本地副本再加载(rb_box_local_extension,box.c),以避免不同盒子加载同一扩展时的冲突,并在进程退出或 Windows 卸载 DLL 时清理(rb_box_unload_local_extensions,box.c)。这也是文档"已知问题"中"安装原生扩展可能在RUBY_BOX=1下因 extconf.rb 栈溢出而失败"这一现象的背景。
已知问题与限制
文档明确列出的已知问题包括:
- 实验性警告:
RUBY_BOX=1启动时会打印实验性警告,可用-W:no-experimental隐藏(前文已述)。 - 原生扩展安装可能失败:
RUBY_BOX=1下,extconf.rb可能因栈深度过深(stack level too deep)导致安装原生扩展失败。 require 'active_support/core_ext'可能失败:该库的全局性猴子补丁与盒式隔离存在冲突。- 盒内定义的方法可能无法被 Ruby 编写的内建方法引用:盒内追加的方法对 root box 中运行的内建方法不可见(详见下文讨论)。
文档 TODO 清单中的计划项包括:在 iseq 上记录其加载盒以检测其他盒子试图运行该 iseq 的情况(仅在VM_CHECK_MODE下加字段)、为盒子分配各自的TOPLEVEL_BINDING、修复盒内warn对$VERBOSE与Warning.warn的引用、让内部数据容器类Ruby::Box::Entry不可见(当前Entry类是Ruby::Box下的可见类,见 box.c)、补充更多关于$LOAD_PATH与$LOADED_FEATURES的测试用例。
设计讨论与未来方向
文档"Discussions"一节记录了该特性的几个关键设计权衡,理解它们有助于把握 Ruby Box 的边界与演进方向。
更多用 Ruby 编写的内建方法
如果 Ruby Box 成为默认特性,内建方法就可以放心地用 Ruby 编写——因为用户无法通过猴子补丁覆盖它们。Ruby 编写的内建方法可以被 JIT 编译,从而带来性能收益。
被内建方法调用的猴子补丁方法
内建方法之间也会互相调用。例如Hash#map会调用Hash#each来获取待映射的条目。在传统 Ruby 中,用户覆盖Hash#each后Hash#map的行为会随之改变;但有了盒子后,Hash#map运行在 root box 中,用户只能在用户盒中定义Hash#each,无法借此改变Hash#map的行为。想要达到目的,用户必须同时覆盖Hash#map与Hash#each(或只覆盖Hash#map)。这对依赖这类"间接猴子补丁"的既有代码是破坏性变更。用户虽然可以用Ruby::Box.root.eval(...)实现,但文档明确指出这显然不是理想的 API。
被内建方法使用的全局变量
与猴子补丁同理:在盒子中为全局变量赋的新值,与 root box 隔离,root box 中引用该全局变量的内建方法无法读到重新赋值后的值。
$LOAD_PATH与$LOADED_FEATURES的上下文
$LOAD_PATH与$LOADED_FEATURES控制require的行为,因此这两个全局变量由加载盒决定而非当前盒。这可能与用户的直觉相冲突,目前仍在寻找解决方案。从源码看,每个rb_box_t都持有独立的load_path、expanded_load_path、loaded_features等数组(box.c),初始化时从 master box 复制,这正是"按盒管理加载路径"的实现基础。
将顶层方法暴露为盒子对象的方法
目前盒子的顶层方法无法从盒子外部访问,但存在"调用其他盒子顶层方法"的潜在用例,值得进一步设计。
结语
Ruby Box 为 Ruby 提供了一条在进程内部实现"应用、库与猴子补丁相互隔离"的新路径:通过RUBY_BOX=1开启,用Ruby::Box.new创建隔离空间,用require/require_relative/load/eval装载代码,实现类定义、常量、全局变量与顶层方法的按盒隔离,甚至在单进程内并行加载同一库的多个版本。它是一个标记为 experimental 的底层特性,在性能(gccct 缓存键、classext CoW 频率)与兼容性(内建方法调用的间接补丁、原生扩展安装、$LOAD_PATH语义)上仍有明确的已知问题与待办事项。对隔离、沙箱与依赖治理感兴趣的读者,可以继续研读 doc/language/box.md 原始设计文档、box.c 与 internal/box.h 的实现,以及 test/ruby/test_box.rb 与 test/ruby/box 目录下的完整测试用例。
【免费下载链接】rubyThe Ruby Programming Language项目地址: https://gitcode.com/GitHub_Trending/ru/ruby
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考