«前の日記(2013年06月12日) 最新 次の日記(2014年12月22日)» 編集

Matzにっき


2013年07月31日 [長年日記]

_ mrubyのmrb_gc_arena_save()/mrb_gc_arena_restore()の使い方

Twitterで質問を受けたので、 mrubyのmrb_gc_arena_save()/mrb_gc_arena_restore()の使い方 という解説を行った。が、1つ140文字のTwitterでの解説にはどうしても無理があるので、 こっちでまとめることにする。

まずは、Twitterの発言*1はこんな感じ。

arenaの目的。利用中のオブジェクトはGCに回収されないよう保護する必要がありますが、Cのスタックはポータブルに参照できません。そこでC関数実行中に生成したオブジェクトは全部「生きている」とみなす事で保守的に保護します。 yukihiro_matz 2013-07-31 08:16:45
arenaの目的(2)。この保護のためにオブジェクトを記録しておく領域がarenaです。mrubyではデフォルトで100個のオブジェクトを登録できます。 yukihiro_matz 2013-07-31 08:19:02
save/restoreの仕事。現状arenaのサイズは固定なのでC関数実行中にあまり沢山オブジェクトを生成するとarenaが溢れます。そこで沢山オブジェクトを生成する前後にsave/restoreを置くことでarenaのサイズを復元し、溢れを回避します yukihiro_matz 2013-07-31 08:27:02
save/restoreの使い方。オブジェクトを生成する領域の前後をsave/restoreで囲みます。ただし、囲まれた範囲内のオブジェクトが保護されなくなりますから、どうしても必要なものはrestore後mrb_gc_protect()で改めて保護してください。 yukihiro_matz 2013-07-31 08:33:61

これを再度まとめてみよう。

mrubyをCで拡張していると「arena overflow error」という「謎のエラー」に悩まされることがある。 これはmrubyで「保守的GC」を実現している「GC arena」という領域があふれたというエラーだ。

GC(ガーベージコレクター)は、オブジェクトがまだ「生きている」、 つまり、プログラムのどこかから参照されているかどうかを判定する必要がある。 これはルートと呼ばれる参照から直接・間接に参照可能かどうかで判別する。 ルートには、ローカル変数・グローバル変数・定数などが含まれる。

プログラムの実行がmruby VMの中でおさまっている時にはこれは問題ない。 VMの持つルートはすべてGCからアクセス可能だからだ。

問題はCで記述された関数を実行中の時。 Cの変数から参照されたオブジェクトも「生きている」わけだが、 mrubyのGCはCの変数の内容を感知できないので、 C変数からしか参照されていないオブジェクトは死んでいると誤解してしまう。

まだ生きているオブジェクトを回収してしまうのは、GCとしてもっともやってはいけないバグだ。

CRubyでは、Cのスタック領域を無理やりスキャンすることで、 Cの変数をルートとしてチェックしている。 もちろん、Cのスタックを単なるメモリ領域としてアクセスするわけだから、 それが整数を意味する値なのか、ポインタ値を意味する値なのか判別することはできない。 しかし、まあ、そこは「ポインタのように見える値は安全側に倒してポインタだと思って処理する」という 方針で処理している。この「安全側に倒す」というポリシーのことを「保守的」と呼ぶ。

され、このようなCRubyの「保守的GC」にはいくつか問題がある。

その最大のものは「移植性のある方法でスタック領域にアクセスする方法がない」ということだ。 つまり、移植性の高さを実現しようとするmrubyのような処理系では使えないってこと。

そこで、mrubyは別の方法で「保守的GC」を実現した。

問題なのは、C関数実行中に生成されたオブジェクトで、 Rubyの世界から参照されてないオブジェクトのうち、 C変数からは参照されているのでまだゴミ扱いしてはいけないものが存在する、 ということだ。

既に述べたようにCRubyは、Cスタックをスキャンしてゴミのように見えるがゴミでないものを保護している。

しかし、その方法が使えないmrubyは、より保守的なポリシーを採用した。 つまり、C関数実行中に生成されたオブジェクトは、極端に安全側に倒して、いっそ全部生きているとみなせば、 少なくともゴミでないものを回収してしまう問題は回避できるんじゃないかと。

この結果、本当はゴミであるものを回収できないので、若干効率が下がることになるが、 移植性が高いまま、保守的なGCを実現できることになる。 CRubyで時々発生する「最適化で参照が削除されてゴミでないのにGCされた」問題とも無縁になる。

