循環参照とGC — unsetしたのにデストラクタが呼ばれない理由

PHPのガベージコレクタは、参照カウントだけでは解放できない循環参照を回収する仕組みです。gc_collect_cycles() を呼べば任意のタイミングで走らせられます。

普段は意識しなくても困りません。困るのは、常駐ワーカーやバッチ処理を書いたときです。メモリが右肩上がりで増えていく、あるいはデストラクタに書いたはずの後始末が動いていない。この二つはだいたい同じ根っこから来ています。

なぜ unset したのにデストラクタが呼ばれないのか

PHPのオブジェクトは参照カウント方式で管理されていて、参照が0になった瞬間に解放されます。「瞬間に」というのが大事なところで、Javaのような世代別GCと違って、普通は決定的に解放されます。ところが互いを参照し合っているオブジェクトは、外から誰も見ていなくてもカウントが0になりません。

親子構造は典型例です。親が子を配列で持ち、子が親への参照を持つ。実務で普通に書く形ですね。

declare(strict_types=1);

final class Node
{
    public ?Node $parent = null;
    /** @var Node[] */
    public array $children = [];

    public function __construct(public readonly string $name) {}

    public function addChild(Node $child): void
    {
        $child->parent = $this;      // 子 → 親
        $this->children[] = $child;  // 親 → 子
    }

    public function __destruct()
    {
        echo "destruct: {$this->name}\n";
    }
}

$root = new Node('root');
$root->addChild(new Node('child'));

unset($root);
echo "unset した\n";

gc_collect_cycles();
echo "GC した\n";

出力は「unset した」が先に出て、そのあとに二つの destruct が出ます(root と child のどちらが先かは保証されません)。unset() した時点では何も解放されていない、というのがポイントです。ここで gc_collect_cycles() を挟まなければ、後始末はスクリプト終了時までずれ込みます。

循環参照は「リーク」ではなく「遅延」

よく誤解されますが、これはメモリリークではありません。放っておいてもGCが自動で回収します。ルートバッファに循環の候補が一定数たまるとGCが自動起動する仕組みで、PHP 7.3以降はそのしきい値と バッファサイズが実行時に自動調整されるようになりました。だから普通のWebリクエストなら、まず問題になりません。

実害が出るのは「いつ解放されるか」が意味を持つ場面です。デストラクタでファイルハンドルを閉じている、ロックを解放している、一時ファイルを消している。こういうコードは、解放が数十秒遅れるだけで挙動が変わります。私は一時ファイルが消えないまま溜まっていくのを追いかけて、最終的にクロージャの循環に行き着いたことがあります。

個人的には、後始末をデストラクタに任せる設計そのものを疑ったほうがいいと思っています。明示的な close() を用意して try/finally で呼ぶほうが、GCの機嫌に左右されなくて済みます。

gc_collect_cycles() はいつ呼ぶべきか

gc_collect_cycles() は回収したサイクル数を int で返します。常駐ワーカーでは、ジョブを何件か処理するごとに呼んで、ついでにメモリを見ておくのが素直な形です。

$limit = 256 * 1024 * 1024;

foreach ($jobs as $i => $job) {   // $jobs はリスト
    $handler->handle($job);

    if ($i % 100 === 0) {
        gc_collect_cycles();

        if (memory_get_usage(true) > $limit * 0.8) {
            // 粘らずにプロセスを畳んで、スーパーバイザに再起動させる
            break;
        }
    }
}

毎ループ呼ぶのはやりすぎです。GCはその時点のルートバッファ全体を走査するので、呼べば呼ぶほど速くなるという類のものではありません。100件ごと、1000件ごとくらいの粒度から始めて、実測で決めるのがいいと思います。

もう一つ、地味に効くのが「粘らない」ことです。上の例のように、しきい値を超えたら潔くプロセスを終わらせて再起動させる。GCで戦うより確実で、運用も楽になります。

gc_status() で何を見るか

推測で調整するより、数字を見たほうが早いです。gc_status() はPHP 7.3以降で使えます。

$s = gc_status();

// runs      : GCが走った回数
// collected : 回収した数の累計
// roots     : いまルートバッファに積まれている候補の数
// threshold : 次にGCが自動起動する候補数

