メインコンテンツへスキップ
  1. Posts/

VUser 300人、Tomcat Thread 60個 — 残り240個のリクエストはどこへ行ったのか?

NineKoo9
著者
NineKoo9
目次

前の記事: Spring Boot Tomcat threads.maxとHikariCP maximum-pool-sizeをデフォルトに設定したときに現れる現象

前回の記事でTomcatのthreads.maxを200から60に減らし、HikariCPのmaximum-pool-sizeを10から40に増やして応答時間を5〜10倍改善しました。

しかし一つの疑問が残りました。

仮想ユーザー(VUser)が300人同時にトラフィックを発生させているのに、TomcatのThread Poolのサイズを60個に減らしたら、残りの240個のリクエストはスレッドを取得するために競合しないのか?

スレッドを減らしたのに性能が良くなったとすれば、減ったスレッドで同じトラフィックをどのように処理できたのかを理解する必要があります。


よくある誤解 — 「1 Connection = 1 Thread」
#

クライアントが接続するかリクエストを送ると → TomcatがすぐにWorker Threadを一つ割り当て → そのスレッドがコネクションのライフサイクル全体を担当する。

Spring MVCの同期サーブレット処理では、実際に実行中のリクエスト1つがWorker Threadを1つ占有することは正しいです。Controller、Service、DB I/Oを実行している間、そのリクエストは1つのWorker Thread上で処理されます。

しかしこれを接続済みクライアント数や到着したリクエスト数だけWorker Threadがすぐに必要になるという意味で理解すると誤解になります。このモデルではVUser 300人が同時に接続するとWorker Threadも300個必要だと見なすことになり、スレッドが60個なら残りの240個のリクエストは拒否されるか失敗しなければなりません。

しかし実際にはそうではありませんでした。VUser 300人の負荷でもリクエストは消えず、Error Rateは0%でした。

この違いを理解するには、Tomcatが**コネクション(Connection)スレッド(Thread)**をどのように管理するかを知る必要があります。


Tomcatの大きな構造 — CoyoteとCatalina
#

Spring Bootの内蔵Tomcatは大きく2つのコンポーネントに分かれます。

**Coyote(コネクター)**はソケットI/OとHTTPプロトコル処理を担当します。ブラウザが接続を確立するとソケットからバイトストリームを読み込んでHTTPをパースし、HttpServletRequest / HttpServletResponseオブジェクトを生成します。その後Catalinaのinvoke()を呼び出して制御権を渡し、応答が完成するとまたバイトにシリアライズしてソケットに送信します。

**Catalina(サーブレットコンテナ)**はサーブレットのライフサイクルとリクエストのルーティングを管理します。Spring Bootではアプリ一つがCatalinaのContext一つに該当します。DispatcherServletはSpring MVCのフロントコントローラーで、urlPatterns = "/"でこのContextに登録されているため、アプリケーションに入ってくるほとんどのWebリクエストがまずこのサーブレットを通過します。

ブラウザ
  → TCPソケット接続
  → Coyote:ソケットI/O → HTTPパース → Req/Resオブジェクト生成 → invoke()
  → Catalina(Context = Spring Bootアプリ)
  → DispatcherServlet → HandlerMapping → @Controller
  → 応答 → Coyote → ソケット → ブラウザ

この構造を理解すれば、以下で扱うNIO ConnectorがCoyote内部のソケットI/O処理方式であることがわかります。


Tomcat NIO Connectorアーキテクチャ
#

Spring Boot 3.xで使用する組み込みTomcatはデフォルトでNIO(Non-blocking I/O)Connectorを使用します。NIO Connectorはリクエストを処理するために3段階のパイプライン構造で動作します。

NIO Connector 3段階パイプライン

第1段階:Acceptor Thread
#

AcceptorはスレッドがTCPが1〜2個で構成されます。OSのaccept queueからTCP接続を受け入れ、受け入れられたソケットチャンネル(SocketChannel)をPollerに登録する役割を担います。

Client → TCP 3-way handshake → OS accept queue → Acceptor.accept() → Pollerに登録

Acceptorは接続を受け入れるだけで、リクエストデータを読んだりビジネスロジックを実行したりしません。

第2段階:Poller Thread
#

PollerはJava NIOのSelectorを使用して少数のスレッドで数千個のコネクションを監視します。

Pollerの役割は登録されたソケットチャンネルを監視して、特定のチャンネルに読み込めるデータが到着したら、そのソケットをWorker Thread Poolに渡すことです。

