PHPで学ぶ

ソケットプログラミング入門

 

PHPカンファレンス2016

2016/11/3

宇都宮 諒(@ryo511)

自己紹介

  • 宇都宮 諒(うつのみや りょう)
  • ソフトウェアエンジニア(アシアル株式会社 所属)
  • @ryo511 (Twitter, Facebook, Qiita等)
  • https://github.com/ryo-utsunomiya
  • 執筆
    • JavaScript入門 ~Webで見たあの機能を自分で作る~(日経BP、2016/10/31)

    • Webアプリの作り方(日経ソフトウエア2016年11月号)

    • PHP7の新機能(日経ソフトウエア2016年8月号)

アジェンダ

  1. ソケットAPIの基本
  2. ストリームによるソケットの抽象化
  3. パフォーマンスの追求

第1部

ソケットAPIの基本

Q. ソケットを使うと何ができるのか?

 

A. HTTPよりも下のレイヤーのプロトコルを扱える

OSI基本参照モデル(1)

L7: アプリケーション層

L6: プレゼンテーション層

L5: セッション層

L4: トランスポート層

L3: ネットワーク層

L2: データリンク層

L1: 物理層

OSI基本参照モデル(2)

  • HTTPはL7(アプリケーション層)
  • TCPやUDPはL4(トランスポート層)
  • IPはL3(ネットワーク層)

ソケットを使うと、

TCPやUDPの上で

動作するアプリケーションを

作成できる

ソケットを使うとできること

  • Webサーバの実装
    • Apache、Nginx、H2O等
    • Puma、Unicorn、Webrick等(Ruby製アプリケーションサーバ)
    • PHPなら reactphp/http
  • 独自のプロトコル/フォーマットによる通信
    • HTTPよりもコンパクトなフォーマットのメッセージをTCPを使って送りたい、という場面があったり

Q. なぜ、PHPでソケットプログラミングをするのか?

 

A. そこにPHPがあるから

  • ソケットAPIはCだけのものではない
  • ソケットAPIを扱えるスクリプト言語も多い
  • WebアプリをPHPで書くなら、ソケットを使うプログラムもPHPで書けばよい

ソケットAPI

  • 1983年のBSD 4.2で初めてリリースされたAPI
    • TCP/IPと同時に生まれた
  • ソケットには「ネットワークソケット」と「UNIXソケット」の2種類がある
  • 本講ではネットワークソケットのみを扱う
  • ネットワークソケットは、様々なネットワークプロトコルを抽象化している
  • 主要なOSはネットワークソケットAPIを備えている
    • WindowsにもWinSockがある

ソケットの役割

  • TCP、IP、Ethernetといったネットワークプロトコルには、固有の決まりがある
  • ↑のようなプロトコルの決まりを知らなくても、ソケットAPIを使えばネットワーク越しに通信できる
  • インターネットにつながるアプリケーションは、ほぼ確実にソケットを使っている
    • Webブラウザ、メーラー、Webサーバ、etc...

必要な前提知識(1) IPアドレス

  • ネットワーク上でコンピュータを一意に特定する識別子

    • IPv4は32bit整数

      • 127.0.0.1 のように8bit毎に「.」で区切って十進法で表記するのが一般的

    • IPv6は128bit整数

      • 0:0:0:0:0:0:0:1(省略形は ::1) のように16bit毎に「:」で区切って十六進法で表記する

  • ちなみに、 127.0.0.1 や ::1 は、自分自身を指すアドレスなので「ループバックアドレス」と呼ばれる

必要な前提知識(2):ポート

  • コンピュータ上で、ネットワークアプリケーションを特定するための番号

  • 0-65535までの番号がある

    • 0-1023はwell-knownポート

      • 待ち受けポートとして使うべきではない

    • 49152-65535はephemeral(短命)ポート

      • OSが自動で割り振る番号なので、使うべきではない

  • 自前のネットワークアプリケーションの待ち受けポートには1024-49151を使えばよさそう…?

