Skip to content

ソケットプログラミング基礎

mini_servmini_db に共通して登場する、TCP ソケットと select(2) の基本概念を 1 ページにまとめた入門ノートです。

各プロジェクトの個別解説 (select_io_multiplexing.md / signal_safe_shutdown.md 等) と内容が一部重なりますが、こちらは「まず最初に押さえておきたい前提」をフラットに並べたもの。詳しい掘り下げは各記事へのリンクを参照してください。

ソケットアドレス構造体 (sockaddr_in)

TCP/IPv4 のソケットでバインドや接続先を指定するときに使う構造体。中身を擬似コードで整理するとこうなります。

c
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)
  • 通常のアプリケーションには十分
  • 高負荷サーバでは 5111024 以上を指定することも多い (nginx 等)

mini_serv / mini_db のような単一プロセス + select 構成では、SOMAXCONN で問題が起きることはまずありません。

I/O 多重化が必要な理由 — ブロッキング問題

素朴な実装だと、サーバ側の recv() は「データが届くまで」スレッドを止めます。これだと:

  • クライアント A からのデータを recv で待っている間に
  • クライアント B が新しく connect してきても、サーバは accept できない

1 つの fd しか待っていない構造そのものが壁 になります。これを 1 スレッドのまま解決するのが I/O 多重化です。

select(2) の仕組み — イベント駆動型ループ

考え方:

  1. 監視したい fd を fd_set (ビットマスク) に登録する (FD_SET)
  2. select(maxfd+1, &readfds, &writefds, &exceptfds, &timeout) を呼ぶ
  3. どれかの fd でイベントが起きるまで ブロックする
  4. 戻ってきた fd_set には「実際にイベントが起きた fd」だけが立っている
  5. ループの先頭に戻る

これで「新しい接続」「既存クライアントからのデータ」を 1 ループで切り替えながら処理できます。

c
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 引数 timeoutNULL でイベントが来るまで無限に待つ

select入力の fd_set を結果で書き換える ため、毎回コピーし直して渡す必要があります。

より深い話 (max_fd+1 の真の意味、fd_set のサイズ制限、poll / epoll / kqueue との比較) は select(2) の I/O 多重化 を参照。

EINTR — シグナルによる割り込み

select (や accept / recv / send) はシグナル受信で 失敗扱いで戻ってきますerrno == EINTR がそれ。

c
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
  • リスニング 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) のような関数の中で、メンバ変数のバッファを扱う際に 参照 を使うのが安全・明快です。

cpp
std::string &buf = buffers_[fd];        // 参照で持つ
buf.append(data, n);

参照のメリット:

  • 実体があるアドレスを必ず指す ため NULL チェック不要
  • 演算子が . で済む (ポインタは ->)
  • コンパイラ側で実体とセットで書かないとエラー になるため、「参照はあるが指し先がない」状況が言語仕様レベルで起こらない
  • 再代入できない ため、データとの紐付けが途中で壊れない

C++ で「明らかに NULL を取らないリソース」を関数間で持ち回るときの第一候補です。

関連ページ

mini_serv 側の深掘り

mini_db 側の深掘り

参考リンク