C++ソケットサーバーのマルチスレッド設計|スレッド数制限・accept分離・非同期I/Oとスレッドプールの最適解

C++でソケットサーバーをマルチスレッド化するとき、「スレッド数をどこまで増やすべきか」「accept用にスレッドを空けるべきか」で迷うのは自然です。実は、悩みの本質は“上限値の正解探し”ではなく、acceptとI/O、重い処理をどう分離して詰まらせないかにあります。ここでは設計パターンとスレッド数の考え方を、32論理コア環境の具体例まで落として解説します。

目次

なぜ「スレッド数を制限すべきか」で悩むのか

マルチスレッド化の目的は、同時接続をさばきつつレスポンスを落とさないことです。しかし、スレッドを増やすほど良いとは限りません。特にソケットサーバーでは、次のような不安が同時に出てきます。

  • 全スレッドが処理で埋まり、新規接続のacceptが回らなくなるのでは?
  • 32論理コア(例:16C/32T)なら、スレッドは30〜31本くらいに絞った方が良い?
  • 上限なしで増やすと、逆に遅くなる?落ちる?

この不安は「スレッドが有限の資源で、しかもスレッドが占有されると戻ってこない」設計を想像していると強くなります。典型例が1接続=1スレッドや、受信・送信・DB呼び出しまで同じスレッドで抱え込む構成です。逆に言えば、占有されにくいI/O層とキューで制御できる処理層に分ければ、この不安はかなり減らせます。

スレッド数の議論を始める前に:「同時性」を4つに分解する

「スレッドは何本?」という問いが難しいのは、ソケットサーバーの“同時性”が1種類ではないからです。実務で整理しやすい分け方は次の4つです。

同時性の種類意味増える要因制御ポイント(例)
同時接続数ソケットを開いている接続の数Keep-Alive、WebSocket、長時間接続最大接続数、アイドルタイムアウト、認証前の制限
同時リクエスト数いま処理中の要求の数クライアントの並列送信、再送、スパイクリクエストキュー上限、レート制限、優先度制御
同時CPU処理数CPUを本気で使う仕事の並列度暗号、圧縮、重いパース、画像処理CPUワーカープールサイズ、バッチ化、キャッシュ
同時外部I/O数DB/外部APIなど“遅い待ち”の並列度遅いクエリ、外部障害、タイムアウトDB接続数、同時クエリ上限、サーキットブレーカ

ここで重要なのは、「同時接続が多い」=「スレッドを増やす」ではないという点です。同時接続は非同期I/Oでさばき、同時CPU処理だけをスレッド数(ワーカー数)でコントロールする、という切り分けができると設計が一気に安定します。

まず押さえる:acceptが止まる条件と「バックログ」の正体

「acceptができない=即死」ではありません。TCPサーバーでは、OSカーネル側に接続待ちキュー(バックログ)があり、ハンドシェイク済みの接続は一定数までここに溜まります。アプリがacceptを呼ぶのは、そのキューから接続を取り出す行為です。

つまり、acceptが一瞬止まっても、バックログに余裕がある間は新規接続がすぐに失われるとは限りません。ただし、バックログが埋まると以下の現象が起きます。

  • 新規接続が遅延したり、タイムアウトしたりする
  • クライアントがリトライを始め、さらに負荷が増える(スパイラル)
  • 監視上は「エラーが増えた」ように見える

よって「acceptを止めない」ことは重要ですが、方法は2つあります。

  • accept自体を軽くして、すぐ戻る(受けて渡すだけ)
  • accept担当を他の重い処理から隔離する(専用スレッド/イベントループ)

スレッドを無制限に増やす設計が危険な理由

「上限なしでスレッドを増やせば、忙しくてもどこかが動くはず」という発想は一見合理的ですが、OSとCPUの現実にぶつかります。スレッドを増やしすぎると、アプリは“仕事”ではなく“切り替え”に時間を使い始めます。

増やしすぎで起きること現象症状対策の方向性
コンテキストスイッチ増大CPUがスレッド切り替えに忙しくなるCPU使用率は高いのにスループットが伸びない、レイテンシが跳ねるスレッドプール化、I/Oを非同期化、CPU処理をキューで平準化
メモリ消費の増大スレッドスタックやTLSなどが積み上がるメモリ不足、スワップ発生、OOMスレッド数に上限、スタックサイズ調整、1接続=1スレッドの回避
ロック競合の増大共有資源にアクセスが集中待ち時間だらけでスレッドが増えるほど遅くなる共有を減らす、シャーディング、ロック粒度の見直し、無駄な共有を消す
キャッシュ効率低下CPUキャッシュが汚れる同じ処理でも遅くなる、揺らぎが増える処理の局所性を上げる、スレッド固定化(場合によりアフィニティ)

