解锁 Ractor:object_id

2025年4月26日

之前一篇关于 Ractor 的文章 中,我解释了为什么我认为你不太可能在 Ractor 中运行整个应用程序,但它们在某些情况下仍然非常有用,可以将 CPU 密集型工作从主线程中移出,并解锁一些并行算法。

但正如我提到的,由于存在许多已知的实现错误,可能导致解释器崩溃,这使得 Ractor 目前还不可行。此外,尽管它们应该并行执行,但 Ruby VM 仍然有一个真正的全局锁,Ractor 需要获取该锁才能执行某些操作,这使得它们的性能通常比等效的单线程代码更差。

但情况正在迅速发展。现在有一个团队致力于解决这个问题:处理已知错误并消除或减少剩余的争用点。

我给出的说明剩余争用的一个例子是 fstring_table,简而言之,这是一个大型内部哈希表,用于删除重复字符串,当你在 Hash 中使用 String 作为键时,Ruby 就会这样做。因为当另一个 Ractor 插入新条目时,查看该表会导致崩溃(或更糟),所以在上周之前,Ruby 必须获取剩余的 VM 锁才能操作该表。

但是 John Hawthorn 最近用一个无锁的 Hash-Set 替换了它,现在这个争用点已经消失了。如果你使用最新的 Ruby master 重新运行之前文章中的 JSON 基准测试,Ractor 版本的速度现在是单线程版本的两倍,而不是慢三倍。

但这仍然不完美,因为基准测试使用了 5 个 Ractor,因此在理想情况下,速度应该几乎是单线程示例的 5 倍,所以我们仍然需要做大量工作来消除或减少剩余的争用点。

你可能没有想到,其中一个剩余的争用点是 #object_id 方法。在从 RubyKaigi 回来的路上,我开始着手解决这个问题。

但在深入探讨我计划做什么之前,让我们先谈谈这个方法是如何成为争用点的。

一点历史

直到 Ruby 2.6,#object_id 的实现还非常简单:

VALUE
rb_obj_id(VALUE obj)
{
  if (STATIC_SYM_P(obj)) {
    return (SYM2ID(obj) * sizeof(RVALUE) + (4 << 2)) | FIXNUM_FLAG;
  }
  else if (FLONUM_P(obj)) {
   return LL2NUM((SIGNED_VALUE)obj);
  }
  else if (SPECIAL_CONST_P(obj)) {
   return LONG2NUM((SIGNED_VALUE)obj);
  }
  return LL2NUM((SIGNED_VALUE)(obj) / 2);
}

当然,对于不熟悉 C 语言的人来说,这可能有点晦涩难懂,但简而言之,对于常见的堆分配对象,它的 object_id 将是对象存储的地址除以 2。所以在某种程度上,#object_id 过去会返回一个指向对象的实际指针。

这使得实现 #object_id 鲜为人知的对应方法 ObjectSpace._id2ref 变得同样简单,将 object_id 乘以 2,你就能得到指向对应对象的指针。

s = "I am a string"
ObjectSpace._id2ref(s.object_id).equal?(s) # => true

但实际上这个实现存在一个主要问题,那就是 Ruby 堆由标准大小的插槽组成。当一个对象不再被引用时,GC 会回收该对象插槽,并且很可能会将其重新用于未来的对象。

因此,如果你持有一个 object_id,并使用 ObjectSpace._id2ref,实际上无法确定你返回的对象是你获取 object_id 的对象,它可能是一个完全不同的对象。

这也意味着,如果你持有一个 object_id 作为一种知道你是否已经见过给定对象的方式,你可能会遇到一些误报。

这就是为什么 在 2018 年已经有一个功能请求要弃用 #object_id_id2ref。当时,Matz 同意在 Ruby 2.7 中弃用 _id2ref,但指出删除 #object_id 会造成太大的破坏性更改,并且它是一个有用的 API。然而,这不知何故被搁置了,_id2ref 从未被正式弃用,这是 我想在 Ruby 3.5 中做的事情

