はじめまして、こんにちは。
今年の10月に福岡で起業して、なんちゃってCTOをしている赤と黒が好きな若造です。
今回は開発中の自社サービスのインタラクションデザインを改善するためにPHPで非同期処理をしてみたという話を書きます。
PHPで処理を並行したい。
ユーザーがあるアクションを起こした時に、PHPでの処理に10秒掛かるとします。
その間ユーザーは10秒待つ羽目になるわけですが、この時間が貴重な現代社会に置いて、ユーザーは10秒待つと当然イライラします。
なのでなるべく早くリクエストからレスポンスの時間を短くしたいわけですが、いくらPHP8で爆速になったとは言え、限界もあります。
そこで「重要な処理」と「重要では無い処理」で処理自体を分割して
「重要な処理」が終わった時点でレスポンスを返し、「重要では無い処理」は裏で並列で処理するアイディアを思いつきました。
これなら実質3秒でユーザーにレスポンスを返す事ができます!
PHPはシングルスレッドだよ
ですが残念なことにPHPは基本的にシングルスレッドで、並列処理は出来ません。1
だから無理です。
今日のアドベントカレンダー記事は以上です。あざした。
…というわけにはいきません。
PHPはシングルスレッドですが、マルチプロセスは可能です。
「並列処理」ではなくて「分割処理」という言葉のほうがイメージしやすいですね
シングルスレッドとマルチプロセスは結果は同じ様に見えますが、プロセスが分割されているので、変数などの情報を共有できないのは注意しなくてはいけません。
(正確にはメモリが共有出来ない)
ではどうやってプロセスを分けるのか、を解説していきます。
exec関数
詳しい解説はPHPの公式のマニュアルに任せます。
PHPマニュアル:https://www.php.net/manual/ja/function.exec.php
PHP 7.4.10の環境で以下のコードを書いてみました。
exec関数で「返り値を/dev/nullに捨てて、バックグラウンド処理する、phpのプロセス」を立ち上げるコマンドを叩くコードです。
<?php
echo "START";
error_log("\n\n".date('Y-m-d H:i:s')." START", 3, __DIR__.'/log.txt');
exec('php '.__DIR__.'/num1_wait5.php > /dev/null &');
exec('php '.__DIR__.'/num2_wait1.php > /dev/null &');
exec('php '.__DIR__.'/num3_wait10.php > /dev/null &');
exec('php '.__DIR__.'/num4_wait0.php > /dev/null &');
error_log("\n".date('Y-m-d H:i:s')." END", 3, __DIR__.'/log.txt');
echo "END";
<?php
sleep(5);
error_log("\n".date('Y-m-d H:i:s')." NUM1_WAIT5", 3, __DIR__.'/log.txt');
<?php
sleep(1);
error_log("\n".date('Y-m-d H:i:s')." NUM2_WAIT1", 3, __DIR__.'/log.txt');
<?php
sleep(10);
error_log("\n".date('Y-m-d H:i:s')." NUM3_WAIT10", 3, __DIR__.'/log.txt');
<?php
error_log("\n".date('Y-m-d H:i:s')." NUM4_WAIT0", 3, __DIR__.'/log.txt');
index.phpで4つのプロセスを立ち上げて処理するコードです。
実行結果は以下になります。
2020-12-12 10:28:00 START
2020-12-12 10:28:00 END
2020-12-12 10:28:00 NUM4_WAIT0
2020-12-12 10:28:01 NUM2_WAIT1
2020-12-12 10:28:05 NUM1_WAIT5
2020-12-12 10:28:10 NUM3_WAIT10
本来であれば全ての処理をシングルプロセスで処理すると完了に5+1+10+0=16秒
掛かるところを
マルチプロセスにしたおかげで、全ての処理の完了に10秒
で済んでしまいました。
しかしexec関数はlinuxのコマンドを叩く処理で、マルチプロセスのためのものではありません。
linuxのコマンドを叩くという性質上、うっかりユーザーの入力をそのままexec関数の引数に当てるなんてことは絶対にしないようにしましょう。
##exec関数でのマルチプロセスの欠点
一見簡単にマルチプロセスで処理できるexec関数ですが、実は弱点があります。
処理を分割するためにプロセスを作るという性質上、処理が立て込んだ時には処理が横に広がりすぎて、サーバーに影響を及ぼすからです。
最悪サーバーが落ちると思います。
#Laravelでの非同期処理
素のPHPでマルチプロセスをしてみましたが、実際の業務やサービスではフレームワークを使うことでしょう。
株式会社ナインステクノロジーズはLaravelが得意なので、Laravelでも非同期処理をやってみましょう。
Laravelではexec関数でのマルチプロセスとは少し違って「ジョブとキュー」で並列処理を実現します。
##ジョブとキュー
厳密に言えば「ジョブとキュー」もマルチプロセス処理の一種で
「ジョブ」と言われる処理の塊を、「キュー」と呼ばれるプロセスが変わって処理するイメージです。
最初ジョブは全く無く、キューもジョブが無いので待機状態になっています。
なんらかのアクションでジョブが記録されると、キューはそれに気づきます。
キューは記録されているジョブを1つ取り出して処理を始めます。
こういう流れで処理を非同期的に処理します。
##php artisan queue
詳しい解説はLaravelのドキュメントを翻訳しているサイトに任せます。
Laravel 6.x キュー:https://readouble.com/laravel/6.x/ja/queues.html
Laravel Framework 6.20.6の環境でジョブとキューの設定をやっていきます。
まずジョブをデータベースに記録したいので、migrationを行います。
php artisan queue:table
php artisan queue:failed-table #初期インストール状態では既にmigrationファイルは存在しているのでエラーが出る。
php artisan migrate
これでjobsテーブル
とfailed_jobsテーブル
が作られます。
そしてjobファイルを生成して、書いていきます。
php artisan make:job Num1Wait5.php
php artisan make:job Num2Wait1.php
php artisan make:job Num3Wait10.php
php artisan make:job Num4Wait0.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class Num1Wait5 implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
sleep(5);
error_log("\n".date('Y-m-d H:i:s')." NUM1_WAIT5", 3, '/log.txt');
}
}
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class Num2Wait1 implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
sleep(1);
error_log("\n".date('Y-m-d H:i:s')." NUM2_WAIT1", 3, '/log.txt');
}
}
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class Num3Wait10 implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
sleep(10);
error_log("\n".date('Y-m-d H:i:s')." NUM3_WAIT10", 3, '/log.txt');
}
}
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class Num4Wait0 implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
sleep(0);
error_log("\n".date('Y-m-d H:i:s')." NUM4_WAIT0", 3, '/log.txt');
}
}
そしてジョブを入れるcontrollerファイルを生成して、書いていきます。
php artisan make:controller TestController
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TestController extends Controller
{
public function test(){
echo "START";
error_log("\n\n".date('Y-m-d H:i:s')." START", 3, '/log.txt');
$this->dispatch(new \App\Jobs\Num1Wait5() );
$this->dispatch(new \App\Jobs\Num2Wait1() );
$this->dispatch(new \App\Jobs\Num3Wait10() );
$this->dispatch(new \App\Jobs\Num4Wait0() );
error_log("\n".date('Y-m-d H:i:s')." END", 3, '/log.txt');
echo "END";
}
}
そして.env
のQUEUE_CONNECTION
を編集します。
QUEUE_CONNECTION=database
最後にキューのプロセスを立ち上げます。
php artisan queue:work > /dev/null &
これで準備完了です。
実際にTestController@test
を実行した結果が以下です。
2020-12-12 12:58:00 START
2020-12-12 12:58:00 END
2020-12-12 12:58:05 NUM1_WAIT5
2020-12-12 12:58:06 NUM2_WAIT1
2020-12-12 12:58:16 NUM3_WAIT10
2020-12-12 12:58:16 NUM4_WAIT0
…あれ?
##php artisan queueでのジョブとキューでの欠点
先程の実行結果は、16秒掛かってしまいました。
exec関数では10秒だったのになぜでしょうか?
実は先程の実行は**「キューが1個だったから」**16秒掛かったのです。
ジョブ1を処理して、
ジョブ1が終わったらジョブ2を処理して、
ジョブ2が終わったらジョブ3を処理して、
ジョブ3が終わったらジョブ4を処理して、
ジョブ4が終わる。
キューは1個なので、処理できるジョブも当然1個です。だから並列的に処理が出来ないんですね。
なので、並列的に処理したい場合はキューの数を増やすと良いでしょう。
キューを増やすことで同時に処理できるジョブの数が増え、効率的に処理をこなすことが出来ます。
キューの数だけジョブを捌けるのですが、ここにも落とし穴があって
例えばジョブの増加速度よりもキューがジョブを捌く速度が遅くなってしまうと
いつまで立っても処理が開始されないジョブが出てきてしまう可能性があります。
まさに炎上状態。
一応Laravelではジョブの優先度やキューへの振り分けを操作出来ますが、万が一こういう自体が起こらない訳ではありません。
#まとめ
PHPはシングルスレッドで並列処理は難しいですが
exec関数やLaravelのqueueで非同期的に処理を実行できます。
活用することでユーザーへのレスポンスが早くなることでしょう。
本当はこの処理と合わせてプログレスバーを実装する予定でしたが、以外と長くなったので今度にします。
もし間違っているところがあればマサカリお願いします。(言葉尻を捉えてイジメるのはやめてくださぃ。
明日は奇しくも同じ福岡の企業に所属するせいけしろーさんです。
ココまで読んでくださってありがとうございました。
LGTMとTwitterをフォローしてくれると嬉しいです!よろしくおねがいします!!!
@9th_tech_Ryo
あと別のアドベントカレンダーになりますが、弊社長も記事を明日書くので、ぜひ見てみてください!!!!!
https://qiita.com/akira_9th