PHPには非同期処理という概念がありません。
集計のような時間がかかる処理を実行するとブロックされ、ブラウザが反応しなくなってしまいます。

今回、Laravelを使ったプロジェクトで、 応答待ちを避けるためのバックグラウンド化 を実装するため試行錯誤しました。

Laravelには標準でキューシステムが用意されており、ドライバとしてBeanstalkdAmazon SQSRedisなどがサポートされていますが、「キューはちょっと大げさ」というケースの参考になれば幸いです。

検証した環境は次の通り

  • Linux : Ubuntu, PHP5.5, Laravel 5.0.0 (Cloud9)
  • Windows 10 : PHP5.4, Laravel 5.0.0

やってみたこと

ケース1:exec()

プロジェクト実装時はベターな方法が分からなく、最終手段のexec()に手を出してしまいました。

$phpPath = PHP_BINARY;
$artisan = base_path(). DIRECTORY_SEPARATOR. "artisan";
$command = "inspire";

exec("nohup {$phpPath} {$artisan} {$command} > /dev/null &");

悔しかったので、よりベターな方法を探します。

ケース2:Symfony/Process

PHPでサブプロセスを実行できるコンポーネントにSymfony/Processがあります。

Symfony/Process
http://symfony.com/doc/current/components/process.html

特徴は以下です。

  • 標準機能のラッパーで機能が豊富、使いやすい
  • Unix/WindowsなどOSの差異を考慮してくれる
  • バイナリセーフ

Symfony/Processのソースを見ると、次のようにproc_open()で新しいプロセスを呼び出していることが分かりました。

$this->process = proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $this->env, $this->options);

LavavelはSymfonyコンポーネントに依存しており、vender以下にSymfony/Processが含まれていますので、これを利用します。

コマンドで頑張ってもダメ

ドキュメントをしっかり読まずに run()を実行すると同期処理 されてしまいます。

// 同期処理される例
$process = new Process('nohup php /path/to/artisan inspire > /dev/null &');
$process->run();

上記はしっかりブロッキングされてしまいました。

非同期処理はstart()

前述のドキュメントに「 start()すれば非同期で処理できるよ 」と案内されています。 次の部分です。

Running Processes Asynchronously

You can also start the subprocess and then let it run asynchronously, retrieving output and the status in your main process whenever you need it. Use the start()
method to start an asynchronous process, the isRunning() method to check if the process is done and the getOutput() method to get the output:

Cloud9で検証したところ、次の呼び出しでバックグラウンド処理されることが確認できました。

$process = new Process('php /path/to/artisan inspire');
$process->start();

ドキュメントのとおり、whileで$process->isRunning()すればブロッキングされ、処理結果を捕捉すること可能です。

nohupの挙動

nohupをつけてstart()してもバックグラウンド処理は可能です。
ただし、こちらは$process->isRunning()も抜けてしまい、結果を捕まえることができなくなってしまいます。

$process = new Process('nohup php /path/to/artisan inspire &');
$process->start();

> /dev/nullも、ほぼ同様の挙動となります。

$process = new Process('php /path/to/artisan inspire > /dev/null');
$process->start();

Windowsのバックグラウンド処理

ローカルでの開発Windowsというケースもありますね。
WindowsでSymfony/Processをstart()させてもコマンドが実行されませんでしたので、対処法を探ります。

以下記事で「 windowsはstatをつけてやればいい 」ということが分かりました。ありがとうございます!

http://somemo.hatenablog.com/entry/2011/08/14/051906

次が、バックグラウンド化できたコードです。

$phpPath = PHP_BINARY;
$artisan = base_path(). DIRECTORY_SEPARATOR. "artisan";
$command = "inspire";

$process = new Process("start {$phpPath} {$artisan} {$command}");
$process->start();

ケース3:Artisanコマンドスケジューラー

多少手間はかかりますが、Artisan::schedule()を利用することもできますね。

実装は次のようなイメージです。

  1. DBにステータス管理のカラムを用意し
  2. ブラウザからの実行時に「待機」から「送信待ち」にする
  3. Artisanのタスクスケジューラーで「送信待ち」ステータスを処理する

N分に1回「送信待ちのステータスをチェック」する実装のため、あまり効率が良いとは言えませんね。
それならシンプルにキューを使ったほうがいいかもしれません。

Artisanコマンドスケジューラーについては以下に詳しく説明を書きました。

[PHP][Laravel]ArtisanタスクスケジューラをMacのlaunchdで動かす
atuweb.net

ダメなケース

Laravelでコマンドラインの処理を実装するにはArtisanに独自のコマンドを追加し、コンソールからphp artisan original:commandというように実行します。

ArtisanコマンドはコントローラーなどMVCなコード上からはArtisan::call()で呼び出すことができます。

Artisan::call("command:name", [
    'argument' => 'foo',
    '--option'   => 'bar'
]);

私は「Artisanはコマンドラインから実行するもの」という認識が強く、「ブラウザからArtisanコマンドを呼び出すことができれば非同期化できるはず」という先入観がありました。
引数に、実行オプションとして> /dev/nullを付ければ非同期で動くかな、、、という甘い考えです。

あれこれテストしてうまく動きませんでしたので、Laravelの実装をチェックしてみます。

Illuminate\Console\CommandがArtisanコマンドの本体です。
このクラスを追っていくと、Artisan::call()はSynfony/Commandをrun()していることが分かります。

Synfony/Commandのソースを追っていくと以下を見つけました。

if ($this->code) {
    $statusCode = call_user_func($this->code, $input, $output);
} else {
    $statusCode = $this->execute($input, $output);
}

execなどで新しいプロセスを呼び出しているのではなく、同じプロセスからコマンドクラスを実行しています。

ということで「Artisan::call()」は「今のプロセス」でコマンドライン処理を実行するものなので、consoleをいくらがんばっても、バックグラウンドに処理を追い出す事は不可能でした。

その他

PHPのパスについて

前述のコードで、PHPバイナリへのパスは定義済み定数のPHP_BINARYを利用しています。
PHPのビルドインサーバでは問題なかったのですが、nginx+PHP-FPM という構成では、PHP-FPMのパスが定義されており、エラーになっていました。

環境別にパスを定義したほうが間違いないかもしれませんね。 かっこ悪いですけれども。

execでも詰まった件

exec()でのバックグラウンド化は> /dev/nullしておけばすぐに処理を抜けられると思っていたのですが、誤りでした。 以下を参考にしてnohupすることで対処しました。

DQNEO起業日記
[PHP]execでバックグラウンド実行するときの落とし穴に落ちた。 nohup!nohup!
http://dqn.sakusakutto.jp/2012/08/php_exec_nohup_background.html


この記事はtomita@atuwebがお届けしました。