Skip to content

Instantly share code, notes, and snippets.

@ngyuki
Last active July 15, 2022 15:12
Show Gist options
  • Save ngyuki/abc090ec191b560d93e6 to your computer and use it in GitHub Desktop.
Save ngyuki/abc090ec191b560d93e6 to your computer and use it in GitHub Desktop.
tcp chat by coroutine

[PHP]PHP のコルーチンを使ってみる

PHP 5.5 でコルーチンが実装されましたが、全く使っていなかったので使ってみました。

コルーチンとは

コルーチンとは何なのかというと・・・Wikipedia によると次の通りです。

コルーチン - Wikipedia

コルーチンはいったん処理を中断した後、続きから処理を再開できる。 接頭辞 co は協調を意味するが、複数のコルーチンが中断・継続により協調動作を行うことによる。

これだけではよくわからないので動くコードを書いて理解します。

サンプル

main() 関数と co() 関数が定義されています。co() がコルーチンです。

<?php
function co()
{
    echo "co 1st yield\n";
    yield;
     
    echo "co 2nd yield\n";
    yield;
     
    echo "co end\n";
}
 
function main()
{
    echo "main init\n";
    $co = co();
     
    echo get_class($co) . "\n";
    $co->rewind();
     
    echo "main 1st send\n";
    $co->send(null);
     
    echo "main 2nd send\n";
    $co->send(null);
     
    echo "main end\n";
}
 
main();

これを実行すると次のように出力されます。

main init
Generator
co 1st yield
main 1st send
co 2nd yield
main 2nd send
co end
main end

出力内容からコードの実行順を考えます。

まず、main()co() を呼び出していますが、この時点では co() の中身は実行されていません。

main()

echo "main init\n";
$co = co();

co() は値を返していませんが、関数内部に yield があるので Generator のインスタンスが返ります(コルーチンとジェネレータは同じものです)。

main()

echo get_class($co) . "\n"; // -> Generator

次に $co->rewind() を実行します。

main()

$co->rewind();

すると co() の最初の yield までが実行されます。

co()

echo "co 1st yield\n";
yield;

そこで一旦 co() の処理は中断され、main() の続きに戻ります。

main()

echo "main 1st send\n";
$co->send(null);

そして $co->send(null) が呼ばれたところで、先ほど中断した co() の続きに戻ります。

.

.

コルーチンの処理が途中で中断して呼び出し元に返り、そして中断したところから再開することで main()co() が交互に実行されています。このような動作がコルーチンの特徴です。

  1. 関数の処理を中断して呼び出し元に処理を戻す
    • コルーチンで yield を呼びます
  2. 中断した場所から関数の処理を再開する
    • 呼び出し元で $co->send() を呼びます

コルーチンの rewind()

上のサンプルではコルーチンの最初の呼び出し後に $co->rewind() をしています。

もし $co->rewind() を呼ばないとどうなるか・・・サンプルの当該箇所をコメントアウトしてみます。

<?php
function co()
{
    echo "co 1st yield\n";
    yield;
     
    echo "co 2nd yield\n";
    yield;
     
    echo "co end\n";
}
 
function main()
{
    echo "main init\n";
    $co = co();
     
    echo get_class($co) . "\n";
    //$co->rewind();
     
    echo "main 1st send\n";
    $co->send(null);
     
    echo "main 2nd send\n";
    $co->send(null);
     
    echo "main end\n";
}
 
main();

少し出力内容が変わりました。

main init
Generator
main 1st send
co 1st yield
co 2nd yield
main 2nd send
co end
main end

最初の $co->send(null) の呼び出しでコルーチンの先頭から2番目の yield までが実行されています。

最初に $co->rewind() を呼んでおけば、その時点で先頭から1番目の yield までが実行され、次の $co->send(null) では1番目の yield から2番目の yield までが実行されます。

なお、$co->rewind() ではなく $co->current()$co->key() でも同じ効果が得られます。

コルーチンと値の受け渡し

コルーチンと呼び出し元は双方向に値を受け渡しすることができます。

コルーチンから呼び出し元に値を渡すときは、yield に引数を付けて $co->send() の戻り値や $co->current() で受け取ります。

呼び出し元からコルーチンに値を渡すときは、$co->send() の引数で指定して yield の戻り値で受け取ります。

