Skip to content

Thread Pool Starvation: From Blocking recv() to Reactor I/O

C++ で書いたインメモリ全文検索エンジンが、本番で応答しなくなった。

170万件のインデックスを持ち、MySQL binlog replication でリアルタイム同期し、Keepalived で Active/Standby の HA 構成を組んでいる。上流の Web アプリケーションサーバー 3 台が VIP 経由で TCP 接続してくる。

OpenResty のリバースプロキシで 5xx が継続的に出ていた。netstat を見ると、100本超の ESTABLISHED な TCP 接続が滞留している。プロセスは生きている。デッドロックか。

/proc/<pid>/task/*/wchan で全スレッドの待機状態を調べた。全 worker が futex_wait_queue(条件変数待ち)か inet_csk_accept(accept 待ち)で正常にブロックしていた。デッドロックではない。プロセスは生存しているし、新しく接続すれば応答も返す。それなのに、滞留している接続のリクエストは処理されない。

なぜ 2 本の接続でサーバーが詰まるのか

原因は 3 つの要因の複合だった。

要因 1: 1 接続 = 1 ワーカーのブロッキング I/O

TCP サーバーの設計は「接続ごとに 1 つの worker thread を割り当て、blocking recv() ループで待機する」というものだった。

cpp
// 旧: HandleConnection — worker が接続のライフタイム全体を占有
void HandleConnection(int client_fd) {
    while (!shutdown) {
        ssize_t n = recv(client_fd, buf, sizeof(buf), 0);  // ← block
        if (n <= 0) break;
        std::string response = dispatcher->Dispatch(parse(buf));
        send(client_fd, response.data(), response.size(), 0);
    }
}
persistent 接続とは

HTTP/1.0 では 1 リクエストごとに TCP 接続を開いて閉じていた。HTTP/1.1 以降、1 本の TCP 接続を使い回して複数のリクエストを送る方式が標準になった。これが persistent 接続(keep-alive 接続)だ。接続のオーバーヘッドが減る一方、サーバー側は「いつ次のリクエストが来るかわからない接続」を保持し続ける必要がある。

persistent 接続のクライアントが idle 状態でも、割り当てられた worker は recv() でブロックし続ける。worker が解放されるのは、クライアントが切断するか SO_RCVTIMEO(デフォルト 60 秒)が発火するまで。

要因 2: hardware_concurrency() そのまま

worker 数の自動決定ロジックがこうなっていた。

cpp
ThreadPool::ThreadPool(size_t num_threads) {
    if (num_threads == 0) {
        num_threads = std::thread::hardware_concurrency();  // → 2 on 2-vCPU VM
    }
}

本番 VM は 2 vCPU。worker = 2。persistent 接続が 2 本あるだけで、全 worker が idle recv 待ちに消費され、後続のリクエストはキューに積まれたまま誰にも処理されない。

要因 3: TCP 半開き接続の滞留

TCP 半開き(half-open)とは

TCP 接続の片方が、相手に通知せずに消えた状態。たとえばクライアントのプロセスがクラッシュしたり、途中のネットワーク機器がセッションを落としたりすると、サーバー側はまだ接続が生きていると思い込んだまま recv() で待ち続ける。SO_KEEPALIVE を設定すれば OS が定期的にプローブパケットを送って死活確認するが、デフォルトでは無効か、有効でもプローブ間隔が非常に長い。

上流の Web アプリケーションは SO_KEEPALIVE を設定していなかった。TCP 接続が半開き状態で放置されるケースがあり、サーバー側も Linux カーネルのデフォルト keepalive(idle 2 時間 + 9 回 × 75 秒 ≈ 2 時間 11 分)では実質検知できない。使われていない ESTABLISHED ソケットが何日も残り続けて worker を食い潰していた。

バグは最初からあった

このブロッキング I/O モデルは、最初から設計に埋め込まれていた。以前は 128GB RAM の大型 VM に同居しており、hardware_concurrency() は 8〜16 を返していた。idle persistent 接続がその数に達するまで worker は枯渇しない。Web アプリケーションは基本的に per-request 接続なので、そこまで溜まることは稀だった。

メモリ使用量の増加に伴い、専用の 2 vCPU VM に分離移設した瞬間、閾値が 2 に落ちた。同時に VictoriaMetrics + Grafana をデプロイしたことで OpenResty の 5xx rate が可視化され、「問題がある」と認識できるようになった。

移設がトリガーで、可視化が発見。両方が同日に起きたのは偶然ではない。VM 分離という 1 つの計画の結果だ。

まず止血する

原因がわかれば、応急処置は単純だ。hardware_concurrency() をそのまま使うのをやめ、worker 数に下限を設けた。加えて worker_threads を YAML 設定ファイルから明示指定できるようにし、接続ごとのタイムアウトやキープアライブもチューニング可能にした。

これは worker が枯渇する閾値を引き上げるだけで、本質的な解決ではない。persistent 接続が閾値を超えれば同じ症状が再発する。

Reactor I/O モデルへの移行

この問題は、Web サーバーの世界ではとっくに解決されている。Apache は thread/process-per-connection モデルで同時接続数の壁にぶつかった。nginx が epoll ベースの event-driven アーキテクチャでそれを置き換えた——2000 年代前半の C10K problem だ。

C10K problem とは

1999 年に Dan Kegel が提起した問題。「1 台のサーバーで 1 万の同時接続を捌けるか?」という問い。当時の主流だった thread/process-per-connection モデルでは、1 万スレッドを立てるとコンテキストスイッチとメモリだけでサーバーが飽和した。epoll(Linux 2.6)や kqueue(FreeBSD 4.1)といった I/O 多重化 API の登場が、この壁を突破する鍵になった。

Redis も最初から single-threaded event loop で設計されており、thread-per-connection という選択肢は最初から存在しない。

今回は C10K ではない。たった 2 本の接続で詰まった。設計時にわたしが「接続数はせいぜい数本だろう」という暗黙の前提を置いていて、それが破綻しただけだ。やるべきことは同じ——ブロッキング recv() ループを epoll/kqueue ベースのイベント駆動モデルに置き換える。

接続と worker の分離

reactor パターンとは

イベント駆動型の I/O 設計パターン。1 つのイベントループが複数の I/O ソースを監視し、準備ができたものだけを処理する。Doug Schmidt が 1995 年に体系化した。Node.js の内部(libuv)、nginx、Redis が代表的な実装例。thread-per-connection モデルの対極にある設計で、少数のスレッドで大量の接続を効率的に捌ける。

fd(ファイルディスクリプタ)とは

Unix 系 OS がファイルやソケットなどの I/O リソースを識別するための整数値。TCP 接続 1 本につき 1 つの fd が割り当てられる。reactor モデルでは、この fd を epoll/kqueue に登録してイベントを監視する。プロセスごとに上限があり(デフォルトで 1024、設定で数万に拡張可能)、これが理論上の最大同時接続数になる。

これが reactor モデルの核心だ。idle な persistent 接続は event loop の fd 登録テーブルに存在するだけで、worker thread を消費しない。完全なコマンドフレームが到着して初めてタスクとして worker に投入される。

「接続数 ≤ worker 数」という制約が消滅した。

接続数     : 10,000 でも OK (fd テーブルのみ)
worker 数  : CPU×2 程度 (dispatch の CPU bound 分)
event loop : 1 thread

1 接続 1 タスクのパターン

1 本のイベントループスレッドが全 fd の readiness を監視し、readable になった fd のデータを non-blocking recv() で読み取る。完全なフレームを worker thread に渡し、worker は dispatch + response 送信のみを担当する。

CAS(Compare-And-Swap)とは

「現在の値が期待値と一致していたら、新しい値に書き換える」というアトミック操作。mutex を使わずにスレッド間の競合を制御できる。C++ では std::atomic::compare_exchange_strong() で使う。

接続ごとに atomic なフラグを持ち、CAS で 1 接続あたり同時に 1 つのタスクしか飛ばないことを保証する。worker がタスクを終えたらフラグをクリアし、その間に新しいフレームが到着していれば再スケジュールする。この「クリアしてから再チェック」のイディオムで取りこぼしを防ぐ。

Non-blocking write と backpressure

backpressure とは

データの送り手が受け手の処理速度を超えて送り続けると、バッファが溢れるか、メモリが枯渇する。backpressure は「受け手が遅いなら、送り手を止めるか制限する」という仕組みの総称。TCP 自体にもウィンドウ制御という backpressure があるが、アプリケーション層でも独自の上限を設けることが多い。

EAGAIN とは

non-blocking ソケットに対して send()recv() を呼んだとき、「いま送れる/読めるデータがない。あとでもう一度試してくれ」という意味で返されるエラーコード。blocking モードでは OS がデータの到着を待ってくれるが、non-blocking モードでは即座に EAGAIN を返して制御を戻す。

送信側も non-blocking 化した。worker の EnqueueResponse() はまずインラインで send() を試み、EAGAIN なら write queue に積んで EPOLLOUT の監視を有効にする。event loop の OnWritable() がキューに残った応答を順次送信する。

接続ごとに max_write_queue_bytes(デフォルト 16 MiB)の上限を設け、超過したら強制切断する。1 本の slow reader がサーバーメモリを食い尽くす経路を封じた。

shared_ptr による所有権モデル

ReactorConnectionstd::enable_shared_from_this で管理される。event loop の connection map と worker のタスクが共同所有する。event loop が先に EPOLLHUP で接続を解除しても、worker 側のタスクが応答の書き込みを完走できる。

cpp
auto self = shared_from_this();
bool submitted = thread_pool_->Submit([self]() { self->DrainTask(); });

Lock hierarchy

並行プログラミングでいちばん怖いのはデッドロックだ。reactor には「接続ごとの書き込みロック」と「reactor 全体のライフサイクルロック」の 2 つがある。ルールは単純で、ロックを取る順序を固定する。

event loop はライフサイクルロックだけを使い、書き込みロックには触らない。worker は書き込みロックを握ったままライフサイクルロック(shared)を取れる。逆順は禁止。この制約をヘッダファイルの thread-safety contract コメントに明記した。

Platform abstraction

level-triggered と edge-triggered

epoll や kqueue には 2 つの通知モードがある。level-triggered は「データがある限り毎回通知する」、edge-triggered は「状態が変わった瞬間だけ通知する」。edge-triggered のほうが効率的に見えるが、1 回の通知で読み切れなかったデータを見逃すリスクがある。level-triggered なら読み残しがあっても次の Poll() で再通知されるので、実装が安全になる。

EventMultiplexer 抽象クラスを用意し、Linux 向け EpollMultiplexer(level-triggered, EPOLLRDHUP 常時監視)と macOS/BSD 向け KqueueMultiplexer を実装した。テストでは MockEventMultiplexer を inject し、event-loop ロジックを決定論的に検証できるようにした。

テスト

レイヤテスト内容件数
EventMultiplexerMock + 実 kqueue/epoll で readability/writability/hangup/batch をパラメタライズ26
IoReactor unitStart/Stop lifecycle, Register after Stop 拒否, close callback 発火6
ReactorConnection unitframe parse, task ordering, write queue cap, partial send tracking12
IntegrationE2E, 100+ concurrent clients, disconnect mid-request, graceful shutdown, write backpressure, rate limit, UDS18+
Starvation regression128 persistent idle + 1 late client → late client must respond < 500ms3
Blocking-mode negative control同条件で blocking path が worker 枯渇を再現することを確認2

worker 枯渇のリグレッションテストが特に重要だった。128 本の persistent idle 接続を張ったまま、1 本の late client がクエリを投げて 500ms 以内に応答が返ることを検証する。blocking モードでは同じテストが確実にタイムアウトする。これが negative control になる。

reactor 関連 70 超のテスト含め、計 2,164 テスト全 PASS。

急いでいないのに 8 時間で終わった

止血は worker 数の引き上げで済んでいたので、reactor の設計と実装に時間制約はなかった。急ぐ必要はなかった。それでも問題の発覚からリリースまで 8 時間だった。

以前のわたしなら、投げ出していただろう。epoll/kqueue の platform abstraction、non-blocking write queue、shared_ptr による所有権モデル、lock hierarchy の設計、70 超のテスト——1 日どころか 3 ヶ月かけてもひとりでは無理だ。Claude Code に丸投げした。

統合テストの過程で、rate limiter が reactor パスで無効化されていた問題と、UDS acceptor が二重 bind で起動に失敗する問題を発見した。それぞれ再現テストを書いてから修正した。テストに時間をかけたのは正解だった。

結果

指標BeforeAfter
5xx rate継続発生0
最大同時接続数(理論)worker_threads(= 2)fd 上限(数万)
worker thread 用途recv 待ち(idle 時も占有)dispatch 実行時のみ(µs 単位)
idle 接続のコスト1 thread(数 MB stack)fd 1 本 + 数 KB heap
half-open 検知Linux default 2h + 11m120 秒(60s idle + 3×20s probe)
本番コード+2,077 行(reactor 本体)
テストコード+2,949 行

3 つの学び

hardware_concurrency() をそのまま thread pool サイズにしてはいけない。 VM 環境では vCPU が 1〜2 のインスタンスが普通にある。1 接続 = 1 ワーカーのモデルでは、自動サイズが「同時に受けられるクライアント数の上限」に直結する。

C++ に標準の reactor I/O ライブラリは存在しない。 Networking TS は 2021 年に棚上げ、std::execution(P2300)は C++26 で議論中だが I/O は含まない。事実上の選択肢は standalone Asio、libuv、libevent、あるいは自前実装。今回は依存を増やさない方針で自前実装を選択した(epoll + kqueue の platform abstraction で約 2,000 行)。Boost.Asio を既に使っているプロジェクトであれば、そちらに乗せる方が保守コストは低い。

HA 構成のインメモリ DB で RPM %systemd_postun_with_restart は使ってはいけない。 rpm -Uvh がパッケージ差替え後に自動で systemctl try-restart を呼ぶ。ステートレスなサービスには便利だが、インメモリ DB ではインデックスが即座に消失する。Keepalived の health check が「プロセスの応答有無」だけを見ている場合、空インデックスのまま VIP を保持して本番トラフィックを受けてしまう。%systemd_postun(auto-restart なし)を使い、再起動タイミングは運用者が制御する。

nginx が 20 年前に解決し、Redis が最初から避けた問題を、2026 年に自分で踏んだ。先人の偉大さを実感させられた障害だった。