error_log(sprintf(
    'gc runs=%d collected=%d roots=%d threshold=%d mem=%dMB',
    $s['runs'],
    $s['collected'],
    $s['roots'],
    $s['threshold'],
    memory_get_usage(true) >> 20
));

roots が常に高止まりしていれば、循環を量産しているコードがどこかにあります。runs が伸びないのにメモリだけ増えるなら、原因は循環参照ではなく別のところ(配列に溜め込んでいるなど)です。切り分けに使えるのがありがたいところですね。

PHP 8.3では runningprotectedfullbuffer_sizeapplication_timecollector_timedestructor_timefree_time が追加されました。GCに何秒使ったかがそのまま出るので、「GCが重いのでは」という疑いを数字で潰せます。

クロージャが作る見えない循環

プロパティ同士の循環は目で追えますが、厄介なのはクロージャです。$this を捕まえたクロージャを、自分が参照しているオブジェクトに登録すると、その時点で循環が完成します。

final class Service
{
    public function __construct(private EventBus $bus)
    {
        // Service → bus → closure → $this(Service) で一周する
        $bus->on('done', function () {
            $this->cleanup();
        });
    }

    public function cleanup(): void { /* ... */ }
}

イベントバス、オブザーバ、ミドルウェアの登録。どれもこの形になりやすいです。書いている側からすると循環を作った自覚がまったくないので、見つけるのに時間がかかります。

断ち切るなら WeakReference(PHP 7.4以降)が使えます。弱参照は参照カウントを増やさないので、そもそも循環になりません。

final class Service
{
    public function __construct(private EventBus $bus)
    {
        $ref = \WeakReference::create($this);

        $bus->on('done', static function () use ($ref) {
            $ref->get()?->cleanup();   // 本体が消えていれば null
        });
    }

    public function cleanup(): void { /* ... */ }
}

嬉しいのは、Service が不要になった瞬間に(GCを待たずに)解放されることです。代わりに、リスナが呼ばれたときに対象が消えている可能性を ?-> で受け止める必要があります。クロージャに static を付けているのは、$this を束縛させないためです。ここを忘れると弱参照にした意味がなくなります。

gc_disable() が効くケース

逆方向の話もしておきます。循環をほとんど作らないと分かっている短命なスクリプトなら、gc_disable() でGCのコストを丸ごと省けます。デプロイスクリプトや、大量のオブジェクトを作って捨てるだけの一発処理などですね。

gc_disable();

// 循環を作らない処理をここで一気に回す

gc_enable();
gc_collect_cycles();

ただしこれは、循環が本当に無いことが前提です。無効化したまま循環を作り続けると、当然メモリは増え続けます。PHP 8.3の collector_time で「そもそもGCにどれだけ時間を使っているか」を測って、削る価値があるか確かめてからにするのが安全だと思います。多くの場合、効果は誤差の範囲です。

まとめ

循環参照でメモリが解放されないのは、リークではなく解放の遅延です。普通のWebリクエストなら気にしなくていい話で、問題になるのは常駐ワーカーと、デストラクタに後始末を書いているコード。前者は一定間隔で gc_collect_cycles() を呼びつつ、メモリが膨らんだら潔くプロセスを畳む。後者はそもそもデストラクタに頼るのをやめる。gc_status()roots を一度ログに出してみると、自分のコードがどれだけ循環を作っているか分かって、なかなか面白いです。

よくある質問

Q. 循環参照を放置すると、メモリリークになりますか?
A. なりません。ルートバッファが一杯になるとGCが自動で走り、スクリプト終了時にも解放されます。問題になるのは解放のタイミングが遅れることで、常駐プロセスやデストラクタでの後始末に依存しているコードで実害が出ます。

Q. gc_collect_cycles() は毎回呼んだほうがいいですか?
A. いいえ。呼ぶたびにルートバッファ全体を走査するので、頻繁すぎると単に遅くなります。ワーカーなら100〜1000件ごとといった粒度で呼び、gc_status()memory_get_usage() を見ながら調整するのがおすすめです。

Q. WeakReference を使えば GC は不要になりますか?
A. 循環を作らずに済む箇所では有効ですが、すべてを弱参照にはできません。参照先がいつ消えてもよい「観測する側」の参照に限って使うのが現実的で、それ以外の循環はGCに任せることになります。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です