Skip to content

mini_serv — マルチクライアント・チャットサーバ

127.0.0.1 で待ち受け、接続中のクライアント同士がテキスト・メッセージをやり取りできる マルチクライアント・チャットサーバ を C で書きます。select(2) で I/O 多重化を行い、複数の接続を 1 プロセス・1 スレッドで捌くのがポイント。

前提知識: ソケット API や select の基本概念は ソケット基礎 にまとめています。sockaddr_in / SO_REUSEADDR / INADDR_LOOPBACK / EINTR などの「まず押さえておきたい話」はそちらを先にどうぞ。

概要

  • 実行形式: ./mini_serv [port]
  • 動作: 127.0.0.1:port で接続を待ち受ける
  • クライアント ID: 接続時に 0 から +1 ずつ採番。切断しても再利用しない
  • イベント通知 (本人を除く全員に送る):
    • 接続時 → server: client %d just arrived\n
    • 切断時 → server: client %d just left\n
    • メッセージ受信時 → 行毎に client %d: <line>\n を全クライアントへ
  • 制約:
    • #define 禁止
    • 使える関数は write / close / select / socket / accept / listen / send / recv / bind / strstr / malloc / realloc / free / calloc / bzero / atoi / sprintf / strlen / exit / strcpy / strcat / memset のみ
    • "lazy client" (受信が遅いクライアント) があってもサーバは止まらないこと

主要技術の深掘り

select(2) による I/O 多重化

複数の fd (リスニングソケット + 全クライアント) のうち どれかが読める / 書ける までブロックする syscall です。forkpthread もなく、シングルスレッドで N クライアントを捌けます。

c
fd_set readfds, writefds, activefds;
FD_ZERO(&activefds);
FD_SET(listen_fd, &activefds);
int maxfd = listen_fd;

while (1) {
    readfds = writefds = activefds;          // 毎回コピー
    if (select(maxfd + 1, &readfds, &writefds, NULL, NULL) < 0)
        continue;                            // EINTR 等で起こされた

    for (int fd = 0; fd <= maxfd; fd++) {
        if (!FD_ISSET(fd, &readfds)) continue;
        if (fd == listen_fd) accept_client();
        else                 receive_from(fd);
        break;                               // 1 イベント処理してから select に戻る
    }
}

writefds も同時に取っているのは、send する前に「相手の受信バッファに空きがあるか」を判定するため。これが lazy client 対策の要になります。詳細は lazy client 問題 を参照。

select の細かい意味論 (max_fd + 1 の意味、EINTRselect vs poll vs epoll/kqueue の違い) は select(2) の I/O 多重化 で深掘りしています。

TCP の境界問題と per-fd 蓄積バッファ

TCP は バイトストリーム なので、recv 1 回でちょうど 1 行届く保証はありません。逆に 1 回の recv に複数行が混ざってくることもある。

そこで「fd 毎に未完成行を \n まで貯める」設計を入れます:

c
static char *g_msgs[65536];                   // fd → 蓄積バッファ

static void receive_from(int fd) {
    int r = recv(fd, g_buf_read, 4096, 0);
    if (r <= 0) { disconnect_client(fd); return; }
    g_buf_read[r] = '\0';
    g_msgs[fd] = str_join(g_msgs[fd], g_buf_read);

    char *msg;
    while (extract_message(&g_msgs[fd], &msg)) {     // '\n' 区切りで 1 行抽出
        sprintf(g_buf_write, "client %d: %s", g_ids[fd], msg);
        send_all(fd);                                 // 送信元を除いて転送
        free(msg);
    }
}

extract_message1 行抽出して残りをバッファに残す ので、部分行も複数行も同じコードで自然に扱えます。プロトコル境界の話の本質は メッセージ・フレーミング で。

送信元除外ブロードキャスト

「他のクライアント全員に転送するが、送信元には返さない」は典型パターン。except 引数で除外を表現します:

c
static void send_all(int except) {
    int len = strlen(g_buf_write);
    for (int fd = 0; fd <= g_maxfd; fd++) {
        if (fd == g_listen_fd || fd == except) continue;
        if (FD_ISSET(fd, &g_writefds))               // 書き込み可な fd だけ
            send(fd, g_buf_write, len, 0);
    }
}

ID 採番ポリシー (再利用しない理由) や、Pub/Sub への発展は ブロードキャスト・パターン で。

Lazy client の取り扱い

「受信が遅いクライアント」が 1 人いるだけでサーバ全体を止めないのが鍵。writefds に入っている fd にだけ send し、入っていない fd はそのイテレーションでスキップします。send の戻り値は無視 (どうせ次の select でまた起こされる)。

ついでに SIGPIPE 対策が要りますが、本実装はシグナル無視を signal(SIGPIPE, SIG_IGN) として 明示的には書いていません (使える関数のリストに signal が入っていないため)。代わりに writefds のチェックで実害が出ない設計になっています。詳細は lazy client 問題 を参照。

ロジックの可視化

全体フロー

extract_message による行切り出し

コラム・読み物

select の意味論や、TCP プロトコル設計の深掘り記事です。

よくある質問 (FAQ)

Q. クライアント数の上限は?

A. fd_set のサイズ (FD_SETSIZE、通常 1024) が事実上の上限です。本実装では g_ids[65536] / g_msgs[65536] を確保していますが、select 自体が 1024 fd で頭打ちになります。さらに増やしたければ poll(2)epoll(7) / kqueue(2) への切り替えが必要。

Q. なぜ O_NONBLOCK を立てない?

A. ここでは使える関数のリストに fcntl が入っていないため。代わりに recv/send の前に必ず select を通す ことで、ブロックしないことを保証しています。「select がレディと言った fd」しか触らないので、recv は即値が返る。

Q. なぜ ID を再利用しない?

A. ID は 「これまで配った最大の ID + 1」を毎回新しく配り、退室した番号は再利用しない、というルールにしています。再利用すると「client 3 just arrived」のあと別人が同じ 3 として再登場してしまい、ログを読み返した人が静かに混乱します。詳しくは ブロードキャスト・パターン を参照。

Q. g_buf_write[200042] の根拠は?

A. 1 メッセージあたりのプレフィックス (client %d: ) と本文を合わせて 200KB 程度を上限にした、ゆとりを持った値です。明示的なサイズ要件はないので、sprintf がバッファ外に書かない大きさなら何でも OK。

Q. lazy client が増えるとどうなる?

A. writefds のチェックでサーバが止まることはありませんが、ブロードキャストが本人に届かないだけです。本物のチャットサーバなら「一定時間返事がない fd は強制切断」「fd 毎の出力キューを別途持って EWOULDBLOCK で再送」などのケアを入れます。ここでは扱いません。

Q. 1 回の select で複数 fd 来てたら、なぜ全部処理せず break する?

A. 1 件処理するごとに g_writefds (= 書き込み可かのスナップショット) が古くなるためです。送信先のクライアントが満杯になるなどで、状態が刻々変わる可能性があるので、安全側に倒して 1 件ずつ select に戻る設計になっています。

参考リンク