筆者がPHPをさわり始めたころ、「PerlのコレはPHPではどうやるんだろう?」と思うことが頻繁にありました。一部の疑問については解説を見つけたり自分でソースコードを読んだりして解決したものの、考えるのをやめてしまったものもあります。その一つが正規表現コンパイル結果の保存に関するもので、最近まで完全に忘れていました。
正規表現のコンパイルというのは与えられた正規表現を解釈して実行しやすいデータ構造に変換する作業のことを指します。具体的にはDFA(決定性有限オートマトン)を構成するか、正規表現エンジン内部で用いられるVM命令列に変換するかといった処理になります。これらは複雑な処理ですので、性能の観点で言えば同じ正規表現に対するコンパイル処理はできるだけ繰り返したくありません。
Perlの場合、スタティックな正規表現のコンパイルは1回しか行われません。一方で、正規表現に変数が使われている場合は毎回内容が変わる可能性があるため、毎回コンパイルが走ります。毎回のコンパイルを防ぐためのoフラグというものがあるなど、多くのPerlプログラマは正規表現がいつコンパイルされるか意識しながらコードを書いているはずです。
一方、PHPでは正規表現コンパイルに関する話題自体をほとんど聞いたことがないように思います。PHPで同じ正規表現処理が何度も実行される場合に、正規表現コンパイルが1回しか行われないのか、毎回行われているのか、この疑問に答えられるPHPプログラマはごく小数ではないでしょうか。
本稿ではPHPの正規表現コンパイルとそのキャッシュの仕組みについて紹介します。
PHPは正規表現処理の関数を2系統持っており、それぞれ下記の拡張モジュールで提供されています。
PCRE拡張で採用されているPCREはPerl正規表現を提供するライブラリで、他のOSSでの採用事例も多く見られます。PHPでは本家PCREのバージョンアップにマメに追従しており、PHP 7.0.12ではPCRE 8.38が同梱されています。
一方、mbstringは日本では定番のマルチバイト処理の拡張モジュールで、正規表現関数も提供しています。mbstringで採用されている正規表現ライブラリはRuby 1.9系でも採用されていた鬼車です。こちらは鬼雲への追従などという話は聞いたことがありません。正直なところ、UTF-8全盛の昨今であればPCREだけで十分な気もします。
ちなみにPHP 5.xまでは更に別のPOSIX正規表現ライブラリも持っていたのですが、7.0からは削除されています。
まずPCREの方から紹介します。PCREでは正規表現のコンパイル結果はPHPのプロセス内で永続化されているグローバル変数で最大4096個キャッシュされる仕組みになっています。言い換えると、正規表現のコンパイル結果はリクエストをまたいで共有されています(ただし、プロセスをまたいでの共有はできません)。
最初の疑問について言えば、同じ正規表現が与えられた場合には最初の1回しか正規表現コンパイルは走らないし、もしかすると以前のリクエストで作られたキャッシュにヒットすれば1回も正規表現コンパイルを行わない可能性さえあるというわけです。
この拡張モジュールでは、コンパイルした正規表現のためにスレッド単位のグローバルキャッシュ (最大 4096) を管理しています。
このキャッシュの効果は簡単な実験で確認できます。次のようなプログラムを実行してみましょう。
<?php $num_regex = 4096; $start = microtime(true); for ($i = 0; $i < 100; $i++) { for ($j = 0; $j < $num_regex; $j++) { preg_match("/([a-z]{1,10}){1,10}$j/", "foo"); } } var_dump(microtime(true)-$start);
これは$num_regex種類の異なる正規表現マッチを繰り返し実行するだけのコードで、私の手元で実行したところ0.45秒程度でした。ところが、$num_regexを1増やして4097にしてみると実行時間が18秒となり、劇的に時間がかかるようになってしまいました。正規表現のキャッシュサイズが4096であるため、それ以上の種類数にしてしまうと毎回キャッシュが追い出されてしまって都度正規表現コンパイルが走るので非常に遅くなるというわけです。
このキャッシュ処理の詳細はPHPソースコードext/pcre/php_pcre.cのpcre_get_compiled_regex_cache関数で記述されています。
mbstringの正規表現コンパイル結果もキャッシュされていますが、こちらは同一リクエスト内のみで使い回され、リクエスト間で共有されることはありません。また、mbstringではコンパイル結果のキャッシュ個数に上限はなく、異なる正規表現をコンパイルするたびにメモリを消費していきます。
また、同じ正規表現で内部的なフラグだけが異なっているような場合は最新1件しかキャッシュされません。つまり、mb_eregとmb_eregiとで同じ$patternを与えたような場合、前に実行した方のコンパイル結果は上書きされてしまい、後で実行した方のキャッシュしか残りません。
このキャッシュ処理はPHPソースコードext/mbstring/php_mbregex.cのphp_mbregex_compile_pattern関数で行われています。
これらのキャッシュは正規表現を理解しているわけではなく、正規表現パターン文字列をキーにしてコンパイル結果を連想配列に格納しているだけです。明らかに同じ内容の正規表現であっても、パターン文字列が異なっていればキャッシュは使われず、再度コンパイルが行われます。
たとえば下記のようなコードを書いた場合も、それぞれ個別にコンパイルされてキャッシュエントリを2個消費してしまいます。
<?php $foo = "foo" preg_match("/foo/", $foo); preg_match("~foo~", $foo);
仕組みを考えれば仕方ないかもしれませんが、少し残念ですね。