18
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【PHP7.4新機能】弱参照(WeakReference)とGCとメモリリークについて整理したよ!

Last updated at Posted at 2019-12-21

ラクス Advent Calendar 2019 の21日目を担当させていただきまっす。

昨日は @ririco さんの『Route53 ヘルスチェックのエンドポイント設定のわな』 でした。

今日は、PHP7.4で実装されたの弱参照(WeakReference)とGC、メモリリークのあたりのお話をしようと思います。

PHP7.4から弱参照:WeakReferenceの機能が実装されました。
恥ずかしながら弱参照について、なんとなくしか理解していなかったのと、リファレンス読んでも理解できなかったので、調べて検証した結果をまとめることにしました。

php.netのリファレンス読んでみる

まずは導入がてら、php.netの弱参照についての説明文を見てみましょう。

php.net:WeakReferenceクラスより

弱い参照により、オブジェクトが破棄されるのを防がないオブジェクトへの参照を保持することが可能です。 この機能は、キャッシュのようなデータ構造を実装するのに役立ちます。

なるほど。なんか分かるような、分からんような…。

弱い参照により、オブジェクトが破棄されるのを防がないオブジェクトへの参照を保持することが可能です。

うんうん。わかるよ、わかる。それはわかるんですよ。で、それをどう活用するかというと…?

この機能は、キャッシュのようなデータ構造を実装するのに役立ちます。

ファッ!?キャッシュ?ナンデ?キャッシュ…ナンデ!?

少なくとも、php.netのサンプルコードを256回くらい読んでも、弱参照の本質には迫れなさそうです。
なので、立ち戻って、弱参照とは何なのか、PHPのGCの仕組みなどを順番に追いかけて整理しながら、弱参照のメリットについて理解することにしました。

弱参照とは何なのか

弱参照は、次のように定義されています。

Wikipedia:弱い参照より

弱い参照(英: weak reference、ウィークリファレンス)あるいは弱参照とは、参照先のオブジェクトをガベージコレクタから守ることのできない参照のことである。

つまり、

普通の参照:参照している変数が存在している限り、ガベージコレクタはそのメモリ領域を開放しない
弱参照:参照している変数が存在していても、ガベージコレクタはそのメモリ領域を開放できる

ということですね。意図しないメモリリークを回避する方法として使われる手法です。

では、PHPにこの弱参照が導入されることで、どんなメリットがあるのかを具体的に考えてみましょう。
弱参照のメリットを理解するためには、PHPのガベージコレクションの仕組みのおさらいが必要です。

PHPのガベージコレクション(以下、GC)

PHPのGCは「参照カウンタ方式」を採用しています。
あるメモリ領域を参照している変数の数をカウントしておいて、そのカウントがゼロになったらメモリを開放するという仕組みですね。
実際にコードを走らせて確認してみるとわかりやすいです。

$baseMemory = memory_get_usage();
echo "1:" , memory_get_usage() - $baseMemory, "byte \n";

$x = new StdClass();
echo "2:" , memory_get_usage() - $baseMemory, "byte \n";
xdebug_debug_zval('x');

$y = $x;
echo "3:" , memory_get_usage() - $baseMemory, "byte \n";
xdebug_debug_zval('x');
xdebug_debug_zval('y');

unset($x);
echo "4:" , memory_get_usage() - $baseMemory, "byte \n";
xdebug_debug_zval('y');

unset($y);
echo "5:" , memory_get_usage() - $baseMemory, "byte \n";

実行結果

1:32byte                                        【初期状態】
2:72byte                                        【stdClassのインスタンスを生成】
x: (refcount=1, is_ref=0)=class stdClass {  }   【$xが指すオブジェクトの参照カウント(refcount)は1】
3:128byte                                       【$y = $x;で、$x,$yが同じオブジェクトを指す】
x: (refcount=2, is_ref=0)=class stdClass {  }    
y: (refcount=2, is_ref=0)=class stdClass {  }   【$x,$yが指すオブジェクトの参照カウント(refcount)は2】
4:128byte                                       【unset($x)しても、メモリは開放されない】
y: (refcount=1, is_ref=0)=class stdClass {  }   【$xが消えたので$yが指すオブジェクトの参照カウント(refcount)は1に減る】
5:32byte                                        【unset($y)で、誰からも参照されなくなり、ここで初めてメモリが開放される】