<?php
function co()
{
    $val = (yield "co 1st");
    echo "co: $val\n";
     
    $val = (yield "co 2nd");
    echo "co: $val\n";
}
 
function main()
{
    $co = co();
     
    $val = $co->current();
    echo "main: $val\n";
     
    $co->send("main 1st");
     
    $val = $co->current();
    echo "main: $val\n";
     
    $co->send("main 2st");
}
 
main();

次のように出力されます。

main: co 1st
co: main 1st
main: co 2nd
co: main 2st

yield の引数で指定した "co 1st" のような文字列を main() 関数の $co->current() で取り出しており、$co->send() の引数で指定した "main 1st" のような文字列を yield の戻り値で取り出しています。

最初の説明の通り、コルーチンから呼び出し元への値の受け渡しは $co->send() の戻り値でも出来ますが・・・

<?php
function co()
{
    $val = (yield "co 1st");
    echo "co: $val\n";
     
    $val = (yield "co 2nd");
    echo "co: $val\n";
}
 
function main()
{
    $co = co();
     
    $val = $co->send("main 1st");
    echo "main: $val\n";
     
    $val = $co->send("main 2st");
    echo "main: $val\n";
}
 
main();

これを実行すると次のように表示されます。

co: main 1st
main: co 2nd
co: main 2st
main: 

前述の コルーチンの rewind() で説明した通り、最初にいきなり $co->send() を呼ぶと2番目の yield までが実行されます、そのため $co->send() の戻り値は2番目の yield の引数になります。よって上の例だと1番目の yield の引数が受け取れていません。

なので、コルーチンから呼び出し元へ値を渡すときは $co->current() の方が良いだろうと思います。

なお、ジェネレータを使ったことがあれば知っているかもしれませんが、yield の引数には次のようにキーと値を指定することができます。

<?php
function co()
{
    yield "Key" => "Value";
}

function main()
{
    $co = co();
    $key = $co->key();
    $val = $co->current();
    echo "$key => $val\n";
}

main();

しかし、次のように配列で2値を渡してもたいして変わらないので、コルーチンとして使う分にはキーを使う必要は無いと思います。

<?php
function co()
{
    yield ["Key", "Value"];
}

function main()
{
    $co = co();
    list ($key, $val) = $co->current();
    echo "$key => $val\n";
}

main();

コルーチンに例外を渡す

$co->throw() の引数で例外オブジェクトを指定すると、コルーチンの yield から例外と飛ばすことができます。

<?php
function co()
{
    echo "co: first\n";
    try {
        yield;
        echo "!!!\n";
    } catch (RuntimeException $ex) {
        echo "co: {$ex->getMessage()}\n";
        throw $ex;
    }
}

function main()
{
    $co = co();
    $co->rewind();
    try {
        echo "main: throw\n";
        $co->throw(new RuntimeException("Error!"));
        echo "!!!\n";
    } catch (RuntimeException $ex) {
        echo "main: {$ex->getMessage()}\n";
    }
}

main();

これを実行すると次のように出力されます。

co: first
main: throw
co: Error!
main: Error!

異常を表す値を $co->send() で渡して条件分岐してもたいして変わらないかもしれませんが、例外ベースの方がコードは書きやすい(こともある)でしょう。

試しにコルーチンを使った簡単な TCP チャットを書いてみました。

cap.gif

チャットの実装部分のコードは下記のリンク先です。

yield でクライアントからの入力を受信して、$send() でクライアントへ送信します。とても手続き的なコードですが、シングルスレッドの非同期 I/O で複数の接続をさばいています。

普通、非同期 I/O で複数の接続をさばこうとするとめんどくさいことになりがちですが、コルーチンを使えばこのように簡単に書くことができます。

もちろん server.php の側でそれなりの実装は必要です。server.php は次の通りです。

見る人が見ればすぐわかるやっつけなコードですが(エラー時の例外処理がないしストリームをデータグラムのように扱っている)、それなりに動作します。

なお、同じようにやっつけで作ったコルーチンを使わない単純なエコーサーバはこちら。

server.php とほとんど同じです。接続を受け入れた時にジェネレータを返すコールバックを呼んでいるのと、クライアントからデータを受信したときや切断されたときにジェネレータの send() を呼んでいるぐらいしか違いはありません。

