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 です。fork も pthread もなく、シングルスレッドで N クライアントを捌けます。
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 の意味、EINTR、select vs poll vs epoll/kqueue の違い) は select(2) の I/O 多重化 で深掘りしています。
TCP の境界問題と per-fd 蓄積バッファ
TCP は バイトストリーム なので、recv 1 回でちょうど 1 行届く保証はありません。逆に 1 回の recv に複数行が混ざってくることもある。
そこで「fd 毎に未完成行を \n まで貯める」設計を入れます:
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_message が 1 行抽出して残りをバッファに残す ので、部分行も複数行も同じコードで自然に扱えます。プロトコル境界の話の本質は メッセージ・フレーミング で。
送信元除外ブロードキャスト
「他のクライアント全員に転送するが、送信元には返さない」は典型パターン。except 引数で除外を表現します:
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 プロトコル設計の深掘り記事です。
- select(2) の I/O 多重化 —
fd_setの意味論、max_fd+1、EINTR、poll/epoll/kqueue比較 - メッセージ・フレーミング — TCP がバイトストリームである故の境界問題
- Lazy client 問題 — 遅い受信者で全員を止めない設計、
SIGPIPE対策 - ブロードキャスト・パターン — N-1 送信、ID 採番、Pub/Sub への発展
よくある質問 (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 に戻る設計になっています。
参考リンク
- select(2) — Linux man page
- Beej's Guide to Network Programming — ソケット入門の名著
- The C10K Problem —
selectの限界とepollへの進化の歴史