PHP
JavaScript
ReactPHP

PHPでもジェネレータを使って、非同期処理を同期処理の文法で書ける

More than 3 years have passed since last update.

こんにちは皆さん。

PHPは基本的に同期処理の言語構造を指定ます。

非同期処理の構造を入れるには、Reactのような別機構を導入する必要があります。

で、非同期処理を入れたらやりたくなるのが、非同期処理を同期処理っぽく書くことです。

以前に、JavaScriptのジェネレータを使うことで、非同期処理を同期処理っぽく書くことができると紹介しました。

で、PHPはどうかというと、既にジェネレータがPHP5.5で追加済みです。

そうです、PHPでも非同期処理を同期処理の文法で書くことができます。

...まあ、PHPで非同期処理を書くこと自体が、かなり冒険的なんですがね..。


ジェネレータ

さて、PHPのジェネレータはどういうものでしょうか。


定義

PHPのジェネレータの定義は、ある意味JSよりも遥かに簡単です。

yieldキーワードがあるかどうかだけです。

// not generator

function test($n)
{
return $n;
}

// generator
function test2($n)
{
yield $n;
}

echo test(10);// 10
echo get_class(test2(10));// Generator

こんな感じです。

JSのジェネレータのように、*マークもいりません。

とりあえず、普通に関数を実行し、はじめのyieldキーワードに到達すると、その時点で処理を中断し、Generatorオブジェクトを返してきます。


foreach 中のジェネレータ

ジェネレータの目的の一つは、イテレータとしての動作なので、foreachの中でも使えます。

function xloop($n)

{
$i = 0;
while($i < $n + 1) {
yield $i++;
}
}

foreach (xloop(10) as $val) {
echo $val;//1, 2, ..., 10
}

PHPが掲げる利点は、この際のメモリ使用量です。

xloop(10)range(1, 10)に書き換えても、このループは成立します。range関数は、引数に従って、配列を作るものなので、foreachループ上には[1, 2, ..., 10]が展開されていることになります。

これが1000000だと、配列の要素数も1000000になり、各々の値を格納するためのメモリの使用量も大きなものになります。

一方で、ジェネレータは一回ずつ値を生成して返しているので、メモリはその生成した値を入れなおすだけでよく、使用量を激減できるというものです。

今回は使いませんが、ジェネレータの各返り値にはキーが設定できます。

function csquare($n)

{
for($i = 1; $i < $n; $i++) {
yield $i => $i * $i;
}
}

foreach (csquare(10) as $key => $val) {
echo $key . ': ' . $val . PHP_EOL;// 1: 1, ...
}

こんなことができます。


Generator オブジェクトのメソッド

Generatorを細かく制御するには、そのメソッドを知る必要があります。

マニュアルページに全て載っていますが、今回は必要な物だけピックアップします。


current

現在のyieldの位置の値を返却します。

function test()

{
yield 1;
yield 2;
yield 3;
}

$gen = test();
echo $gen->current();// 1


next

ジェネレータの参照位置を次のyieldに移動します。

function test()

{
yield 1;
yield 2;
yield 3;
}

$gen = test();
echo $gen->current();// 1
$gen->next();
echo $gen->current();// 2

注意しなければならないのは、このnext引数をとらないし、値も返却しないということです。

この辺は、次のsendと混乱する元になります。


send

yieldに値を送り、次のyieldの値を返します。

function test()

{
$x = (yield 2);
$y = (yield $x * $x);
$z = (yield $y * $y);
}

$gen = test();
echo $gen->current();// 2
echo $gen->send($gen->current()); // 4
echo $gen->send($gen->current()); // 16

こんな感じです。

yield文をカッコでくくっているのは、PHPの仕様です。

PHP 7以降は、このカッコ入らなくなっているはずです。

とりあえず、ジェネレータに外部から値を注入できる機構を確認しました。


非同期処理の同期処理化

これで準備が整いました。

まず、JavaScriptでの非同期処理の同期処理化の方法をPHPでどのように実現するかを考えましょう。


ジェネレータを使った流れ

JSの方法を模倣すると大体つぎのような流れになります。


  1. ジェネレータが非同期処理の部分で処理を停止する

  2. 非同期処理は処理が完了すると、コールバックでジェネレータに値を送る

  3. 再びジェネレータが起動し、1に戻る

