並列リソース実行 Alpha

BEAR.Asyncはこれまで逐次取得されていた#[Embed]埋め込みリソースを透過的に並列実行します。リソースのコードに手を入れることなく、並列実行用の起動スクリプトを用意するだけで、埋め込みリソースは自動的に並列取得に切り替わります。

概要

標準のBEAR.Sundayでは#[Embed]リソースは順次取得されますが、BEAR.Asyncでランタイム環境を選択すると並列に取得されます。

[順次実行]                       [並列実行]
Request                          Request
    │                                │
    ├── Embed 1 ──── 50ms            ├── Embed 1 ──┬── 50ms
    ├── Embed 2 ──── 50ms            ├── Embed 2 ──┤
    ├── Embed 3 ──── 50ms            ├── Embed 3 ──┤
    └── Embed 4 ──── 50ms            └── Embed 4 ──┘
    │                                │
Response (200ms)                 Response (50ms)

インストール

composer require bear/async

ランタイム環境

サーバー構成に応じて適切なランタイム環境を選択します。

用途 エントリポイント ランタイム設定
PHP-FPM / Apache(埋め込みリソースあり) bin/async.php ライブラリのbootstrap.phpAppModuleに並列ランタイムを重ねる
Swoole HTTPサーバー bin/swoole.php AsyncSwooleModuleAppModuleにインストール

並列実行(ext-parallel)

PHP-FPM / Apache上で動作する一般的なWebアプリケーション向けのランタイム環境です。ext-parallelのスレッドプールを使って#[Embed]を並列実行します。

bin/app.phpの隣にbin/async.phpを追加します。このエントリポイントはライブラリのbootstrap.phpに処理を委譲し、通常のAppModuleの上にext-parallelランタイムを重ねます。

bin/async.php → vendor/bear/async/bootstrap.php → AppModule + 並列ランタイム
<?php // bin/async.php

declare(strict_types=1);

require dirname(__DIR__) . '/autoload.php';

$bootstrap = dirname(__DIR__) . '/vendor/bear/async/bootstrap.php';
if (! file_exists($bootstrap)) {
    throw new LogicException('"bear/async" is not installed.');
}

$defaultContext = PHP_SAPI === 'cli' ? 'cli-hal-api-app' : 'hal-api-app';
$context = getenv('APP_CONTEXT') ?: $defaultContext;

exit((require $bootstrap)(
    $context,
    'MyVendor\MyApp',
    dirname(__DIR__),
    $GLOBALS,
    $_SERVER,
));

ワーカープールのサイズ(デフォルトはCPUコア数)を変更したい場合は、第6引数として明示的に指定します。

exit((require $bootstrap)($context, 'MyVendor\MyApp', dirname(__DIR__), $GLOBALS, $_SERVER, 8));

ext-parallelの制約

ワーカーは別スレッドで動作し、それぞれ独立したZendメモリ空間を持ちます。並列実行する埋め込みリソースは、順序に依存しない読み取り専用(冪等なGET)リソースにしてください。各ワーカーは独自のDIコンテナを持つため、リクエストローカルな可変状態や「同一インスタンスである」という前提はスレッド境界を越えて引き継がれません。

スレッド境界をまたぐ引数と戻り値はコピー可能でなければなりません。具体的にはスカラー値・null・それらをネストした配列です。オブジェクトやクロージャ、リソースを渡した場合は即座にエラーになります。並列実行される埋め込みリソースに適用するインターセプターは冪等に保ち、リクエストローカルな共有状態を書き換えないでください。

Swoole実行(ext-swoole)

すでにSwoole HTTPサーバー上で稼働しており、高い並行性能を求めるアプリケーション向けのランタイム環境です。

ext-parallelはワーカー(別スレッド)で動作するため別エントリポイントから選択しますが、ext-swooleは同一サーバープロセス内で動作するため、アプリケーションモジュールとしてインストールします。

use BEAR\Async\Module\AsyncSwooleModule;
use BEAR\Async\Module\PdoPoolEnvModule;

class AppModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->install(new AsyncSwooleModule());
        $this->install(new PdoPoolEnvModule('PDO_DSN', 'PDO_USER', 'PDO_PASSWORD'));
    }
}

Swooleではコルーチン同士がメモリを共有するため、PdoPoolEnvModuleによる接続プールが必要です。読み取り中心で埋め込みリソースを多用する構成では、外部から到達するHTTPリクエスト数だけでなく、1リクエスト内で同時に実行される埋め込みの数も加味してプールサイズを見積もります。キュー待ちを避けたい場合は PDO_POOL_SIZE >= embed_count * request_concurrency を目安にし、DBへの同時接続数を抑えたい場合はあえて小さめに設定します。

技術ノート(プール接続の取得方式): プールからの接続取得はコルーチン単位で管理されます。同じコルーチン内でPDOExtendedPdoの両方が注入された場合でも、両者は同一の接続を共有し、コルーチン終了時にCoroutine::defer()で一度だけプールへ返却されます。これにより、1つの処理が意図せず2本の接続を握ることを防ぎます。さらに#[Embed]で埋め込まれたリクエストは遅延評価されるため、埋め込みリソースを#[Embed]で宣言した時点ではプールから接続を確保せず、各リクエストが実際に実行される時点まで取得を遅らせます。

技術ノート(PDOProxyの扱い): Swooleはコルーチン対応のためにPDOを独自にPDOProxyでラップしますが、BEAR.Asyncはこのラップを内部で吸収して通常のPDOとして扱えるようにします。何らかの理由で元のPDOを取り出せない場合は、リフレクション失敗をそのまま伝播させず、PDOプロキシ抽出専用のドメイン例外として扱います。