<?php
require_once __DIR__ . '/server.php';
$list = array();
server(1234, function ($myid, callable $send) use (&$list) {
$send("Your name ? ");
$myname = yield;
$num = 0;
for (;;) {
$send("Choice your color number ? ");
$num = yield;
if ($num >= 1 && $num <= 7) {
break;
}
$send("invalid color, color is 1, 2, 3, 4, 5, 6, 7\n");
}
$say = function ($message) use ($myname, $num) {
return "\x1B[1;3{$num}m[{$myname}] {$message}\x1B[m\n";
};
$sys = function ($message) {
return "\x1B[38;5;247m*** {$message} ***\x1B[m\n";
};
$list[$myid] = $send;
try {
foreach ($list as $send) {
$send($sys("hello $myname"));
}
for (;;) {
$data = yield;
if ($data === false) {
break;
}
if (strlen($data) === 0) {
continue;
}
foreach ($list as $id => $send) {
if ($myid !== $id) {
$send($say($data));
}
}
}
} finally {
unset($list[$myid]);
}
foreach ($list as $send) {
$send($sys("goodbye $myname"));
}
});
<?php
class Connection
{
/**
* @var resource
*/
public $socket;
/**
* @var string
*/
public $buffer = '';
}
function server($port)
{
$listen = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($listen, SOL_SOCKET, SO_REUSEADDR, 1);
socket_set_nonblock($listen);
socket_bind($listen, '0.0.0.0', $port);
socket_listen($listen, 50);
/* @var $connections Connection[] */
$connections = [];
for (;;) {
$r = [(int)$listen => $listen];
$w = [];
$e = [];
foreach ($connections as $id => $conn) {
$r[$id] = $conn->socket;
if (strlen($conn->buffer)) {
$w[$id] = $conn->socket;
}
}
socket_select($r, $w, $e, null);
foreach ($w as $socket) {
$conn = $connections[(int)$socket];
$len = socket_write($socket, $conn->buffer, strlen($conn->buffer));
$conn->buffer = substr($conn->buffer, $len);
}
foreach ($r as $socket) {
if ($socket === $listen) {
$socket = socket_accept($listen);
socket_set_nonblock($socket);
$id = (int)$socket;
$conn = $connections[$id] = new Connection();
$conn->socket = $socket;
} else {
$data = socket_read($socket, 4096);
$id = (int)$socket;
$conn = $connections[$id];
if (strlen($data) > 0) {
$conn->buffer .= $data;
} else {
socket_close($conn->socket);
unset($connections[$id]);
}
}
}
}
}
<?php
class Connection
{
/**
* @var resource
*/
public $socket;
/**
* @var string
*/
public $buffer = '';
/**
* @var \Generator
*/
public $generator;
}
function server($port, callable $callback)
{
$listen = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($listen, SOL_SOCKET, SO_REUSEADDR, 1);
socket_set_nonblock($listen);
socket_bind($listen, '0.0.0.0', $port);
socket_listen($listen, 50);
/* @var $connections Connection[] */
$connections = [];
for (;;) {
$r = [(int)$listen => $listen];
$w = [];
$e = [];
foreach ($connections as $id => $conn) {
$r[$id] = $conn->socket;
if (strlen($conn->buffer)) {
$w[$id] = $conn->socket;
}
}
socket_select($r, $w, $e, null);
foreach ($w as $socket) {
$conn = $connections[(int)$socket];
$len = socket_write($socket, $conn->buffer, strlen($conn->buffer));
$conn->buffer = substr($conn->buffer, $len);
}
foreach ($r as $socket) {
if ($socket === $listen) {
$socket = socket_accept($listen);
socket_set_nonblock($socket);
$id = (int)$socket;
$conn = $connections[$id] = new Connection();
$conn->socket = $socket;
$conn->generator = $callback($id, function ($data) use ($conn) {
$conn->buffer .= $data;
});
/** @noinspection PhpUndefinedMethodInspection */
$conn->generator->rewind();
} else {
$data = socket_read($socket, 4096);
$id = (int)$socket;
$conn = $connections[$id];
if (strlen($data) > 0) {
$conn->generator->send(trim($data));
} else {
socket_close($conn->socket);
unset($connections[$id]);
$conn->generator->send(false);
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment