【PHP7.4】PHPで簡単に永続プリロードできるようになる

PHPはHTTPリクエストが来るたびに全てのPHPコードをバイトコードに変換し、そして実行しています。
毎回そんなことやってるのにあれだけ速度が出るのは驚異的ですが、それでもやはりコンパイルにかかる時間だけどうしても遅くなってしまいます。

そこで、もっと高速化するために[OPcache](https://php.net/manual/ja/book.opcache.php)のような仕組みが存在します。
これはバイトコードをメモリ上に保持し、リクエストを超えて使い回すことでコンパイルの手間を省略し、高速化を実現するというものです。
効果はというと、単純なものでも[ターンアラウンドタイムが2/3](https://qiita.com/morimorim/items/fb39ae7d673a8b88f413)、大きなフレームワークでは半分以下と、お手軽かつ強力な効果があります。

とはいえOPcacheには、元のPHPファイルに変更があるかどうかを監視したりといった僅かなコストが残っています。
特にバイトコードはファイル単位でしかキャッシュできないらしく、extendsなどで別のファイルを参照しているときは毎回再リンクしているようです。

そこで新たに[Preloading](https://wiki.php.net/rfc/preload)というRFCが提出されました。

提出というか既に投票フェーズに入っていて、投票期間は2018/11/14までですが、賛成46/反対0でほぼ導入確定です。


# RFC: Preloading

## 書式

`php.ini`に新しいディレクティブ、`opcache.preload`が追加されます。
これは単一のPHPファイルを指定し、Webサーバの起動時にこのファイルが読み込まれます。
その中で[opcache_compile_file()](https://php.net/manual/ja/function.opcache-compile-file.php)を指定すると、そのファイルがキャッシュされます。

```php:php.ini
opcache.preload="path/to/preload.php"
```

```php:path/to/preload.php
opcache_compile_file('path/to/hoge.php');
opcache_compile_file('path/to/fuga.php');
```

これでサーバの再起動時に`hoge.php`と`fuga.php`がキャッシュされます。
以後このサーバでは、その中で定義されているクラスや関数を、ネイティブ関数と同じようにいつでもどこでも自由に使うことができるようになります。

RFCにはZend Frameworkをまるごとプリロードする例が載っていました。

```php
function _preload($preload, string $pattern = "/\.php$/", array $ignore = []) {
  if (is_array($preload)) {
    foreach ($preload as $path) {
      _preload($path, $pattern, $ignore);
    }
  } else if (is_string($preload)) {
    $path = $preload;
    if (!in_array($path, $ignore)) {
      if (is_dir($path)) {
        if ($dh = opendir($path)) {
          while (($file = readdir($dh)) !== false) {
            if ($file !== "." && $file !== "..") {
              _preload($path . "/" . $file, $pattern, $ignore);
            }
          }
          closedir($dh);
        }
      } else if (is_file($path) && preg_match($pattern, $path)) {
        if (!opcache_compile_file($path)) {
          trigger_error("Preloading Failed", E_USER_ERROR);
        }
      }
    }
  }
}

set_include_path(get_include_path() . PATH_SEPARATOR . realpath("/var/www/ZendFramework/library"));
_preload(["/var/www/ZendFramework/library"]);
```

## 注意事項

この方法で作成したキャッシュは、サーバが生きているかぎり永続します。
元のPHPファイルを変更してもキャッシュは更新されないし、たとえ[opcache_reset()](https://php.net/manual/ja/function.opcache-reset.php)を使ったとしても消えません。
更新する方法はサーバの再起動だけとなります。

つまり、OPcacheの通常の使用法では残っていた元ファイル監視などのコストを排除し、さらなる高速化を図ったものといえます。

## 制限

プリロードの恩恵を受けられるのは、親クラス・インターフェイス・トレイト・定数の定義をプリロードの範囲内で解決できるクラスだけです。
それが満たされないクラスは普通のOpcacheと同じようにopcache SHMに格納されます。
ってなってるんだけど[SHM](https://php.net/manual/ja/opcache.configuration.php#ini.opcache.preferred-memory-model)って何だ？
`/dev/shm`のこと？

## 下位互換性のない変更点

明示的に使用しないかぎり何も起らないので、何もしなければ影響はありません。

プリロードされたクラスや関数は`function_exists()`や`class_exists()`に常にtrueを返します。

2種類以上のアプリケーションを走らせているサーバでは、うっかりPreloadingを使うと問題が起こる可能性があります。
2つのアプリでそれぞれ独自の`h()`関数を作っていたとして、片方をPreloadingに載せると、もう片方が`Cannot redeclare`のFatal errorになります。

## 導入されるバージョン

PHP 7.4。

## パフォーマンス

プリロード1000ファイル、通常ロード150個の[テスト](https://github.com/php/php-src/pull/3538#issuecomment-436312058)で、ターンアラウンドタイムが36.4ミリ秒から29.1ミリ秒になりました。


# 感想

`opcache.preload`で読み込んだ関数やクラスは、`strlen()`や`Exception`といったビルトイン関数・クラスと同じように前準備なしで動くようになります。
つまり、これまではわざわざ[エクステンション](https://qiita.com/d_nishiyama85/items/98f901dcc848a79c81fb)を作らなくてはいけなかったものが、素のPHPでできるようになるということです。

そのかわり、それらは普通のPHPファイルのようにソースを変更しても変更が反映されることはなくなり、反映にはサーバの再起動が必要となります。
この点もエクステンションっぽいですね。

これが普及すれば、フレームワークの本体側はpreloadに載せておいて、ユーザによる変更部分のみ通常のPHPとして読み込む、といった運用が増えていくことでしょう。
バージョンアップごとにApache再起動させられるのは手間ですが。