ギフハブ
三行要約
- adsr/phpspy の PHP 版みたいな奴として reliforp/reli-prof を趣味で作ってる(2020 年くらいから少しずつ)
- もはや PHP に不可能なことはあまりなく、システムコールやメモリ操作を伴う比較的低レベルのプログラミングさえ可能で、PHPer が PHP を極めるための道ももはや果てしない
- ランタイム好きな人にはデバッガとかサンプリングプロファイラとか作るの超オススメ
なにこれ……
- 別プロセスで動作中の PHP プロセスのコールトレースをのぞき見る PHP プログラム
つまり?
$ ./reli i:trace -- php -r "fgets(STDIN);"
みたくやると、--
から先のコマンドが実行され、
0 fgets <internal>:-1
1 <main> <internal>:-1
0 fgets <internal>:-1
1 <main> <internal>:-1
0 fgets <internal>:-1
1 <main> <internal>:-1
...
みたいな「今こんな関数実行してます」が延々出力される。
$ sudo php ./reli i:trace -p 2182685
みたくやると、プロセス ID 2182685 で実行中の PHP プロセスの動作内容を盗み取り、
0 time_nanosleep <internal>:-1
1 PhpProfiler\Lib\Loop\LoopMiddleware\NanoSleepMiddleware::invoke /home/sji/work/php-profiler/src/Lib/Loop/LoopMiddleware/NanoSleepMiddleware.php:33
2 PhpProfiler\Lib\Loop\LoopMiddleware\KeyboardCancelMiddleware::invoke /home/sji/work/php-profiler/src/Lib/Loop/LoopMiddleware/KeyboardCancelMiddleware.php:39
3 PhpProfiler\Lib\Loop\LoopMiddleware\RetryOnExceptionMiddleware::invoke /home/sji/work/php-profiler/src/Lib/Loop/LoopMiddleware/RetryOnExceptionMiddleware.php:37
4 PhpProfiler\Lib\Loop\Loop::invoke /home/sji/work/php-profiler/src/Lib/Loop/Loop.php:26
5 PhpProfiler\Command\Inspector\GetTraceCommand::execute /home/sji/work/php-profiler/src/Command/Inspector/GetTraceCommand.php:133
6 Symfony\Component\Console\Command\Command::run /home/sji/work/php-profiler/vendor/symfony/console/Command/Command.php:291
7 Symfony\Component\Console\Application::doRunCommand /home/sji/work/php-profiler/vendor/symfony/console/Application.php:979
8 Symfony\Component\Console\Application::doRun /home/sji/work/php-profiler/vendor/symfony/console/Application.php:299
9 Symfony\Component\Console\Application::run /home/sji/work/php-profiler/vendor/symfony/console/Application.php:171
10 <main> /home/sji/work/php-profiler/php-profiler:45
0 time_nanosleep <internal>:-1
1 PhpProfiler\Lib\Loop\LoopMiddleware\NanoSleepMiddleware::invoke /home/sji/work/php-profiler/src/Lib/Loop/LoopMiddleware/NanoSleepMiddleware.php:33
2 PhpProfiler\Lib\Loop\LoopMiddleware\KeyboardCancelMiddleware::invoke /home/sji/work/php-profiler/src/Lib/Loop/LoopMiddleware/KeyboardCancelMiddleware.php:39
3 PhpProfiler\Lib\Loop\LoopMiddleware\RetryOnExceptionMiddleware::invoke /home/sji/work/php-profiler/src/Lib/Loop/LoopMiddleware/RetryOnExceptionMiddleware.php:37
4 PhpProfiler\Lib\Loop\Loop::invoke /home/sji/work/php-profiler/src/Lib/Loop/Loop.php:26
5 PhpProfiler\Command\Inspector\GetTraceCommand::execute /home/sji/work/php-profiler/src/Command/Inspector/GetTraceCommand.php:133
6 Symfony\Component\Console\Command\Command::run /home/sji/work/php-profiler/vendor/symfony/console/Command/Command.php:291
7 Symfony\Component\Console\Application::doRunCommand /home/sji/work/php-profiler/vendor/symfony/console/Application.php:979
8 Symfony\Component\Console\Application::doRun /home/sji/work/php-profiler/vendor/symfony/console/Application.php:299
9 Symfony\Component\Console\Application::run /home/sji/work/php-profiler/vendor/symfony/console/Application.php:171
10 <main> /home/sji/work/php-profiler/php-profiler:45
...
のような感じでコールトレースを垂れ流し続ける。
$ sudo php ./reli i:daemon -P "^/usr/sbin/httpd"
みたく正規表現でプロセスのコマンドラインを指定してやるとデーモンとして動作し、同じマシンで実行中の httpd のワーカープロセスが見つかるたびに自動でアタッチし、内部の mod_php の処理系状態を盗み取ってやはりコールトレースを垂れ流し続ける。
ナニ…コレ……?
- rbspy を参考に作られた phpspy を参考に PHP による実装をした
- ZTS 対応や VM の実行中オペコードの出力など phpspy にない機能も一部付けている
- と言って通じる人は、この先を読まなくていい
何に使える?
-
プロファイリング=プログラムの遅いところを見つけられる
- それも対象プログラム無修正で!
- 動作中のプログラムに性能障害が起きた場合、どこで時間を食ってるかを 止めずに観察できる
- tideways / xhprof や xdebug などのプロファイラでは計測しづらい、速い関数が大量に呼び出されるケースでも計測オーバヘッドが累積せず、比較的正確にボトルネックのイメージを得られる
前提知識
メモリ
- コンピュータにはメモリというデータを覚えておく装置がついてる
プログラム
- プログラムは実行時、メモリにロードされて動作
PHP のプログラム
- PHP のスクリプトは、PHP 処理系という、C 言語で書かれたプログラムが解釈して実行
PHP 処理系の働き方(を超雑に)
- メモリ内にスクリプト(PHP コード)を読み込む
- メモリ内にスクリプトと対応する中間コードを生成(コンパイル)
- 中間コードを解釈
- 中間コードに記述された通り処理を行い、ある種の仮想マシンとして処理系の内部状態(メモリ内容)を更新していく
OS のプロセス
-
Linux のような OS は、複数のプログラムを同時並行で動かすための仕組みを持つ
- マシンを有効に活用するため
- OS 上で動作中のプログラムのことをプロセスと呼ぶ
- 先に説明した通り、プログラムは実行時、メモリにロードされて動作
- OS は各プロセスがそれぞれ固有のメモリ空間(仮想アドレス空間)を持つかのように環境を隔離
- 複数のプログラム実行(プロセス)が相互に意図せぬ干渉をしないようにするため
- 別プロセスのメモリ内容にはふつうにはアクセスできない
- CPU の機能を利用
- あるプロセスにおける仮想メモリアドレス 0x10000000 番地は、別のプロセスの仮想メモリアドレス 0x10000000 番地とは異なる内容を持ち得る。
PHP 処理系やその内部状態といった登場人物は全て、とにかくメモリ上のどこかに配置される。
- 外部プロセスのメモリ内容を覗き見る方法
- 外部プロセスのメモリ内のどこに何があるかを知る方法
この 2 つさえ分かれば、なんかすごいことができる
なんかすごいこと = のぞき見
たとえば、動作中の処理系の内部状態をメモリから観察することで、外部プロセスの PHP プログラムが今まさに何をしているか、を、そのプログラムの動作へ影響を与えずに盗み見ることが可能
- よくあるプログラムは関数呼び出しの繰り返しで作られてる
- ある関数が別の関数を呼び出し、終わったら次の関数を呼び出す
- 「今何を実行中か」を大体等間隔でサンプリングして取得する
- サンプリングでよくとれる関数は処理時間が長かったり頻繁に呼び出されてる(ボトルネック)
- ボトルネックを見つければ高速化ができる
- 速度が問題になったときどこを改善すればどのくらい変わるかが図れる
- 実際に外部プロセスのメモリ内容を読むのはやや大道芸じみたプログラムになる
- でもこんな大道芸が人類の役に立つなんて!うれしい!
構成
今回作ったプログラムは大きく分けて以下のようなモジュールで構成
- /proc/<pid> 下の内容を解釈する部品
- ELF を解釈する部品
- FFI で process_vm_readv(2) や ptrace(2) を呼び、外部プロセスのメモリやレジスタ内容を読み取る部品
- Zend Engine 内部のメモリレイアウトの知識を持ちトレースデータを生成する部品
- コマンドラインインターフェース用の部品
- デーモン動作用の部品
- 解析対象となる実行中の PHP プロセスを探す部品
- 解析対象の PHP プロセスを解析する部品
- 両者をマルチスレッド or マルチプロセスで並列実行させつつ通信で協調させ結果出力をする部品
- ext-parallel 有効時はマルチスレッド動作するようにしてみている(非有効時はマルチプロセス)
- 出力内容を整形して出力する部品
- フレームグラフや speedscope 形式での出力も可能に
/proc/<pid>/maps
/proc/<pid>/maps を見ると対象プロセスのメモリマップが取得でき、reli はこれのパーサを持っている。
% sudo cat /proc/10364/maps
56187c10d000-56187c20e000 r--p 00000000 08:07 1181323 /usr/local/sbin/php-fpm
56187c30d000-56187c6a9000 r-xp 00200000 08:07 1181323 /usr/local/sbin/php-fpm
56187c70d000-56187cef3000 r--p 00600000 08:07 1181323 /usr/local/sbin/php-fpm
56187d26a000-56187d30d000 r--p 00f5d000 08:07 1181323 /usr/local/sbin/php-fpm
56187d30d000-56187d314000 rw-p 01000000 08:07 1181323 /usr/local/sbin/php-fpm
56187d314000-56187d335000 rw-p 00000000 00:00 0
56187f100000-56187f2c6000 rw-p 00000000 00:00 0 [heap]
7f6b76c00000-7f6b76e00000 rw-p 00000000 00:00 0
7f6b76eb3000-7f6b76f34000 rw-p 00000000 00:00 0
7f6b76f48000-7f6b76f4b000 r--p 00000000 08:07 1052066 /lib/x86_64-linux-gnu/libnss_files-2.28.so
見ての通り、各メモリ領域と対応する実行ファイルや .so のパスが含まれている。
mod_php なら .so ファイル、cli や php-fpm なら実行ファイルに PHP の処理系が入っている。
例えば /usr/bin/php というパスにあるファイルで、中身が sl コマンド ということはあまりない筈。
それっぽいパスでさえあれば、PHP 処理系の入ったファイルだとみなしてよさそう。
正規表現でそれっぽいパスを持つ行を抜き出し、そこにあるのが PHP 処理系のファイルの各部分がロードされているメモリアドレスの始点と終点であり、またそのパスが PHP 処理系のパスでもある、ということに。
ELF を解釈する
reli は ELF のパーサを PHP で実装 している。
Linux において、プログラムコードは ELF というファイル形式でディスク上に格納されている。
ELF には大きく分けて、再配置可能オブジェクト(いわゆる .o)、実行可能ファイル(/bin とか /usr/bin に置いてあるような実行したら動くファイル)、共有オブジェクト(いわゆる .so)の 3 種類がある。
といっても便宜上の分類であり、ld.so のように実行可能でもある共有オブジェクト、といったものも存在する。
細かな分類の話は一旦置いておいて、重要なのは、これらのファイルはプログラムのシンボル情報を含んでいる、ということ。
シンボル情報は分割コンパイルされたコードを結合するため、コードの各部品について、どの名前の部品が各ファイルのどの部分に格納されているか、を保持する情報だ。
詳しく説明すると少し本筋から脱線するので、とりあえず折りたたみにして置いておく。
- プログラマはもう少し人間の言葉に近い言語でプログラムを書く日が多くなった。多少人間の言葉に近い言語で書いたプログラムを、機械語に翻訳する。その翻訳をプログラムで自動的にやる。いわゆるコンパイル。
- 人間に近い言語でプログラムを書くことができるようになったことで、プログラムはより多くの人々が関わるようになり、より大規模化
- 当然、プログラム全体を一度にコンパイルするのに長い時間がかかるようになる
- プログラミングはトライアンドエラーを伴う仕事
- コードを修正して動作を確かめるまでに数十時間といった時間がかかると困る
- 快適なトライアンドエラーができない
- したがってプログラムがいつまでも完成しない
コンパイルを分割して少しずつ行えるようにし、分割したそれぞれの機械語コードを結合できる仕組みを用意することで、変更のあった部分だけを再コンパイルして時間を短縮可能
こうして分割コンパイルの仕組みが産まれ、そして分割したコードを結合するための仕組みもまた、様々な状況へ対応できるよう高度化
Executable and Linking Format、実行可能かつ結合する形式。ELF の L はこの結合(リンク)の L 。
ここらでシンボル情報の話へと戻る。
-
人間に近い言葉で書かれたプログラムでは、様々なものへ名前が付けられている。
-
関数に hoge とか func とか
-
変数に対して hige とか data とか
-
関数や変数は、実行時プロセスのメモリ上で固有のメモリアドレスを持つ
分割したプログラム断片のそれぞれの部分で、同じ関数、つまり同じ名前を持つ関数やデータが、別々のアドレスを持つようになっては少し困る。
- 正常な結合のためには、名前とメモリアドレス、あるいはファイルやファイルの一部領域の先頭からのオフセットのような、その名前についての情報をセットで持つような何かが必要
- これをざっくりシンボル情報と呼ぶ。
とにかく ELF ファイルのヘッダや、ヘッダからたどれる様々な情報を解析することで、ある ELF ファイルが別の ELF ファイルとリンクするために提供しているシンボル情報、つまりそのシンボルがメモリ上に配置された際にどのようなアドレスを見ればアクセスできるか、という情報を、抜き出すことができる。
PHP 処理系は EG にその内部状態を格納している。そしてこの内部状態は、xdebug.so や pdo.so といった拡張機能の ELF ファイルと実行時にリンクするため、公開されているシンボル情報を通じて辿ることができる!
一旦ここまでの内容をまとめる。
- cli や fpm といった SAPI では PHP の実行可能ファイル、mod_php なら mod_php の .so ファイルを、/proc/<pid>/maps から探し出す
- これで処理系の ELF ファイルとメモリ上の配置先が手に入る
- ELF ファイルを解析し、シンボル情報を読み取り、メモリ上の配置先にあわせて値を調整すれば、外部プロセスにおける EG の仮想アドレスを特定することができる。
- あとは外部プロセスの指定仮想アドレスのデータに、何らかの方法でアクセスすることができればよい。
- ここで FFI
※ なお ELF については他にも色々書きたいことはあるが、明らかに本筋から脱線するため、折りたたみにしつつ情報を書きなぐって気持ちを供養する。
脱線1: ELF についてもっと詳しく
ELF の基本的な仕様は Linux Foundation にリンクがあるので、詳しくはそちらを見てほしい。雑に言って、プロセッサや OS に依存しないベースの仕様、プロセッサや OS ごとに異なる詳細を補足する仕様の 2 つに分かれている。
また、後年 GNU の拡張で DT_HASH と別に DT_GNU_HASH というものが作られた。「公式な」形で標準化された仕様は存在しないが、以下に詳しい解説がある。
https://flapenguin.me/elf-dt-gnu-hash
実際に解析をしてみると、セクションヘッダの情報がストリップされプログラムヘッダ経由で情報を得るしかないバイナリについて、シンボルテーブルの要素数を知る方法が自明でないことに少し戸惑うかもしれない。この場合、シンボルテーブルの要素数はハッシュテーブルの解析を通じて要素数を得るしかない。chromium の crashpad などでも同じ対応をしているようだ。
https://groups.google.com/a/chromium.org/forum/#!topic/crashpad-dev/Y3XQb8BwUrk
脱線2: ELF の TLS について詳しく
PHP 処理系は ZTS 版だと TLS 経由じゃないと EG のアドレスが取得できないので、reli は TLS のシンボル解決にも対応している。
雑に言うと glibc の libthread_db 用のシンボル情報を読み取りつつ対象プロセスのセグメントレジスタの値を ptrace(2) 経由で取得し、ELF の TLS 仕様にもとづいてそれらを解釈して各プロセスの TLS ブロックのベースアドレスを算出している。
ELF の TLS (Thread Local Storage) サポートは後から仕様に追加された。
仕様は***あの*** Ulrich Drepper の手でかなり明瞭に書かれているが、それでもこの仕様だけを見て全容を把握するのは、少し難易度が高いかもしれない。x86-64 での具体的な実装について解析した良記事が存在するので、こちらもあわせて見ることをおすすめする。
TLS の外部プロセスからの解析は少し面倒な手順を踏む必要がある。が、現に GDB のようなデバッガは TLS の解析を実現しており、おおむね同様の段取りをたどることになるだろう。各デバッガでの議論を見ると、しきりに libthread_db というライブラリへ言及されていることに気付くだろう。
- GDB https://sourceware.org/legacy-ml/gdb/2002-06/threads.html#00157
- LLVM review https://reviews.llvm.org/D1944
glibc の libpthread はスレッドのデバッグのため、libthread_db というライブラリを別提供している。libthread_db を通じたデバッグを可能にするため、libpthread.so (最近の glibc では libc.so) 側にもデバッガからアクセスするための特別なシンボルが埋め込まれている。このシンボルを読み取ることでプロセス外の TLS へのアクセスも可能となる。
Android ELF TLS のドキュメントに Debugger Support という節があり、非常によくまとまっている。こちらを参照すれば libthread_db の実装を見ずとも必要な各シンボルの扱い方が分かる筈だ。
PHP の FFI とは仕組み的に相性が悪いため、reli の実装では残念ながら利用できなかったが(そのため自前でこのあたりのデータへのアクセスを実装した)、libthread_db について実装を追ったり使ってみたりしたい場合、以下の記事が参考になる筈。
http://timetobleed.com/notes-about-an-odd-esoteric-yet-incredibly-useful-library-libthread_db/
FFI で process_vm_readv(2) や ptrace(2) を呼ぶ
FFI (Foreign Function Interface)
- FFI は PHP 7.4 で追加された仕組み
- C 言語の関数やデータに PHP からアクセスできる
process_vm_readv(2)
- 呼び出したプロセスのアドレス空間の指定領域へ PID で指定されたプロセスのアドレス空間の指定領域をコピー
- process_vm_readv(2) によってプロセスの壁を超えることができる
FFI 経由で Zend Engine 内部の構造体にアクセスする
-
PHP 処理系は C で書かれている
-
処理系の内部構造は過去にちょっと解説したことがある
- なおこの記事の後の方で解説した phpspy の解説とかなり似たことを reli でもやってる
-
処理系の内部構造は過去にちょっと解説したことがある
- PHP 処理系の内部構造体の定義を php-src から一部抜き出す
- FFI 経由で定義を抜き出したヘッダを読む
- process_vm_readv(2) で実行中の処理系内部データをコピーしてきたバッファのポインタを PHP 構造体へのポインタとしてキャスト
-
\FFI\CData のプロクシを作って PHP の型として各構造体へアクセスできるようにする
- ちなみに構造体のネストなどでこの CData のメンバに CData がある場合、メンバ側の CData の参照を保持していても外側の CData の参照カウントは増えず、外側の CData の参照カウントが 0 になると普通に全体が GC される。その状態でメンバ側の CData を見に行くと SEGV になる
- PHP 側でポインタ用の型を定義し、process_vm_readv(2) を内部で呼び出すデリファレンサ経由でしかアクセスできないようにする
- 構造体内のポインタはリモートプロセスの中での仮想アドレスを指しているため直接アクセスすると SEGV になる
- EG 経由でコールトレースをたどる
可視化
- トレース内容はテキストとして出力
- phpspy の形式に準じてる
- ワンライナーで集計したりできる
- フレームグラフとしての可視化も可能
- speedscope 形式への変換にも対応
その他の枝葉末節
- PHP の stream 関数で procfs を読もうとすると正しいデータを得られないケースがあるため、FFI 経由でファイルを読む処理を別途用意している
- ファイルやメモリ上のバイナリを扱うにあたっては chr() や ord() や \ArrayAccess を利用している
なんで PHP でこんなことを
- FFI 入ったり JIT 入ったりで最近の PHP は Web 以外での利用もできそう感を出してきていたので、試してみた
- PHP コードを最適化するのは PHPer なので、PHPer が自由にいじれるプロファイラがあると便利そう
- もともと低レベル寄りのプログラミングが好きで、仕事で使ってるのは PHP。つまり趣味プログラミングで PHP を使いつつ低レベル寄りのプログラミングができる pet project があれば、仕事の勉強にもなるし欲求も満たせてお得だなと思った
- 最近の PHP は言語的にあまり不足を感じることがなく、Psalm や PHPStan のような静的型解析器を入れるとジェネリクスも付いてくるし型の扱いもガチガチめにでき、普段使いの言語として悪くないという感触が日頃からあった
- こんな大道芸を含めてさえ、そこまで向いてないタスクというのはもう無いなと思う
得られた学び
- PHP でもうわりとなんでもできる
- 今の PHP では高レベルの設計手法から低レベルの技術詳細までほとんどあらゆる種類の知識とワザが活用可能
- コンピュータについて学べば学ぶほど PHP でできることは広がり、学びの対象を既存のよくある Web の作り方やその周辺に限る理由はもうない
- ランタイムの大道芸まじ楽しい
将来の展望
- 変数の内容監視など phpspy にある機能を一通り入れる
- 細かい使い勝手の向上
- DWARF 情報を解析して処理系内の C 言語レベルのトレースをあわせて得られるようにしたり、メモリプロファイラ機能をつけるなど、phpspy にもないような機能を足していく
- Perfetto のビューアなど、他の出力形式への対応
- 高速化
- 各コンポーネントを独立したパッケージにし、ドキュメントや API を整理し、PHPer がカスタマイズ可能なプロファイリングフレームワークにする
名前について
- この記事の公開時点でこのツールの名前は
sj-i/php-profiler
というシンプルなわかりやすい名前だった - 改修にあたって Zend ライセンスだけでなく PHP ライセンスのコードを使いたくなったが、PHP ライセンスのコードを使う際には「PHPほげほげ」という名前を使ってはいけないという縛り(第 4 条項)がある
- てきとうに選んだ文字列操作関数
strrev()
を元の名前に適用して "reliforp-php" となり、これが "reli for p(php)" みたく見えるので、"reli" という名前に変わった
この文書について
- わりと書きなぐってるので、そのうち全体的な文章を清書したりするかもしれない、しないかもしれない