注意!

  • 49151-65535がエフェメラルポートなのは、

    RFC6335に沿って実装されたOSのみ

  • 最近のWindowsやBSD系のUNIXはRFC6335に沿っている

  • Linuxは32768-61000

  • 結論:待受ポートには1024-32767を使うのが安全

    • IANAに登録されてる既存アプリがないか、あったとして自分のアプリに影響しそうかだけ確認しておく

必要な前提知識(3):まとめ

  • TCP/IPでは、IPアドレスとポート番号の組み合わせによって、通信相手のコンピュータとアプリケーションを特定する

  • 「127.0.0.1:8000」と書くと、「127.0.0.1というIPアドレスのコンピュータの8000番ポート」という意味になる

  • ソケットはIPアドレスとポート番号に紐づく

PHPにおけるソケットAPI

  • 2種類のAPIが用意されている

  • ソケット拡張(socket_XXX()という名前の関数群)

    • デフォルトでは使えない

    • C言語用のAPIに近い、低レベルな制御ができる

  • ストリーム(主にstream_XXX()という名前の関数群)

    • PHPに組み込み

    • ソケットAPIを抽象化した機能を提供している

  • 学習用にはソケット拡張がオススメ

  • ストリームの方が実装は簡単

サーバのライフサイクル

サーバソケットは以下のような流れで動作する

  1. create (ソケットの作成)

  2. bind (ソケットを特定のIPアドレスとポートに紐付け)

  3. listen (接続の待受を開始)

  4. accept (接続を受信)

  5. close (接続を切断)

1. create

<?php

// TCPソケット(IPv4)
$tcpSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

// UDPソケット(IPv4)
$udpSocket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
  • socket_create()はリソースオブジェクトを返す

  • socket(2) システムコールに相当

    • ※システムコール:OSの機能の呼び出し

  • このリソースオブジェクトの操作にはソケット拡張の提供する関数を使用する必要がある

2. bind

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000); //0.0.0.0:8000に紐付け
  • socket_bind() によってソケットがIPアドレスとポートに紐付けられる

  • bind(2) システムコールに相当

  • IPアドレスは自分のコンピュータに使用可能なものである必要がある

    • 0.0.0.0 はループバックも含め、全てのインタフェースに対するコネクションを受け付けるアドレス

3. listen

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5); // 待ち受けを開始し、最大5件の接続待ちをキューに溜める
  • socket_listen()によって接続待ちを開始する

  • listen(2) システムコールに相当

  • 接続待ちクライアントの最大数を設定できる

    • 設定可能な最大値はSOMAXCONN定数で参照可能

4. accept

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5);
$remote = socket_accept($socket);
  • socket_accept()によって接続を受け取る

  • accept(2) システムコールに相当

  • 接続が来るまで、プロセスをブロックする

  • ↑のコードは、クライアントからの接続が来ないと、socket_accept()を呼び出したところで固まったまま終了しなくなる

5. close

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5);
$remote = socket_accept($socket);
// 何か処理をする
socket_close($remote);
socket_close($socket);
  • socket_close()によって接続を受け取る

  • close(2) システムコールに相当

  • ファイル操作と同様、使い終わったらcloseするのがお作法

クライアントのライフサイクル

クライアントソケットは以下のような流れで動作する

  1. create (ソケットの作成)

  2. bind(ソケットを特定のIPアドレスとポートに紐付け)

  3. connect (リモートソケットに接続)

  4. close (接続を切断)

createとbind

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// クライアントの場合、bindは不要
  • ソケットの作成方法はサーバと同じ

  • クライアントは、基本的にbindを行わない

  • bindしなければ、OSによってエフェメラルポートが割り振られる

connectとclose

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_connect($socket, '0.0.0.0', 8000);
// 何か処理をする
socket_close($socket);
  • socket_connect()で別のソケットに接続できる

  • connect(2)システムコールに相当

データの送受信

  • 基本は以下

    • socket_read() で受信

    • socket_write() で送信

  • 他にも、微妙に異なるバリエーションの関数がいくつかある

