最近書かれた『Lexers, Parsers, and ASTs, OH MY!: How Ruby Executes』という記事に刺激を受けて、PHPコードの実行プロセスについて記事を書くことにしました。
はじめに
ほんのわずかなPHPコードを実行する場合でも、内部では多くの処理がされています。大まかにいって、PHPインタプリタは、次の4つのステージでコードを実行します。
- 字句解析(Lexing)
- 構文解析(Parsing)
- コンパイル(Compilation)
- 実行(Interpretation)
この記事では、4つのステージにざっと目を通し、各ステージのアウトプットを確認する方法を説明しながら、どのようなことが起きているのかを確かめていきます。使用する拡張モジュールの一部(tokenizerやOPcache)はPHPをインストールする際に標準で組み込まれていますが、そのほかの拡張モジュール(php-astやVLD)は手動でインストールし有効化する必要があります。
ステージ1:字句解析
字句解析(もしくはトークン化)は文字列(記事の場合はPHPソースコード)をトークンの列に変換するプロセスです。トークンとは、マッチした値を表すための、名前の付いた識別子です。PHPはre2cを使用して、zend_language_scanner.l定義ファイルからレキサー(字句解析器)を生成します。
tokenizer拡張モジュールを利用して、字句解析ステージのアウトプットが確認できます。
$code = <<<'code'
<?php
$a = 1;
code;
$tokens = token_get_all($code);
foreach ($tokens as $token) {
if (is_array($token)) {
echo "Line {$token[2]}: ", token_name($token[0]), " ('{$token[1]}')", PHP_EOL;
} else {
var_dump($token);
}
}
アウトプットは次のようになります。
Line 1: T_OPEN_TAG ('<?php
')
Line 2: T_VARIABLE ('$a')
Line 2: T_WHITESPACE (' ')
string(1) "="
Line 2: T_WHITESPACE (' ')
Line 2: T_LNUMBER ('1')
string(1) ";"
アウトプットには、特筆すべき点が2つあります。1つは、ソースコードすべてが名前のあるトークンではないということです。代わりに、シンボルの一部(=、;、:、?など)はそれ自体がトークンと見なされています。
もう1つは、実はレキサーは単純にトークンの列を出力しているだけではないということです。ほとんどの場合、レキサーは字句(トークンとマッチした値)およびマッチしたトークンのライン番号(スタックトレースなどに用いられます)も保存します。
ステージ2:構文解析
同様に、パーサー(構文解析器)はBisonを使用してBNF grammar fileから生成されます。PHPはLALR(1)(look ahead、left-to-right)文脈自由文法を使用しています。「look ahead(先読み)」とは、パーサーが構文解析中に生じるあいまいさを解決するために、n個のトークン(この場合は1)を先読みできることを示しています。「left-to-right(左から右へ)」は、パーサーがトークンの列を左から右に構文解析することを意味します。
生成されたパーサーはこのステージで、トークンの列をインプットとしてレキサーから受け取ったあと、2つの役割を担います。1つは、トークンがBNF grammar fileに定義された文法ルールのいずれかと一致することを検証し、トークンの順序が有効かどうかを確かめます。同時に、トークンの列の各トークンが有効な言語構成要素を形成していることも確認します。
もう1つの役割は、次のステージ(コンパイル)で使用する、ソースコードを木構造で表現したAST(abstract syntax tree:抽象構文木)を生成することです。
php-ast拡張モジュールを使用することで、パーサーが生成するASTの構造を見られます。内部ASTは「クリーン」ではない(一貫性や全体的な使いやすさの点で)ため、php-ast拡張モジュールはASTをそのまま外部に表示するのではなく、少し変形を加えることでより扱いやすい形にしています。
初歩的なコードのASTを見てみます。
$code = <<<'code'
<?php
$a = 1;
code;
print_r(ast\parse_code($code, 30));
アウトプットは次のようになります。
ast\Node Object (
[kind] => 132
[flags] => 0
[lineno] => 1
[children] => Array (
[0] => ast\Node Object (
[kind] => 517
[flags] => 0
[lineno] => 2
[children] => Array (
[var] => ast\Node Object (
[kind] => 256
[flags] => 0
[lineno] => 2
[children] => Array (
[name] => a
)
)
[expr] => 1
)
)
)
)
木(木構造で表現したAST)のノード(通常はast\Node)はプロパティをいくつか持っています。
- kind:ノードの種類を表す整数値で、それぞれの値が対応する定数を持つ(例:AST_STMT_LIST => 132,AST_ASSIGN => 517,AST_VAR => 256)
- flags:オーバーロード動作を示すための整数値 (例:ast\AST_BINARY_OPノードはどの二項演算をしているか区別するフラグを持つ)
- lineno:上の、トークン情報であるライン番号
- children:サブノードであり、通常は、さらに分解されるノードの集まり(例:functionノードはparameters、return type、bodyなどのchildrenを持つ)
このステージのアウトプット、ASTは、静的コード解析ツール(例:Phan)などを利用するときに役立ちます。
ステージ3:コンパイル
コンパイルのステージではASTを使用し、木(木構造で表現したAST)を再帰的に走査してオペコードを出力します。また、このステージでは最適化を実行します。最適化には、引数がリテラル値の関数呼び出しの一部を変更すること(例:strlen("abc")をint(3)に変更する)や数式の定数畳み込み(例:60 * 60 * 24をint(86400)に変更する)が含まれています。
オペコードのアウトプットは、OPcache、VLD、PHPDBGなど、さまざまな方法で見られます。記事ではアウトプットが分かりやすいVLDを使用します。
次のスクリプト(file.php)のアウトプットを見てみます。
if (PHP_VERSION === '7.1.0-dev') {
echo 'Yay', PHP_EOL;
}
次のコマンドを実行します。
php -dopcache.enable_cli=1 -dopcache.optimization_level=0 -dvld.active=1 -dvld.execute=0 file.php
アウトプットは次のようになります。
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > > JMPZ <true>, ->3
4 1 > ECHO 'Yay'
2 ECHO '%0A'
7 3 > > RETURN 1
このオペコードはオリジナルのソースコードとある程度似ているため、基本的な処理の流れを追うには十分です(オペコードを詳細に説明すると、それだけで記事が何本も書けてしまうので、この記事ではそこまでするつもりはありません)。上のスクリプトでは最適化をしませんでしたが、見てのとおり、PHP_VERSION === '7.1.0-dev'がtrueになっているなど、条件が常に変わらない部分にはコンパイルフェーズで変更が加えられています。
OPcacheは、オペコードをキャッシュする(字句解析、構文解析、コンパイルの各ステージをバイパス可能になります)だけではありません。OPcacheにはさまざまなレベルの最適化も用意されています。最適化レベルを4パスに上げて結果を見てみます。
コマンドです。
php -dopcache.enable_cli=1 -dopcache.optimization_level=1111 -dvld.active=-1 -dvld.execute=0 file.php
アウトプットは次のようになります。
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
4 0 E > ECHO 'Yay%0A'
7 1 > RETURN 1
条件が常に変わらない部分は除かれ、2つのECHO命令が1つの命令にまとめられています。これらは、スクリプトのオペコードに対し(最適化)パスを実行する際にOPcacheが適用するさまざまな最適化のほんの一部にすぎません。各最適化レベルについて説明する場合も記事が1つ書けてしまうので、ここまでにしておきます。
ステージ4:実行
最後のステージはオペコードの実行です。Zend Engine(ZE)VM上でオペコードが実行されるのがこのステージです。このステージは、少ししか説明することがありません(少なくとも、マクロな視点では)。PHPスクリプトがecho、print、var_dumpといった命令によって出力されるあらゆるものがこのステージのアウトプットです。
そこで、このステージではなにか複雑なことを詳説するのではなく、興味深い事実を1つ紹介します。PHPはVMを生成するとき、自分自身に依存しなければならないのです。VMはPHPスクリプトによって生成されるということが理由なのですが、そうすることで、よりシンプルに書け、メンテナンスもより容易になります。
最後に
PHPコードを実行する際に、PHPインタプリタが通過する4つのステージを簡単に説明してきました。各ステージのアウトプットを処理、検証するために、さまざまな拡張モジュール(tokenizer、php-ast、OPcache、VLDなど)を使う必要がありました。
この記事が、PHPインタプリタの全体像をより良く理解するために役立ち、OPcache拡張モジュールの重要性(キャッシュと最適化能力の両方について)が伝われば幸いです。
※本記事はYounes Rafieが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:How PHP Executes – from Source Code to Render)
[翻訳:薮田佳佑/編集:Livit]