特に「無制限スレッド」は、負荷が上がった瞬間に“防波堤”がないため、サーバーが自滅的にリソースを食い尽くす危険があります。実務では、スレッド数よりも同時に走らせる重い処理の上限を決め、キューで制御するのが安定します。

「1接続=1スレッド」がスケールしにくい具体的な理由

1接続=1スレッドは分かりやすい反面、同時接続が増えるタイプのサーバー(HTTP Keep-Alive、チャット、通知、ゲーム、IoTなど)では壁に当たりやすい構成です。理由は大きく3つあります。

  • 待ち時間が長い:ソケット待ちやクライアントの入力待ちの間、スレッドが“何もしない”のに存在し続けます。
  • メモリを先に使い切る:スレッドごとにスタック領域などが必要で、接続数に比例してメモリが増えます(デフォルトは環境により数MB単位になることもあります)。
  • ピーク時に悪化が急:スレッドが増えるほど切り替えが増え、レイテンシが雪崩のように悪化しやすいです。

もし「同時接続は数十〜数百程度で頭打ち」「接続は短命で、すぐ終わる」なら、1接続=1スレッドでも現実的なことがあります。しかし「同時接続が数千〜数万」を目指すなら、早い段階でイベント駆動や非同期I/Oを検討した方がコストが下がります。

代表的なソケットサーバー設計パターン

C++のソケットサーバーでよく見かける設計を、メリット・デメリットとともに整理します。どれを選ぶかで「スレッド数の正解」が変わります。

パターン概要向いているケース弱点
1接続=1スレッド(ブロッキングI/O)acceptしたらスレッドを割り当て、read/writeをそのスレッドで実行接続数が少ない、実装を最短で作りたい、プロトタイプ接続が増えるとスレッド爆発。メモリと切り替えで崩れる
固定スレッドプール(ブロッキングI/O)acceptしたソケットをキューに積み、ワーカースレッドが処理リクエストが短命で、接続を握り続けない(例:短いTCPリクエスト)Keep-Aliveや長時間接続だとワーカーが占有され、並列度が頭打ち
イベント駆動(非同期/ノンブロッキングI/O)+ ワーカープール少数のI/Oスレッドが多数のソケットを監視し、重い処理は別プールへ同時接続が多い、I/O待ちが多い、スケールさせたい実装がやや複雑。状態管理とキュー設計が必要

「全スレッドが忙しくなってacceptできないのでは?」という懸念は、上の表でいうとブロッキングI/Oでワーカーが占有される設計で起きやすい問題です。イベント駆動に寄せるほど、acceptや受信は“薄い処理”にでき、詰まりにくくなります。

非同期I/O(イベント駆動)を選ぶときの現実的な選択肢

非同期I/Oと一口に言っても、OSや採用ライブラリで体験が変わります。ここでは「何を選ぶと何が得られるか」をざっくり把握できるようにまとめます。

環境代表的なI/O機構特徴実装の近道
Linuxepoll(イベント通知)実績が多く、学習コストと運用知見が豊富Boost.Asio、libevent、独自epollループ
Linux(より新しめ)io_uring高性能を狙えるが、使いこなしはやや上級者向け対応ライブラリの検討、段階導入
WindowsIOCP(I/O Completion Port)多数接続に強い。Windows系の高性能サーバーで定番Boost.Asio、独自IOCP、AcceptExなど
汎用select/poll移植性は高いが、大量FDでは不利になりやすい小規模なら可。大規模ではepoll/IOCPへ

「まずは実装コストを抑えたい」「C++で安全に非同期を組みたい」という場合、Boost.Asioのような実績ある抽象化を使い、内部でepoll/IOCPに乗せる形は現場でもよく選ばれます。自前実装は最適化余地が大きい反面、タイムアウト・切断・部分受信・メモリ管理などの落とし穴も増えます。

実務で崩れにくい「役割分担」アーキテクチャ

実務で安定しやすいのは、次のように役割を分ける構成です。ポイントは「I/O担当は待たない」「重い処理はキューで流量を制御する」です。

(推奨イメージ)
[accept/接続管理]  ──>  [I/Oイベントループ(受信/送信)]  ──>  [処理キュー]  ──>  [CPUワーカープール]
                                                       └──>  [DB/外部I/O用キュー] ──> [DBワーカープール]
  • accept/接続管理:新規接続を受け、ソケットをノンブロッキング化し、接続テーブルに登録するだけ。重い処理はしない。
  • I/Oイベントループ:受信したらバッファに溜め、プロトコル解析の“入口”まで。送信はキューに入ったデータを吐くだけ。
  • CPUワーカープール:暗号、圧縮、JSON処理、画像処理などCPUを食う処理を担当。スレッド数は制御対象。
  • DB/外部I/O用プール:DBや外部APIなど遅延が大きい処理を隔離。ここが詰まってもI/Oループが止まらないようにする。

この構成にすると、「acceptが止まる」のではなく「処理キューが伸びる」形で負荷が見えるようになります。負荷が上がっても、サーバーが即座にスレッド爆発しないため、劣化が“段階的”になります。

スレッド数は「コア数に揃える」が常に正しいわけではない

スレッド数の考え方は、処理がCPUバウンドかI/Oバウンドかで変わります。ここを混ぜると「30〜31本が良いのか?」の議論が噛み合わなくなります。

処理の性質例スレッドが増えるとワーカー数の目安
CPUバウンド暗号化/復号、圧縮、画像処理、重いパース、機械学習推論コア数を超えると切り替えとキャッシュミスで逆効果になりやすい物理コア数前後(まずは物理=16、次に論理=32を試す)
I/Oバウンドソケット待ち、DB待ち、外部API待ち、ディスク待ち待機中スレッドが増えるだけでCPUは空く。スレッド多めでも成り立つ場合があるブロッキングI/Oなら多めになりがち。可能なら非同期化して少数化
混在(現実の多く)受信→軽い解析→DB→整形→返信どこかの待ちで占有が起きると急に詰まりやすい役割分担し、CPU部分だけを「コア数近辺」に制御する

特に32論理コア(16C/32T)のような環境では、ハイパースレッディング(SMT)により論理コア数が増えますが、CPUバウンド処理の性能が単純に2倍になるわけではありません。“重い処理だけ”は物理コア基準で考え、I/Oは非同期化して薄く保つと設計が安定します。

32論理コア(16C/32T)での現実的な初期設定例

「30〜31スレッドに絞るべき?」の問いに対し、実務では“全部を同一カテゴリのスレッドとして数える”こと自体を避けます。役割ごとにスレッドを分け、どこがボトルネックかを見ながら調整します。以下は初期値の例です。

想定ワークロードaccept/接続管理I/OイベントループCPUワーカープールDB/外部I/O狙い
同時接続が多い(Keep-Alive多め)、処理は軽い12〜48〜16別途(非同期/接続プール)I/Oを詰まらせず、CPUは過剰に増やさない
CPU重め(暗号/圧縮/画像処理)12〜416前後(物理コア基準)必要なら別プールCPUの過飽和を避け、キューで平準化
DB待ちが支配的(遅いクエリ/外部API)12〜48〜16DB側に上限(例:接続数、同時クエリ数)DBを守る。サーバー側は待ちで膨れないようにする

重要なのは「合計スレッドが30か31か」ではなく、CPUを食う区間の並列度をいくつに制限するかです。I/Oイベントループは“薄い”ため、合計がコア数を少し超えても問題になりにくい一方、CPUワーカーを過剰にするとレイテンシが急に悪化します。

「accept専用に1〜2スレッド予約」は必要か

結論としては、専用スレッドを置く設計はよくありますが、本質は「acceptが重い処理に巻き込まれないこと」です。判断を整理すると次の通りです。

状況accept専用スレッドコメント
ブロッキングI/Oで、接続ごとの処理が長い置くべきacceptがワーカー占有に巻き込まれやすい。受けるだけのスレッドを隔離すると改善しやすい
イベント駆動で、I/Oスレッドがノンブロッキング必須ではないイベントループがacceptも扱える。とはいえ実装単純化のために1本分けるのはアリ
接続スパイクが激しい(短時間で大量の新規接続)検討価値ありaccept処理自体の回転数がボトルネックになり得る。複数acceptやSO_REUSEPORT等も選択肢

「スレッドを空ける」という発想は、“同じプールに全部を入れている”ときの応急処置になりやすいです。設計を分離できるなら、空けるよりも止めたくない処理を別レーンにする方が再現性のある解決になります。

acceptを複数にするのはどんなときか

多くのサーバーではacceptは軽く、1スレッドで十分なことがほとんどです。ただし、次の条件がそろうと「acceptを並列化したい」局面が出ます。

  • 新規接続が非常に多い(接続の生成・破棄が激しい)
  • 受けた直後にやるべき作業(TLSハンドシェイクなど)が重く、accept直後の処理が薄くできていない
  • 複数プロセス/複数リスナーで負荷分散したい(OS機能を使った分散)