// Pollerの核心動作(概念的なコード)
Selector selector = Selector.open();

while (running) {
    // 登録された数千個のチャンネルのうちデータが準備できたチャンネルを検知
    selector.select();

    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    for (SelectionKey key : selectedKeys) {
        if (key.isReadable()) {
            // データが到着したソケットだけをWorker Thread Poolに渡す
            workerThreadPool.execute(new SocketProcessor(key));
        }
    }
}

ここでselector.select()がどのようにたった一度の呼び出しで数千個のコネクションを監視できるかをもう少し詳しく見てみましょう。

Acceptorが新しいコネクションを受け入れると、そのソケットチャンネルはselector.register(channel, OP_READ)を通じてSelectorに登録されます。Selectorは内部的に登録されたすべてのチャンネルのファイルディスクリプター(fd)を管理します。

selector.select()が呼び出されると、JavaはこのタスクをOSカーネルのI/Oマルチプレクシングシステムコールに委任します。

  • Linuxepoll_wait() — カーネルが登録されたfdの状態変化を監視し、データが到着したfdだけを返す
  • macOSkqueue — 同じ原理でイベントが発生したfdだけを返す

これらのシステムコールは**イベントベース(event-driven)**で動作します。登録された数千個のコネクションを一つずつ巡回して確認するのではなく、カーネルがネットワーク割り込みを通じてデータが到着したソケットをすでに知っているため、準備ができたコネクションだけをすぐに返せます。

Pollerスレッドの動作フロー:

selector.select()  ← 準備できたチャンネルがなければここでブロッキング(CPU消費なし)
       ├── チャンネルAにデータが到着 → Worker Thread Poolに渡す
       ├── チャンネルBにデータが到着 → Worker Thread Poolに渡す
       │   (残りの数百個のチャンネルはまだSelectorに登録されたまま待機)
       └── 再びselector.select()呼び出し → 次のイベントを待機

つまり、Pollerスレッド自体が監視スレッドです。コネクションごとに別の監視スレッドが割り当てられるのではなく、1〜2個のPollerスレッドがselector.select() → イベント処理 → selector.select()のループを繰り返しながらすべてのコネクションを管理します。準備できたチャンネルがなければselect()でブロッキングされてCPUを消費せず、イベントが発生すれば目覚めて該当チャンネルだけをWorker Thread Poolに渡します。

核心: Pollerは1〜2個のスレッドだけで数千個のコネクションを監視します。これが可能な理由はselector.select()がOSカーネルのI/Oマルチプレクシング(epoll/kqueue)を活用して、一度の呼び出しで登録されたすべてのチャンネルの状態を確認するからです。コネクションを維持するために別のWorkerスレッドは必要ありません。

第3段階:Worker Thread Pool
#

Worker Thread Poolはthreads.max(デフォルト値200、現在の設定60)分のスレッドで構成されます。Pollerから渡されたソケットからHTTPリクエストを読み込み、DispatcherServletを呼び出して実際のビジネスロジックを実行します。

Poller → Workerスレッド割り当て → HTTPリクエストパース → DispatcherServlet → Controller → Service → DB I/O → 応答作成 → Workerスレッドを返す

Workerスレッドはビジネスロジックの実行が終わるとプールに返され、次のリクエストを処理する準備をします。


では残りの240個のリクエストはどこへ行ったのか?
#

VUser 300人が同時にリクエストを送り、Worker Threadが60個のときの流れを整理すると以下のようになります。

3階層キューダイアグラム
  1. 300個のTCPコネクションがTomcatに確立されます。max-connectionsのデフォルト値は8192(NIO/NIO2)のため、300個は全容量の3.7%に過ぎません。すべてのコネクションが問題なく収容されます。

  2. Pollerが300個のコネクションをSelectorに登録して監視します。このときWorkerスレッドは消費されません。

  3. 300個のコネクションからデータが到着すると、Pollerはこれを検知してWorker Thread Poolにタスクを渡します。

  4. 60個のWorkerスレッドが先に到着した60個のリクエストを処理します。残りの240個のリクエストはWorker Thread PoolのTaskQueueで待機します。

  5. Workerスレッドが一つのリクエスト処理を完了すると、TaskQueueで待機している次のリクエストを取り出して処理します。

結論: 240個のリクエストは消えたのではなく、NIO Pollerがコネクションを維持した状態でWorker Thread Poolのキューで順番を待っていました。


リクエストが待機する3つの階層
#

Tomcat NIO Connectorにはリクエストが待機できる3つの階層が存在します。各階層は互いに異なる設定値で制御されます。

階層設定値デフォルト値役割
OS TCP backlogserver.tomcat.accept-count100max-connections到達時のOSカーネルレベルのTCP接続待機列
Tomcat NIOserver.tomcat.max-connections8192(NIO/NIO2)サーバーが受け入れて処理する最大コネクション数
Worker Thread Poolserver.tomcat.threads.max200実際にリクエストを処理するワーカースレッド数

各階層の動作
#

第1階層 — Worker Thread Pool(threads.max

すべてのWorkerスレッドが忙しければ、新しいリクエストはWorker Thread Poolの内部TaskQueueに積み上がります。スレッドが返されるとキューで待機中のタスクを一つずつ処理します。

第2階層 — Tomcat NIO(max-connections

Tomcatが受け入れてPollerに登録できる最大コネクション数です。Tomcatの公式ドキュメントによるとNIO/NIO2のデフォルト値は8192です。この値に達するとAcceptorは新しいコネクションの受け入れを一時停止し、既存のコネクションが閉じるまで待ちます。

第3階層 — OS TCP backlog(accept-count

max-connectionsに達してAcceptorが受け入れを停止すると、その後に入ってくるTCP接続リクエストはOSカーネルのTCP backlogキューに積み上がります。このキューのサイズがaccept-count(デフォルト値100)です。このキューまでいっぱいになると、OSは新しい接続リクエストを**拒否(Connection Refused)**します。

[VUser 300]
[OS TCP Backlog] ─── accept-count:100(max-connections到達時のみ使用)
[Acceptor Thread] ─── コネクション受け入れ
[Poller(Selector)] ─── max-connections:8192(コネクション監視、スレッド消費なし)
[Worker Thread Pool] ─── threads.max:60(実際のリクエスト処理)
[DispatcherServlet → Controller → Service → DB]

ではTomcatが拒否せずに受け入れられる最大接続数は?
#

3階層の設定値を合算するとTomcatがConnection Refusedなしに収容できる総TCP接続数を計算できます。

収容可能な最大接続数 = max-connections + accept-count
                   = 8192 + 100
                   = 8292個

ここで注意すべき点は、この8292個の接続がすべて同時に「処理」されるわけではないということです。各階層での状態が異なります。

階層接続数状態
Worker Thread Poolthreads.max個(60個)実際にビジネスロジックを実行中
Tomcat NIO Poller最大8192個Selectorに登録されて監視中(スレッド消費なし)
OS TCP Backlog最大100個OSカーネルでTCP接続だけが確立されたまま待機

つまり、8292個の接続を拒否なしに受け入れることができますが、そのうち実際に同時にリクエストを処理するのはthreads.max個だけです。残りはPollerで監視されるかWorker Thread PoolのTaskQueueで待機する状態です。

**8293番目のTCP接続からはOSがConnection Refusedを返します。**3階層すべてが満杯の状態でこれ以上接続を収容する空間がないためです。

私たちの状況では?
#

VUser 300人の場合:

  • 300接続 « 8192(max-connections) → すべてのコネクションが収容可能、全容量の3.7%に過ぎない
  • OS TCP backlog(accept-count:100)はmax-connectionsに達したときだけ使用される待機列のため、このシナリオではキューが空の状態
  • 60個のWorkerスレッドが300個のリクエストを順次処理し、残りの240個はTaskQueueで待機

BIO vs NIO — なぜ以前のモデルではこれが不可能だったのか?
#

Tomcat 7以前はBIO(Blocking I/O)Connectorがデフォルトでした。BIOではコネクションとスレッドが1:1でマッピングされます。

項目BIO(Blocking I/O)NIO(Non-blocking I/O)
Connection:Threadの比率1:1N:1
max-connectionsのデフォルト値= threads.max8192
アイドルコネクションのコストスレッド1個を占有(高コスト)Selectorに登録(低コスト)
threads.max=60の場合最大60個のコネクションのみ可能8192個のコネクションが可能

BIOではthreads.max=60なら同時に60個のコネクションしか維持できませんでした。61番目の接続はスレッドが返されるまで待たなければなりませんでした。この構造ではスレッドを減らすと即座に処理できるコネクション数が減るため、同じトラフィックで性能が低下する可能性がありました。

しかしNIOではコネクション管理とリクエスト処理が分離されています。Pollerが数千個のコネクションをスレッドなしで維持するため、threads.maxを減らしてもコネクションの収容能力には影響がありません。

Spring Boot 3.xの組み込みTomcatはNIO Connector(Http11NioProtocol)をデフォルトで使用します。


コードで直接確認する
#

理論だけでは確信しにくいです。実際にコネクション数とスレッド数が独立して動作するかをコードで確認してみましょう。

テスト実行環境: 以下のテストはアプリケーションをまず実行した状態でlocalhost:8080に直接HTTPリクエストを送る方式です。@SpringBootTestを使った統合テストではないため、テスト前にapplication.ymlの設定を変更してからサーバーを実行する必要があります。

テスト1:Slow Endpointを使ったバッチ処理の確認
#

まず、意図的に遅いエンドポイントを作ってWorkerスレッドが占有された状態を維持します。

@RestController
public class SlowEndpointController {

    @GetMapping("/test/slow")
    public String slowEndpoint() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        long startTime = System.currentTimeMillis();

        Thread.sleep(3000); // 3秒間スレッドを占有

        long elapsed = System.currentTimeMillis() - startTime;
        return String.format("Thread: %s, Elapsed: %dms", threadName, elapsed);
    }
}

そしてapplication.ymlthreads.max=5に設定した後、同時に20個のリクエストを送ります。

# application.yml
server:
  tomcat:
    threads:
      max: 5
@Test
@DisplayName("threads.maxより多い同時リクエストを送ってもすべてのリクエストが処理される — Connection ≠ Thread の確認")
void batchProcessing() throws Exception {
    // given — スレッド(5個)より4倍多い同時リクエストを準備
    int totalRequests = 20;
    ExecutorService executor = Executors.newFixedThreadPool(totalRequests);
    CountDownLatch latch = new CountDownLatch(totalRequests);
    List<Future<String>> futures = new ArrayList<>();

    HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    long testStart = System.currentTimeMillis();

    // when — 20個のリクエストを同時に送信
    for (int i = 0; i < totalRequests; i++) {
        futures.add(executor.submit(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create("http://localhost:8080/test/slow"))
                        .GET()
                        .build();

                HttpResponse<String> response = client.send(request,
                        HttpResponse.BodyHandlers.ofString());

                long elapsed = System.currentTimeMillis() - testStart;
                return String.format("[%5dms] Status: %d, Body: %s",
                        elapsed, response.statusCode(), response.body());
            } finally {
                latch.countDown();
            }
        }));
    }

    latch.await(30, TimeUnit.SECONDS);

    // then — すべてのリクエストが5個ずつバッチで処理されて応答を受け取る
    for (Future<String> future : futures) {
        System.out.println(future.get());
    }

    executor.shutdown();
}

