練習のテーマ
select(2) を用いた単一プロセス TCP マルチクライアント・チャットサーバ
- 行 (
\n) 単位のメッセージ境界処理 (per-fd 受信バッファの動的成長)
- 入退室の通知ブロードキャストと、送信元を除外した転送
- 許可関数のみ (
#define 不可・fcntl 不可) の制約下で書く
mini_serv の位置付け
1ポート=1プロセスで動作するインメモリ・マルチクライアント・チャットサーバ。
127.0.0.1 上の指定ポートで TCP 接続を待ち受ける。
- 接続は persistent: 1 接続で複数行を連続して送受信する。
- 受信した 1 行は 送信元を除く 全クライアントへブロードキャストする。
- 入退室イベント (
server: client N just arrived/left\n) も同様にブロードキャスト。
- 永続化なし: SIGINT 等の終了処理は持たず、状態保持はしない (mini_db との差分)。
- ID は再利用しない: subject 明文に従い、新規 ID = これまで採番した最大 ID + 1。
グローバル状態
| 変数 | 役割 |
g_listen_fd | リスニングソケットの fd (ブロードキャスト対象から除外するために保持) |
g_maxfd | select に渡す現在の最大 fd |
g_next_id | 次に採番するクライアント ID (0 から1ずつ加算) |
g_ids[65536] | fd → クライアント ID の対応表 |
g_msgs[65536] | fd → 未完成行 (改行が来るまでのバッファ) のポインタ |
g_readfds | select 直前に g_activefds から複製する読み込み用集合 |
g_writefds | select 直前に g_activefds から複製する書き込み用集合 |
g_activefds | 現在アクティブな fd 集合 (リスニング + 全クライアント) |
g_buf_read[4097] | recv 用固定バッファ (4096 + 終端 '\0') |
g_buf_write[200042] | ブロードキャスト用の整形済みメッセージバッファ |
全体構造
構造視点での全体像。mini_serv はクラスを持たないため、 翻訳単位 (TU) 内に閉じた static グローバルと自由関数群でモジュールを構成する。 実行時の流れ (時間軸) は下の「全体シーケンス」「処理フロー」を参照。
責務分類
設計上の why
- なぜ readfds と writefds の両方を
select に渡す?: g_writefds を FD_ISSET(fd, &g_writefds) で確認し、書き込み可能と分かった fd にだけ send するため。「メッセージを読まない (lazy) クライアント」への send で ブロックしたり、SIGPIPE でプロセスが落ちることを防ぐ。
- なぜ
g_msgs[] は固定長ではなく動的確保?: 許可関数制約 (fcntl 不可・ 非ブロッキング不可) と、行長の上限が定められていないため。recv で来た 断片を結合し、'\n' が来るまで保持する必要がある。
- なぜ 1 イテレーションで 1 fd だけ処理して
select に戻る?: g_writefds は select 時点での書き込み可否なので、別 fd に send を行うと陳腐化しうる。 break で select に戻すことで、毎回最新の書き込み可否で send_all が走る。
- なぜ ID は再利用しない?: subject に "the new client id is the maximum
given id, +1" と明文。
g_ids[fd] = g_next_id++ の単調増加で済み、切断時の リサイクル管理が要らない。
- なぜ
static ファイルスコープ globals?: 翻訳単位 (TU) 内に閉じ込めて 外部リンケージを与えないため。mini_db の無名 namespace に対応する C の慣用。
- **なぜ bind は
127.0.0.1 のみ (htonl(0x7f000001))?**: subject 例の踏襲 + 公開ネットワークへの露出を避けるため。INADDR_ANY (0.0.0.0) だと外部からも 到達してしまう。
- なぜ
select の戻り値 < 0 で fatal せず continue する?: subject の "Your server must never crash" 解釈。シグナル割り込み (EINTR) などの一時 エラーで終了させず、再度 select から復帰する。
- なぜ
printf ではなく write + バイト数ハードコード?: 許可関数に printf 系が含まれないため (Wrong number of arguments\n / Fatal error\n)。代わりに write(2, ..., 26) のように長さをハードコードする。
- なぜ
printf は不可なのに sprintf は使える?: 許可関数表に sprintf だけが入っている (FILE* に書く printf 系は不可、メモリに書く sprintf のみ可)。 整形が要る部分 (ブロードキャスト文字列) はすべて sprintf + g_buf_write で組み立て、 send でバイト列として送る。
全体シーケンス
処理フロー (受信〜ブロードキャスト)
◆ main()
| int main |
( |
int |
ac, |
|
|
char ** |
av |
|
) |
| |
プログラムのエントリポイント
実装のステップ
- 引数チェック:
ac != 2 なら stderr に "Wrong number of arguments\n" を出して exit(1)。
socket(AF_INET, SOCK_STREAM, 0) でリスニングソケット作成。失敗で fatal()。
sockaddr_in を構築:
sin_family = AF_INET
sin_addr.s_addr = htonl(0x7f000001) (= 127.0.0.1 のみ)
sin_port = htons(atoi(av[1]))
bind / listen(128) を実行。いずれも失敗で fatal()。
- グローバル状態を初期化:
FD_ZERO(&g_activefds) → FD_SET(g_listen_fd, &g_activefds)
g_maxfd = g_listen_fd、g_next_id = 0
g_ids / g_msgs を bzero で全消去
- 永久ループ:
g_readfds = g_writefds = g_activefds で複製。
select で待機。< 0 (= シグナル割込み等) なら continue でやり直し。
0..g_maxfd を走査し、FD_ISSET(fd, &g_readfds) の最初の 1 件だけ処理:
break で内側 for を抜け、再度 select から始める。
- 注意
- 1 イテレーションで複数の fd を順に処理せず、
break で 1 件ずつ 裁いて select に戻る設計。fd 集合 (g_writefds) が古くなる タイミングを最小化する。
- 引数
-
| [in] | ac | 引数数 (2 を期待) |
| [in] | av | av[1] = ポート番号 (文字列) |
- 戻り値
- 通常は到達しない (永久ループ)。
fatal() または引数エラーで終了する。
mini_serv.c の 299 行目に定義があります。
◆ fatal()
| static void fatal |
( |
void |
| ) |
|
|
static |
Fatal error\n を stderr に書いて exit(1) する
実装のステップ
write(2, "Fatal error\n", 12) で 12 バイト書き出す。
exit(1) で終了する。
- 注意
printf 系は許可関数に含まれないため使えない。文字列リテラルの長さは ハードコードする (12 = strlen("Fatal error\n"))。
mini_serv.c の 358 行目に定義があります。
◆ accept_client()
| static void accept_client |
( |
void |
| ) |
|
|
static |
新規クライアントを accept し、ID を採番して入室通知をブロードキャスト
実装のステップ
accept(g_listen_fd, ...) で新しい fd を取得。失敗で fatal()。
cfd > g_maxfd なら g_maxfd を更新。
g_ids[cfd] = g_next_id++ で新規 ID を発番。**ID は再利用しない** (subject: "the new client id is the maximum given id, +1")。
g_msgs[cfd] = NULL で蓄積バッファを未確保状態に。
FD_SET(cfd, &g_activefds) で select の監視対象に追加。
sprintf(g_buf_write, "server: client %d just arrived\n", g_ids[cfd])。
send_all(cfd) で 本人を除く 全クライアントに通知。
- 注意
- 自分自身に "just arrived" を送らない。
except = cfd。
mini_serv.c の 379 行目に定義があります。
◆ receive_from()
| static void receive_from |
( |
int |
fd | ) |
|
|
static |
1 つのクライアント fd からデータを読み、行ごとに整形・転送する
実装のステップ
recv(fd, g_buf_read, 4096, 0) で読み込む。
r <= 0 なら disconnect_client(fd) を呼んで戻る。
g_buf_read[r] = '\0' で null 終端する (g_buf_read[4097] で1バイト余裕あり)。
g_msgs[fd] = str_join(g_msgs[fd], g_buf_read) で per-fd バッファに連結。
extract_message(&g_msgs[fd], &msg) が真を返す限りループ:
sprintf(g_buf_write, "client %d: %s", g_ids[fd], msg) で整形 (msg は末尾に \n を含むため、書式に追加の \n は不要)。
send_all(fd) で送信元を除いて転送。
free(msg) で 1 行分のバッファを解放。
部分行の扱い
- 1回の
recv に \n が無い: 蓄積するだけで何も送らない。
- 1回の
recv に \n が複数: 各行を独立にブロードキャスト。
- 引数
-
mini_serv.c の 415 行目に定義があります。
◆ send_all()
| static void send_all |
( |
int |
except | ) |
|
|
static |
g_buf_write の内容を except と listen 以外の全クライアントへ送信する
実装のステップ
strlen(g_buf_write) で送信長を求める。
0..g_maxfd を走査:
fd == g_listen_fd ならスキップ (リスニング fd には書かない)。
fd == except ならスキップ (送信元へエコーバックしない)。
FD_ISSET(fd, &g_writefds) (= select が書き込み可と判定) なら send。
- 注意
- 「メッセージを読まない (lazy) クライアント」の
send 失敗で プロセスを巻き込まれないよう、戻り値は無視する。SIGPIPE 対策は g_writefds (= writefds) のチェックに委ねる。
- 引数
-
| [in] | except | ブロードキャスト対象から除外する fd (送信元クライアント) |
mini_serv.c の 451 行目に定義があります。
◆ disconnect_client()
| static void disconnect_client |
( |
int |
fd | ) |
|
|
static |
クライアントを切断し、退室通知をブロードキャスト + 後始末
実装のステップ
sprintf(g_buf_write, "server: client %d just left\n", g_ids[fd])。
FD_CLR(fd, &g_activefds) で監視から外す。
close(fd) で fd を閉じる。
send_all(fd) で 退室者を除く 他クライアントに通知。
free(g_msgs[fd]) で蓄積バッファを解放。g_msgs[fd] = NULL に戻す。
- 注意
close の前に g_buf_write を作る順序は、g_ids[fd] が まだ有効な間に値を取り出すためでも問題ない (ID 配列は静的)。
- 引数
-
mini_serv.c の 478 行目に定義があります。
◆ str_join()
| static char * str_join |
( |
char * |
buf, |
|
|
char * |
add |
|
) |
| |
|
static |
既存バッファ buf に add を結合した新しいバッファを返す (buf は free)
実装のステップ
- 結合後のサイズを
(strlen(buf or 0)) + strlen(add) + 1 (終端 '\0' 用) で算出。
malloc で新領域を確保。失敗で fatal()。
res[0] = '\0' で空文字列に初期化。
buf が非 NULL なら strcat(res, buf) で先に旧内容をコピー。
- 旧
buf を free。
strcat(res, add) で受信チャンクを末尾に追加。
res を返す。所有権は呼び出し側へ移譲。
- 注意
- 戻り値で
g_msgs[fd] を上書きする使い方を想定。 旧ポインタを使い続けないこと。
- 引数
-
| [in] | buf | 旧バッファ (NULL 可)。呼び出し後は無効。 |
| [in] | add | 末尾に追加する null 終端文字列 |
- 戻り値
- 新しい結合済みバッファ (呼び出し側で
free する)
mini_serv.c の 507 行目に定義があります。
◆ extract_message()
| static int extract_message |
( |
char ** |
buf, |
|
|
char ** |
msg |
|
) |
| |
|
static |
蓄積バッファから先頭の 1 行を切り出す
実装のステップ
*msg = NULL で初期化、*buf == NULL なら 0 を返す。
*buf を 1 文字ずつ走査し、\n を探す。
- 見つかったら、改行の 次 からの残りを
calloc で新バッファに strcpy。
- 旧
*buf を *msg に渡し、 (*msg)[i + 1] = '\0' で改行の直後を切り、 1 行分 (\n 込み) を独立した null 終端文字列にする。
*buf = newbuf (残り) に置き換え、1 を返す。
- 改行が見つからなければ
0 を返す。
- 注意
*msg の所有権は呼び出し側に渡る。free 必須。
- 引数
-
| [in,out] | buf | 蓄積バッファへのポインタ (改行までを除いた残りに置き換わる) |
| [out] | msg | 切り出した 1 行 (NULL 終端) のポインタを格納する場所 |
- 戻り値
- 1: 1 行抽出した / 0: 改行が無かった
mini_serv.c の 539 行目に定義があります。