Swooleのコルーチンと有効化されたXdebugを併用すると安全に動作しません。Swoole用のエントリポイントはXdebugを読み込まないPHPで実行するか、ローカル確認時にはXDEBUG_MODE=offを設定してください。

使用方法

ランタイム環境を選択すると、既存の#[Embed]リソースは自動的に並列実行されます。

class Dashboard extends ResourceObject
{
    #[Embed(rel: 'user', src: '/user{?id}')]
    #[Embed(rel: 'notifications', src: '/notifications{?user_id}')]
    #[Embed(rel: 'stats', src: '/stats{?user_id}')]
    public function onGet(string $id): static
    {
        $this->body['id'] = $id;
        return $this;
    }
}

開発環境ではbin/app.phpで同期実行してデバッグし、本番環境ではbin/async.phpから起動して並列実行に切り替えます。

なぜコード変更なしで動くのか

BEAR.Sundayでは、情報がリソースとして URI で構造化されています。#[Embed]はそのリソースの実行結果ではなく、リソースリクエストそのものを埋め込み、リソース間の関係を宣言します。実行戦略 — 逐次・ext-parallelワーカー・Swooleコルーチン — を選ぶのは Linker の役割で、リソースクラスは自分が同期で呼ばれたか並列で呼ばれたかを知る必要がありません。

通常モードではレンダリング時にこれらのリクエストが1つずつ逐次解決されますが、並列実行モードでは、最初の埋め込みリクエストが解決される時点で残りの埋め込みリクエストもまとめて並列に実行されます。BEAR.Asyncの非同期リクエストはBEAR.Resourceの通常リクエストと同じ型として扱えるため、HALレンダラなど周辺の仕組みはこの差を意識せずシリアライズに統合できます。

非同期プログラミングでしばしば言われる「関数の色」問題 — 非同期関数を呼ぶ関数は自身も非同期でなければならず、コード全体が非同期に汚染される問題 — も、リソースという境界がこれを遮断します。同期と並列でコードは同じ、変わるのは実行戦略だけです。

これはBEAR.Async固有ではなく、BEAR.Sunday全体の性質です。MVCフレームワークが「どう実行するか」を手続きで書く箇所を、BEAR.Sundayはリソース間の関係を宣言として表します。宣言は実行戦略から独立しているため、戦略の差し替えはコードに影響しません。

デモとベンチマーク

BEAR.AsyncリポジトリにはSync・ext-parallel・Swooleの動作を比較できる、Dockerベースのデモとベンチマークスクリプトが含まれています。詳細はデモガイドベンチマーク結果を参照してください。

動作要件

各ランタイム環境は対応するPHP拡張を必要とします。

ランタイム環境 必要なもの アプリケーション側の変更
ext-parallel ZTS PHP + ext-parallel bin/async.phpを追加
ext-swoole ext-swoole AsyncSwooleModuleをインストール、bin/swoole.phpを使用

SQLリソースの並列化(BDR + #[Embed]

1ページで複数のSQLを発行したい場合、SQLごとにResourceObjectを分け、上位リソースから#[Embed]で束ねます。AsyncLinkerが#[Embed]をランタイムに応じて並列実行するので、利用側はリソースを組み合わせるだけで並列化されます。

Ray.MediaQueryのBDRパターン#[DbQuery]インターフェース + ファクトリ + 不変ドメインオブジェクト)と組み合わせると、SQLはvar/sql/*.sqlにまとまり、リソース側はドメインオブジェクトを扱うだけになります。

レシピ依存(BEAR.Asyncには同梱されません):

composer require ray/media-query
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\ResourceObject;
use Ray\MediaQuery\Annotation\DbQuery;

// ドメインオブジェクト — 不変スナップショット
final class UserAccount
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
    ) {
    }
}

// リポジトリ — SQLは var/sql/user.sql に置く
// UserFactoryで行をUserAccountにハイドレートする(ファクトリの詳細はBDR_PATTERN.md参照)
interface UserRepositoryInterface
{
    #[DbQuery('user', factory: UserFactory::class)]
    public function getUser(int $id): UserAccount;
}

// リソース — SQL 1つにつき1リソース
class User extends ResourceObject
{
    public function __construct(private UserRepositoryInterface $repo)
    {
    }

    public function onGet(int $id): static
    {
        $this->body = ['user' => $this->repo->getUser($id)];

        return $this;
    }
}

// 集約リソース — `#[Embed]` がAsyncLinkerで自動的に並列化される
class UserDashboard extends ResourceObject
{
    #[Embed(rel: 'user',     src: 'app://self/user{?id}')]
    #[Embed(rel: 'posts',    src: 'app://self/user/posts{?id}')]
    #[Embed(rel: 'comments', src: 'app://self/user/comments{?id}')]
    public function onGet(int $id): static
    {
        return $this;
    }
}
  • SQLはvar/sql/*.sqlに置く(Ray.MediaQueryの規約)
  • ドメインオブジェクトは不変スナップショット。呼び出し側で $results['user'][0] ?? null のような配列のお作法は不要
  • AsyncLinkerが3つのEmbedをext-parallel(PHP-FPM / Apache)またはSwooleコルーチンで並列実行
  • ext-parallel/Swooleなしでも、同じコードがリクエストごとに同期実行される(PHP-FPMはリクエスト=プロセスなので機能としては問題なし)
  • Swooleで動かす場合はPdoPoolEnvModuleをインストールし、各コルーチンがプールからPDO接続を借りるようにする

参考リンク