この場合は「acceptスレッドを増やす」より、accept直後の処理をさらに薄くする、あるいはプロセス/ソケットの分散機構(例:ポート共有や複数リスナー)を検討する方が効果が出やすいです。むやみに増やすと“同じ接続待ちに複数スレッドが起きる”問題(いわゆるthundering herd)を招くことがあるため、設計とOS機能の理解が必要になります。

負荷が上がったときに崩れないための「バックプレッシャ」設計

スレッド数を決めても、ピーク負荷や異常系(DB遅延、外部API障害、攻撃的な接続増加)でサーバーは簡単に詰まります。ここで重要なのが、負荷を受け止める“仕組み”です。

キューの上限を決める

CPUワーカーへの投入キューは、無限に伸びるとメモリが先に死にます。上限を決め、溢れたら捨てる/遅延させる/早期エラーで返す方が、全体としては健全です。

  • HTTPなら過負荷時に503を返す、あるいは低優先度のリクエストを落とす
  • 社内プロトコルなら「後で再送」可能なエラーコードを返す
  • ログは必ず「落とした理由」「キュー長」「処理時間」を残す

I/O側で“読みすぎない”

I/Oイベントループが受信を続けると、処理キューが膨張してしまいます。そこで、キューが閾値を超えたら一時的に読み取りイベントを外す、あるいは受信バッファを抑えるなどの制御を入れます。これはTCPのフロー制御とも相性が良く、無理にアプリ内バッファを膨らませないために有効です。

外部I/O(DB/HTTP)を隔離する

DB待ちや外部API待ちをCPUワーカーで抱えると、CPUワーカーが“待ち”で埋まり、CPU処理が進まなくなります。外部I/Oは別プールや非同期APIで隔離し、同時実行数を制限します。DBであれば接続数や同時クエリ数を上限管理し、アプリ側はキューに積んで待たせる方がシステム全体が安定します。

タイムアウトを「層ごと」に設定する

ソケット受信、アプリ内キュー待ち、DB待ち、外部API待ちに、それぞれタイムアウトを置きます。1つのタイムアウトに丸めると、どこで詰まっているか分からず、復旧が遅れます。層ごとのタイムアウトは、障害時の切り分けと自動回復に効きます。

計測して調整する:スレッド数チューニングの現場手順

スレッド数は“設定して終わり”ではなく、負荷試験と本番計測で詰めます。机上の理屈より、ボトルネックがCPUなのかI/O待ちなのかを見分ける方が重要です。

見るべき指標何が分かるかありがちな誤解次のアクション
レイテンシ(p50/p95/p99)ユーザー体感の劣化点平均だけ見るとスパイクを見落とすp95/p99が跳ねた瞬間のCPU・キュー長・外部I/Oを関連付ける
キュー長(処理待ち件数)どこで詰まっているかスレッドを増やせば解決すると思いがちキューが伸びるなら、処理時間短縮か並列度の適正化、または落とす設計
コンテキストスイッチ数スレッド過多の兆候CPU使用率だけでは分からないスレッド削減、ロック削減、I/O非同期化を検討
CPU使用率(ユーザー/システム)CPUが本当に詰まっているか高い=悪いではないCPUバウンドならワーカー数調整。システム比率が高いならI/Oやロックを疑う
外部I/O時間(DB/API)待ちの原因アプリの最適化だけで治ると思うクエリ改善、キャッシュ、タイムアウト、同時実行数制限

この計測を回すと、「accept用に1〜2スレッド空けるべき?」という問いは、「acceptが止まったときバックログがどれだけ溢れているか」「I/Oループがブロックしていないか」という、より具体的な改善に変わります。

サンプル:I/Oスレッドを薄く保ち、重い処理だけをプールで制御する

ここでは考え方が伝わるよう、概念コードを示します。実運用ではエラー処理、部分受信、送信バッファ、TLS、タイムアウトなどが必要ですが、「I/O担当が待たない」「処理をキューに渡す」という骨格は同じです。

#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

// ざっくりした固定サイズワーカープール
class ThreadPool {
public:
  explicit ThreadPool(size_t n) : stop_(false) {
    workers_.reserve(n);
    for (size_t i = 0; i < n; ++i) {
      workers_.emplace_back([this] {
        for (;;) {
          std::function<void()> job;
          {
            std::unique_lock<std::mutex> lk(mu_);
            cv_.wait(lk, [&] { return stop_ || !q_.empty(); });
            if (stop_ && q_.empty()) return;
            job = std::move(q_.front());
            q_.pop();
          }
          job();
        }
      });
    }
  }

