TCP通信の実装と粘包問題の対策

TCP(Transmission Control Protocol)は、信頼性の高い接続指向通信を提供するトランスポート層プロトコルです。クライアント・サーバー(C/S)モデルやブラウザ・サーバー(B/S)モデルの基盤として広く利用されています。

TCPヘッダ構造 TCPヘッダには以下のフィールドが含まれます:

  • **ソース/宛先ポート:**通信を行うプロセスを識別します。
  • **シーケンス番号:**送信側が送信したデータバイトの順序を示します。
  • **確認番号:**受信側が正しく受信した直前のバイト数を返します(ACKフラグが立っている場合に有効)。
  • 制御フラグ:
  • URG:緊急データポインタの有効性
  • ACK:確認番号の有効性
  • PSH:受信アプリケーションに即時データ読み取りを要求
  • RST:接続の強制再設定(リセットパケット)
  • SYN:接続確立要求(同期パケット)
  • FIN:接続終了通知(終了パケット)

ウィンドウサイズ:受信バッファの空き容量を示し、フロー制御に使用されます。 チェックサム:データの整合性を検証します。 緊急ポインタ:緊急データの位置を示します。

粘包(Packet Sticky)問題 TCPはストリーム指向のプロトコルであるため、複数のアプリケーションメッセージが単一のTCPセグメントに結合されたり、単一の大きなメッセージが複数のセグメントに分割されたりすることがあります。これにより、受信側でメッセージの境界が不明確になり、データの解析が困難になります。

発生要因

  1. **送信側の連続送信:**高速で複数のデータを送信すると、TCP/IPスタックがNagleアルゴリズムやセグメント結合により複数のメッセージを1つのパケットにまとめます。
  2. **受信側の処理遅延:**受信バッファに複数のメッセージが蓄積され、一度に読み取られると境界が失われます。
  3. **滑動ウィンドウの動的変更:**ネットワーク状況に応じてウィンドウサイズが変化し、パケットの分割・結合が発生します。

解決策

  1. **固定長メッセージ:**各メッセージを固定サイズで送信。簡易だが無駄なバイトが発生し、柔軟性に欠けます。
  2. **メッセージヘッダ方式:**各メッセージの先頭に長さ情報(例:4バイトの整数)を追加。受信側はまずヘッダを読み取り、その後指定長分のデータを取得します。
  3. **区切り文字:**メッセージ間に特殊文字(例:\n\0)を挿入。受信側で区切り文字を検出して分割します。
  4. **心拍パケット:**一定間隔で空のデータを送信し、通信の活性状態を維持。境界の明確化にも寄与します。
  5. **高度なアプリケーションプロトコル:**HTTP/2やQUICのように、フレーム構造を明確に定義したプロトコルを利用します。

滑動ウィンドウとフロー制御 TCPは滑動ウィンドウ方式でフロー制御を行います。送信側は受信側の受信可能容量(ウィンドウサイズ)に基づいて送信量を制御します。これにより、受信バッファのオーバーフローを防ぎ、ネットワークの過負荷を回避します。

例:チャットアプリで、1メッセージずつ送信して返信を待つ(一発一応)と効率が悪くなります。一方、複数メッセージを一度に送信し、後でACKを受けて処理する方式(滑動ウィンドウ)では、待機時間を有効に活用でき、通信効率が大幅に向上します。

TCP接続の確立と解放 信頼性の高さは、以下のプロセスによって保証されます:

  • **三度のハンドシェイク:**接続確立(SYN → SYN-ACK → ACK)
  • **シーケンス番号と確認番号:**送信データの順序と欠落の検出
  • **四度のハンドシェイク:**接続終了(FIN → ACK → FIN → ACK)

UDPとの比較

項目 UDP TCP
接続性 接続なし(無接続) 接続あり
信頼性 非信頼 信頼
転送形式 データグラム単位 バイトストリーム
オーバーヘッド
使用例 リアルタイム音声、DNS Web、ファイル転送、メール

TCPクライアント・サーバー実装

サーバー側(受信) サーバーは以下の手順で通信を開始します:

  1. socket():IPv4/TCP用ソケット作成
  2. bind():ローカルIPとポートにバインド
  3. listen():接続要求の待ち受け状態に移行
  4. accept():クライアント接続を受付、新しいソケットを返す
  5. recv()send():データの受信・送信
  6. close():ソケットを破棄
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int create_tcp_server(int port) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) return -1;

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        close(server_fd);
        return -1;
    }

    if (listen(server_fd, 5) == -1) {
        close(server_fd);
        return -1;
    }

    return server_fd;
}

int handle_client(int server_fd) {
    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);
    int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &len);
    if (client_fd == -1) return -1;

    char buffer[1024] = {0};
    ssize_t bytes = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
    if (bytes > 0) {
        printf("受信: %s\n", buffer);
    }

    const char* response = "サーバーからの返信";
    send(client_fd, response, strlen(response), 0);

    close(client_fd);
    return 0;
}

クライアント側(送信) クライアントは以下の手順でサーバーに接続します:

  1. socket():ソケット作成
  2. connect():サーバーへ接続要求
  3. send()recv():データ送受信
  4. close():ソケット解放
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int create_tcp_client(const char* ip, int port) {
    int client_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (client_fd == -1) return -1;

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(port);
    inet_pton(AF_INET, ip, &server_addr.sin_addr);

    if (connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        close(client_fd);
        return -1;
    }

    return client_fd;
}

int send_file_data(int client_fd, const char* filename) {
    FILE* fp = fopen(filename, "rb");
    if (!fp) return -1;

    char buffer[1024];
    size_t bytes_read;

    while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
        if (send(client_fd, buffer, bytes_read, 0) == -1) {
            fclose(fp);
            return -1;
        }
    }

    fclose(fp);
    return 0;
}

ファイル転送の実装 ファイル転送では、ファイル名を最初に送信し、その後バイナリデータを連続して送信します。受信側はファイル名を受信後、同名のファイルを作成し、受信データを書き込みます。

注意点として、recv() の戻り値が 0 の場合、接続が正常に切断されたことを示します。これにより、送信側がファイル転送を完了したことを検出できます。

ファイル転送の例では、送信側が read() でファイルを読み込み、send() で送信。受信側は recv() で受信し、write() でファイルに書き込みます。この際、バッファのクリアやエラーチェックが不可欠です。

エラー対応:Address already in use サーバー再起動時に「Address already in use」エラーが発生するのは、前回の接続が TIME_WAIT 状態でポートを占有しているためです。対策として、setsockopt()SOL_SOCKETSO_REUSEADDR オプションを有効にします。

int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

これにより、同じアドレス・ポートを再利用できるようになります。

タグ: TCP 滑動ウィンドウ 粘包 ソケットプログラミング ネットワーク通信

5月30日 04:42 投稿