リファレンスの循環参照によるメモリリークを Scalar::Util::weaken で解決する

Perlガベージコレクション(GC)の実装にはリファレンスカウント方式のGCが採用されています。リファレンスカウントというのは、あるデータ構造やオブジェクトがあった際、それらを参照するリファレンスの数を内部で換算して、その数が 0 になったらメモリから実体を破棄するという仕組みです。(JavaRubyなどの他言語のGC方式については 'Perl、Java、Ruby における GC アルゴリズム' あたりを。)

リファレンスカウント式GCは、仕組みがシンプルで分かりやすい利点を持つ反面、相互参照が原因で参照を切ることができずにオブジェクトが解放されず、結果メモリリークを引き起す場合があるという欠点も持っています。

このメモリリークはバッチスクリプトCGI のように、一度の実行で Perl のプロセスそのものが終了するプログラムではあまり問題になりませんが、デーモンプログラム、あるいは mod_perlFastCGI のような Perl のプロセスを永続化させているような環境ではデーモンのプロセスや httpd が時間と共に太り続けたりする現象が発生するので厄介です。(Apache で RequestPerChild を設定するとかして凌ぐことはできますが。) また、デストラクタ(DESTROY)が必ず呼ばれることを期待して何か特殊なロジックを実装していると(そもそもそういった実装するのは好ましくないですが)痛い目に逢ったりもします。

このケース、今後説明する手間を省くためにも図解してみます。

上図は特に問題がない場合の参照関係。丸いのがオブジェクトで矢印がリファレンスの方向です。A は図に示していない別のオブジェクトから参照されているとします。

各オブジェクトはそれぞれ1つのオブジェクトから参照されているので、ここに示すすべてのオブジェクトのリファレンスカウントは1になります。ここでたとえば B が自分のスコープの外に出たとかで、A → B のリファレンスがなくなったとします。すると、B のリファレンスカウントは 0 になり、B は解放されます。

もし、図の外から A に来ているリファレンスがなくなったとすると、まず A のリファレンスカウントが 0 になり A が解放されます。このとき A は C へのリファレンスを持っていますが、A 自身は他から参照されていないのでおかまいなしに解放されます。すると、A → C のリファレンスも切れるため、C も自動的に解放されます。無事、すべてのオブジェクトが解放されました。

一方、上図は A と C が相互参照している図です。A のリファレンスカウントは 2、それ以外は 1 になります。

本来図の外から A に来ているリファレンスがなくなったところで、ABC は用済みなので解放されてほしいのですが、A のリファレンスカウントは 1 減って 2 から 1 になるだけで、まだ C から参照されているせいでリファレンスカウントが残っているため、解放されません。明示的に A - C 間のリファレンスを切ってやる必要が出てきます。これに気づかずに自動で解放されるだろうと GC をあてにしているとメモリリークが起こります。

わざとらしいですが、コードにすると以下のような場合が当てはまります。

package Object::A;
use strict;

sub new {
    my $class = shift;
    my $self = {};
    $self->{object_array} = [];
    return bless $self, $class;
}

sub create_b {
    my $self = shift;
    my $b = Object::B->new($self);
    push @{$self->{object_array}}, $b;
    return $b;
}

...

package Object::B;
use strict;

sub new {
    my $class = shift;
    my $self = {};
    $self->{object_a} = shift;
    return bless $self, $class;
}

...

Object::A が Object::B の管理役 (例えば Prototype パターンや Factory Method パターンで生成したインスタンスを覚えておく場合を思い浮かべるといいかも) だったとします。Object::B を取得するときは Object::A にたずねる、という仕様。ただし Object::B は Object::A のプロパティやメソッドにアクセスしたいケースがあるので、Object::A のインスタンスをコンストラクタで受け取っている...というものです。

このとき Object::A インスタンスはリストを介して Object::B インスタンスへのリファレンスを持つことになり、一方の Object::B インスタンスも Object::A インスタンスのリファレンスを持つことになります。相互参照ができあがってしまいました。

とある別のクラスで

{
    my $a = Object::A->new;
    my $b = $a->create_b;
    ...
}

というコードを書いたとき、通常ならこのブロックを越えた場合 $a や $b はスコープの外に出るので即座に解放されるところですが、例によって相互参照があるので残念ながらそうはいきません。Perl プロセス終了時まで持ち越しになります。きちんと解放させるためにはどこかで $b->{object_a} あるいは $a->{object_array} をクリアしてやる必要があります。

このどこかで、というのが曲者で、

  • もし A や B をインスタンス化している呼び出し元(クライアント)のコードでやるには、Object::A や Object::B の中の実装を知っていて且つそれを直接いじってやる必要がある(無論 OO 的にいけてない)
  • A や B のメソッドのどこかでやる場合、どのメソッドが最後に呼ばれるか、というのを事前に知っている必要がある。Template Method パターンなどを使っている場合以外は微妙な感じ
  • A や B に close() とかいうメソッドを用意して、相互参照をクリアするためのインタフェースを呼び出し元に公開してやり、ちゃんとそれを最後に呼んでね、みたいな方法をとる

という選択肢になってしまい、ちょっと嫌な感じです。

実はちょうど今日、はてなのコードの中にこの相互参照なロジックを発見してしまい、その対処策を考える必要がありました。そこで id:hideoki に教えてもらったのが Yet Another かつベターな解決方法としての Weakened reference です。リファレンスカウントが 0 にならないことが原因なら、相互参照で発生するリファレンスに対してカウントアップをしなければいい、という解決策。

図でいうところの緑破線のリファレンスが、A に対するリファレンスカウントをアップさせない「弱いリファレンス」であれば一番最初の例と同様の参照関係になるため、すべてのオブジェクトは問題なく解放されます。

この Weakened reference を作るには Scalar::Util の weaken メソッドを使えば ok です。Scalar::Util は Perl の標準モジュールなのですが、恥ずかしながら今日はじめてその使い方を知りました。

先の例ですと、たとえば

package Object::B;
use strict;
use Scalar::Util;

sub new {
    my $class = shift;
    my $self = {};
    $self->{object_a} = shift;
    Scalar::Util::weaken($self->{object_a});
    return bless $self, $class;
}

...

として、B → A のリファレンスを弱めてやれば ok、となります。

そもそもこういった相互参照は必要がないのであれば作らないというのがベターですが、気づかないうちにできている場合もよくあります。本論とは反れますが、リファクタリングの書籍なんかを呼んでいても、$self 渡しによりコードを綺麗にする例なんかも出てきますし、そういうことをしているうちにいつの間にか、というのもあるでしょう。解決策としての Scalar::Util::weaken というのを覚えておいて損はなさそうです。

蛇足ですが、任意のオブジェクトのリファレンスカウントがいくつかというのをコード中で調べるには Devel::PeekDevel::Leak を使って調べることができます。

うーむ、簡単に説明のつもりがなんかずいぶん長い解説になってしまった。