read

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5);
$remote = socket_accept($socket);
$data = socket_read($remote, 1024); // 1024バイトまで読み込み
echo $data . PHP_EOL;
socket_close($remote);
socket_close($socket);
  • socket_read():データを受信する

  • read(2)システムコールに相当

  • 一度に読み込めるのはバッファの最大長まで

  • ↑の例では、1025バイト以上データが送られると、取りこぼしが発生する

全部readする

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5);
$remote = socket_accept($socket);

while ($data = socket_read($remote, 1024)) {
    echo $data . PHP_EOL;
}

socket_close($remote);
socket_close($socket);
  • socket_read()は読み込むデータが無くなると空文字列を返す

  • 空文字列が返ってくるまで読みこめばよい

データが送られてこない時は?

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5);
$remote = socket_accept($socket);

socket_set_nonblock($remote); // リモートソケットをノンブロッキングモードにする
while ($data = socket_read($remote, 1024)) {
    echo $data . PHP_EOL;
}

socket_close($remote);
socket_close($socket);
  • socket_read()はデータが全く送られてこないと止まってしまう

  • データが送られてこない場合はすぐに結果を返すようにするには、ノンブロッキングモードを使用する

write

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_connect($socket, '0.0.0.0', 8000);
socket_write($socket, 'hi');
socket_close($socket);
  • socket_write()でデータを書き込める

  • write(2)システムコールに相当

echoサーバの実装

<?php

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '0.0.0.0', 8000);
socket_listen($socket, 5);

while ($remote = socket_accept($socket)) {
    $data = '';
    while ($buffer = socket_read($remote, 1024)) {
        $data .= $buffer;
    }
    socket_write($remote, $data);
    socket_close($remote);
}
  • echoサーバとは、クライアントから受け取ったデータをオウム返しにクライアントに返すサーバのこと

  • acceptをループにすることで実現

第2部

ストリームによる

ソケットの抽象化

ストリームAPIについて

  • 主に stream_xxx() といった名前の関数群

  • PHP 5以降ではコンパイルオプション関係なく有効

  • ファイルの入出力に加えて、ソケットも同じAPIで扱うことができる

stream_socket_server()

<?php

$socket = stream_socket_server('tcp://127.0.0.1:8000');

while ($remote = stream_socket_accept($socket)) {
    $data = stream_get_contents($remote);
    fwrite($remote, $data);
    fclose($remote);
}
fclose($socket);
  • echoサーバをストリームを使って実装すると↑のようになる

  • stream_socket_server()はcreate, bind, listenを行う

stream_socket_client()

<?php

$socket = stream_socket_client('tcp:127.0.0.1:80');
fwrite($socket, 'hi');
fclose($socket);
  • stream_socket_client()はcreateとconnectを行う

第3部

パフォーマンスの追求

ソケットプログラムの高速化

  • 1プロセス1コネクションモデルでは、サーバの高速化には限界がある

    • 遅いクライアントがあるとサーバのスループットが出なくなる

  • サーバのスループットを改善するには、コネクションを並列処理する必要がある

  • ※並列処理を入れるとプログラムは複雑化するので、そもそもどの程度のパフォーマンスが必要か検討すること

代表的な並列化手法と実現方法

  • 非同期IO(select(2)等の非同期APIを使用)

    • socket_select()またはstream_select()

    • Libevent拡張

  • 非同期IO(Reactorパターン)

    • reactphp/socket等

  • マルチプロセス

    • pcntl拡張

  • マルチスレッド

    • pthreads拡張

どれを使うべきか?

  • まずは非同期APIを使うべき

  • 非同期APIを使っても1プロセスでは足りない場合…

    • (1) Reactorパターンを使用

      • Pros: PHP組み込みの関数だけで実装可能

      • Cons: 耐障害性が低い

    • (2) マルチプロセスを使用

      • Pros: 耐障害性が高い

      • Cons: Windowsだと遅い

    • (3) マルチスレッドを使うなら別言語のほうが…

      • リソースの取り扱いが難しい