このポリシーで、「C関数実行中に生成されたオブジェクト」を登録しておくテーブルが 「GC arena」である。arenaはスタック状になっていて、 C関数の実行が終わるとその間に登録されたオブジェクトはポップされる。

原則としてはこれだけで、普通の場合は、これでめでたしめでたしなのだが、 GC arenaの存在は別の問題を引き起こすことがある。 これが前述した「arena overflow error」だ。

メモリが少ない環境でも動作することを考慮したmrubyはarenaのサイズを固定長にしており、 しかも、そのサイズはデフォルトで100とかなり小さめに設定されている。

実は当初はサイズ1000とかちょっと大きめにしていたのだが、 このテーブルサイズが厳しい環境があったのと、 後述するようなテクニックを使い、適切にarenaを管理すれば100でも普通に動くので、 現状は100にしている。

その結果、C関数の実行中に数多くのオブジェクトを生成すると、 arenaがあふれることになる。

その対策に用いるのが、表題のmrb_gc_arena_save()とmrb_gc_arena_restore()というふたつの関数である。

int mrb_gc_arena_save(mrb)はGC arenaの現在のスタック位置を返し、 void mrb_gc_arena_restore(mrb, idx)はarenaのスタック位置を保存された位置に戻す。

int arena_idx = mrb_gc_arena_save(mrb);

...なんかオブジェクトを作る処理...
mrb_gc_arena_restore(mrb, arena_idx);

というような使い方をする。

もともとのC関数の実行は、このようにsave/restoreに囲まれているのだが、 一時的にオブジェクトを作り、その後は不要になる領域を 明示的にsave/restoreを囲むことにより、arena overflowを避けるわけだ。

とはいうものの、具体例を見ないとわからないケースもあるだろう。 ここでは、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_buf_new(mrb, 64);
  mrb_str_buf_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_buf_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_buf_cat(mrb, arystr, RSTRING_PTR(s), RSTRING_LEN(s));
    mrb_gc_arena_restore(mrb, ai);
  }

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

  return arystr;
}

実際のコードをそのまま引用してきたので、若干複雑になっているが、 Array#inspectの処理の本質は、 配列の各要素をinspectメソッドを用いて文字列化した上で、 それらを結合して配列全体のinspect表現を作ることにある。

全体のinspect表現の文字列を作ってしまえば、 処理途中に生成した各要素の文字列はもはや不要になる。 ということは、GC arenaにこれらのオブジェクトを登録しておく必要もない、ということだ。

そこで、ary_inspect()関数では、

  • mrb_gc_arena_save()でインデックスを保存
  • 要素のinspect表現文字列を取得
  • 生成中の配列inspect表現文字列に結合
  • mrb_gc_arena_restore()でインデックスを復旧

という手順により、arena領域の消費を抑えている。

ここで注意すべき点は、最終結果となる配列inspect表現となる文字列は、 mrb_gc_arena_save()の呼び出しよりも前に生成していることである。 こうしないと、必要なオブジェクトがGCで回収されてしまうことになる。

処理のパターンとしては、さまざまな一時オブジェクトを生成した上で、 そのうちの一部だけを参照し続けるというケースも考えられる。 このようなケースでは、ary_inspect()のように既存のオブジェクトに結合するような手は 使えないので、mrb_gc_arena_restore()の呼び出しの後に mrb_gc_protect(mrb, obj)を呼び出して、そのオブジェクトをarenaに再登録する必要がある。 ただし、mrb_gc_protect()は注意して用いないと、これ自身がarena overflow errorの原因になることがあるので 注意するように。

などということを、Twitterだけで説明するのは現実的じゃないよなあ。

追記

あ、そうそう。

このmrubyのAPIには、改善が必要だなと思ってる点があって、 その最大のものは、トップレベルでmrb_funcall()を呼ぶと GC arenaに戻り値が登録されるのでそのうちarena overflow errorになることだ。

戻り値を使わないmrb_funcall()のようなものを用意すればいいんだと思うんだけど。 いい名前が思いつかないんだよなあ。

*1 tDiaryにTwitterの発言を引用するプラグインが欲しいなあ


«前の日記(2013年06月12日) 最新 次の日記(2014年12月22日)» 編集