hnwの日記

PHPでは正規表現コンパイル結果のキャッシュが暗黙に行われている

筆者がPHPをさわり始めたころ、「PerlのコレはPHPではどうやるんだろう?」と思うことが頻繁にありました。一部の疑問については解説を見つけたり自分でソースコードを読んだりして解決したものの、考えるのをやめてしまったものもあります。その一つが正規表現コンパイル結果の保存に関するもので、最近まで完全に忘れていました。

正規表現コンパイルというのは与えられた正規表現を解釈して実行しやすいデータ構造に変換する作業のことを指します。具体的にはDFA(決定性有限オートマトン)を構成するか、正規表現エンジン内部で用いられるVM命令列に変換するかといった処理になります。これらは複雑な処理ですので、性能の観点で言えば同じ正規表現に対するコンパイル処理はできるだけ繰り返したくありません。

Perlの場合、/foobar/ のようなスタティックな正規表現コンパイルは1回しか行われません。一方で、正規表現中に変数が使われている場合は毎回内容が変わる可能性があるため、毎回コンパイルが走ります。毎回のコンパイルを防ぐためのoフラグというものがあるなど、このあたりの仕組みについてはラクダ本でもページ数を割いて説明されており、多くのPerlプログラマ正規表現コンパイルがいつ走るかを意識しながらコードを書いているはずです。

一方、PHPでは正規表現コンパイルに関する話題自体をほとんど聞いたことがないように思います。PHPで同じ正規表現処理が何度も実行される場合に、正規表現コンパイルが1回しか行われないのか、毎回行われているのか、この疑問に答えられるPHPプログラマはごく少数ではないでしょうか。

本稿ではPHP正規表現コンパイルとそのキャッシュの仕組みについて紹介します。

PHPでの正規表現処理

PHP正規表現処理の関数を2系統持っており、それぞれ下記の拡張モジュールで提供されています。

  • PCRE
  • mbstring

PCRE拡張で採用されているPCREはPerl正規表現を提供するライブラリで、他のOSSでの採用事例も多く見られます。PHPでは本家PCREのバージョンアップにマメに追従しており、PHP 7.0.12ではPCRE 8.38が同梱されています。

一方、mbstringは日本では定番のマルチバイト処理の拡張モジュールで、正規表現関数も提供しています。mbstringで利用している正規表現ライブラリはRuby 1.9系でも採用されていた鬼車で、PHPに同梱されているのは鬼車 5.9.6です。いまのところ鬼車6.x系への追従や鬼雲に切り替えるなどという話は聞いたことがありません。正直なところ、UTF-8全盛の昨今であればPCREだけで十分な気もします。

ちなみにPHP 5.xまでは更に別のPOSIX正規表現ライブラリも持っていたのですが、7.0からは削除されています。

PCREとコンパイル結果のキャッシュ

まずPCREの方から紹介します。PCRE拡張では正規表現コンパイル結果のキャッシュがPHPのプロセス内で最大4096個までキャッシュされる仕組みになっています。このキャッシュはプロセス内で永続化されているため、正規表現コンパイル結果はリクエストをまたいで共有されます(ただし、プロセスをまたいでの共有はできません)。

最初の疑問について言えば、同じ正規表現が与えられた場合には最初の1回しか正規表現コンパイルは走らないし、もしかすると以前のリクエストで作られたキャッシュにヒットすれば1回も正規表現コンパイルを行わない可能性さえあるというわけです。

実は、このことはPHPマニュアルにも書いてあります。

この拡張モジュールでは、コンパイルした正規表現のためにスレッド単位のグローバルキャッシュ (最大 4096) を管理しています。

http://php.net/manual/ja/intro.pcre.php

このキャッシュの効果は簡単な実験で確認できます。次のようなプログラムを実行してみましょう。

<?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の正規表現コンパイル結果もキャッシュされていますが、こちらは同一リクエスト内のみで使い回され、リクエスト間で共有されることはありません。mbstringではコンパイル結果のキャッシュ個数に上限はなく、異なる正規表現コンパイルするたびにメモリを消費していきます。

また、同じ正規表現で内部的なフラグだけが異なっているような場合は最新1件しかキャッシュされません。つまり、mb_eregとmb_eregiとで同じ$patternを与えたような場合、前に実行した方のコンパイル結果は上書きされてしまい、後で実行した方のキャッシュしか残りません。

このキャッシュ処理はPHPソースコードext/mbstring/php_mbregex.cphp_mbregex_compile_pattern関数で行われています。

キャッシュに関する注意点

これらのキャッシュは正規表現を理解しているわけではなく、正規表現パターン文字列をキーにしてコンパイル結果を連想配列に格納しているだけです。明らかに同じ内容の正規表現であってもパターン文字列が異なっていればキャッシュは使われず、再度コンパイルが行われます。

たとえば下記のようなコードを書いた場合も、それぞれ個別にコンパイルされてキャッシュエントリを2個消費してしまいます。

<?php
$foo = "foo"
preg_match("/foo/", $foo);
preg_match("~foo~", $foo);

仕組みを考えれば仕方ないかもしれませんが、少し残念ですね。

まとめ