ソケットプログラミング基礎
mini_serv と mini_db に共通して登場する、TCP ソケットと select(2) の基本概念を 1 ページにまとめた入門ノートです。
各プロジェクトの個別解説 (select_io_multiplexing.md / signal_safe_shutdown.md 等) と内容が一部重なりますが、こちらは「まず最初に押さえておきたい前提」をフラットに並べたもの。詳しい掘り下げは各記事へのリンクを参照してください。
ソケットアドレス構造体 (sockaddr_in)
TCP/IPv4 のソケットでバインドや接続先を指定するときに使う構造体。中身を擬似コードで整理するとこうなります。
struct sockaddr_in {
sa_family_t sin_family; // AF_INET
in_port_t sin_port; // ネットワークバイトオーダのポート
struct in_addr sin_addr; // .s_addr に IP を入れる
};ソケット API は IPv4 専用ではなく、Unix ドメイン (sockaddr_un) / IPv6 (sockaddr_in6) / Bluetooth など他の通信形式も同じ bind / connect で扱えるよう設計されています。汎用ポインタ struct sockaddr * にキャストして渡すのはそのため。
127.0.0.1 (INADDR_LOOPBACK) にバインドする意義
mini_serv も mini_db も、bind 時に IP を INADDR_LOOPBACK (= 127.0.0.1) に固定しています。
- コンピューター内部からしか接続できない (ループバックインタフェース経由のみ)
- 外部ネットワークからの攻撃面を 0 にできる
- 典型用途: フロントエンド専用 DB、開発用の補助サーバなど「同じマシン内のプロセスとだけ話す」場合
全アドレスで待ち受けたければ INADDR_ANY (= 0.0.0.0) を使いますが、課題やローカル実験ではループバック限定で十分。
SO_REUSEADDR と TIME_WAIT
bind 直後にサーバを再起動すると bind: Address already in use で落ちることがあります。原因は TIME_WAIT。
- TCP は接続終了 (FIN/ACK 交換) の後、しばらく TIME_WAIT 状態に留まる
- 遅延して届いた古いパケットが、次の同じ 4-tuple の接続に混入してデータ破損するのを防ぐため
- OS によって同じポートが 1〜4 分 程度ロックされる
setsockopt(SO_REUSEADDR, 1) を立てると、TIME_WAIT 中のポートでも再 bind できるようになります。開発・課題用途では事実上必須。
余談: SO_REUSEADDR を使わないケース
- 金融や人命に関わる通信 (古いパケット混入を絶対に避けたい)
- マルチユーザー環境で他プロセスがポートを横取りする恐れがある場合
- UDP マルチキャスト以外で、本当に「1 プロセスのみが持つべきポート」と分かっているとき
通常のアプリケーション開発では立てておくのが普通です。
listen backlog と SOMAXCONN
listen(fd, backlog) の第 2 引数は、accept で取り出される前に OS が代わりに溜めておく完了済み接続の数。
SOMAXCONN= Linux カーネルのデフォルト上限値 (慣習的に 128)- 通常のアプリケーションには十分
- 高負荷サーバでは
511や1024以上を指定することも多い (nginx等)
mini_serv / mini_db のような単一プロセス + select 構成では、SOMAXCONN で問題が起きることはまずありません。
I/O 多重化が必要な理由 — ブロッキング問題
素朴な実装だと、サーバ側の recv() は「データが届くまで」スレッドを止めます。これだと:
- クライアント A からのデータを
recvで待っている間に - クライアント B が新しく
connectしてきても、サーバはacceptできない
1 つの fd しか待っていない構造そのものが壁 になります。これを 1 スレッドのまま解決するのが I/O 多重化です。
select(2) の仕組み — イベント駆動型ループ
考え方:
- 監視したい fd を
fd_set(ビットマスク) に登録する (FD_SET) select(maxfd+1, &readfds, &writefds, &exceptfds, &timeout)を呼ぶ- どれかの fd でイベントが起きるまで ブロックする
- 戻ってきた
fd_setには「実際にイベントが起きた fd」だけが立っている - ループの先頭に戻る
これで「新しい接続」「既存クライアントからのデータ」を 1 ループで切り替えながら処理できます。
fd_set rfds = active; // 毎回コピー (select は破壊的に書き換える)
int rv = select(maxfd + 1, &rfds, NULL, NULL, NULL);
for (int fd = 0; fd <= maxfd; ++fd) {
if (!FD_ISSET(fd, &rfds)) continue;
if (fd == listen_fd) accept_client();
else recv_data(fd);
}各引数の意味:
| 引数 | 役割 |
|---|---|
第 1 引数 maxfd + 1 | 監視対象の fd 番号の最大値 + 1 |
第 2 引数 readfds | 読み込み可能 になったら通知してほしい fd 集合 |
第 3 引数 writefds | 書き込み可能 になったら通知してほしい fd 集合 |
第 4 引数 exceptfds | 例外 (out-of-band データ等) を通知してほしい fd 集合 |
第 5 引数 timeout | NULL でイベントが来るまで無限に待つ |
select は 入力の fd_set を結果で書き換える ため、毎回コピーし直して渡す必要があります。
より深い話 (
max_fd+1の真の意味、fd_setのサイズ制限、poll/epoll/kqueueとの比較) は select(2) の I/O 多重化 を参照。
EINTR — シグナルによる割り込み
select (や accept / recv / send) はシグナル受信で 失敗扱いで戻ってきます。errno == EINTR がそれ。
if (select(...) < 0) {
if (errno == EINTR) continue; // プログラム終了させず、ループ継続
fatal();
}プログラムを終了させずにループを継続する のがほぼ常に正解です。例外は「シグナルハンドラで終了フラグを立てて、ループ条件で抜けたい」場合で、その時は continue の前にフラグを再評価します。
シグナル安全な終了パターン (
volatile sig_atomic_tフラグ + ループ脱出) は シグナル安全な終了パターン を参照。
accept(2) と接続キュー
listen 状態のソケットは、OS の中に 接続待ちキュー を持っています。クライアントが connect で 3-way handshake を完了すると、このキューに (client_ip, client_port, ...) が積まれます。
accept(listen_fd, &addr, &addrlen):
- キューの先頭から 1 つ取り出し、そのクライアント専用の新しい fd を返す
- 第 2・第 3 引数 (相手アドレス・長さ) は 興味がなければ NULL でよい
- mini_db のようにアドレス情報を使わないなら
accept(fd, NULL, NULL)で OK
- mini_db のようにアドレス情報を使わないなら
- リスニング fd 自体はそのまま生き続ける
accept が失敗するとき
ほとんどがタイミング起因のエラーで、握りつぶしてループに戻すのが正解 です。
| 状況 | errno | 対応 |
|---|---|---|
| シグナル割り込み | EINTR | ループ継続 |
| クライアントが速攻で切断 | ECONNABORTED | 無視してループ継続 |
| Non-Blocking ソケットでキューが空 | EAGAIN / EWOULDBLOCK | 次の select を待つ |
| fd 枯渇 | EMFILE / ENFILE | ログを出して少し待つ、古い接続を強制的に切るなどの 対応が必要 |
fd 枯渇は本物の問題なので、サービス品質を保ちたいなら能動的な対処が要ります。
補足: 参照 vs ポインタ (C++ で recv_data を書くとき)
mini_db は C++ なので、recv_data(int fd) のような関数の中で、メンバ変数のバッファを扱う際に 参照 を使うのが安全・明快です。
std::string &buf = buffers_[fd]; // 参照で持つ
buf.append(data, n);参照のメリット:
- 実体があるアドレスを必ず指す ため NULL チェック不要
- 演算子が
.で済む (ポインタは->) - コンパイラ側で実体とセットで書かないとエラー になるため、「参照はあるが指し先がない」状況が言語仕様レベルで起こらない
- 再代入できない ため、データとの紐付けが途中で壊れない
C++ で「明らかに NULL を取らないリソース」を関数間で持ち回るときの第一候補です。