TORANA TECH BLOG

株式会社トラーナのエンジニアチームの開発ブログ

Laravel と swoole で安全にコルーチンを使用するには

みなさん、こんにちは!めもりー(@m3m0r7) です。 今回は弊社の技術スタックの一つである Laravel と swoole, そして laravel-swoole で安全にコルーチンを使う方法を弊社プロダクトを開発する上で得た知見をもとに解説していこうと思います。

そもそも swoole とは?

swoole とは

Swoole is an event-driven asynchronous & coroutine-based concurrency networking communication engine with high performance written in C++ for PHP.

引用元: https://github.com/swoole/swoole-src

と書いてあるように イベントドリブンな非同期かつコルーチンベースの並行処理が行える ライブラリです。 より直感的にわかり易い言葉で表すと PHP をめっちゃ早くするライブラリ です。

laravel-swoole とは?

laravel-swoole は Laravel 向けの Swoole サーバーを提供するライブラリで、イメージとして Next.js/Nuxt.js に近いです。 Laravel にはハイパフォーマンスに動作する HTTP サーバーがビルトインされており laravel-swoole はそれを使うような仕組みになっています。そもそも swoole 自体 php-fpm で動作させられないとういう面がある一方で、このような機能が提供されています。

laravel-swoole 以外にも似たようなものはいくつかあり、有名所だと hhxsv5/laravel-s が当てはまります。 それぞれメリット・デメリットはありますが、後ほど紹介する資料になぜ laravel-swoole を選定したのかを記載しているので、ご興味があれば是非ご覧ください!

Laravel と swoole の相性の悪さ

swoole だけが相性悪いというよりか Laravel は PHP で実装された CLI の HTTP サーバー全般が Laravel のシングルトンパターンなどが相性の悪い根源となっています。 これは最近出てきた laravel/octane でもワークアラウンドの対策を提示しているものです。

php-fpm であれば、リクエストそれぞれが独立しているため、他のリクエストを処理している最中でもその処理に干渉することはないので安全です。 一方で、CLI サーバーを自作している場合には、静的フィールドの値がリクエスト時にリセットされなかったり、グローバルに行った設定(例えば date_default_timezone_set など)がCLI サーバーを終了するまで永続的に残ってしまったりといった課題があります。

そもそも、どのライブラリも現状コルーチンは使えない(または推奨していない)

laravel-swoole は公式より以下のように表明しています。

Does this package provide coroutine feature? There's an experimental coroutine driver for PDO in this package. However, this may cause unpredictable errors in your app in this moment. The coroutine >feature is a long-term plan in the roadmap. I can't guarantee when it will be completed though. 引用元: https://github.com/swooletw/laravel-swoole/wiki/Z4.-Q&A

試験的には提供しているが、予測不可能であるエラーが出る可能性もある。ただ、コルーチン自体は長期的なロードマップとして実装する予定ではある、というものです。

他方で laravel/octane では、Swoole のタスクを用いてコルーチンではなく、HTTP サーバーを介した並行処理を提供する仕組みが用意されています。

とはいえ、これは並行処理ではあるもののコルーチンではありません。

そもそも laravel/octane はコルーチンを使用しないように設定をしています。

laravel-s に関しても Coroutine セクションにかかれているように代替案としてカスタムプロセスを使用することと書かれています。

なぜコルーチンが使えないのか?

Swoole を導入する大きなメリットの一つであるコルーチンですが、なぜ各ライブラリではコルーチンが使えないのか、上記のシングルトンパターンに加え

  • デフォルトのままだとデータベースや KVS などのリソースが壊れる

    • laravel-s の README.md に書かれている内容が非常にわかりやすいです。

      Warning: The order of code execution in the coroutine is out of order. The data of the request level should be isolated by the coroutine ID. However, there are many singleton and static attributes in Laravel/Lumen, the data between different requests will affect each other, it's Unsafe. For example, the
      database connection is a singleton, the same database connection shares the same PDO resource. This is fine in the synchronous blocking mode, but it does not work in the asynchronous coroutine mode. Each query needs to create different connections and maintain IO state of different connections, which requires a connection pool.

    • 簡潔に要約をすると、

      コードの実行順序がコルーチンでは保証されていません。リクエストのデータは独立した コルーチン ID で分離する必要があります。しかし、シングルトンや静的フィールドを Laravel で多用しているため、各リクエストで影響が発生し、それは安全ではない形になります。例えばデータベースの接続はシングルトンであり、PDO リソースは他リクエストと共有されている、そしてそれらは同期的にブロックされているため、非同期なコルーチンでは動作しません。そのため、各クエリは異なる接続を持って、I/O の状態を維持が必要、そしてそれはコネクションプールを必要とします。

    • となりますが、これは裏を返せば、これらの課題をクリアすればコルーチンが使えるということになります。laravel-swoole に関しては sandbox を生成し各リクエストを分離しているため The data of the request level should be isolated by the coroutine ID が実は満たされていて、安全といえます。

  • ヘルパー関数によって異なるリクエストを参照する可能性がある

    • 上記にも理由は似ていますが、同一のアプリケーションコンテナを使い回すと起こります。

などがあるからではないでしょうか。

実際に弊社で起きた課題

ローカルの開発環境上では一切問題が起きていなかったのですが、実際にステージング環境へ昇華した際に様々な課題が発生しました。

  • MySQL, Redis のリソースが壊れる
    • 何回か通信をしたあとに一切通信ができなくなる。
    • Swoole\Runtime::enableCoroutine(true, SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP) とすると解決するが、コルーチンの恩恵を受けられなくなる
  • newrelic がめちゃくちゃ遅い
    • 外すことで早くなった。
    • Datadog は問題がなかった。
  • 他のユーザーのセッションを使ってしまう
    • session() を使っていたことによる弊害で、$request->getSession() に置き換えたことで解決
    • 内部の app() が別のユーザーのリクエストを参照していた。
      • app() は laravel-swoole が提供している Context::getApp() を使用するようにすればよい。
  • リクエストごとに消える想定の静的フィールドが存在していたことで、バグの温床に
  • --enable-swoole-curl をビルド時に有効せずに curl を使うと他のコルーチンが詰まるような感覚が出る
    • リードスルーキャッシュで、キャッシュが生成されていない時に、重たい外部 API を叩いた際にそこで処理が詰まっていた

いくつか課題はありましたが、主に MySQL と Redis 周りが一番大きな弊害でした 他にも色々と課題を克服してきているので、もし興味があれば PHPerKaigi で私が発表した資料をご覧いただけると幸いです。

speakerdeck.com

MySQL と Redis のリソースを壊さないために

Swoole はコルーチンの中で PDO や Redis のコネクションリソースを使い回すことを推奨していないというのは README.md を見れば明らかです。 なぜなら coroutine の中で PDO リソースまたは Redis のリソースを生成するか Swoole\Coroutine\Channel を介して別のコルーチンに渡している例しかないという点があります。

例えば Redis であれば以下のように README.md に記載されています。

<?php
// ...
go(function () {
    $pool = new RedisPool();
    // max concurrency num is more than max connections
    // but it's no problem, channel will help you with scheduling
    for ($c = 0; $c < 1000; $c++) {
        go(function () use ($pool, $c) {
            for ($n = 0; $n < 100; $n++) {
                $redis = $pool->get();
                assert($redis->set("awesome-{$c}-{$n}", 'swoole'));
                assert($redis->get("awesome-{$c}-{$n}") === 'swoole');
                assert($redis->delete("awesome-{$c}-{$n}"));
                $pool->put($redis);
            }
        });
    }
});

引用元: https://github.com/swoole/swoole-src#the-simplest-example-of-a-connection-pool

実際弊社でも Swoole\Runtime::enableCoroutine(true) を実行した後 Laravel 標準の MySQL ドライバーや Redis ドライバーでは動作しませんでした。 そのため、ConnectionPool を使用するように既存のドライバーに手を加えたものを使用しています。 結果として、リソースが壊れることもなく動作をしています。

PDO は PDOPool という物が既存で用意されているのですが Redis は RedisPool というものが用意されていないので Redis 向けの ConnectionPool 自体自作する必要があります。 また、デフォルトの ConnectionPool は接続数が 64 と多めに設定されており、MySQL 側ですぐに Too many connections といったエラーが表示されるため、この接続数を低くするために別のクラスでラップした上で MySQL 側の max_connectionsmax_prepared_stmt_count といったパラメータ調整も行う必要があります。 さらに、既存の PDOPool だと、Pool にリソースをプッシュした後の、ある特定のコネクションのみにしか PDO::setAttribute を呼べない課題があり、 これによって、プールされているコネクションごとによって PDO の設定がまばらになってしまいます。

そのため、リソースを作成するタイミングで、そのリソースに対して PDO::setAttribute を呼び出すように一手間加えてあげる必要があります。 これは書き込み用コネクションにも同じことが言えます。書き込み用コネクションの場合、トランザクションの有無が発生しますが、複数の書き込み用コネクションがある場合、それぞれ接続しているセッションが異なります。例えば A リソースでトランザクションを有効にしたあと、B リソースを $...->get() で取得してきても A リソースとは当然のように異なるため、 PDO::lastInsertId で値を上手く取得できないといったような問題があります。

弊社では以下のようにしています。

<?php

declare(strict_types=1);

namespace App\Domain\Connection;

use PDO;
use Swoole\ConnectionPool;
use Swoole\Database\PDOConfig;
use Swoole\Database\PDOProxy;
use Throwable;
class ExtendedPDOPool extends ConnectionPool
{
    public const DEFAULT_SIZE = 2;
    public const EVENT_MADE_CONNECTION = 0;

    /** @var PDOConfig */
    protected $config;

    protected array $events = [];

    public function __construct(PDOConfig $config, int $size = self::DEFAULT_SIZE)
    {
        $this->config = $config;
        parent::__construct(fn () => new PDO(
            "{$this->config->getDriver()}:" .
            (
                $this->config->hasUnixSocket() ?
                "unix_socket={$this->config->getUnixSocket()};" :
                "host={$this->config->getHost()};" . "port={$this->config->getPort()};"
            ) .
            "dbname={$this->config->getDbname()};" .
            "charset={$this->config->getCharset()}",
            $this->config->getUsername(),
            $this->config->getPassword(),
            $this->config->getOptions()
        ), $size, PDOProxy::class);
    }

    public function dispatch(int $type, callable $callback): self
    {
        $this->events[$type][] = $callback;

        return $this;
    }

    protected function make(): void
    {
        ++$this->num;

        try {
            if ($this->proxy) {
                $connection = new $this->proxy($this->constructor);
            } else {
                $constructor = $this->constructor;
                $connection = $constructor();
            }
        } catch (Throwable $throwable) {
            --$this->num;

            throw $throwable;
        }

        // Run event callback
        foreach (($this->events[static::EVENT_MADE_CONNECTION] ?? []) as $callback) {
            $callback($connection);
        }

        $this->put($connection);
    }
}

PDO は以下のように生成しています。

<?php
// ...
    protected function makePDOPool(bool $isRead): ExtendedPDOPool
    {
        $pool = new ExtendedPDOPool(
            (new PDOConfig())
                ->withHost($this->config[$isRead ? 'read' : 'write']['host'])
                ->withPort((int) $this->config['port'])
                ->withDbName($this->config['database'])
                ->withCharset($this->config['charset'])
                ->withUsername($this->config['username'])
                ->withPassword($this->config['password']),
            $isRead
                ? ExtendedPDOPool::DEFAULT_SIZE
                : 1
        );

        $pool->dispatch(
            ExtendedPDOPool::EVENT_MADE_CONNECTION,
            function (PDOProxy $pdo) {
                foreach (($this->config['options'] ?? []) as $key => $value) {
                    $pdo->setAttribute($key, $value);
                }
            }
        );

        return $pool;
    }

Laravel 向けのコルーチンに対応した MySQL ドライバーを実装するに当たり、提供されているメソッドを以下のようにしています。

<?php
// ...
    public function select($query, $bindings = [], $useReadPdo = true)
    {
        return $this->run($query, $bindings, static::bindQueryCache(function ($query, $bindings) use ($useReadPdo) {
            if ($this->pretending()) {
                return [];
            }

            $useReadPdo = $this->transactions > 0 ? false : $useReadPdo;

            $pool = $this->{($useReadPdo ? 'read' : 'write') . 'PdoPool'};

            $pdo = $pool->get();

            $statement = $this->preparedForProxy($pdo->prepare($query));
            $this->bindValues($statement, $this->prepareBindings($bindings));
            $statement->execute();

            try {
                return $statement->fetchAll();
            } finally {
                $pool->put($pdo);
            }
        }));
    }
// ...

select 以外にも statement などがあるので、それぞれ同じように $...->get()$...->pop() で囲んでいくことが、コルーチンに対応した MySQL ドライバーの実装に必要です。Redis のドライバーも同様に実装をしていく必要があり、これらが実装できればあとは、上記にも上げている課題に気をつけて実装し、以下のようにサービスプロバイダーで登録してあげれば良いです。

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Domain\Connection\SafetyMySQLConnection;
use App\Domain\Connection\SafetyRedisConnector;
use Illuminate\Database\DatabaseManager;
use Illuminate\Redis\RedisManager;
use Illuminate\Support\ServiceProvider;

class DatastoreServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->extend(DatabaseManager::class, function (DatabaseManager $db) {
            $db->extend('concurrent-safety-mysql', function ($config, $name) {
                $config['name'] = $name;

                return new SafetyMySQLConnection($config);
            });

            return $db;
        });

        $this->app->extend(RedisManager::class, function (RedisManager $redis) {
            $redis->extend('concurrent-safety-redis', function () {
                return new SafetyRedisConnector();
            });

            return $redis;
        });
    }
}

最後に config/database.php でデフォルトの MySQL ドライバー、Redis ドライバーを呼び出している箇所を concurrent-safety-mysqlconcurrent-safety-redis に差し替えてあげます。これで Laravel でもコルーチンが使えるようになります。

まとめ

弊社が使っている技術の一部の swoole と laravel-swoole について、そして安全にコルーチンを扱う方法のご紹介でした。 こういった比較的新しい技術を弊社では使っています。

もしご興味があれば、カジュアル面談からでもお気軽に!

www.wantedly.com

www.wantedly.com