PHPの循環参照ガベージコレクションで気をつけること

# はじめに

[【Laravel5.8】Faker::realText()を叩き続けると死ぬ](https://qiita.com/rana_kualu/items/1c0848bcd401baca1bc6#comment-fa5c974ece3e7a465ccd)の原因だった循環参照のガベージコレクションについて調べてわかったことのメモ。

# 循環参照のガベージコレクションとは

PHPはもともと参照カウントに基づいたガベージコレクションしかなかったので循環参照の回収ができない問題があったのですが、5.3から循環参照に対応したガベージコレクションが導入されました。マニュアルの「[循環の収集](https://www.php.net/manual/ja/features.gc.collecting-cycles.php)」のところに記述がありますが、スライド「[PHPのGCの話](https://www.slideshare.net/y-uti/php-gc)」がわかりやすく書いてあっておすすめです。

# なにが問題なのか

新しいGCが導入されたので循環参照してても回収できるようになりましたですめばよかったのですが、残念ながら現在のPHPの実装には問題があります。それは「いつ循環参照GCを開始するか」です。

循環参照のGCは複雑で手間がかかる処理なのであまり頻繁に行うと性能が低下します。そこで現在の実装では以下のようにしています。

* ゴミの候補が一定数(1万個)たまったら循環参照のGCを開始する

なぜこれが問題かというと、ゴミの個数とゴミによって消費されているメモリ量は必ずしも比例しないので、ゴミの個数が少なくてもメモリ消費が大きいと、候補がその一定数に達する前にメモリの上限(memory_limit)に達してしまって、GCすればメモリが回収できるはずの状態なのにもかかわらずメモリ不足のエラーになってしまうことがあるのです。「Faker::realText()を叩き続けると死ぬ」問題の原因はこれです。

# 対策

[gc_collect_cycles()](https://www.php.net/manual/ja/function.gc-collect-cycles.php)という関数が用意されていて、これを使えば循環参照のGCを強制的に行わせることができます。これをいい感じのタイミングで呼んでやればメモリが上限に達する前に回収できるようになると言うものです。

# 実験

以下のようなプログラムで実験してみました。

```php
<?php

class C
{
    private $ref;
    private $data;

    public function setRef($ref)
    {
        $this->ref = $ref;
    }

    public function setData($data)
    {
        $this->data = $data;
    }
}

$DATA_SIZE = 100000;
for ($i = 0; $i < 5001; $i++) {
    $c1 = new C;
    $c2 = new C;

    // 相互参照する状態を作る
    $c1->setRef($c2);
    $c2->setRef($c1);

    $c1->setData(str_repeat('x', $DATA_SIZE));

    # gc_collect_cycles();

    $gc_status = gc_status();
    printf("%-24s runs=%d collected=%d threshold=%d roots=%d usage=%d limit=%s\n",
        $i,
        $gc_status['runs'],
        $gc_status['collected'],
        $gc_status['threshold'],
        $gc_status['roots'],
        memory_get_usage(),
        ini_get('memory_limit'),
    );
}
```

## `$DATA_SIZE = 1`の場合

```text
...
4997 runs=0 collected=0 threshold=10001 roots=9996 usage=1534024 limit=128M
4998 runs=0 collected=0 threshold=10001 roots=9998 usage=1534224 limit=128M
4999 runs=0 collected=0 threshold=10001 roots=10000 usage=1534424 limit=128M
5000 runs=1 collected=20000 threshold=10000 roots=2 usage=534624 limit=128M
5001 runs=1 collected=20000 threshold=10000 roots=4 usage=534824 limit=128M
...
```
rootsがゴミ候補の数で、ループ毎に2個ずつ増えていきます。10000に達した次でGCが行われ、rootsとusageが減るのがわかると思います。

## `$DATA_SIZE = 100000`の場合

```text
...
1271 runs=0 collected=0 threshold=10001 roots=2544 usage=130892440 limit=128M
1272 runs=0 collected=0 threshold=10001 roots=2546 usage=130995000 limit=128M
1273 runs=0 collected=0 threshold=10001 roots=2548 usage=131097560 limit=128M
PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 102400 bytes) in /Users/matsui/hoge.php on line 28

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 102400 bytes) in /Users/matsui/hoge.php on line 28
```

メモリ使用量(usage)の増え方が急になるので、rootsが10000に達する前にメモリの上限に達してエラーになってしまいます。

## `gc_collect_cycles()`のコメントを外して呼ぶようにする

```text
0 runs=1 collected=0 threshold=10001 roots=0 usage=514584 limit=128M
1 runs=2 collected=4 threshold=10001 roots=0 usage=514616 limit=128M
2 runs=3 collected=8 threshold=10001 roots=0 usage=514616 limit=128M
3 runs=4 collected=12 threshold=10001 roots=0 usage=514616 limit=128M
4 runs=5 collected=16 threshold=10001 roots=0 usage=514616 limit=128M
...
```

強制起動したGCによって使われてないメモリが回収されるのでメモリ使用量が増えなくなります。ただ毎回呼ぶのはやりすぎですね。これはテストなのでそれほど遅くなった感じはしませんが実環境では問題になるかも知れません。

# 自動的にやってくれないものだろうか

いちいち自分で`gc_collect_cycles()`を呼ぶのは面倒なので、メモリが足りなくなったらエラーにする前に自動的にやってくれないかなと考えるのは自然の成り行きなので、すでに [Request #60982 GC not called prior to memory exhausted error](https://bugs.php.net/bug.php?id=60982) という要望が出されています。2012年だからかなり前ですね。どちらかというとマイナーな問題なのでやる人がいないのかな。

# まとめ

PHPのガベージコレクションは循環参照があっても基本的には大丈夫なのですが、オブジェクトの個数に比べてメモリ使用量が大きいとメモリ使用量のエラーが先に出てしまうことがあります。

その場合`gc_collect_cycles()`を適切に呼んでやることでこの問題を回避できます。