select(2)

  • select(2)は、複数のリソースの状態をチェックするシステムコール

  • ソケットの配列をselect(2)に渡すと、読み込みや書き込みが可能なソケットだけが返却される

<?php

$read = [$socket]; // $socketはサーバソケットのリソース
$write = null;
$except = null;

$changed = socket_select($read, $write, $except);

if ($changed > 0) {
    foreach ($read as $socket) {
        // ソケットからデータを読み取り
    }
}

select(2)のパフォーマンス

  • select(2)は、監視するソケットの数が多いと遅くなる

  • epoll(2)やkqueue(2)といった、より高速なシステムコールもあるが、OSによって使えるものが異なる

    • Linuxはepoll(2)、BSDはkqueue(2)

  • epoll(2)、kqueue(2)等のシステムコールを抽象化したlibeventというライブラリがある

  • PHPでlibeventを使うには、Libevent拡張を使用する

Libeventは難しい

// 「PHP libevent で多重化エコーサーバー」
// http://qiita.com/d_nishiyama85/items/7e9a72a69f90487a892d より引用

<?php
// サーバーソケットの作成
$socket = stream_socket_server('tcp://0.0.0.0:8000', $errno, $errstr);
if (!$socket) {
    die("$errstr ($errno)\n");
}
stream_set_blocking($socket, 0);

$base = event_base_new();
$event = event_new();
event_set($event, $socket, EV_READ | EV_PERSIST, 'ev_accept', $base);
event_base_set($event, $base);
event_add($event);
event_base_loop($base);

// 接続中のクライアントを管理する変数
$GLOBALS['connections'] = [];
// クライアントの接続をバッファリングするオブジェクトを管理する変数
$GLOBALS['buffers'] = [];

// さらに50行ほど続く 

reactphp/event-loop

<?php

$server = stream_socket_server('tcp://127.0.0.1:8080');
stream_set_blocking($server, 0);
$loop = React\EventLoop\Factory::create();

$loop->addReadStream($server, function ($server) use ($loop) {
    $conn = stream_socket_accept($server);
    $data = stream_get_contents();
    $loop->addWriteStream($conn, function ($conn) use (&$data, $loop) {
        $written = fwrite($conn, $data);
        if ($written === strlen($data)) {
            fclose($conn);
            $loop->removeStream($conn);
        } else {
            $data = substr($data, $written);
        }
    });
});

$loop->run();

reactphp/event-loopとは

  • 非同期IOを抽象化したライブラリ

  • Libevent拡張がインストールされていればLibeventを使用する

  • Libeventが使えない場合はstream_select()を使用する

非同期IO(Reactor)

  • イベント駆動でコールバック関数を実行するモデル

  • 1プロセスで処理可能な接続数を増やす

  • 代表的な実装例はNode.js

  • PHPなら、reactphp/socketを使うと簡単に実装できる

<?php

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

$socket = new React\Socket\Server($loop);
$socket->on('connection', function ($conn) {
    $conn->on('data', function ($data) use ($conn) {
        $conn->write($data);
        $conn->close();
    });
});
$socket->listen(1337);

$loop->run();

マルチプロセス

まとめ

  • ソケットを使うと、普段PHPで触っているよりも下の世界に触れることができる

  • (ソケットのような)低レイヤーの基礎的な技術は、枯れていて変わりにくいので、投資する価値がある

参考文献

  • Working With TCP Sockets

    • ソケットプログラミングについて丁寧に解説

    • サンプルコードはRuby

    • オススメ

  • PHP Socket Programming Handbook

    • サンプルコードがPHP!(GitHubで公開中)

    • Working With〜 に比べると解説が駆け足

  • TCP/IPソケットプログラミング C言語編

    • 2003年の本で、原書は改訂版が出ている

ご清聴ありがとう

ございました

PHPで学ぶソケットプログラミング入門

By Ryo Utsunomiya

PHPで学ぶソケットプログラミング入門

PHPカンファレンス2016

  • 34,384