LoginSignup
8
1

More than 3 years have passed since last update.

自社サービスのインタラクションデザインを改善するためにPHPで非同期処理をしてみた

Last updated at Posted at 2020-12-12

はじめまして、こんにちは。
今年の10月に福岡で起業して、なんちゃってCTOをしている赤と黒が好きな若造です。
今回は開発中の自社サービスのインタラクションデザインを改善するためにPHPで非同期処理をしてみたという話を書きます。

PHPで処理を並行したい。

ユーザーがあるアクションを起こした時に、PHPでの処理に10秒掛かるとします。
その間ユーザーは10秒待つ羽目になるわけですが、この時間が貴重な現代社会に置いて、ユーザーは10秒待つと当然イライラします。
image.png
なのでなるべく早くリクエストからレスポンスの時間を短くしたいわけですが、いくらPHP8で爆速になったとは言え、限界もあります。
そこで「重要な処理」と「重要では無い処理」で処理自体を分割して
「重要な処理」が終わった時点でレスポンスを返し、「重要では無い処理」は裏で並列で処理するアイディアを思いつきました。
image.png
これなら実質3秒でユーザーにレスポンスを返す事ができます!

PHPはシングルスレッドだよ

ですが残念なことにPHPは基本的にシングルスレッドで、並列処理は出来ません。1
だから無理です。
今日のアドベントカレンダー記事は以上です。あざした。

image.png
 

 

…というわけにはいきません。
PHPはシングルスレッドですが、マルチプロセスは可能です。
「並列処理」ではなくて「分割処理」という言葉のほうがイメージしやすいですね
image.png
シングルスレッドとマルチプロセスは結果は同じ様に見えますが、プロセスが分割されているので、変数などの情報を共有できないのは注意しなくてはいけません。
(正確にはメモリが共有出来ない)

ではどうやってプロセスを分けるのか、を解説していきます。

exec関数

詳しい解説はPHPの公式のマニュアルに任せます。
PHPマニュアル:https://www.php.net/manual/ja/function.exec.php

PHP 7.4.10の環境で以下のコードを書いてみました。
exec関数で「返り値を/dev/nullに捨てて、バックグラウンド処理する、phpのプロセス」を立ち上げるコマンドを叩くコードです。

index.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";
num1_wait5.php
<?php
    sleep(5);
    error_log("\n".date('Y-m-d H:i:s')."  NUM1_WAIT5", 3, __DIR__.'/log.txt');
num2_wait1.php
<?php
    sleep(1);
    error_log("\n".date('Y-m-d H:i:s')."  NUM2_WAIT1", 3, __DIR__.'/log.txt');
num3_wait10.php
<?php
    sleep(10);
    error_log("\n".date('Y-m-d H:i:s')."  NUM3_WAIT10", 3, __DIR__.'/log.txt');
num4_wait0.php
<?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

図にするとこんな感じです。
image.png

本来であれば全ての処理をシングルプロセスで処理すると完了に5+1+10+0=16秒掛かるところを
マルチプロセスにしたおかげで、全ての処理の完了に10秒で済んでしまいました。

しかしexec関数はlinuxのコマンドを叩く処理で、マルチプロセスのためのものではありません。
linuxのコマンドを叩くという性質上、うっかりユーザーの入力をそのままexec関数の引数に当てるなんてことは絶対にしないようにしましょう。

exec関数でのマルチプロセスの欠点

一見簡単にマルチプロセスで処理できるexec関数ですが、実は弱点があります。
処理を分割するためにプロセスを作るという性質上、処理が立て込んだ時には処理が横に広がりすぎて、サーバーに影響を及ぼすからです。
最悪サーバーが落ちると思います。
image.png

Laravelでの非同期処理

素のPHPでマルチプロセスをしてみましたが、実際の業務やサービスではフレームワークを使うことでしょう。
株式会社ナインステクノロジーズはLaravelが得意なので、Laravelでも非同期処理をやってみましょう。

Laravelではexec関数でのマルチプロセスとは少し違って「ジョブとキュー」で並列処理を実現します。

ジョブとキュー

厳密に言えば「ジョブとキュー」もマルチプロセス処理の一種で
「ジョブ」と言われる処理の塊を、「キュー」と呼ばれるプロセスが変わって処理するイメージです。

image.png
最初ジョブは全く無く、キューもジョブが無いので待機状態になっています。

image.png
なんらかのアクションでジョブが記録されると、キューはそれに気づきます。

image.png
キューは記録されているジョブを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
/app/Jobs/Num1Wait5.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');
    }
}
/app/Jobs/Num2Wait1.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 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');
    }
}
/app/Jobs/Num3Wait10.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 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');
    }
}
/app/Jobs/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 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
/app/Http/Controllers/TestController.php
<?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";
    }
}

そして.envQUEUE_CONNECTIONを編集します。

.env
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秒掛かったのです。

image.png
キューが1個なので以下の流れで処理をします。

ジョブ1を処理して、
ジョブ1が終わったらジョブ2を処理して、
ジョブ2が終わったらジョブ3を処理して、
ジョブ3が終わったらジョブ4を処理して、
ジョブ4が終わる。

キューは1個なので、処理できるジョブも当然1個です。だから並列的に処理が出来ないんですね。
なので、並列的に処理したい場合はキューの数を増やすと良いでしょう。

image.png
キューを増やすことで同時に処理できるジョブの数が増え、効率的に処理をこなすことが出来ます。

キューの数だけジョブを捌けるのですが、ここにも落とし穴があって
例えばジョブの増加速度よりもキューがジョブを捌く速度が遅くなってしまうと
いつまで立っても処理が開始されないジョブが出てきてしまう可能性があります。
image.png

まさに炎上状態。
一応Laravelではジョブの優先度やキューへの振り分けを操作出来ますが、万が一こういう自体が起こらない訳ではありません。

まとめ

PHPはシングルスレッドで並列処理は難しいですが
exec関数やLaravelのqueueで非同期的に処理を実行できます。

活用することでユーザーへのレスポンスが早くなることでしょう。
本当はこの処理と合わせてプログレスバーを実装する予定でしたが、以外と長くなったので今度にします。

もし間違っているところがあればマサカリお願いします。(言葉尻を捉えてイジメるのはやめてくださぃ。

明日は奇しくも同じ福岡の企業に所属するせいけしろーさんです。

ココまで読んでくださってありがとうございました。
LGTMとTwitterをフォローしてくれると嬉しいです!よろしくおねがいします!!!
@9th_tech_Ryo

あと別のアドベントカレンダーになりますが、弊社長も記事を明日書くので、ぜひ見てみてください!!!!!
https://qiita.com/akira_9th


  1. ptreadsというモジュールを使えば可能らしい 

8
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1