  // 重要:キュー上限を設け、過負荷時の振る舞いを決めるのが実務
  bool try_submit(std::function<void()> job, size_t max_queue = 10000) {
    {
      std::lock_guard<std::mutex> lk(mu_);
      if (q_.size() >= max_queue) return false; // ここで落とす/エラーにする等
      q_.push(std::move(job));
    }
    cv_.notify_one();
    return true;
  }

  ~ThreadPool() {
    {
      std::lock_guard<std::mutex> lk(mu_);
      stop_ = true;
    }
    cv_.notify_all();
    for (auto& t : workers_) t.join();
  }

private:
  std::mutex mu_;
  std::condition_variable cv_;
  std::queue<std::function<void()>> q_;
  std::vector<std::thread> workers_;
  bool stop_;
};

// I/Oスレッド(例:epoll/IOCP等でread/writeイベントを回す)から、重い処理を投げる
void on_message_received(ThreadPool& cpu_pool, int client_fd /*socket*/) {
  // 受信データをコピー/参照して解析・ビジネスロジックへ
  const bool ok = cpu_pool.try_submit([client_fd] {
    // ここがCPUを食う区間:パース、検証、暗号、圧縮、ルーティングなど
    // ここでDBや外部HTTPを同期で呼ぶと、CPUワーカーが「待ち」で埋まるので注意
    // 結果を送信用キューへ積み、I/Oスレッドが送る
  });

  if (!ok) {
    // 過負荷:この接続/リクエストを落とす、簡易エラーを返す等
  }
}

この形のメリットは、CPUワーカー数を固定できるため、32論理コア環境でも「CPUを食う処理の並列度」を明確にコントロールできる点です。acceptや受信はI/O層で薄く処理し、詰まりはキューとして見えるようになります。

よくある落とし穴と対策

「ワーカープールに投げたのに、結局I/Oスレッドが重い」

I/Oスレッド側で、巨大なコピーや重いプロトコル解析、同期ログ書き込みをしていると、イベントループが止まります。I/Oスレッドは「受けて区切って渡す」程度に留め、重い作業はワーカーへ寄せます。ログも非同期化やバッファリングを検討します。

「DBが遅いとき、スレッドを増やしても治らない」

DB待ちがボトルネックなら、スレッドを増やしても待ち人数が増えるだけです。むしろDBを攻撃して状況を悪化させがちです。DB側の同時実行数上限、タイムアウト、サーキットブレーカ、キャッシュなど、“遅い外部”を前提に守る設計が必要です。

「共有キュー1本がボトルネックになった」

スレッド数を増やすと、1本のmutex付きキューはすぐ詰まります。対策は、用途別にキューを分ける、接続ごと/コアごとにシャーディングする、ワークスティーリングを使うなどです。まずは計測で「ロック待ちが支配的」か確認し、必要な範囲だけ改善します。

「スレッド数をコア数に揃えたのにレイテンシが悪い」

CPUワーカー数をコア数に揃えても、I/O待ちやロック待ちが混ざっていると、実効的な並列度が落ちます。逆に、CPUが軽いのにワーカーが少なくて待ちが出ているケースもあります。p99レイテンシ、キュー長、外部I/O時間をセットで見て調整します。

「接続が増えるとメモリがじわじわ増える」

イベント駆動にしても、接続ごとの送受信バッファや未処理データが溜まるとメモリは増えます。接続ごとの上限(最大リクエストサイズ、最大保留レスポンス量)を決め、超えたら切断するなどのルールが必要です。攻撃的なクライアント対策としても有効です。

まとめ:答えは「上限の数字」ではなく「止めないための分離」

C++ソケットサーバーのマルチスレッド設計で悩みやすい「スレッド数を制限すべきか」問題は、単純に“30本が正解”のような話ではありません。

  • 無制限に増やすと、コンテキストスイッチやメモリで自滅しやすい
  • acceptを守りたいなら、accept/受信を重い処理から隔離する
  • 同時接続が多いなら、非同期I/O(イベント駆動)でI/O層を薄くする
  • CPUを食う処理は、固定サイズのワーカープール+キューで並列度を制御する
  • 32論理コアなら、CPUワーカーはまず物理コア基準で置き、計測で増減する

最終的には、あなたのサーバーが「CPUで詰まっているのか」「外部I/Oで詰まっているのか」「ロックで詰まっているのか」を計測して決めるのが最短ルートです。スレッド数は“結果”であり、“設計の分離”が先にあります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次