[PHP]stream_socket_shutdown完全解説|ソケット接続を安全に閉じる方法とTCP半二重通信の実践

PHP

はじめに

ソケット通信を実装していると、「接続を閉じる」という一見シンプルな操作が、実は非常に重要な意味を持つことに気づきます。stream_socket_shutdown() は、PHPのソケットストリームに対して「読み取り」「書き込み」「両方」のいずれかの方向を選択して停止できる関数です。

単純にfclose()で閉じるのとは異なり、TCP通信における半二重(Half-Duplex)制御を実現できるため、プロトコルの正確な実装やリソースの適切な解放に欠かせません。

本記事では、関数の基本仕様から実践的なユースケースまでを丁寧に解説します。


関数の基本情報

項目内容
関数名stream_socket_shutdown()
利用可能バージョンPHP 5.2.1以降
所属ストリーム関数(Stream Functions)
戻り値bool(成功時true、失敗時false
拡張機能不要(コア関数)

シグネチャ

stream_socket_shutdown(resource $stream, int $mode): bool

パラメータ

パラメータ説明
$streamresourcestream_socket_client()stream_socket_server()で取得したストリームリソース
$modeintシャットダウンの方向(下表参照)

$mode 定数一覧

定数意味
STREAM_SHUT_RD0読み取りのみ停止
STREAM_SHUT_WR1書き込みのみ停止(FINパケット送信)
STREAM_SHUT_RDWR2読み取り・書き込みの両方を停止

ポイント: STREAM_SHUT_WRを使うと、相手側には「これ以上データを送らない」というTCPのFINシグナルが届きます。これがTCP半二重通信の要です。


fclose() との違い

fclose()              → ストリームリソース自体を破棄(OSのファイルディスクリプタを解放)
stream_socket_shutdown() → 通信の方向を制御(リソースは存在したまま)

推奨パターン: シャットダウン後にfclose()を呼ぶことで、適切な順序でTCPコネクションを終了できます。

stream_socket_shutdown($socket, STREAM_SHUT_WR); // FIN送信
// 相手からの残データを受信...
fclose($socket); // リソース解放

実践サンプル集(PHP 8.x対応)

サンプル1:基本的なTCPクライアントで書き込みをシャットダウン

最も基本的な使い方。リクエストを送信し終えたら書き込みを止めて、相手のレスポンスを最後まで受け取るパターンです。

<?php
declare(strict_types=1);

$socket = stream_socket_client(
    'tcp://example.com:80',
    $errno,
    $errstr,
    30
);

if ($socket === false) {
    throw new RuntimeException("接続失敗: {$errstr} ({$errno})");
}

// HTTPリクエスト送信
fwrite($socket, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n");

// 書き込みをシャットダウン(FIN送信)
stream_socket_shutdown($socket, STREAM_SHUT_WR);

// レスポンスを最後まで読み取る
$response = '';
while (!feof($socket)) {
    $response .= fread($socket, 8192);
}

echo $response;
fclose($socket);

解説: STREAM_SHUT_WRを呼ぶことで、サーバー側に「クライアントはデータ送信を終えた」と伝えられます。サーバーがEOFを待っているプロトコルでは、これを呼ばないとレスポンスが来ないことがあります。


サンプル2:クラスベースのTCPクライアント実装

<?php
declare(strict_types=1);

class TcpClient
{
    private mixed $socket = null;

    public function __construct(
        private readonly string $host,
        private readonly int $port,
        private readonly float $timeout = 10.0
    ) {}

    public function connect(): void
    {
        $this->socket = stream_socket_client(
            "tcp://{$this->host}:{$this->port}",
            $errno,
            $errstr,
            $this->timeout
        );

        if ($this->socket === false) {
            throw new RuntimeException(
                "TCP接続エラー [{$errno}]: {$errstr}"
            );
        }

        stream_set_timeout($this->socket, (int)$this->timeout);
    }

    public function send(string $data): void
    {
        if ($this->socket === null) {
            throw new LogicException('未接続です。connect()を先に呼んでください。');
        }
        fwrite($this->socket, $data);
    }

    public function shutdownWrite(): void
    {
        if ($this->socket !== null) {
            stream_socket_shutdown($this->socket, STREAM_SHUT_WR);
        }
    }

    public function readAll(): string
    {
        $buffer = '';
        while ($this->socket !== null && !feof($this->socket)) {
            $chunk = fread($this->socket, 4096);
            if ($chunk === false) {
                break;
            }
            $buffer .= $chunk;
        }
        return $buffer;
    }

    public function close(): void
    {
        if ($this->socket !== null) {
            fclose($this->socket);
            $this->socket = null;
        }
    }
}

// 使用例
$client = new TcpClient('example.com', 80);
$client->connect();
$client->send("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n");
$client->shutdownWrite();
$response = $client->readAll();
$client->close();

echo mb_substr($response, 0, 200);

解説: shutdownWrite()メソッドで責務を明確に分離しています。send()shutdownWrite()readAll()close()という順序が、TCP通信の正しい終了フローです。


サンプル3:TCPサーバー側でのシャットダウン制御

<?php
declare(strict_types=1);

class TcpEchoServer
{
    private mixed $server = null;

    public function __construct(
        private readonly string $address = '0.0.0.0',
        private readonly int $port = 8080
    ) {}

    public function start(): void
    {
        $this->server = stream_socket_server(
            "tcp://{$this->address}:{$this->port}",
            $errno,
            $errstr
        );

        if ($this->server === false) {
            throw new RuntimeException("サーバー起動失敗: {$errstr} ({$errno})");
        }

        echo "サーバー起動: {$this->address}:{$this->port}\n";

        while (true) {
            $client = stream_socket_accept($this->server, 30);
            if ($client === false) {
                continue;
            }
            $this->handleClient($client);
        }
    }

    private function handleClient(mixed $client): void
    {
        $peer = stream_socket_get_name($client, true);
        echo "接続: {$peer}\n";

        // クライアントからのデータを読み取る
        $received = '';
        while (!feof($client)) {
            $chunk = fread($client, 1024);
            if ($chunk === false || $chunk === '') {
                break;
            }
            $received .= $chunk;
        }

        // レスポンスを送信
        fwrite($client, "ECHO: {$received}");

        // 書き込みシャットダウン(レスポンス完了を通知)
        stream_socket_shutdown($client, STREAM_SHUT_WR);

        fclose($client);
        echo "切断: {$peer}\n";
    }
}

$server = new TcpEchoServer();
$server->start();

解説: サーバー側でもSTREAM_SHUT_WRを使うことで、「これがレスポンスの終わりだ」とクライアントに明示的に伝えられます。単純なfclose()だとタイミングによってはクライアントがEOFを検出できないことがあります。


サンプル4:非同期ソケット処理での安全なシャットダウン

<?php
declare(strict_types=1);

class AsyncSocketManager
{
    /** @var array<int, mixed> */
    private array $sockets = [];

    /** @var array<int, string> */
    private array $writeBuffers = [];

    public function addSocket(mixed $socket, string $dataToSend): void
    {
        $id = (int)$socket;
        $this->sockets[$id] = $socket;
        $this->writeBuffers[$id] = $dataToSend;
        stream_set_blocking($socket, false);
    }

    public function processAll(): array
    {
        $results = [];

        // 書き込み処理
        foreach ($this->sockets as $id => $socket) {
            if (isset($this->writeBuffers[$id])) {
                fwrite($socket, $this->writeBuffers[$id]);
                // 書き込み完了後にシャットダウン
                stream_socket_shutdown($socket, STREAM_SHUT_WR);
                unset($this->writeBuffers[$id]);
            }
        }

        // stream_selectで読み取り可能なソケットを監視
        $read = array_values($this->sockets);
        $write = null;
        $except = null;

        $changed = stream_select($read, $write, $except, 5);
        if ($changed === false || $changed === 0) {
            return $results;
        }

        foreach ($read as $socket) {
            $id = (int)$socket;
            $buffer = '';
            while (!feof($socket)) {
                $chunk = fread($socket, 4096);
                if ($chunk === false || $chunk === '') {
                    break;
                }
                $buffer .= $chunk;
            }
            $results[$id] = $buffer;

            // 読み取りもシャットダウンして完全クローズ
            stream_socket_shutdown($socket, STREAM_SHUT_RD);
            fclose($socket);
            unset($this->sockets[$id]);
        }

        return $results;
    }
}

解説: ノンブロッキングモードとstream_select()を組み合わせた非同期処理の例です。書き込みシャットダウン→読み取り待機→読み取りシャットダウンという段階的なクローズにより、データロストを防ぎます。


サンプル5:UNIXドメインソケットでのシャットダウン

<?php
declare(strict_types=1);

class UnixSocketClient
{
    private mixed $socket = null;
    private readonly string $socketPath;

    public function __construct(string $socketPath)
    {
        $this->socketPath = $socketPath;
    }

    public function connect(): self
    {
        if (!file_exists($this->socketPath)) {
            throw new RuntimeException(
                "ソケットファイルが存在しません: {$this->socketPath}"
            );
        }

        $this->socket = stream_socket_client(
            "unix://{$this->socketPath}",
            $errno,
            $errstr,
            5.0
        );

        if ($this->socket === false) {
            throw new RuntimeException("UNIX接続失敗: {$errstr}");
        }

        return $this;
    }

    public function request(string $payload): string
    {
        if ($this->socket === null) {
            throw new LogicException('未接続です');
        }

        // ペイロード送信
        $written = fwrite($this->socket, $payload . "\n");
        if ($written === false) {
            throw new RuntimeException('送信失敗');
        }

        // 書き込み方向をシャットダウン(EOFをサーバーに通知)
        stream_socket_shutdown($this->socket, STREAM_SHUT_WR);

        // レスポンス受信
        $response = stream_get_contents($this->socket);
        if ($response === false) {
            throw new RuntimeException('受信失敗');
        }

        return trim($response);
    }

    public function disconnect(): void
    {
        if ($this->socket !== null) {
            fclose($this->socket);
            $this->socket = null;
        }
    }
}

// 使用例(/tmp/myapp.sock が存在する場合)
// $client = new UnixSocketClient('/tmp/myapp.sock');
// $result = $client->connect()->request('{"action":"ping"}');
// echo $result;
// $client->disconnect();

解説: UNIXドメインソケット(unix://)でもstream_socket_shutdown()は同様に動作します。TCPと違いネットワークを介さないため高速ですが、シャットダウンの手順は変わりません。


サンプル6:シャットダウン状態の管理と多段クローズ

<?php
declare(strict_types=1);

/**
 * シャットダウン状態を追跡するソケットラッパー
 */
class ManagedSocket
{
    private mixed $socket;
    private bool $writeShutdown = false;
    private bool $readShutdown = false;

    public function __construct(mixed $socket)
    {
        $this->socket = $socket;
    }

    public function shutdownWrite(): void
    {
        if ($this->writeShutdown) {
            return; // 二重シャットダウンを防ぐ
        }
        stream_socket_shutdown($this->socket, STREAM_SHUT_WR);
        $this->writeShutdown = true;
        echo "[Shutdown] 書き込み停止\n";
    }

    public function shutdownRead(): void
    {
        if ($this->readShutdown) {
            return;
        }
        stream_socket_shutdown($this->socket, STREAM_SHUT_RD);
        $this->readShutdown = true;
        echo "[Shutdown] 読み取り停止\n";
    }

    public function shutdownBoth(): void
    {
        if (!$this->writeShutdown && !$this->readShutdown) {
            stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR);
        } elseif (!$this->writeShutdown) {
            $this->shutdownWrite();
        } elseif (!$this->readShutdown) {
            $this->shutdownRead();
        }
        $this->writeShutdown = true;
        $this->readShutdown = true;
        echo "[Shutdown] 双方向停止\n";
    }

    public function isWritable(): bool
    {
        return !$this->writeShutdown;
    }

    public function isReadable(): bool
    {
        return !$this->readShutdown;
    }

    public function write(string $data): int|false
    {
        if ($this->writeShutdown) {
            throw new LogicException('書き込みはシャットダウン済みです');
        }
        return fwrite($this->socket, $data);
    }

    public function read(int $length = 4096): string|false
    {
        if ($this->readShutdown) {
            throw new LogicException('読み取りはシャットダウン済みです');
        }
        return fread($this->socket, $length);
    }

    public function close(): void
    {
        if (!$this->writeShutdown || !$this->readShutdown) {
            $this->shutdownBoth();
        }
        fclose($this->socket);
        echo "[Close] ソケットクローズ\n";
    }
}

// 動作確認(実際のソケット接続があれば)
// $raw = stream_socket_client('tcp://example.com:80', $e, $es, 5);
// $sock = new ManagedSocket($raw);
// $sock->write("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n");
// $sock->shutdownWrite();
// $data = '';
// while (!feof($raw)) { $data .= $sock->read(); }
// $sock->close();

解説: シャットダウン済みの方向に再度操作しないよう、状態フラグで保護するラッパークラスです。複雑な非同期処理や長期接続では、このようなステート管理が重要です。


シャットダウンモードの使い分け

ユースケース推奨モード
リクエスト送信完了をサーバーに通知したいSTREAM_SHUT_WR
サーバーからのレスポンスをすべて受け取り終えたSTREAM_SHUT_RD
接続を完全に終了したい(fclose()前)STREAM_SHUT_RDWR
HTTPや独自プロトコルでEOF通知が必要STREAM_SHUT_WR
セキュリティ上、受信を即時停止したいSTREAM_SHUT_RD

よくある落とし穴

① fclose()だけでは不十分な場合がある

// ❌ 不完全:FINパケットが送られずサーバーが待ち続けることがある
fclose($socket);

// ✅ 正しい:FINを送ってからリソース解放
stream_socket_shutdown($socket, STREAM_SHUT_WR);
fclose($socket);

② 二重シャットダウンは警告が出ることがある

stream_socket_shutdown($socket, STREAM_SHUT_WR);
stream_socket_shutdown($socket, STREAM_SHUT_WR); // 環境によってはWarning

フラグ管理またはラッパークラスで防ぎましょう(サンプル6参照)。

③ シャットダウン後に書き込むと失敗する

stream_socket_shutdown($socket, STREAM_SHUT_WR);
fwrite($socket, 'データ'); // false を返す。エラーになる

シャットダウン後はその方向の操作は行えません。

④ UDP接続では効果が限定的

stream_socket_shutdown()はTCP/UNIXドメインソケット向けの機能です。UDPはコネクションレスなので、シャットダウンの概念が適用されず期待通りに動作しない場合があります。


まとめ

ポイント内容
主な用途TCP通信におけるFIN送信・半二重制御
fclose()との違いリソースを保持したまま通信方向を停止
推奨フローsendSHUT_WRread allfclose
注意点二重シャットダウン・UDPでの非適用・シャットダウン後の操作禁止

stream_socket_shutdown()を正しく使うことで、TCP接続の正確な終了シーケンスを実装でき、相手側の受信処理を確実に完結させることができます。単純にfclose()するだけでは解決できないプロトコル実装の問題に直面したとき、この関数が力を発揮します。


PHP 8.x / 執筆時点の最新安定版にて動作確認済み

タイトルとURLをコピーしました