結果
#

Slow Endpointテスト

結果を見ると正確に5個ずつ同じ時間に応答が返ってくるのではなく、同じバッチ内でも数ミリ秒の差が発生します。これは20個のクライアントスレッドがリクエストを送るタイミングがわずかに異なり、Workerスレッドがタスクを完了した後にTaskQueueから次のリクエストを取り出すタイミングもOSのスレッドスケジューリングによって変わるためです。重要なのは全体的なパターン — 約3秒、約6秒、約9秒、約12秒の区間にわたって5個ずつまとめて処理され、20個のリクエストが一つも消えなかったという点です。Connection ≠ Threadであることが確認できます。

テスト2:Tomcatの内部状態の照会
#

Tomcatの内部状態をコードで直接確認することもできます。まずTomcatのコネクション数とスレッドプールの状態をJSONで返すエンドポイントを作ります。AbstractProtocolgetConnectionCount()メソッドはTomcatの公式APIで提供されており、JMXを通じてもアクセスできます。

@RestController
@RequiredArgsConstructor
public class TomcatInternalController {

    private final WebServerApplicationContext webServerAppContext;

    @GetMapping("/tomcat/internals")
    public Map<String, Object> getTomcatInternals() {
        TomcatWebServer tomcatWebServer =
                (TomcatWebServer) webServerAppContext.getWebServer();

        Connector connector = tomcatWebServer.getTomcat()
                .getConnector();

        AbstractProtocol<?> protocol =
                (AbstractProtocol<?>) connector.getProtocolHandler();

        ThreadPoolExecutor executor =
                (ThreadPoolExecutor) protocol.getExecutor();

        Map<String, Object> info = new LinkedHashMap<>();

        // Connectorレベル
        info.put("maxConnections", protocol.getMaxConnections());
        info.put("currentConnections", protocol.getConnectionCount());
        info.put("acceptCount", protocol.getAcceptCount());

        // Thread Poolレベル
        info.put("maxThreads", executor.getMaximumPoolSize());
        info.put("currentThreads", executor.getPoolSize());
        info.put("activeThreads", executor.getActiveCount());
        info.put("queueSize", executor.getQueue().size());

        return info;
    }
}