我不确定最初为什么要添加 _id2ref,因为 git blame 指向 一个来自 1999 年的提交,该提交由 cvs2svn 生成。但如果让我猜,我会说它是为 drb 添加的,今天它仍然是 stdlib 中该 API 的唯一重要用户,但 即使这种情况也即将改变

GC 压缩

无论为什么添加 _id2ref,其设计中的那个主要缺陷都成为了 Aaron Patterson 在 Ruby 2.7 中实现 GC 压缩 的阻碍。由于 GC 压缩意味着对象可以从一个插槽移动到另一个插槽,#object_id 不能再从对象地址派生,否则它将无法保持稳定。

Aaron 所做的事情在概念上很简单:

module Kernel
 def object_id
  unless id = ObjectSpace::OBJ_TO_ID_TABLE[self]
   id = ObjectSpace.next_obj_id
   ObjectSpace.next_obj_id += 8
   ObjectSpace::OBJ_TO_ID_TABLE[self] = id
   ObjectSpace::ID_TO_OBJ_TABLE[id] = self
  end
  id
 end
end
module ObjectSpace
 def self._id2ref(id)
  ObjectSpace::ID_TO_OBJ_TABLE[id]
 end
end

简而言之,Ruby 添加了两个内部 Hash 表。其中一个以对象为键,ID 为值,另一个则相反。每当你第一次访问对象的 ID 时,都会通过递增一个内部计数器来创建一个唯一的 ID,并且对象及其 ID 之间的关系存储在这两个哈希表中。

作为 Ruby 用户,你可以通过打印一些 object_id 轻松观察到此更改:

p Object.new.object_id
p Object.new.object_id

在 Ruby 2.6 之前,上面的代码将打印一些大的、看似随机的整数,例如 50666405449360,而在 Ruby 2.7 之后,它将打印小的整数,可能是 816

此更改既解决了 _id2ref 的历史问题,又允许 GC 在将对象从一个地址移动到另一个地址时保持稳定的 ID,但同时也使 object_id 比以前更加昂贵。

Ruby 的哈希表实现为每个条目存储 3 个指针大小的数字。一个用于键,一个用于值,一个用于哈希码:

struct st_table_entry {
  st_hash_t hash;
  st_data_t key;
  st_data_t record;
};

鉴于每个 object_id 都存储在两个哈希表中,这使得每个 object_id 总共占用 48B(加上一些零头)。对于一个小数来说,这相当多的内存。

此外,访问 object_id 现在需要进行哈希查找,而以前只是一个简单的除法,并且每当 GC 释放或移动具有 ID 的对象时,它都需要更新这两个哈希表。

需要明确的是,我没有任何证据表明这两个表会在现实世界的 Ruby 应用程序中造成显着的内存或 CPU 开销。我只是说 #object_id 比人们想象的要昂贵得多。

进入 Ractor

后来,当 Koichi Sasada 实现 Ractor 时,由于现在多个 Ractor 可能会尝试并发访问这两个哈希表,他不得不在 #object_id 周围添加一个锁,从而将 #object_id 变成了一个争用点:

module Kernel
 def object_id
  RubyVM.synchronize do
   unless id = ObjectSpace::OBJ_TO_ID_TABLE[self]
    id = ObjectSpace.next_obj_id
    ObjectSpace.next_obj_id += 8
    ObjectSpace::OBJ_TO_ID_TABLE[self] = id
    ObjectSpace::ID_TO_OBJ_TABLE[id] = self
   end
   id
  end
 end
end
module ObjectSpace
 def self._id2ref(id)
  RubyVM.synchronize do
   ObjectSpace::ID_TO_OBJ_TABLE[id]
  end
 end
end

此时,你可能会怀疑这是否真的重要。毕竟,#object_id 主要用于调试,但在实际生产代码中并不常用。这基本上是正确的,但它确实出现在真实世界的代码中,例如 mail gem 中rubocop,当然还有 在 Rails 中相当多

但是,调用 Kernel#object_id 并不是你可能依赖对象 ID 的唯一方式。

例如,Object#hash 方法依赖于它:

static st_index_t
objid_hash(VALUE obj)
{
  VALUE object_id = rb_obj_id(obj);
  if (!FIXNUM_P(object_id))
    object_id = rb_big_hash(object_id);
  return (st_index_t)st_index_hash((st_index_t)NUM2LL(object_id));
}
VALUE
rb_obj_hash(VALUE obj)
{
  long hnum = any_hash(obj, objid_hash);
  return ST2FIX(hnum);
}

常见的 Value 类,例如 StringArray 等,确实定义了自己的 #hash 方法,该方法不依赖于对象 ID,但默认情况下,所有其他按标识比较的对象最终都将使用 Object#hash,因此会访问 object_id

例如,这是来自一个 Rails 类的相当典型的 #hash 实现:

# activerecord/lib/arel/nodes/delete_statement.rb
 def hash
  [self.class, @relation, @wheres, @orders, @limit, @offset, @key].hash
 end

这绝对不明显,但在这里我们正在哈希一个 Class 对象,并且类像默认对象一样按标识进行索引:

>> Class.new.method(:hash).owner
=> Kernel
>> Object.new.method(:hash).owner
=> Kernel

因此,上面的代码目前需要锁定整个虚拟机,才能生成哈希码。

反优化

那么,我们可以做些什么来消除或减少在访问对象 ID 时同步整个虚拟机的需要呢?

首先,鉴于 ObjectSpace._id2ref 很少使用,并且很可能很快就会被标记为已弃用,我们可以乐观地开始不创建或更新 id -> object 表,直到有人需要它,希望这在绝大多数程序中都不会发生:

module Kernel
 def object_id
  RubyVM.synchronize do
   unless id = ObjectSpace::OBJ_TO_ID_TABLE[self]
    id = ObjectSpace.next_obj_id
    ObjectSpace.next_obj_id += 8
    ObjectSpace::OBJ_TO_ID_TABLE[self] = id
    if defined?(ObjectSpace::ID_TO_OBJ_TABLE)
     ObjectSpace::ID_TO_OBJ_TABLE[id] = self
    end
   end
   id
  end
 end
end
module ObjectSpace
 def self._id2ref(id)
  RubyVM.synchronize do
   unless defined?(ObjectSpace::ID_TO_OBJ_TABLE)
    ObjectSpace::ID_TO_OBJ_TABLE = ObjectSpace::OBJ_TO_ID_TABLE.invert
   end
   ObjectSpace::ID_TO_OBJ_TABLE[id]
  end
 end
end

这还没有删除锁,但假设你的程序从不调用 ObjectSpace._id2ref,它会从锁内删除一些工作,因此它不应该被持有那么长时间。即使你不使用 Ractor,它也应该略微减少内存使用,并减少 GC 的工作,如微基准测试所示:

benchmark:
 baseline: "Object.new"
 object_id: "Object.new.object_id"

compare-ruby: ruby 3.5.0dev (2025-04-10T09:44:40Z master 684cfa42d7) +YJIT +PRISM [arm64-darwin24]
built-ruby: ruby 3.5.0dev (2025-04-10T10:13:43Z lazy-id-to-obj d3aa9626cc) +YJIT +PRISM [arm64-darwin24]
warming up..
|      |compare-ruby|built-ruby|
|:----------|-----------:|---------:|
|baseline  |   26.364M|  25.974M|
|      |    1.01x|     -|
|object_id |   10.293M|  14.202M|
|      |      -|   1.38x|

与往常一样,如果可以避免,加快代码速度最有效的方法是不调用它。

如果你想了解实际实现,你可以看看 pull request

内联存储

虽然节省一些内存和 CPU 很好,但我们仍然没有显着减少争用,所以我们还能做什么?

这里的关键问题是 object_id 存储在一个集中的哈希表中,只要情况如此,就需要同步,除非实现一个无锁哈希表,但这非常棘手。比 John 用于 fstring_table 的哈希集复杂得多。

但更重要的是,用于存储所有对象的所有 ID 的集中式数据结构无论如何都不利于局部性。更重要的是,需要进行哈希查找才能访问对象的属性非常昂贵,而从概念上讲,它应该直接存储在对象内部。

