PHP で SO_REUSEPORT を体感する

Apache 2.4.17 の [ChangeLog](http://www.apache.org/dist/httpd/CHANGES_2.4.17) に次のようなものがあるのを見て、

> MPMs: Support SO_REUSEPORT to create multiple duplicated listener records for scalability.

`SO_REUSEPORT` って噂には聞いたことあるけど使ったことはないなーと思ったので、使ってみました、PHP で、PHPer だから。

## 準備

PHP を簡単にマルチプロセスにするために [travail/parallel-prefork](https://packagist.org/packages/travail/parallel-prefork) を入れます。

```
composer require travail/parallel-prefork
```

## 親プロセスでソケットを開く版

プリフォークなサーバってこんな感じでしょうか？

```php
<?php
require_once __DIR__ . '/vendor/autoload.php';

use Parallel\Prefork;

function main()
{
    printf("main process %d\n", posix_getpid());

    $pp = new Prefork(array(
        'max_workers'  => 2,
        'trap_signals' => array(
            SIGHUP  => SIGTERM,
            SIGTERM => SIGTERM,
        ),
    ));

    init();

    while ($pp->signalReceived() !== SIGTERM) {
        if ($pp->start()) {
            continue;
        }
        work();
        $pp->finish();
    }
    $pp->waitAllChildren();
}

$listenSocket = null;
$listenPort = 12345;

function init()
{
    global $listenSocket;
    global $listenPort;

    $listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP) or die;
    socket_set_option($listenSocket, SOL_SOCKET, SO_REUSEADDR, 1) or die;
    socket_bind($listenSocket, "0.0.0.0", $listenPort) or die;
    socket_listen($listenSocket) or die;

    printf("server listen %d\n", $listenPort);
}

function work()
{
    global $listenSocket;

    printf("start worker process %d\n", posix_getpid());

    for (;;) {
        $socket = socket_accept($listenSocket);

        socket_getpeername($socket, $addr, $port);
        printf("connected %s:%s\n", $addr, $port);

        for (;;) {
            $str = socket_read($socket, 1024);
            if (strlen($str) === 0) {
                break;
            }
            while (strlen($str) > 0) {
                $len = socket_write($socket, $str);
                $str = substr($str, $len);
            }
        }

        printf("disconnect %s:%s\n", $addr, $port);
        $socket = null;
    }
}

main();
```

- 親プロセスでリッスン用のソケットを開く
- 子プロセスで `accept` する
    - 排他制御する必要があるかどうかは知らないし面倒なのでやっていない
- `accept` で得られたピアのソケットを使って送受信する
- 接続が切れたらまた `accept` する

## 子プロセスでソケットを開く版

`SO_REUSEPORT` を使ったプリフォークです。

```php
<?php
require_once __DIR__ . '/vendor/autoload.php';

use Parallel\Prefork;

function main()
{
    printf("main process %d\n", posix_getpid());

    $pp = new Prefork(array(
        'max_workers'  => 2,
        'trap_signals' => array(
            SIGHUP  => SIGTERM,
            SIGTERM => SIGTERM,
        ),
    ));

    init();

    while ($pp->signalReceived() !== SIGTERM) {
        if ($pp->start()) {
            continue;
        }
        work();
        $pp->finish();
    }
    $pp->waitAllChildren();
}

$listenSocket = null;
$listenPort = 12345;

function init()
{
}

function work()
{
    global $listenSocket;
    global $listenPort;

    printf("start worker process %d\n", posix_getpid());

    $listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP) or die;
    socket_set_option($listenSocket, SOL_SOCKET, SO_REUSEADDR, 1) or die;
    socket_set_option($listenSocket, SOL_SOCKET, SO_REUSEPORT, 1) or die;
    socket_bind($listenSocket, "0.0.0.0", $listenPort) or die;
    socket_listen($listenSocket) or die;

    printf("server listen %d\n", $listenPort);

    for (;;) {
        $socket = socket_accept($listenSocket);

        socket_getpeername($socket, $addr, $port);
        printf("connected %s:%s\n", $addr, $port);

        for (;;) {
            $str = socket_read($socket, 1024);
            if (strlen($str) === 0) {
                break;
            }
            while (strlen($str) > 0) {
                $len = socket_write($socket, $str);
                $str = substr($str, $len);
            }
        }

        printf("disconnect %s:%s\n", $addr, $port);
        $socket = null;
    }
}

main();
```

違いは次のとおりです、`init` 関数（親プロセス）の中身を `work` 関数（小プロセス）に移動しています。

- ソケットは子プロセスで開く
- ソケットに `SO_REUSEPORT` を指定

`SO_REUSEPORT` を忘れると `bind` でエラーになります（子プロセスが複数のソケットに同じポートで `bind` しようとするため）。

```
unable to bind address [98]: Address already
```

また、こちらの版ではサーバを複数立ち上げることができます。
ので、コードを書き換えて新しいサーバを起動→古いサーバを安全に停止、とかすればホットデプロイもできるようです。