PHPのGCの基本はコレ↑です。
さて、参照カウンタ方式のGCの重要な要素の一つに循環参照領域の回収というものがあります。
弱参照のメリットを理解するには、こちらも理解する必要がありますので、整理してみたいと思います。

循環参照

循環参照が起きているコードとは、次のようなものです。

$x->foo = $y; // $xは内部で$yを束縛している
$y->foo = $x; // $yは内部で$xを束縛している

$xと$yゎ、、、ズッ友だよ、、、!!

では、循環参照が起きている時のメモリの状態を見てみましょう。

$baseMemory = memory_get_usage();
echo "1:" , memory_get_usage() - $baseMemory, "byte \n";

$x = new stdClass();
$y = new stdClass();

$x->foo = $y;
$y->foo = $x; 
echo "2:" , memory_get_usage() - $baseMemory, "byte \n";
xdebug_debug_zval('x');
xdebug_debug_zval('y');

unset($x);
echo "3:" , memory_get_usage() - $baseMemory, "byte \n";
xdebug_debug_zval('y');

unset($y);
echo "4:" , memory_get_usage() - $baseMemory, "byte \n";

実行結果

1:32byte 
2:864byte 
x: (refcount=2, is_ref=0)=class stdClass { public $foo = (refcount=2, is_ref=0)=class stdClass { public $foo = (refcount=2, is_ref=0)=... } } 
   【$xは$yの内部で参照されているのでrefcountが2】
y: (refcount=2, is_ref=0)=class stdClass { public $foo = (refcount=2, is_ref=0)=class stdClass { public $foo = (refcount=2, is_ref=0)=... } }
   【$yは$xの内部で参照されているのでrefcountが2】
3:864byte 
y: (refcount=2, is_ref=0)=class stdClass { public $foo = (refcount=1, is_ref=0)=class stdClass { public $foo = (refcount=2, is_ref=0)=... } }
   【unset($x)したにもかかわらず、$yのrefcountが減っていない!】
4:864byte
   【$xも$yもunsetしているのに、メモリが開放されない!】

循環参照が起きていない時と、振る舞いが違うことが分かります。
循環参照が起きている領域を開放するためには、**「未開放のオブジェクトから参照元を全部辿って、その領域が未使用であることを調べる」**というコストがかかる処理を行う必要があるため、即時実行するのではなく、一定の条件が成り立った時にまとめて回収を行うようになっています。
※ちなみに循環参照領域の回収の仕組みがPHPに導入されたのは、v5.3からです。

では、強制的にGCを走らせた場合にどうなるか見てみましょう。

$baseMemory = memory_get_usage();

$x = new stdClass();
$y = new stdClass();

$x->foo = $y;
$y->foo = $x; 
echo "1:" , memory_get_usage() - $baseMemory, "byte \n";

unset($x);
unset($y);
echo "2:" , memory_get_usage() - $baseMemory, "byte \n";
gc_collect_cycles(); // GCを強制実行
echo "3:" , memory_get_usage() - $baseMemory, "byte \n";

実行結果

1:864byte 
2:864byte        【unsetしただけでは開放されない】 
3:32byte         【GCが実行され、循環参照領域が開放された】

PHPのGCサイクルによって、循環参照領域が開放される様子が確認できました。
もう一つ、弱参照のメリットをより理解するために、PHPのGCサイクルについて理解してみたいと思います。

PHPにおけるGCサイクル

PHPのGCサイクルは、デフォルトでオブジェクトが10,000個作られた時です。

php.net:循環の収集

ガベージコレクタがオンの場合、 ルート・バッファが満杯になるといつでも、先に述べたように循環検出法が実行されます。 ルート・バッファでは、可能性があるルートのサイズが一万件に固定されています。

※なお、この値はPHPをコンパイルする際にGC_ROOT_BUFFER_MAX_ENTRIES定数を指定することで変更できるようです。

実際に、2種類のコードを走らせて、メモリがどれだけ使われているかによらず、10,000オブジェクトが生成される度にGCが実行されることを確認してみます。

コンストラクタに渡した値で任意のサイズの配列を作成するFooクラスを使い、3万回、循環参照を起こすオブジェクトを作って検証します。

class Foo
{
    public array $val;
    public function __construct(int $arrayRange) {
        $this->val = range(0, $arrayRange);
    }
}

$arraySize = $argv[1];
$baseMemory = memory_get_usage();

for ( $i = 0; $i <= 30000; $i++ )
{
    $x = new Foo($arraySize);
    $x->self = $x;
    if ( $i % 1000 === 0 )
    {
        echo sprintf( '%8dオブジェクト: ', $i ), memory_get_usage() - $baseMemory, " byte\n";
    }
}

配列のサイズを1にした場合

$ php gc_test.php 0
       0オブジェクト: 840 byte
    1000オブジェクト: 808840 byte
    2000オブジェクト: 1625032 byte
    3000オブジェクト: 2449416 byte
    4000オブジェクト: 3257416 byte
    5000オブジェクト: 4098184 byte
    6000オブジェクト: 4906184 byte
    7000オブジェクト: 5714184 byte
    8000オブジェクト: 6522184 byte
    9000オブジェクト: 7395720 byte
   10000オブジェクト: 124528 byte    <------------ GCが走ってメモリ解放
   11000オブジェクト: 932528 byte
   12000オブジェクト: 1740528 byte
   13000オブジェクト: 2548528 byte
   14000オブジェクト: 3356528 byte
   15000オブジェクト: 4164528 byte
   16000オブジェクト: 4972528 byte
   17000オブジェクト: 5780528 byte
   18000オブジェクト: 6588528 byte
   19000オブジェクト: 7396528 byte
   20000オブジェクト: 126144 byte    <------------ GCが走ってメモリ解放
   21000オブジェクト: 934144 byte
   22000オブジェクト: 1742144 byte
   23000オブジェクト: 2550144 byte
   24000オブジェクト: 3358144 byte
   25000オブジェクト: 4166144 byte
   26000オブジェクト: 4974144 byte
   27000オブジェクト: 5782144 byte
   28000オブジェクト: 6590144 byte
   29000オブジェクト: 7398144 byte
   30000オブジェクト: 127760 byte    <------------ GCが走ってメモリ解放

次に、配列のサイズを100にして実行してみます。

$ php gc_test.php 99
       0オブジェクト: 8712 byte
    1000オブジェクト: 8688712 byte
    2000オブジェクト: 17376904 byte
    3000オブジェクト: 26073288 byte
    4000オブジェクト: 34753288 byte
    5000オブジェクト: 43466056 byte
    6000オブジェクト: 52146056 byte
    7000オブジェクト: 60826056 byte
    8000オブジェクト: 69506056 byte
    9000オブジェクト: 78251592 byte
   10000オブジェクト: 140272 byte    <------------ GCが走ってメモリ解放
   11000オブジェクト: 8820272 byte
   12000オブジェクト: 17500272 byte
   13000オブジェクト: 26180272 byte
   14000オブジェクト: 34860272 byte
   15000オブジェクト: 43540272 byte
   16000オブジェクト: 52220272 byte
   17000オブジェクト: 60900272 byte
   18000オブジェクト: 69580272 byte
   19000オブジェクト: 78260272 byte
   20000オブジェクト: 157632 byte    <------------ GCが走ってメモリ解放
   21000オブジェクト: 8837632 byte
   22000オブジェクト: 17517632 byte
   23000オブジェクト: 26197632 byte
   24000オブジェクト: 34877632 byte
   25000オブジェクト: 43557632 byte
   26000オブジェクト: 52237632 byte
   27000オブジェクト: 60917632 byte
   28000オブジェクト: 69597632 byte
   29000オブジェクト: 78277632 byte
   30000オブジェクト: 174992 byte    <------------ GCが走ってメモリ解放

実際にどれくらいメモリを使用しているかによらず、10,000オブジェクトを基準にGCによって循環参照領域の開放が行われていることが分かりました。

…ちなみに、この実行環境のmemory_limitは128MBに設定しています。
では、配列のサイズをもう少し大きくして、300にしてみましょう。

$ php gc_test.php 299
       0オブジェクト: 21000 byte
    1000オブジェクト: 20989000 byte
    2000オブジェクト: 41965192 byte
    3000オブジェクト: 62949576 byte
    4000オブジェクト: 83917576 byte
    5000オブジェクト: 104918344 byte
    6000オブジェクト: 125886344 byte

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) in /tmp/app/gc_Test.php on line 6

おおっと!メモリ枯渇で死んでしまいましたね。

そうです。循環参照が発生している大きなオブジェクトを使えば使うほど、GCが走るまでの間にメモリ枯渇しやすくなってしまうのですね。

ようやく弱参照の出番です!

さて、お待たせ致しました。ようやく弱参照の出番です!!

前述の通り、弱参照とは「参照している変数が存在していても、ガベージコレクタはそのメモリ領域を開放できる」ことを実現するものです。
コイツがあれば、僕達はもう循環参照なんて怖くないぜ!

では、PHP7.4で実装されたWeakReferenceを使って、弱参照を使って循環参照を回避した例を実行してみましょう!

$baseMemory = memory_get_usage();

$x = new stdClass();
$x->name = "x";
$y = new stdClass();
$y->name = "y";

$x->foo = WeakReference::create($y); // ★弱参照★
$y->foo = WeakReference::create($x); // ★弱参照★
echo "1:" , memory_get_usage() - $baseMemory, "byte \n";

xdebug_debug_zval('x');  // 参照カウンタを確認
xdebug_debug_zval('y');  // 参照カウンタを確認

echo "2:" , $x->foo->get()->name, "\n"; // $yを参照できているか確認
echo "3:" , $y->foo->get()->name, "\n"; // $xを参照できているか確認

unset($x);
unset($y);
echo "4:" , memory_get_usage() - $baseMemory, "byte \n";
gc_collect_cycles(); // GCを強制実行
echo "5:" , memory_get_usage() - $baseMemory, "byte \n";

実行結果

1:1280byte 
x: (refcount=1, is_ref=0)=class stdClass { public $name = (refcount=1, is_ref=0)='x'; public $foo = (refcount=1, is_ref=0)=class WeakReference {  } }
   【参照カウンタは1のまま(循環参照が発生していない)】
y: (refcount=1, is_ref=0)=class stdClass { public $name = (refcount=1, is_ref=0)='y'; public $foo = (refcount=1, is_ref=0)=class WeakReference {  } }
   【参照カウンタは1のまま(循環参照が発生していない)】
2:y        【循環参照していないが、お互いの情報にアクセスできている】
3:x        【循環参照していないが、お互いの情報にアクセスできている】
4:352byte  【unsetした時点、つまり、GC実行前にメモリが開放されている!】
5:352byte  【GCが走っても変化は無し】

いかがでしょうか?$x$yを双方向に参照するという条件を満たしつつ、効率よくメモリを使えていることが分かりますね。
それでは、先程メモリ枯渇で死んでしまったプログラムも、弱参照に置き換えてみましょう。

class Foo
{
    public array $val;
    public function __construct(int $arrayRange) {
        $this->val = range(0, $arrayRange);
    }
}