如果你仔细想想,object_id 与实例变量没有太大区别:

module Kernel
 def object_id
  @__object_id ||= ObjectSpace.generate_next_obj_id
 end
end

你需要 ID 生成是线程安全的,这很容易使用原子递增操作完成,但除此之外,假设该对象不是可以从多个 Ractor 访问的特殊对象之一,你可以修改它来存储 object_id,而无需锁定整个 VM。

然而,按照惯例,事情从来没有那么简单。

最终形状

自 Ruby 3.2 以来,对象使用形状来定义其实例变量的存储方式。

在这里,让我们再次使用一些伪 Ruby 代码来说明它们的工作原理的基础知识。

首先,形状是一种树状结构。每个形状都有一个父级(除了根形状)和 0-N 个子级:

class Shape
 def initialize(parent, type, edge_name, next_ivar_index)
  @parent = parent
  @type = type
  @edge_name = edge_name
  @next_ivar_index = next_ivar_index
  @edges = {}
 end
 def add_ivar(ivar_name)
  @edges[ivar_name] ||= Shape.new(self, :ivar, ivar_name, next_ivar_index + 1)
 end
end

这样,当 Ruby VM 必须执行如下代码时:

class User
 def initialize(name, role)
  @name = name
  @role = role
 end
end

它可以动态计算对象形状,例如:

# Allocate the object
object = new_object
object.shape = ROOT_SHAPE
# add @name
next_shape = object.add_ivar(:@name)
object.shape = next_shape
object.ivars[next_shape.next_ivar_index - 1] = name
# add @role
next_shape = object.add_ivar(:@role)
object.shape = next_shape
object.ivars[next_shape.next_ivar_index - 1] = role

此方法可能看起来令人惊讶,但实际上它非常高效,原因有很多,我将不在此处赘述,因为我写了 另一篇关于它的文章,大约在一年前,如果你想了解更多,请阅读它。

但实例变量的布局方式并不是形状记录的唯一内容。它们还会跟踪对象的大小,即它可以存储多少个实例变量,以及它是否已被冻结。

仍然使用伪 Ruby 代码,它看起来像这样:

class Shape
 def add_ivar(ivar_name)
  if @type == :frozen
   raise "Can't modify frozen object"
  end
  @edges[ivar_name] ||= Shape.new(self, :ivar, ivar_name, next_ivar_index + 1)
 end
 def freeze
  @edges[:__frozen] ||= Shape.new(self, :frozen, nil, next_ivar_index)
 end
end

因此,frozen 形状是最终的。预计 frozen 类型的形状永远不会有任何子项。

但在 object_id 的情况下,我们希望能够在任何对象上存储 ID,无论它们是否被冻结。因此,第一步是修改形状以允许这样做,我通过一个相对简单的提交完成了这一步

但这里也存在一些复杂情况。在少数情况下,例如在调用 Object#dup 时,Ruby 需要找到未冻结版本的形状。以前,由于冻结形状不可能有子项,因此非常简单:

class Object
 def dup
  new_object = self.class.allocate
  if self.shape.type == :frozen
   new_object.shape = self.shape.parent
  else
   new_object.shape = self.shape
  end
  # ...
 end
end

一旦你允许冻结形状拥有子项,此操作就会变得更加复杂,因为你现在需要向上遍历树以查找最后一个非冻结形状,然后重新应用你希望传递的所有子形状。

完成此小型重构后,我可以引入一种新的形状类型:SHAPE_OBJ_ID,其行为与实例变量形状非常相似:

class Shape
 def object_id
  # First check if there is an OBJ_ID shape in ancestors
  shape = self
  while shape.parent
   return shape if shape.type == :obj_id
   shape = shape.parent
  end
  # Otherwise create one.
  @edges[:__object_id] ||= Shape.new(self, :obj_id, nil, next_ivar_index + 1)
 end
end

就这样,我们现在能够在任何对象内部保留一些内联空间来存储 object_id,并且在 某些情况下,我们能够完全无锁地访问对象的 ID。

无锁形状