非同期処理はPromiseの文脈で処理させることにします。


実装

一応、リポジトリにあげています。

https://github.com/niisan-tokyo/as2sm/blob/master/src/As2sm.php

まず、枠を作ります。

namespace NiisanTokyo\As2sm

class As2sm
{

...
// 内容
...

}

こいつを使って、

function gen()

{
//非同期処理を同期処理みたいに書いたもの
}

As2sm::wrap('gen');// 処理を実行

こんな感じで書きたいと思います。

まず、wrap関数を書きましょう。

    public static function wrap($function)

{
$generator = $function();

if ($generator instanceof \Generator) {
$value = $generator->current();
return self::execute($generator, $value);
}

throw new NotGeneratorException('You give not Generator');

}

若干面倒くさい書き方ですが、内容を解説します。

wrap関数はジェネレータ関数をいい感じに実行してくれる関数です。

初めに引数で与えられた関数を実行しジェネレータを作成します。

与えられた関数がジェネレータであれば、currentを使ってはじめの値を取り出し、次の実行関数に与えます。

ジェネレータでなければ例外を投げます。

    private static function execute($generator, $value)

{
if ($value instanceof Deferred) {
$promise = $value->promise();
} else {
$promise = $value;
}

if ($promise instanceof PromiseInterface) {
return $promise->then(function($res) use ($generator){
self::execute($generator, $generator->send($res));
});
}
}

ここは一種の再帰関数となっています。

返却された値がPromiseオブジェクトであれば、その処理結果をジェネレータに送りつけ、再度処理を行います。


動かしてみる

作ったAs2smを使って、まずは次のような実装を作れます。

As2sm::wrap(function(){

$defer = new Deferred();
$defer->resolve(5);
$value = (yield $defer);

$defer2 = new Deferred();
$defer2->resolve($value * $value);
$val2 = (yield $defer2);
echo $val2;// 25
});

わざわざジェネレータを使うまでもないですが、とりあえずジェネレータとPromiseを使って処理と値の行き来が起こっていることを確認できます。

更に、タイムアウト処理と組み合わせてみましょう。

$ composer require --dev react/promise-timer

まず、タイマーを導入してから、次のようにコードを書きます。

require '../vendor/autoload.php';

use NiisanTokyo\As2sm\As2sm;
use React\Promise\Timer;

$loop = React\EventLoop\Factory::create();

//非同期のタイマー関数
function asyncTimer($time, $number, $loop)
{
return Timer\resolve($time, $loop)->then(function() use ($number) {
return $number * $number;
});

}

//処理をラップして、直列化
As2sm::wrap(function() use ($loop) {
$number = 5;
$value = (yield asyncTimer(0.5, $number, $loop));
echo $value.PHP_EOL;// 25 = 5 * 5

$val2 = (yield asyncTimer(0.5, $value, $loop));
echo $val2.PHP_EOL;// 625 = 25 * 25
});

$loop->run();

非同期のタイマー関数は、受け取った秒数だけ待った後、受け取った値の2乗を返すという、簡単なものです。

正確には2乗の値をresolve時のコールバックの引数にするというものですが、まあ、あまり気にしなくていいと思います。

実際、ラップした処理では、見た目上、非同期で生成した値がそのまま統合=の左辺に代入されています。

そして、実処理では0.5秒ずつで2回非同期のタイマー関数を起動させています。

なにはともあれ、このようにしてPHPでも非同期処理を同期処理の文法で書くことができました。


まとめ

PHPでもジェネレータがあるので、できるんじゃないかな、とは思っていましたが、案外簡単に実装できました。

非同期処理を同期処理みたいに書くというのは、私のように順次処理に慣れきった人間にとっては、重宝するテクニックだったりします。

また、以前にJSでやったことも有りましたので、別言語でどのように実装できるかを調べるのも楽しかったです。


参考資料

ジェネレータとは

PHPはReactで非同期処理対応のWEBサーバを構築する

Koaへの道: JavaScriptのジェネレータを使って非同期処理をコールバックを(あまり)使わずに実現する - 理論編

Koaへの道: JavaScriptのジェネレータを使って非同期処理をコールバックを(あまり)使わずに実現する - 実践編