$arraySize = $argv[1];
$baseMemory = memory_get_usage();

for ( $i = 0; $i <= 30000; $i++ )
{
    $x = new Foo($arraySize);
    $x->self = WeakReference::create($x); // 弱参照!
    if ( $i % 1000 === 0 )
    {
        echo sprintf( '%8dオブジェクト: ', $i ), memory_get_usage() - $baseMemory, " byte\n";
    }
}

先程の例ではサイズ300でメモリ枯渇をしていました。
ここではサイズを一気に1000に上げて実行してみましょう。

実行結果

$ php gc_test.php 999
       0オブジェクト: 37752 byte
    1000オブジェクト: 37752 byte
    2000オブジェクト: 37752 byte
    3000オブジェクト: 37752 byte
    4000オブジェクト: 37752 byte
    5000オブジェクト: 37752 byte
    6000オブジェクト: 37752 byte
    7000オブジェクト: 37752 byte
    8000オブジェクト: 37752 byte
    9000オブジェクト: 37752 byte
   10000オブジェクト: 37752 byte
   11000オブジェクト: 37752 byte
   12000オブジェクト: 37752 byte
   13000オブジェクト: 37752 byte
   14000オブジェクト: 37752 byte
   15000オブジェクト: 37752 byte
   16000オブジェクト: 37752 byte
   17000オブジェクト: 37752 byte
   18000オブジェクト: 37752 byte
   19000オブジェクト: 37752 byte
   20000オブジェクト: 37752 byte
   21000オブジェクト: 37752 byte
   22000オブジェクト: 37752 byte
   23000オブジェクト: 37752 byte
   24000オブジェクト: 37752 byte
   25000オブジェクト: 37752 byte
   26000オブジェクト: 37752 byte
   27000オブジェクト: 37752 byte
   28000オブジェクト: 37752 byte
   29000オブジェクト: 37752 byte
   30000オブジェクト: 37752 byte

おお!余裕で走りきりました!
弱参照を使用しているので、循環参照領域が滞留することなく、一定のメモリ消費で処理を実行できていますね!
弱参照を使用することのメリットが体感できました!

で、循環参照ってどうなの?

  • PHPが参照カウンタ方式のGCを採用している以上、循環参照はメモリ枯渇のリスクをはらんでいるので、不用意に使うのは良くないのではないかと思います。
  • 一方で、親子関係を持ったような概念を表現したいときは循環参照してる方が取り回しがしやすい場合もありそう。
  • 親子関係のような、情報の主従関係がある場合において、親オブジェクトがない状態で子オブジェクトを使うことが無いような場合は弱参照を使っておくと良さそう。
$parentObj = new SomeClass();
$childObj = new SomeClass();

$parentObj->child = $childObj;
$childObj->parent = WeakReference::create($parentObj);

補足

  • PHP7.4で導入されたWeakReferenceは、実は以前からPECL拡張に存在しており、それをPHPコアに導入されたものです。※全く同じものではありませんが

  • で、「キャッシュのようなデータ構造を実装するのに役立ちます」って何だったの?

    • 実はこれ、まだ良くわかってないんですよね。色々考えてみたんですが、いまひとつ「キャッシュのようなデータ構造」に弱参照を使うシーンが思い浮かばなかったです。ただ、間違いなく「キャッシュのようなテータ構造」のためのものではなく、循環参照によるメモリリークを回避するのがこの機能の主たる目的だと思います。
  • ちょうど、弱参照を持てるMap構造にに関するRFC:Weak mapsが投票フェーズに入っていますね。今の所、賛成票の方が多いのでこれもv8.0に乗ってくるんじゃないでしょうか。

    • ちなみにPythonにはすでに同様の仕組みが存在しています

おわりに

というわけで、ラクス Advent Calendar 2019 の21日目、いかがでしたでしょうか?
明日は、@moomooyaさんの担当記事です。お楽しみに!

ではでは。

18
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?