我为什么要说 在某些情况下 是因为仍然存在许多限制。

首先,由于形状大多是不可变的,我们可以在不获取锁的情况下访问对象的形状及其所有祖先。但是,查找或创建形状的子项目前仍然需要同步 VM。因此,即使我的补丁被应用,Ruby 仍然会在第一次访问对象的 ID 时锁定,只有在后续访问时才会是无锁的。

能够以无锁的方式查找或创建子形状对于 object_id 用例来说将非常有用,因此希望我们将来能够实现它,我还没有对此进行过多思考,但我希望我们可以找到解决方案。但即使我们不能以无锁的方式执行此操作,我认为我们至少可以使用专用的锁,这样我们就不会与同步整个 VM 的所有其他代码路径竞争,而只会与执行相同操作的路径竞争。

然后,如果该对象可能在 Ractor 之间共享,我们仍然需要在存储 ID 之前获取锁,否则,并发写入可能会导致竞争条件。鉴于我们需要同时更新对象的形状和将 object_id 写入对象内部,我们无法以原子方式完成所有操作。

最后,并非所有对象都以相同的方式存储其实例变量。

泛型实例变量

作为 Rubyist,你可能知道在 Ruby 中一切皆对象,但这并不意味着所有对象都是平等的。

在实例变量的上下文中,基本上有三种类型的对象:T_OBJECTT_CLASS/T_MODULE,然后是所有其他对象。

T_OBJECT 是从 BasicObject 类继承的经典对象。它们的实例变量直接内联存储在对象插槽内部,只要它足够大。如果它最终溢出,则会分配一个单独的内存位置,并将实例变量移动到该位置,然后对象插槽仅包含指向该辅助内存的指针。

T_CLASST_MODULE 顾名思义是 ClassModule 类的所有实例。这些比常规对象大得多,因为它们需要跟踪很多东西,例如它们的方法表、指向父类的指针等等:

>> ObjectSpace.memsize_of(Object.new)
=> 40
>> ObjectSpace.memsize_of(Class.new)
=> 192

因此,它们从不内联存储其实例变量,它们始终将它们存储在辅助内存中,并且在它们的对象插槽中具有专用空间来存储辅助内存指针:

# internal/class.h
struct rb_classext_struct {
  VALUE *iv_ptr; // iv = instance variable
  // ...
}

最后,还有所有其他对象,例如 T_STRINGT_ARRAYT_HASHT_REGEXP 等。这些对象在其插槽中都没有可用空间来存储内联变量,甚至没有空间来存储辅助内存指针。

那么当你在这样的对象中添加实例变量时,Ruby 会怎么做呢?好吧,它当然会将其存储在哈希表中!

在伪 Ruby 中,它看起来像这样:

module GenericIvarObject
 class GenericStorage
  attr_accessor :shape
  attr_reader :ivars
  def initialize
   @ivars = []
  end
 end
 def instance_variable_get(ivar_name)
  store = RubyVM.synchronize do
   GENERIC_STORAGE[self] ||= GenericStorage.new
  end
  if ivar_shape = store.shape.find(ivar_name)
   store.ivars[ivar_shape.next_ivar_index - 1]
  end
 end
end

你可能已经注意到甚至猜到,由于这又是另一个全局哈希表,任何访问都需要同步,这意味着对于 T_OBJECTT_CLASST_MODULE 以外的对象,我的补丁将一个全局同步哈希替换为另一个……

因此,也许对于这些对象,保留原始的 object -> id 表会更好,这是我仍然需要弄清楚的事情。

结论

我的补丁尚未完成。我仍然需要弄清楚如何最好地处理“泛型”对象,并且可能需要进一步改进实现,并且最终甚至可能根本不会合并。

但我想分享它,因为解释某些事情可以帮助我思考问题,并且还因为虽然我不认为 object_id 目前是最大的 Ractor 瓶颈,但它很好地展示了需要完成的工作类型,以使 Ractor 更加并行。

如果你对补丁感到好奇,这是 截至本文撰写时它的当前外观

类似的工作将必须对其他内部表进行,例如符号表和各种方法表。