テスト1と同じthreads.max=5の設定で、20個のslowリクエストでスレッドを占有した後に内部状態を照会します。

@Test
@DisplayName("コネクション数とアクティブスレッド数が独立して管理されることを確認する")
void tomcatInternals() throws Exception {
    int totalRequests = 20;
    ExecutorService executor = Executors.newFixedThreadPool(totalRequests);
    HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    // 20個のslowリクエストを同時に送信(各3秒かかる)
    for (int i = 0; i < totalRequests; i++) {
        executor.submit(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create("http://localhost:8080/test/slow"))
                        .GET()
                        .build();
                client.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }

    // リクエストがサーバーに到達するまでの時間を確保した後に内部状態を照会
    Thread.sleep(1000);

    HttpRequest internalsRequest = HttpRequest.newBuilder()
            .uri(URI.create("http://localhost:8080/tomcat/internals"))
            .GET()
            .build();

    HttpResponse<String> response = client.send(internalsRequest,
            HttpResponse.BodyHandlers.ofString());

    System.out.println(response.body());
    executor.shutdown();
}

結果
#

Tomcatの内部状態照会 — 1回目の実行
Tomcatの内部状態照会 — 2回目の実行

2回実行した結果はどちらもcurrentConnections: 22currentThreads: 5で同じですが、activeThreadsは1回目で2個、2回目で4個と異なりました。これは/tomcat/internalsを照会するタイミングがバッチ間の切り替えタイミングによって変わるためです。queueSizeが0なのも同じ理由で、照会時点ですでに待機中のリクエストがWorkerスレッドに渡された後だったためです。

核心はコネクションが22個維持されている間もスレッドは最大5個しか使用されないという点です。残りのコネクションはPollerがスレッドなしで管理しており、これがNIO Connectorの核心的な動作です。

テスト3:コネクションの限界を超えると?
#

NIOでも限界は存在します。max-connectionsaccept-countを極端に減らして限界を確認してみましょう。

# application.yml
server:
  tomcat:
    max-connections: 10
    accept-count: 5
    threads:
      max: 3

この設定で20個の同時リクエストを送ると:

区間コネクション数状態
1〜10番目10個Tomcatが受け入れ、Pollerで管理
11〜15番目5個OS TCP backlogキューで待機
16〜20番目5個収容不可 — OSが接続拒否またはタイムアウト
@Test
@DisplayName("max-connections + accept-countを超えるリクエストは失敗する")
void connectionLimit() throws Exception {
    // given — max-connections(10) + accept-count(5) = 15個を超える20個のリクエストを準備
    int totalRequests = 20;
    ExecutorService executor = Executors.newFixedThreadPool(totalRequests);
    CountDownLatch latch = new CountDownLatch(totalRequests);
    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failCount = new AtomicInteger(0);

    HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    long testStart = System.currentTimeMillis();

    // when — 20個のリクエストを同時に送信
    for (int i = 0; i < totalRequests; i++) {
        final int requestId = i;
        executor.submit(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create("http://localhost:8080/test/slow"))
                        .GET()
                        .build();

                HttpResponse<String> response = client.send(request,
                        HttpResponse.BodyHandlers.ofString());

                long elapsed = System.currentTimeMillis() - testStart;
                successCount.incrementAndGet();
                System.out.printf("[%5dms] Request %d: SUCCESS (status=%d)%n",
                        elapsed, requestId, response.statusCode());

            } catch (Exception e) {
                long elapsed = System.currentTimeMillis() - testStart;
                failCount.incrementAndGet();
                System.out.printf("[%5dms] Request %d: FAILED (%s)%n",
                        elapsed, requestId, e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(120, TimeUnit.SECONDS);

    // then
    System.out.printf("%n成功:%d、失敗:%d%n", successCount.get(), failCount.get());
    executor.shutdown();
}

結果
#

コネクション限界超過テストのコンソール出力

タイムスタンプを基準に結果を分析すると:

タイミング状態
約3秒3個 SUCCESS1回目のバッチ(threads.max=3
約5秒5個 FAILEDconnectTimeout(5秒)超過でタイムアウト
約6秒3個 SUCCESS2回目のバッチ
約9秒3個 SUCCESS3回目のバッチ
約12秒1個 SUCCESS4回目のバッチ(最初の10個のうちの最後)
約35秒3個 SUCCESSbacklogのリクエスト — keep-alive期限切れ後に受け入れ
約38秒2個 SUCCESSbacklogのリクエスト — keep-alive期限切れ後に受け入れ

最終結果は成功15個、失敗5個です。

まず、Tomcatが受け入れた10個のコネクションはthreads.max:3によって3個ずつバッチで処理されて約3秒、約6秒、約9秒、約12秒にわたってすべて成功します。この間にTCP backlogキューのリクエストのうち5個はクライアントのconnectTimeout(5秒)を超えてクライアントが先にタイムアウトで接続を諦めます。

興味深いのは約12秒に最初の10個の処理がすべて終わったのに、残りのbacklogのリクエストが約35秒にやっと処理されるという点です。約23秒の空白が生じた理由はHTTP/1.1 keep-aliveのためです。応答が終わってもコネクションがすぐに閉じないためmax-connections:10の場所がずっと占有されており、TomcatのデフォルトのkeepAliveTimeout(20秒)が期限切れになって既存のコネクションが解放された後にやっとbacklogのリクエストが受け入れられます。

このテストはNIOでもコネクションの収容限界が存在することを示しています。デフォルト値(max-connections=8192accept-count=100)では合計8292個の同時コネクションを収容できるため、VUser 300は全く問題にならなかったのです。


前の記事の結果を再解釈する
#

NIO Connectorの構造を理解したので、前の記事の結果を再び見てみましょう。

threads.max=200のとき(前の記事のデフォルト設定)
#

前の記事で確認したGrafanaダッシュボードをNIOの観点から再解釈してみましょう。

JVMスレッドの状態 — timed-waiting 197個
HikariCPコネクションの状態 — Active 10個、Pending 189個
  • 200個のWorkerスレッドがすべてアクティブになってリクエストを処理
  • HikariCPのコネクションは10個だけ → 200個のスレッドが10個のコネクションを競合
  • 190個のスレッドがtimed-waiting状態でコネクションを待機
  • 2個のvCPUで200個のスレッド → 過度なコンテキストスイッチング
  • 結果:GET 1秒、POST 1.2秒

threads.max=60のとき(前の記事の最適設定)
#

  • 60個のWorkerスレッドだけがアクティブ
  • 残りの240個のコネクションはNIO Pollerがスレッドなしで管理
  • HikariCPのコネクションは40個 → 60個のスレッドが40個のコネクションを使用(はるかに少ない競合)
  • 2個のvCPUで60個のスレッド → コンテキストスイッチングコストの削減
  • 結果:GET 200ms、POST 125ms

**スレッドを減らしても性能が良くなった本当の理由は、NIOがコネクション管理とリクエスト処理を分離してくれたためです。**おかげで少数のスレッドがリソース競合なしに効率的に働くことができました。

性能向上は2つの要因が共に作用した結果です:

  1. NIOがコネクション管理を分離:スレッドを減らしても300個のコネクションはすべて維持される
  2. スレッド-コネクションプールの比率改善:200:10から60:40へ → timed-waitingの競合が大幅に減少

まとめ
#

この記事では「スレッドを減らしたのになぜ性能が良くなったのか?」という質問に対して、Tomcat NIO Connectorのアーキテクチャを通じて答えを見つけました。

核心内容をまとめると:

  1. Connection ≠ Thread:NIO Connectorではコネクションとスレッドが1:1でマッピングされません。PollerがSelectorを使用して少数のスレッドで数千個のコネクションを管理します。

  2. 3階層キューイングモデル:リクエストはOS TCP backlog → Tomcat NIO(Poller) → Worker Thread Poolの3階層を経て処理されます。各階層はaccept-countmax-connectionsthreads.maxで制御されます。

  3. **threads.maxは並列処理数を制御するものであり、サーバーが収容できるコネクション数を制御するものではありません。**コネクションの収容能力はmax-connections(デフォルト値8192)が決定します。

  4. スレッド数の削減 → 性能向上のメカニズム:NIOがコネクション管理を分離してくれたため、スレッドを減らしてもコネクションは維持されながらコンテキストスイッチングとリソース競合だけが減少しました。

最初は単純に設定値を調整することが性能にどのような影響を与えるかだけが気になっていました。しかしその原因をソケットレベルまで掘り下げていく中で、Tomcatがどのように同時実行性を管理するのか、そしてなぜSpring MVCベースのブロッキングモデルでも少数のスレッドで多くのリクエストを処理できるのかを理解することができました。