关于 GC arena

译者 Lanza Schneider

当你使用 C 语言扩展 mruby 时,可能会遇到神秘的"arena overflow error"或是内存泄漏,又或者是严重的执行效率拖慢问题。出现这种情况就说明 mruby 的保守 GC 遭遇了"GC arena 溢出"的错误。

GC (garbage collector 即垃圾收集器)必须检测对象的存活性,所谓存活性,就是说一个对象是否还在被程序的某处所引用。为了达到这个目的,我们设置了 root,也就是 GC 的根节点,如果一个对象能直接或间接地与 root 连接,就可以说明这个对象存活。本地临时变量、全局变量和常量都是 root 。

如果程序一直在 mruby 虚拟机内执行,那就没有什么可担心的了。因为 GC 显然可以获取到这个过程当中所有的 root 。

问题发生在执行 C 函数时。按理来说,在 C 函数里产生的对象应该也算是"本地临时变量",所以应该是存活的,但是 mruby GC 显然是没办法认识到这点的,所以它会误认为在 C 函数执行过程中产生的对象是非存活的。

那么显然,如果这时 GC 试图收集掉这个本应存活的对象,那就是一个致命的错误。

在 CRuby 当中,我们可以扫描 C 的栈空间,这样就可以把 C 栈空间里的变量视作 root 来检查对象是否存活。当然,因为 C 栈空间只是一个内存区域,所以我们没有办法知道里面到底是一个 int 数据还是一个指针。所以我们在它看起来像是一个指针的情况下,就假设它为一个指针。我们称之为"conservative(保守)"。

顺带一提,Cruby 的 "conservative GC" 有一些问题。最大的问题就是我们没有办法用一种可移植的方式来访问 C 栈空间。mruby 的目标是实现一个高度可移植的 runtime ,所以这种方法就不能用了。

所以我们想到了另一个能够在 mruby 中实现 "conservative GC" 的方式:把所有在 C 函数中创建的对象都视为存活,这样就不会出现 GC 把它们误认为非存活对象的问题。

这意味着,由于我们不能收集真正的非存活对象,所以可能会影响效率;也正是以此为代价,才实现了 mruby 的高度可移植性。

这样就告别了在 CRuby 中有时会出现的 "收集了本不该收集的对象" 问题。

顺着思路讲,我们有一个叫做 "GC arena" 的表,它的作用是记忆哪些对象是在 C 函数中创建的。

arena 是一个栈结构,当 C 函数执行完毕返回到 mruby 虚拟机时,所有 arena 中注册的对象都会弹出。

这个做法很不错,不过也会导致其它问题:"arena overflow error" 或内存泄漏。

在写这篇文章时(译者注:2013年),mruby 会自动扩展 arena 来记忆 C 函数中的对象。如果你在 C 函数中创建了许多对象,内存使用量就会增加,因为 GC 并没有真正工作过。这种内存消耗也许看起来像是内存泄漏,也可能会导致执行速度的拖慢(因为需要分配更多内存)。

通过配置(译者注:可以看 mrbconf 那一章),你可以让 arena 变为固定大小,并且限制它的最大对象数目(默认 100)。如果你在 C 函数中创建了很多对象,arena就会溢出——也就是抛出一个"arena overflow error"。

为了解决这些问题,mruby C API 提供了 mrb_gc_arena_save()and mrb_gc_arena_restore()函数。

int mrb_gc_arena_save(mrb)返回 GC arena 栈顶的当前位置,而 void mrb_gc_arena_restore(mrb, idx)会把 GC arena 栈顶的位置设置为 idx 。

可以像这样使用它:

int arena_idx = mrb_gc_arena_save(mrb);

// ... 创建 mruby 对象 ...
mrb_gc_arena_restore(mrb, arena_idx);

在 mruby 中,C 函数的调用过程实际上就被这种 save/restore 所包围了, 但是我们可以通过手动在小范围内的 save/restore 进一步优化内存使用,也可以避免 "arena overflow error" 。

来看个实际的例子,这是 Array#inspect的代码:

static mrb_value
inspect_ary(mrb_state *mrb, mrb_value ary, mrb_value list)
{
  mrb_int i;
  mrb_value s, arystr;
  char head[] = { '[' };
  char sep[] = { ',', ' ' };
  char tail[] = { ']' };

  /* check recursive */
  for(i=0; i<RARRAY_LEN(list); i++) {
    if (mrb_obj_equal(mrb, ary, RARRAY_PTR(list)[i])) {
      return mrb_str_new(mrb, "[...]", 5);
    }
  }

  mrb_ary_push(mrb, list, ary);

  arystr = mrb_str_new_capa(mrb, 64);
  mrb_str_cat(mrb, arystr, head, sizeof(head));

  for(i=0; i<RARRAY_LEN(ary); i++) {
    int ai = mrb_gc_arena_save(mrb);

    if (i > 0) {
      mrb_str_cat(mrb, arystr, sep, sizeof(sep));
    }
    if (mrb_array_p(RARRAY_PTR(ary)[i])) {
      s = inspect_ary(mrb, RARRAY_PTR(ary)[i], list);
    }
    else {
      s = mrb_inspect(mrb, RARRAY_PTR(ary)[i]);
    }
    mrb_str_cat(mrb, arystr, RSTRING_PTR(s), RSTRING_LEN(s));
    mrb_gc_arena_restore(mrb, ai);
  }

  mrb_str_cat(mrb, arystr, tail, sizeof(tail));
  mrb_ary_pop(mrb, list);

  return arystr;
}

这是个实际的例子,所以有点长,请耐心看下去; Array#inspect的本质就是对每个元素调用 inspect方法进行字符串化后, 再把这些结果给连接起来,这样就可以获得整个数组的 inspect表示。

在每次调用 inspect获得结果后, 我们就会把它通过 mrb_str_cat把它连接到 arystr中,所以这个单个的临时字符串就不需要再进入 GC arena 了。

因此,为了不让这些临时对象占用 GC arena 的空间, ary_inspect()函数将会执行如下操作:

  • mrb_gc_arena_save()保存 arena 栈顶的位置
  • 获取当前元素的 inspect结果(一个字符串)
  • 把这个字符串连接到整个数组的 inspect结果中(这时,这个字符串就不再需要了)
  • mrb_gc_arena_restore()恢复 arena 栈顶的位置

注意临时的 inspect添加到整个结果之后,我们才调用 mrb_gc_arena_restore()的。否则这个临时对象可能来不及用就被回收了。

我们可能遇到这种情况:在创建了很多临时对象之后,还要保留其中的一些。在这种情况下,我们就不能采用相同的思路了。在 mrb_gc_arena_restore()调用之后,你需要用 mrb_gc_protect(mrb, obj)重新把这些临时对象注册到 GC arena 里。

要谨慎使用 mrb_gc_protect(),因为它也会导致"arena overflow error"。

还需要提到的一点是,当你在顶层调用 mrb_funcall时,它的返回值也会注册到 GC arena, 所以重复调用 mrb_funcall也可能会导致 "arena overflow error"。