namalzi

← 글 목록

Tomcat 소스 파헤치기 5: 커넥터와 NIO 엔드포인트 — 연결은 어떤 스레드가 받나

Tomcat · 게시

server.xml 의 <Connector port="8080"> 한 줄이 Connector, Http11NioProtocol, NioEndpoint 라는 세 객체가 되고, 그 안에서 Acceptor·Poller·작업 스레드가 연결 하나를 나눠 맡는 과정을 소스로 따라갑니다. keep-alive 연결이 요청 사이에 스레드를 놓아주는 길, JDK 와 다르게 동작하는 Tomcat 의 스레드 풀(TaskQueue), maxConnections·acceptCount·maxThreads 가 걸리는 자리를 jstack 과 소켓 예제로 확인하고, main 브랜치에서 NIO2 커넥터가 빠진 것도 11.0.x 와 비교합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 다섯 번째 글입니다. 2편에서 Tomcat 을 Catalina(서블릿 컨테이너)와 Coyote(커넥터)로 나눠 보았고, 3편에서 catalina.sh start 뒤에 컴포넌트들이 init()·start() 되는 순서를, 4편에서 웹앱마다 따로 있는 클래스로더를 봤습니다. 이번에는 Tomcat 의 입구로 갑니다. 브라우저가 8080 번 포트에 접속한 순간부터 그 연결이 서블릿을 부를 스레드에 닿기까지입니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 Apache Tomcat
버전 12.0.0-M1 (main)
커밋 17aa1769b0 (2026-10-23)
주로 읽는 클래스 catalina.connector.Connector, coyote.AbstractProtocol, coyote.http11.Http11NioProtocol, tomcat.util.net.NioEndpoint·AbstractEndpoint·Acceptor, tomcat.util.threads.ThreadPoolExecutor·TaskQueue·LimitLatch

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

NioEndpoint.java 하나가 2,274줄이고 AbstractEndpoint.java 는 2,576줄입니다. TLS 핸드셰이크, sendfile, 비동기 I/O 처럼 같은 파일 안에 함께 사는 기능이 많습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

커넥터(connector)는 server.xml 의 <Connector> 요소이자, 네트워크에서 요청을 받아 Catalina 에 넘기는 부품 전체를 부르는 이름입니다. 소스에서는 한 객체가 아니라 아래 세 층으로 나뉘어 있습니다. Catalina 와 Coyote 를 나눈 이유와 둘을 잇는 Adapter 는 2편에서 봤습니다.

층 클래스 하는 일
Catalina 쪽 얼굴 org.apache.catalina.connector.Connector server.xml 의 <Connector> 그 자체. Lifecycle 에 맞춰 아래 층을 켜고 끈다
프로토콜 처리기 org.apache.coyote.http11.Http11NioProtocol HTTP/1.1 을 안다. 바이트를 요청으로 바꿀 프로세서를 만든다
엔드포인트 org.apache.tomcat.util.net.NioEndpoint 프로토콜을 모른다. 소켓을 열고, 연결을 받고, 스레드에 일을 나눠 준다

NIO(New I/O)는 Java 1.4 에 들어온 java.nio 패키지입니다. 옛 java.io 소켓은 read() 를 부르면 데이터가 올 때까지 그 스레드가 멈춥니다(블로킹). NIO 의 소켓 채널은 논블로킹으로 둘 수 있어, 읽을 것이 없으면 기다리지 않고 바로 0 바이트를 돌려줍니다.

Selector(셀렉터)는 NIO 의 "여러 소켓을 한 스레드가 지켜보는" 도구입니다. 채널들을 등록해 두고 select() 를 부르면, 등록된 채널 가운데 읽거나 쓸 수 있게 된 것이 생길 때까지 기다렸다가 그 목록을 돌려줍니다. Linux 에서는 그 밑에 epoll 이 있습니다. httpd 의 event MPM 이 쓰는 것과 같은 운영체제 기능입니다(httpd 8편).

Acceptor·Poller·작업 스레드는 NioEndpoint 가 만드는 세 종류의 스레드입니다. Acceptor 는 새 접속을 받고(accept()), Poller 는 받은 연결들을 Selector 로 지켜보다 데이터가 오면 알리고, 작업 스레드는 실제로 요청을 읽어 서블릿까지 부릅니다. 이름이 각각 http-nio-8080-Acceptor, http-nio-8080-Poller, http-nio-8080-exec-N 입니다. 이 글은 이 셋이 일을 어떻게 나누는지가 중심입니다.

keep-alive: HTTP/1.1 에서는 응답을 보낸 뒤에도 연결을 끊지 않고 같은 연결로 다음 요청을 받을 수 있습니다. 다음 요청이 언제 올지 모르므로 서버는 정해진 시간(keepAliveTimeout) 동안 기다립니다. "기다리는 동안 누가 연결을 붙잡고 있나"가 httpd 8편의 핵심 질문이었고, 이 글의 4절에서 Tomcat 의 답을 봅니다.

1. <Connector> 한 줄이 객체 셋이 되기까지

커넥터를 이루는 층. server.xml의 Connector 요소가 Catalina 쪽 Connector 객체가 되고, Connector는 protocol 값으로 Http11NioProtocol을 만든다. Http11NioProtocol은 생성자에서 NioEndpoint를 만든다. NioEndpoint는 Acceptor 스레드, Poller 스레드, 작업 스레드 풀을 만든다. 작업 스레드는 ConnectionHandler와 Http11Processor를 거쳐 CoyoteAdapter에 닿는다. Connector는 initInternal에서 CoyoteAdapter를 만들어 프로토콜 처리기에 꽂아 준다. 파란 상자가 설정에서 이어지는 객체, 노란 상자가 엔드포인트가 만드는 스레드입니다. 회색 상자는 6·7편에서 봅니다.

protocol="HTTP/1.1" 은 어떤 클래스가 되나

3편에서 본 대로 Catalina.load() 는 Digester 로 server.xml 을 읽어 객체를 만듭니다. <Connector> 에 걸린 규칙은 세 줄입니다.

        digester.addRule("Server/Service/Connector", new ConnectorCreateRule());
        digester.addSetProperties("Server/Service/Connector",
                new String[] { "executor", "sslImplementationName", "protocol" });
        digester.addSetNext("Server/Service/Connector", "addConnector", "org.apache.catalina.connector.Connector");

java/org/apache/catalina/startup/Catalina.java 542–545행

ConnectorCreateRule 은 요소의 protocol 속성을 꺼내 new Connector(protocolName) 을 부릅니다(ConnectorCreateRule.java 63–64행). Connector 의 생성자는 그 문자열을 ProtocolHandler.create() 에 넘깁니다(Connector.java 101–120행).

    static ProtocolHandler create(String protocol)
            throws ClassNotFoundException, InstantiationException, IllegalAccessException, IllegalArgumentException,
            InvocationTargetException, NoSuchMethodException, SecurityException {
        if (protocol == null || "HTTP/1.1".equals(protocol) ||
                org.apache.coyote.http11.Http11NioProtocol.class.getName().equals(protocol)) {
            return new org.apache.coyote.http11.Http11NioProtocol();
        } else if ("AJP/1.3".equals(protocol) ||
                org.apache.coyote.ajp.AjpNioProtocol.class.getName().equals(protocol)) {
            return new org.apache.coyote.ajp.AjpNioProtocol();
        } else {
            // Instantiate protocol handler
            Class<?> clazz = Class.forName(protocol);
            return (ProtocolHandler) clazz.getConstructor().newInstance();
        }
    }

java/org/apache/coyote/ProtocolHandler.java 250–264행

"HTTP/1.1" 이나 protocol 을 아예 안 적은 경우(null)는 Http11NioProtocol 이 됩니다. 그 밖의 값은 클래스 이름으로 보고 리플렉션으로 만듭니다. 그리고 Http11NioProtocol 의 기본 생성자는 this(new NioEndpoint()) 한 줄입니다(Http11NioProtocol.java 36–38행). 그래서 <Connector protocol="HTTP/1.1"> 하나가 Connector → Http11NioProtocol → NioEndpoint 세 객체가 됩니다. 클래스 계층은 Http11NioProtocol → AbstractHttp11Protocol → AbstractProtocol(ProtocolHandler 구현), NioEndpoint → AbstractNetworkChannelEndpoint → AbstractEndpoint 입니다.

두 층을 잇는 마지막 줄은 Connector.initInternal() 에 있습니다. adapter = new CoyoteAdapter(this); 다음 protocolHandler.setAdapter(adapter); 로, 프로토콜 처리기가 요청을 다 읽었을 때 넘길 곳을 꽂아 줍니다(Connector.java 1300–1302행). CoyoteAdapter 가 그다음 일을 어떻게 하는지는 7편에서 봅니다.

main 에는 NIO 하나만 남았다

java/org/apache/tomcat/util/net/ 폴더에서 엔드포인트 클래스는 AbstractEndpoint, AbstractNetworkChannelEndpoint, NioEndpoint 셋뿐입니다. 다른 브랜치와 비교하면 이렇습니다(각 브랜치의 같은 폴더를 git ls-tree 로 확인했습니다).

브랜치 엔드포인트 HTTP/1.1 프로토콜 클래스
9.0.x NioEndpoint, Nio2Endpoint, AprEndpoint NIO, NIO2, APR
10.1.x, 11.0.x NioEndpoint, Nio2Endpoint Http11NioProtocol, Http11Nio2Protocol
main (12.0.0-M1) NioEndpoint Http11NioProtocol

APR(Apache Portable Runtime, httpd 가 쓰는 C 라이브러리로 소켓을 다루던 커넥터)은 2021년 커밋 314568fa45 "Remove APR/Native connector implementation." 으로 빠졌고, 이 커밋은 10.1.x 부터의 브랜치에 들어 있습니다. NIO2 는 main 에서 커밋 21f0936fe3(2025-05-15) "Drop NIO2 connector from Tomcat 12" 로 빠졌고(1편에서 본 그 커밋입니다), 변경 기록 12.0.0-M1 절에도 "Remove NIO2 connector." 가 있습니다(webapps/docs/changelog.xml 480행). 11.0.x 에서 protocol="org.apache.coyote.http11.Http11Nio2Protocol" 로 NIO2 를 쓰던 설정은 12 에서는 클래스를 찾지 못합니다. 참고로 11.0.x 의 Nio2Endpoint 에는 Poller 스레드가 없고, 작업 스레드 풀을 JDK 의 AsynchronousChannelGroup 에 넘겨 쓰는 방식이었습니다(11.0.x 소스에서 확인). 이 글은 main 에 남은 NIO 만 봅니다.

ProtocolHandler.create() 는 11.0.x 에서도 같은 모양입니다. 두 브랜치 모두 "HTTP/1.1" 의 기본은 NIO 입니다.

스레드 이름 http-nio-8080 은 어디서 오나

Http11NioProtocol 은 이름의 앞부분을 정합니다.

    @Override
    protected String getNamePrefix() {
        if (isSSLEnabled()) {
            return "https-" + getSslImplementationShortName() + "-nio";
        } else {
            return "http-nio";
        }
    }

java/org/apache/coyote/http11/Http11NioProtocol.java 96–103행

AbstractProtocol.getNameInternal() 이 여기에 - 와 포트(주소를 지정했으면 주소도)를 붙여 http-nio-8080 을 만들고(659–685행), init() 에서 그 이름을 엔드포인트에 넘깁니다(960–961행). JMX 이름에 쓰려고 따옴표로 감싼 값이라 앞뒤 한 글자씩을 잘라 넘깁니다. 엔드포인트는 이 이름 뒤에 -Acceptor, -Poller, -exec- 를 붙여 스레드 이름을 짓습니다(2절). TLS 를 켠 커넥터라면 https-jsse-nio-8443-exec-1 처럼 됩니다(getSslImplementationShortName() 의 값은 구현에 따라 다릅니다).

헷갈리기 쉬운 곳: Connector 에는 setMaxThreads() 가 없습니다

server.xml 에 maxThreads="5" 를 적으면 Digester 의 addSetProperties 가 Connector 에서 setMaxThreads 를 찾습니다. 그런 메서드는 Connector 에 없습니다. 그러면 IntrospectionUtils.setProperty() 는 "setXxx 가 없으면 setProperty(이름, 값) 을 부른다"는 대체 경로를 탑니다(IntrospectionUtils.java 215–216행). Connector.setProperty() 는 받은 이름과 값을 그대로 프로토콜 처리기에 다시 넘깁니다.

    public boolean setProperty(String name, String value) {
        if (protocolHandler == null) {
            return false;
        }
        return IntrospectionUtils.setProperty(protocolHandler, name, value);
    }

java/org/apache/catalina/connector/Connector.java 368–373행

AbstractProtocol.setMaxThreads() 는 다시 endpoint.setMaxThreads() 를 부릅니다(340–342행). maxConnections, acceptCount, keepAliveTimeout 도 같은 길입니다(358행, 430행, 489행). 결국 <Connector> 에 적는 스레드·연결 관련 속성 대부분은 AbstractEndpoint 의 필드입니다. 기본값을 찾을 때는 Connector 가 아니라 AbstractEndpoint 를 보면 됩니다. 반대로 이 길은 오타를 잡아 주지 않습니다. 이름이 틀리면 어느 층에도 setter 가 없어 값이 적용되지 않을 뿐입니다(경고를 남기는 곳은 이 글에서 따라가지 않았습니다).

2. 스레드 셋: Acceptor, Poller, 작업 스레드

엔드포인트가 시작될 때(Connector.startInternal() → protocolHandler.start() → endpoint.start()) 스레드들이 생깁니다.

            // Create worker collection
            if (getExecutor() == null) {
                createExecutor();
            }

            initializeConnectionLatch();

            // Start poller thread
            poller = new Poller();
            Thread pollerThread = new Thread(poller, getName() + "-Poller");
            pollerThread.setPriority(pollerThreadPriority);
            pollerThread.setDaemon(true);
            pollerThread.start();

            startAcceptorThread();

java/org/apache/tomcat/util/net/NioEndpoint.java 533–547행

순서가 그대로 의존 관계입니다.

  1. 작업 스레드 풀을 먼저 만듭니다. 바깥에서 받은 Executor 가 없을 때만 자기 풀을 만듭니다(createExecutor(), 5절).
  2. 연결 수 제한 장치(LimitLatch)를 만듭니다(6절).
  3. Poller 스레드 하나를 띄웁니다. poller 는 필드 하나이고 이 메서드는 new Poller() 를 한 번만 부르므로, 커넥터 하나에 Poller 는 하나입니다.
  4. 마지막으로 Acceptor 스레드 하나를 띄웁니다. 받은 연결을 넘길 Poller 와 풀이 준비된 뒤라야 접속을 받기 시작할 수 있기 때문입니다. startAcceptorThread() 는 getName() + "-Acceptor" 라는 이름으로 스레드를 만듭니다(AbstractEndpoint.java 2329–2337행).

듣기 소켓 자체는 그보다 앞, init() 단계의 bind() 에서 이미 열립니다(bindOnInit 의 기본값이 true(AbstractEndpoint.java 1317행), 2242–2246행). 3편에서 본 Lifecycle 의 init 과 start 가 여기서는 "포트 열기"와 "스레드 띄우기"로 나뉩니다.

예제 1: 스레드 이름과, 서블릿까지의 호출 스택

정말 이렇게 생기는지 기본 설정 그대로 띄운 Tomcat 의 스레드를 jstack 으로 찍었습니다. 그리고 요청을 처리하는 스레드의 이름과 호출 스택을 돌려주는 서블릿을 하나 올렸습니다.

이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 예제 폴더는 ex/u05-endpoint 이고, 서블릿의 핵심은 이렇습니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 요청을 처리하는 스레드의 이름과 호출 스택을 돌려주는 서블릿.
@WebServlet("/u05/*")
public class ThreadInfo extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String path = req.getPathInfo();
        StringBuilder sb = new StringBuilder();
        if ("/slow".equals(path)) {
            // 일부러 오래 걸리는 요청 — 그동안 이 스레드는 다른 일을 못 한다
            long ms = Long.parseLong(req.getParameter("ms"));
            // ... Thread.sleep(ms) ...
        }
        sb.append("thread=").append(Thread.currentThread().getName()).append('\n');
        if ("/stack".equals(path)) {
            // 지금 이 스레드의 호출 스택을 아래(먼저 불린 쪽)에서 위로 찍는다
            StackTraceElement[] frames = new Throwable().getStackTrace();
            // ... 프레임마다 "클래스.메서드:줄" 한 줄씩 ...
        }
        // ... 길이(Content-Length)를 적고 본문을 쓴다 ...
    }
}

[예제 ex/u05-endpoint/ThreadInfo.java 에서 줄임]

run.sh 는 server.xml 을 그대로 둔 채 이 앱을 ROOT 로 배포하고 catalina.sh start 한 뒤 아래를 찍습니다.

=== 1. default connector: threads named after the endpoint ===
--- <Connector port="8080" ... >
http-nio-8080-Acceptor http-nio-8080-Poller http-nio-8080-exec-1 http-nio-8080-exec-2 http-nio-8080-exec-3 http-nio-8080-exec-4 http-nio-8080-exec-5 http-nio-8080-exec-6 http-nio-8080-exec-7 http-nio-8080-exec-8 http-nio-8080-exec-9 http-nio-8080-exec-10 
--- Poller and one exec thread while idle
"http-nio-8080-Poller" #45 [94] daemon prio=5 os_prio=0 cpu=2.98ms elapsed=1.46s tid=0x00007fe20469f7b0 nid=94 runnable  [0x00007fe1865f4000]
	at sun.nio.ch.EPoll.wait(java.base@21.0.12.1/Native Method)
	at org.apache.tomcat.util.net.NioEndpoint$Poller.run(NioEndpoint.java:1093)
"http-nio-8080-exec-1" #35 [84] daemon prio=5 os_prio=0 cpu=187.21ms elapsed=1.88s tid=0x00007fe20468a620 nid=84 waiting on condition  [0x00007fe186ffe000]
	at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method)
	at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:155)
	at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:34)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:900)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:947)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:481)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:74)

#45, nid, tid, cpu, elapsed 같은 값은 실행마다 다릅니다. Poller 와 작업 스레드는 jstack 의 각 블록에서 맨 위 프레임과 org.apache.tomcat 프레임만 남겼습니다. 읽을 것이 셋입니다.

NioEndpoint.startInternal()이 스레드를 띄우는 순서와, 요청이 없을 때의 모습. ① createExecutor()가 작업 스레드 http-nio-8080-exec-1부터 exec-10까지를 요청 전에 미리 만든다(minSpareThreads 기본 10). ② initializeConnectionLatch()가 LimitLatch를 만든다(6절). ③ new Poller()를 한 번 불러 커넥터에 하나뿐인 http-nio-8080-Poller를 띄운다. ④ startAcceptorThread()가 http-nio-8080-Acceptor 하나를 띄운다. 예제 1의 jstack에서 요청이 없을 때 Poller는 EPoll.wait에서 소켓에 일이 생기기를, exec-1부터 exec-10은 TaskQueue.take()에서 큐에 일감이 들어오기를 기다린다. 위 코드의 순서와 예제 1의 jstack 출력을 한 장에 모았습니다. 3절은 이 두 기다림 사이를 잇는 이야기입니다.

서블릿이 돌려준 호출 스택입니다(아래가 먼저 불린 쪽).

--- call stack of the servlet (bottom = first called)
thread=http-nio-8080-exec-2
  java.lang.Thread.run:1583
  org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run:74
  org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run:481
  org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker:968
  org.apache.tomcat.util.net.SocketProcessorBase.run:74
  org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun:2218
  org.apache.coyote.AbstractProtocol$ConnectionHandler.process:1317
  org.apache.coyote.AbstractProcessorLight.process:71
  org.apache.coyote.http11.Http11Processor.service:404
  org.apache.catalina.connector.CoyoteAdapter.service:347
  org.apache.catalina.core.StandardEngineValve.invoke:71
  org.apache.catalina.valves.AbstractAccessLogValve.invoke:777
  org.apache.catalina.valves.ErrorReportValve.invoke:82
  org.apache.catalina.core.StandardHostValve.invoke:115
  org.apache.catalina.authenticator.AuthenticatorBase.invoke:540
  org.apache.catalina.core.StandardContextValve.invoke:77
  org.apache.catalina.core.StandardWrapperValve.invoke:164
  org.apache.catalina.core.ApplicationFilterChain.doFilter:111
  org.apache.tomcat.websocket.server.WsFilter.doFilter:59
  org.apache.catalina.core.ApplicationFilterChain.doFilter:132
  jakarta.servlet.http.HttpServlet.service:762
  jakarta.servlet.http.HttpServlet.service:673
  ThreadInfo.doGet:30

어느 exec-N 이 받았는지는 실행마다 다릅니다. 줄 번호는 모두 기준 커밋의 소스와 맞는 것을 확인했습니다. 스택을 세 토막으로 읽으면 연재의 지도가 됩니다.

서블릿까지의 호출 스택을 세 토막으로 나눈 그림. 첫째 토막은 이 글(5편)의 범위로, 풀의 작업 스레드가 일감을 꺼내 실행한다. Thread.run, TaskThread$WrappingRunnable.run, ThreadPoolExecutor$Worker.run, runWorker, SocketProcessorBase.run, NioEndpoint$SocketProcessor.doRun 순서다. 둘째 토막은 Coyote 프로토콜 처리기로, AbstractProtocol$ConnectionHandler.process, AbstractProcessorLight.process, Http11Processor.service(6편), CoyoteAdapter.service(7편)다. 셋째 토막은 Catalina 컨테이너로, StandardEngineValve, AbstractAccessLogValve, ErrorReportValve, StandardHostValve, AuthenticatorBase, StandardContextValve, StandardWrapperValve(8편), ApplicationFilterChain, WsFilter, ApplicationFilterChain(9편), HttpServlet.service, ThreadInfo.doGet이다. Acceptor와 Poller는 일감을 큐에 넣고 자기 일로 돌아갔으므로 이 스택에 없다. 위 출력의 스택을 먼저 불린 쪽부터 위에서 아래로 놓았습니다(출력은 아래가 먼저). 괄호 안은 그 부분을 자세히 보는 편입니다.

헷갈리기 쉬운 곳: 스택에 Acceptor 도 Poller 도 없습니다

요청은 Acceptor 가 받은 연결로 들어왔고 Poller 가 "데이터가 왔다"고 알려서 처리됐지만, 서블릿을 부른 스레드의 스택에는 둘 다 없습니다. 둘은 일감(SocketProcessor)을 만들어 큐에 넣고 자기 일로 돌아가는 스레드라서, 그 일감을 실행하는 작업 스레드의 스택에는 흔적이 남지 않습니다. 같은 이유로 서블릿 안에서 오래 걸리는 일을 해도 Acceptor 와 Poller 는 멈추지 않습니다.

3. 연결 하나의 여정

연결 하나가 작업 스레드에 닿기까지. 커널의 대기열(크기는 acceptCount)에서 Acceptor가 countUpOrAwaitConnection으로 자리를 확보하고 serverSock.accept를 부른다. setSocketOptions가 소켓을 논블로킹으로 바꾸고 NioSocketWrapper로 감싸 Poller의 events 큐에 OP_REGISTER 이벤트를 넣는다. Poller는 events로 Selector에 등록하고 select를 기다리다, 읽을 수 있게 되면 processKey에서 관심사에서 OP_READ를 빼고 processSocket으로 SocketProcessor를 작업 스레드 풀에 넘긴다. 작업 스레드가 SocketProcessor.doRun에서 ConnectionHandler.process를 부른다. 노란 상자는 기다리는 곳(대기열·큐), 파란 상자는 그 사이에서 일하는 코드입니다.

1) Acceptor: 자리를 확보하고 accept

                try {
                    // if we have reached max connections, wait
                    endpoint.countUpOrAwaitConnection();

                    // Endpoint might have been paused while waiting for latch
                    // If that is the case, don't accept new connections
                    if (endpoint.isPaused()) {
                        continue;
                    }

                    U socket;
                    try {
                        // Accept the next incoming connection from the server
                        // socket
                        socket = endpoint.serverSocketAccept();
                    } catch (Exception e) {
                        // ...
                    }
                    // Successful accept, reset the error delay
                    errorDelay = 0;

                    // Configure the socket
                    if (!stopCalled && !endpoint.isPaused()) {
                        // setSocketOptions() will hand the socket off to
                        // an appropriate processor if successful
                        if (!endpoint.setSocketOptions(socket)) {
                            endpoint.closeSocket(socket);
                        }
                    } else {
                        endpoint.destroySocket(socket);
                    }

java/org/apache/tomcat/util/net/Acceptor.java 131–170행

Acceptor.run() 은 stopCalled 가 될 때까지 이 토막을 되풀이합니다(92행). 한 바퀴에 하는 일은 셋입니다.

  1. countUpOrAwaitConnection() — 연결 수를 하나 올립니다. 이미 maxConnections 개면 하나가 닫힐 때까지 여기서 멈춥니다(6절의 예제 4).
  2. serverSocketAccept() — 듣기 소켓에서 accept() 를 부릅니다. 듣기 소켓은 블로킹으로 열어 두었으므로(serverSock.configureBlocking(true), NioEndpoint.java 497행) 새 접속이 없으면 여기서 기다립니다.
  3. setSocketOptions() — 받은 소켓을 Poller 에게 넘깁니다. Acceptor 의 일은 여기서 끝나고 바로 1로 돌아갑니다.

2) setSocketOptions(): 논블로킹으로 바꿔 Poller 에게

            NioSocketWrapper newWrapper = new NioSocketWrapper(channel, this);
            channel.reset(socket, newWrapper);
            connections.put(socket, newWrapper);
            socketWrapper = newWrapper;

            // Set socket properties
            // Disable blocking, polling will be used
            socket.configureBlocking(false);
            if (getUnixDomainSocketPath() == null) {
                socketProperties.setProperties(socket.socket());
            }

            socketWrapper.setReadTimeout(getConnectionTimeout());
            socketWrapper.setWriteTimeout(getConnectionTimeout());
            socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests());
            poller.register(socketWrapper);
            return true;

java/org/apache/tomcat/util/net/NioEndpoint.java 753–769행

연결 소켓은 논블로킹으로 바꿉니다(760행). 이제부터 이 소켓을 읽는 쪽은 읽을 것이 없어도 멈추지 않습니다. 소켓은 NioSocketWrapper 로 감싸는데, 이 객체가 연결 하나에 대한 Tomcat 쪽 기록(읽기·쓰기 타임아웃, 마지막으로 읽은 시각, 남은 keep-alive 요청 수, 지금 붙어 있는 프로세서)입니다. 마지막 줄 poller.register() 는 Selector 를 직접 만지지 않고, OP_REGISTER 라는 이벤트를 Poller 의 큐(events)에 넣기만 합니다(1068–1072행). Selector 에 채널을 등록하는 일은 Poller 스레드가 직접 합니다. Selector 를 여러 스레드가 동시에 만지지 않게 하려는 구조입니다.

3) Poller: 큐를 비우고, select 하고, 깨어난 연결을 넘긴다

            while (true) {

                boolean hasEvents = false;

                try {
                    if (!close) {
                        hasEvents = events();
                        if (wakeupCounter.getAndSet(-1) > 0) {
                            // If we are here, means we have other stuff to do
                            // Do a non-blocking select
                            keyCount = selector.selectNow();
                        } else {
                            keyCount = selector.select(selectorTimeout);
                        }
                        wakeupCounter.set(0);
                    }
                    // ...
                }
                // ...

                Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
                // Walk through the collection of ready keys and dispatch
                // any active event.
                while (iterator != null && iterator.hasNext()) {
                    SelectionKey sk = iterator.next();
                    iterator.remove();
                    NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
                    // Attachment may be null if another thread has called
                    // cancelledKey()
                    if (socketWrapper != null) {
                        processKey(sk, socketWrapper);
                    }
                }

                // Process timeouts
                timeout(keyCount, hasEvents);
            }

java/org/apache/tomcat/util/net/NioEndpoint.java 1081–1134행

Poller 의 한 바퀴는 세 단계입니다.

  1. events() — 다른 스레드들이 넣어 둔 PollerEvent 를 꺼내 Selector 에 반영합니다. OP_REGISTER 면 sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper) 로 새 채널을 등록하고, 그 밖의 것이면 이미 등록된 키의 관심사(interest ops, "이 채널의 무엇을 지켜볼지")에 읽기나 쓰기를 더합니다(1009–1061행).
  2. select() — 지켜보는 채널 가운데 준비된 것이 생길 때까지 최대 selectorTimeout(기본 1000ms, 316행) 기다립니다. 예제 1의 Poller 가 EPoll.wait 에 있던 자리가 1093행 바로 이 줄입니다. Poller 가 이렇게 기다리는 동안 큐에 이벤트가 들어오면 addEvent() 가 selector.wakeup() 으로 이 기다림을 깨웁니다(968–973행. wakeupCounter 로 Poller 가 select() 에 들어가 있는 때에만 부릅니다).
  3. processKey() 와 timeout() — 준비된 채널을 처리하고, 시간이 다 된 연결을 정리합니다(4절).

processKey() 에서 읽을 수 있게 된 채널의 길은 이렇습니다.

                            unreg(sk, socketWrapper, sk.readyOps());
                            boolean closeSocket = false;
                            // Read goes before write
                            if (sk.isReadable()) {
                                if (socketWrapper.readOperation != null) {
                                    if (!socketWrapper.readOperation.process()) {
                                        closeSocket = true;
                                    }
                                } else if (socketWrapper.readBlocking) {
                                    synchronized (socketWrapper.readLock) {
                                        socketWrapper.readBlocking = false;
                                        socketWrapper.readLock.notify();
                                    }
                                } else if (!processSocket(socketWrapper, SocketEvent.OPEN_READ, true)) {
                                    closeSocket = true;
                                }
                            }

java/org/apache/tomcat/util/net/NioEndpoint.java 1154–1170행

첫 줄 unreg() 가 중요합니다. 준비된 관심사(여기서는 읽기)를 키에서 뺍니다. 소스 주석은 "여러 스레드가 소켓을 건드리지 않게 하려면 꼭 필요하다"고 적어 두었습니다(1330–1333행). 빼지 않으면 작업 스레드가 아직 읽고 있는 동안에도 다음 select() 가 같은 소켓을 또 "읽을 수 있음"으로 돌려줘, 같은 연결에 일감이 두 개 생깁니다. 일반적인 새 데이터는 마지막 else if 의 processSocket(..., SocketEvent.OPEN_READ, true) 로 갑니다. 그 위의 두 갈래(readOperation, readBlocking)는 이미 작업 스레드가 이 소켓에서 읽기를 기다리고 있는 경우로, 일감을 새로 만들지 않고 그 스레드를 깨웁니다.

4) processSocket(): 일감을 만들어 풀에 넣는다

    public boolean processSocket(SocketWrapperBase<S> socketWrapper, SocketEvent event, boolean dispatch) {
        try {
            if (socketWrapper == null) {
                return false;
            }
            SocketProcessorBase<S> sc = null;
            if (processorCache != null) {
                sc = processorCache.pop();
            }
            if (sc == null) {
                sc = createSocketProcessor(socketWrapper, event);
            } else {
                sc.reset(socketWrapper, event);
            }
            Executor executor = getExecutor();
            if (dispatch && executor != null) {
                executor.execute(sc);
            } else {
                sc.run();
            }
        // ...

java/org/apache/tomcat/util/net/AbstractEndpoint.java 2131–2150행

SocketProcessor 는 "이 연결에 이런 일(SocketEvent.OPEN_READ)이 생겼으니 처리하라"는 Runnable 입니다. 쓰고 나면 processorCache 에 돌려놓았다가 다시 씁니다(객체를 매번 새로 만들지 않으려고). Poller 는 executor.execute(sc) 로 넘기고 곧바로 다음 키로 갑니다. Poller 가 요청을 직접 읽지 않는 것이 핵심입니다. Poller 는 하나뿐이라, 여기서 요청 하나를 처리하느라 멈추면 모든 연결이 멈춥니다.

5) 작업 스레드: 소켓마다 한 번에 하나씩

작업 스레드가 꺼내 실행하는 SocketProcessorBase.run() 은 먼저 그 소켓의 잠금을 잡습니다.

    @Override
    public final void run() {
        Lock lock = socketWrapper.getLock();
        lock.lock();
        try {
            // It is possible that processing may be triggered for read and
            // write at the same time. The lock above makes sure that processing
            // does not occur in parallel. The test below ensures that if the
            // first event to be processed results in the socket being closed,
            // the subsequent events are not processed.
            if (socketWrapper.isClosed()) {
                return;
            }
            doRun();
        } finally {
            lock.unlock();
        }
    }

java/org/apache/tomcat/util/net/SocketProcessorBase.java 61–78행

읽기와 쓰기 이벤트가 동시에 와도 한 연결은 한 번에 한 스레드만 처리합니다. doRun()(NIO 쪽은 NioEndpoint.SocketProcessor.doRun(), 2169–2246행)은 TLS 라면 핸드셰이크를 먼저 하고, 끝났으면 getHandler().process(socketWrapper, event) 를 부릅니다(2218행, 예제 1 스택의 그 줄). 이 핸들러가 AbstractProtocol.ConnectionHandler 이고, 여기서부터는 프로토콜 처리기의 영역입니다. ConnectionHandler.process() 는 이 연결에 붙어 있던 프로세서가 있으면 그것을, 없으면 재활용 목록에서 꺼내거나 새로 만들어(getProtocol().createProcessor(), 1299–1311행) processor.process() 를 부릅니다(1317행). HTTP/1.1 이면 그 프로세서가 Http11Processor 입니다.

헷갈리기 쉬운 곳: Poller 가 "읽는" 것은 아닙니다

이름 때문에 Poller 가 소켓에서 데이터를 읽어 넘겨준다고 생각하기 쉽지만, Poller 는 "이 소켓은 지금 읽으면 데이터가 있다"는 사실만 알립니다. 실제 read() 는 작업 스레드가 Http11Processor 안에서 합니다(6편). 그래서 예제 1의 스택에서 서블릿을 부른 것도, 요청 줄을 읽은 것도 같은 exec-2 입니다.

4. keep-alive — 요청 사이에 스레드를 놓아준다

응답을 다 보낸 작업 스레드는 그 연결로 다음 요청이 오는지 봅니다. Http11Processor.service() 는 while 루프로 같은 연결의 요청을 차례로 처리하는데, 매 바퀴 맨 앞에서 요청 줄을 읽습니다(6편에서 자세히 봅니다). 두 번째 바퀴부터는 keptAlive 가 true 입니다.

                if (byteBuffer.position() >= byteBuffer.limit()) {
                    if (keptAlive) {
                        // Haven't read any request data yet so use the keep-alive
                        // timeout.
                        wrapper.setReadTimeout(keepAliveTimeout);
                    }
                    if (!fill(false)) {
                        return false;
                    }
                    // At least one byte of the request has been received.
                    // Switch to the socket timeout.
                    wrapper.setReadTimeout(connectionTimeout);
                }

java/org/apache/coyote/http11/Http11InputBuffer.java 335–347행

두 가지를 봅니다. 하나, 다음 요청을 기다리는 동안의 읽기 타임아웃을 keepAliveTimeout 으로 바꿉니다. 둘, fill(false) 의 false 는 "블로킹하지 말 것"입니다. 소켓에 아직 아무것도 없으면 기다리지 않고 false 를 돌려줍니다. 그러면 service() 는 루프를 빠져나와 SocketState.OPEN("연결은 열려 있지만 요청 사이")을 돌려주고(Http11Processor.java 484–486행), ConnectionHandler 는 그 값을 받아 이렇게 합니다.

                if (state == SocketState.LONG) {
                    // In the middle of processing a request/response. Keep the
                    // socket associated with the processor. Exact requirements
                    // depend on type of long poll
                    longPoll(wrapper, processor);
                    if (processor.isAsync()) {
                        getProtocol().addWaitingProcessor(processor);
                    }
                } else if (state == SocketState.OPEN) {
                    // In keep-alive but between requests. OK to recycle
                    // processor. Continue to poll for the next request.
                    release(processor);
                    processor = null;
                    wrapper.registerReadInterest();

java/org/apache/coyote/AbstractProtocol.java 1378–1391행

주석이 그대로 설명합니다 — "keep-alive 중이지만 요청 사이다. 프로세서를 재활용해도 된다. 다음 요청을 계속 poll 한다." 프로세서(Http11Processor 객체)는 반납하고, registerReadInterest() 는 Poller 에게 "이 소켓의 읽기를 다시 지켜봐 달라"는 이벤트를 넣습니다(getPoller().add(this, SelectionKey.OP_READ), NioEndpoint.java 1897–1902행). 그리고 process() 가 돌아오면 작업 스레드는 풀로 돌아가 다음 일감을 기다립니다. 놀고 있는 keep-alive 연결에는 스레드가 붙어 있지 않습니다. 붙어 있는 것은 Selector 의 등록 하나와 NioSocketWrapper 객체 하나뿐입니다.

keep-alive 연결이 요청 사이에 지나는 길. 작업 스레드가 Http11Processor.service에서 요청 하나를 처리하고 응답을 보낸 뒤, 읽기 타임아웃을 keepAliveTimeout으로 바꿔 다음 요청 줄을 읽어 본다. 논블로킹이라 읽을 바이트가 없으면 기다리지 않고 SocketState.OPEN을 돌려준다. ConnectionHandler는 프로세서를 반납하고 registerReadInterest를 부른다. 작업 스레드는 풀로 돌아가고, Poller가 스레드 없이 연결을 지켜본다. 데이터가 오면 아무 작업 스레드에게 다시 맡기고, 아무것도 오지 않으면 timeout이 keepAliveTimeout 뒤에 ERROR로 연결을 닫고 연결 수를 하나 줄인다. 초록 상자가 이 설계의 목적입니다. 기다리는 동안 스레드는 풀에 있습니다.

시간이 다 된 연결은 Poller 가 닫는다

다음 요청이 끝내 오지 않으면 Poller 의 timeout() 이 정리합니다. 이 메서드는 Selector 에 등록된 모든 키를 훑으며, 읽기를 기다리는 연결의 지금 - 마지막으로 읽은 시각 이 읽기 타임아웃(여기서는 keepAliveTimeout)을 넘었는지 봅니다(1386–1392행). 넘었으면 SocketEvent.ERROR 로 processSocket() 을 불러(1414행) 작업 스레드가 연결을 닫게 합니다. 닫을 때 SocketWrapperBase.close() 가 연결 수를 하나 내립니다(countDownConnection(), SocketWrapperBase.java 661–675행). 6절의 예제 4에서 이 숫자가 Acceptor 를 풀어 주는 것을 봅니다.

모든 키를 훑는 일은 연결이 많으면 비싸므로 매 바퀴 하지는 않습니다. select() 가 아무 일 없이 시간이 다 돼 돌아왔거나, 지난번 검사 뒤 timeoutInterval(기본 1000ms, SocketProperties.java 173행)이 지났을 때만 합니다(주석과 조건, 1355–1364행). 그래서 연결이 닫히는 시각은 keepAliveTimeout 보다 조금 늦을 수 있습니다.

예제 2: 작업 스레드 2개에 keep-alive 연결 5개

httpd 8편의 예제 3과 같은 실험을 했습니다. 커넥터에 maxThreads="2" 를 주고, 소켓을 직접 다루는 작은 클라이언트(Client.java)로 연결 5개를 열어 요청을 하나씩 보낸 뒤 닫지 않고 놀려 둡니다. 그 상태에서 새 연결로 요청을 하나 더 보내고, 마지막으로 첫 번째 연결로 한 번 더 요청합니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
            case "keepalive" -> {
                // 연결 n개: 요청을 하나씩 보내고, 닫지 않고 놀려 둔다
                for (int i = 1; i <= n; i++) {
                    Socket s = new Socket("127.0.0.1", 8080);
                    idle.add(s);
                    String r = get(s, "/u05/thread");
                    System.out.println(t() + " idle conn " + i + ": " + r);
                }
                // 놀고 있는 연결이 스레드를 붙잡고 있다면 이 요청은 기다려야 한다
                try (Socket s = new Socket("127.0.0.1", 8080)) {
                    String r = get(s, "/u05/thread");
                    System.out.println(t() + " new conn    : " + r);
                }
                // 처음 연결로 한 번 더 — 같은 연결이어도 다른 스레드가 받을 수 있다
                String r = get(idle.get(0), "/u05/thread");
                System.out.println(t() + " idle conn 1 again: " + r);
            }

[예제 ex/u05-endpoint/Client.java 에서 줄임 — get() 은 GET … HTTP/1.1 을 보내고 Content-Length 만큼 응답을 읽는 함수, t() 는 시작부터 지난 초]

=== 2. maxThreads=2: five idle keep-alive connections, then a new one ===
--- <Connector port="8080" ... maxThreads="2">
http-nio-8080-Acceptor http-nio-8080-Poller http-nio-8080-exec-1 http-nio-8080-exec-2 
 0.02s idle conn 1: thread=http-nio-8080-exec-2
 0.05s idle conn 2: thread=http-nio-8080-exec-1
 0.06s idle conn 3: thread=http-nio-8080-exec-2
 0.06s idle conn 4: thread=http-nio-8080-exec-1
 0.07s idle conn 5: thread=http-nio-8080-exec-2
 0.08s new conn    : thread=http-nio-8080-exec-1
 0.09s idle conn 1 again: thread=http-nio-8080-exec-1

시각은 실행마다 조금씩 다릅니다.

httpd 로 같은 실험을 했을 때, 연결을 붙잡는 worker MPM 에서는 새 요청이 KeepAliveTimeout 만큼(5.7초) 기다렸고 event MPM 에서는 0.001초였습니다(httpd 8편). Tomcat 의 NIO 커넥터는 event MPM 쪽입니다.

헷갈리기 쉬운 곳

5. 작업 스레드 풀 — JDK 의 풀과 무엇이 다른가

createExecutor(): 이름 짓기와 크기

    public void createExecutor() {
        internalExecutor = true;
        if (getUseVirtualThreads()) {
            executor = new VirtualThreadExecutor(getName() + "-virt-");
        } else {
            TaskQueue taskqueue = new TaskQueue(maxQueueSize);
            TaskThreadFactory tf = new TaskThreadFactory(getName() + "-exec-", daemon, getThreadPriority());
            executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), getThreadsMaxIdleTime(),
                    TimeUnit.MILLISECONDS, taskqueue, tf);
            taskqueue.setParent((ThreadPoolExecutor) executor);
        }
    }

java/org/apache/tomcat/util/net/AbstractEndpoint.java 1961–1972행

ThreadPoolExecutor 는 java.util.concurrent 의 것이 아니라 org.apache.tomcat.util.threads.ThreadPoolExecutor 입니다. 파일 머리에 "Written by Doug Lea with assistance from members of JCP JSR-166" 이 남아 있는 것으로 보아 JDK 의 코드를 가져와 고친 것이고, JDK 클래스를 상속하지 않고 AbstractExecutorService 를 바로 상속합니다(217행). 11.0.x, 10.1.x, 9.0.x 의 같은 파일도 extends AbstractExecutorService 입니다. 고친 곳 가운데 이 글에서 볼 것은 둘입니다.

하나, 만들 때 기본 크기만큼 스레드를 미리 띄웁니다. 생성자 끝에서 prestartAllCoreThreads() 를 부릅니다(1108행). 이 메서드의 주석이 "일이 들어올 때만 기본 스레드를 띄우는 기본 정책을 덮어쓴다"고 설명합니다(1410–1415행). 예제 1에서 요청 전부터 exec-1~exec-10 이 있었던 이유입니다.

둘, 큐보다 스레드를 먼저 늘립니다. 이것이 TaskQueue 의 일입니다.

TaskQueue: "스레드를 더 만들 수 있으면 큐에 넣지 않는다"

JDK 의 ThreadPoolExecutor 는 기본 크기만큼 스레드가 있으면 새 일을 큐에 먼저 넣고, 큐가 꽉 찼을 때만 최대 크기까지 스레드를 늘립니다. TaskQueue 의 클래스 주석이 바로 이 차이를 적고 있습니다 — 보통의 큐는 일을 먼저 쌓고 큐가 차야 스레드를 만들지만, 이 큐는 모든 스레드가 바쁘고 아직 최대에 이르지 않았으면 쌓기를 거절해 실행기가 새 스레드를 만들게 한다(TaskQueue.java 28–33행). 큐가 사실상 무한인 Tomcat 에서 JDK 방식을 쓰면 큐가 차는 일이 없으니 스레드는 영원히 10개에 머물 것입니다.

    @Override
    public boolean offer(Runnable o) {
        // we can't do any checks
        if (parent == null) {
            return super.offer(o);
        }
        // we are maxed out on threads, simply queue the object
        if (parent.getPoolSizeNoLock() == parent.getMaximumPoolSize()) {
            return super.offer(o);
        }
        // we have idle threads, just add it to the queue
        if (parent.getSubmittedCount() <= parent.getPoolSizeNoLock()) {
            return super.offer(o);
        }
        // if we have less threads than maximum force creation of a new thread
        if (parent.getPoolSizeNoLock() < parent.getMaximumPoolSize()) {
            return false;
        }
        // if we reached here, we need to add it to the queue
        return super.offer(o);
    }

java/org/apache/tomcat/util/threads/TaskQueue.java 92–112행

Tomcat 스레드 풀이 일감을 받는 순서. executor.execute가 submittedCount를 하나 올린다. 스레드 수가 corePoolSize(minSpareThreads)보다 적으면 새 스레드를 만들어 바로 맡긴다. 아니면 TaskQueue.offer를 부른다. 스레드가 이미 maxThreads개면 큐에 넣고, 노는 스레드가 있으면(submittedCount가 스레드 수 이하) 큐에 넣고, maxThreads보다 적으면 false를 돌려 큐에 넣지 않는다. 거절당했으니 실행기가 새 스레드를 만든다. 노란 상자는 큐에 쌓이는 경우, 초록 상자는 스레드가 늘어나는 경우입니다.

offer() 가 false 를 돌려주면 실행기는 "큐에 못 넣었으니 스레드를 하나 더 만든다"는 JDK 의 원래 규칙(executeInternal() 의 3단계, 1167–1168행 주석)대로 스레드를 늘립니다. 큐의 규칙만 바꿔 풀의 동작을 뒤집은 것입니다. "노는 스레드가 있나"를 보려고 쓰는 submittedCount 는 JDK 에 없는 필드로, execute() 에서 하나 올리고(1119행) 일이 끝나면 afterExecute() 에서 내립니다(1867–1873행).

스레드를 늘리려다 그사이 다른 일감이 먼저 최대를 채워 거절(RejectedExecutionException)이 나면, execute() 가 그것을 잡아 queue.force(command) 로 큐에 억지로 넣습니다(1122–1131행). 소스 주석은 최대 크기 근처에서 execute() 가 동시에 불리면 TaskQueue 때문에 일부 일이 큐 대신 거절될 수 있어 이렇게 한다고 적었습니다.

예제 3: 작업 스레드 1개에 느린 요청 3개

maxThreads="1" 로 띄우고, 2초씩 걸리는 요청(/u05/slow?ms=2000) 셋을 서로 다른 연결로 동시에 보냈습니다.

=== 3. maxThreads=1: three slow requests (2000 ms each) at once ===
--- <Connector port="8080" ... maxThreads="1">
 2.04s request 3 done: thread=http-nio-8080-exec-1
 4.07s request 1 done: thread=http-nio-8080-exec-1
 6.09s request 2 done: thread=http-nio-8080-exec-1

셋 모두 exec-1 이 처리했고 2초 간격으로 끝났습니다. 스레드가 이미 최대(1개)라 TaskQueue.offer() 의 첫 검사에서 큐에 쌓였다가 하나씩 꺼내진 것입니다. 연결 셋은 모두 바로 받아졌으므로(Acceptor 와 Poller 는 멈추지 않으므로) 오류 없이 기다리기만 했습니다. 어느 요청이 먼저 끝나는지는 실행마다 다릅니다. 예제 2와 견주면, 스레드를 붙잡는 것은 놀고 있는 연결이 아니라 실행 중인 요청입니다.

<Executor>: 커넥터 여럿이 나눠 쓰는 풀

server.xml 에는 주석으로 막힌 <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="150" minSpareThreads="4"/> 와, 그것을 executor="tomcatThreadPool" 로 가리키는 커넥터 예가 있습니다(conf/server.xml 57–79행). 이 요소는 org.apache.catalina.core.StandardThreadExecutor 가 되고, startInternal() 에서 엔드포인트와 같은 TaskQueue + Tomcat ThreadPoolExecutor 조합을 만듭니다(StandardThreadExecutor.java 121–131행). 기본값만 다릅니다 — 이름 앞부분 tomcat-exec-, minSpareThreads 25, maxThreads 200(62–77행). ConnectorCreateRule 이 executor 속성을 보고 서비스에서 그 풀을 찾아 프로토콜 처리기의 setExecutor() 로 꽂아 주면(59–67행), 엔드포인트는 2절의 if (getExecutor() == null) 에서 자기 풀을 만들지 않습니다.

헷갈리기 쉬운 곳: 외부 풀을 쓰면 maxThreads 가 -1 로 읽힙니다

AbstractEndpoint.getMaxThreads() 는 자기 풀(internalExecutor)일 때만 값을 돌려주고 아니면 -1 입니다(1523–1529행). <Executor> 를 쓰는 커넥터에 maxThreads 를 적어도 쓰이지 않고, 스레드 수는 <Executor> 쪽에서 정해야 합니다.

6. 한계 넷 — 어디서 기다리고, 어디서 막히나

지금까지 본 것을 "요청이 기다릴 수 있는 곳"으로 다시 줄 세우면 이렇습니다.

연결과 요청이 기다리는 다섯 자리. 클라이언트의 connect 뒤 첫째는 커널 대기열로 크기가 acceptCount(기본 100)이고 TCP 연결은 이미 맺어진 상태다. 둘째는 Acceptor의 LimitLatch로 maxConnections(기본 8192)가 열린 연결 수를 센다. 셋째는 Poller가 지켜보는 연결로 keep-alive로 노는 연결도 여기 있다. 넷째는 TaskQueue로 maxQueueSize 기본값은 Integer.MAX_VALUE다. 다섯째가 작업 스레드로 maxThreads(기본 200)개다. 응답 뒤 keep-alive 연결은 다시 셋째 자리로 돌아간다. 노란 상자가 기다리는 자리입니다. 숫자는 각 속성의 기본값입니다.

속성 기본값 걸리는 곳 넘치면
acceptCount 100 (1290행) serverSock.bind(addr, getAcceptCount()) — 운영체제의 대기열 크기 운영체제가 정한다(Tomcat 코드 밖)
maxConnections 8192 (1027행) Acceptor 의 countUpOrAwaitConnection() Acceptor 가 accept() 를 멈춘다. 새 접속은 커널 대기열에 쌓인다
maxThreads 200 작업 스레드 풀의 최대 크기 일감이 TaskQueue 에 쌓인다(예제 3)
keepAliveTimeout connectionTimeout 과 같음 Poller 의 timeout() 노는 연결을 닫고 연결 수를 내린다

acceptCount 는 Tomcat 이 아니라 커널의 대기열

            serverSock = ServerSocketChannel.open();
            socketProperties.setProperties(serverSock.socket());
            InetSocketAddress addr = new InetSocketAddress(getAddress(), getPortWithOffset());
            serverSock.bind(addr, getAcceptCount());

java/org/apache/tomcat/util/net/NioEndpoint.java 492–495행

acceptCount 는 bind() 의 두 번째 인자, 곧 backlog 로만 쓰입니다. 클라이언트의 TCP 연결은 운영체제가 맺어 이 대기열에 넣어 두고, 애플리케이션이 accept() 를 부를 때 하나씩 꺼내 줍니다. 필드 주석도 "acceptCount (backlog)" 라고 부릅니다(1286–1289행). 대기열이 찼을 때 새 접속을 어떻게 다루는지, 넘긴 값을 그대로 쓰는지는 운영체제의 일이라 이 글에서는 확인하지 않았습니다.

maxConnections 는 Acceptor 를 멈추는 LimitLatch

maxConnections 는 엔드포인트가 시작될 때 new LimitLatch(getMaxConnections()) 가 됩니다(2425–2433행). LimitLatch 는 JDK 의 AbstractQueuedSynchronizer 위에 만든 "정해진 수만큼만 통과시키는 문"입니다.

        @Override
        protected int tryAcquireShared(int ignored) {
            long newCount = count.incrementAndGet();
            if (!released && newCount > limit) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("limitLatch.exceeded", Long.valueOf(limit)));
                }
                // Limit exceeded
                count.decrementAndGet();
                return -1;
            } else {
                return 1;
            }
        }

java/org/apache/tomcat/util/threads/LimitLatch.java 44–57행

숫자를 하나 올려 보고 한도를 넘으면 되돌린 뒤 -1("못 들어감")을 돌려줍니다. 그러면 AbstractQueuedSynchronizer 가 호출한 스레드(Acceptor)를 대기열에 세워 잠재우고, 누군가 countDown() 으로 숫자를 내리면 깨웁니다. 숫자를 내리는 곳은 연결이 닫힐 때의 SocketWrapperBase.close()(4절)와, accept() 가 실패했을 때입니다(Acceptor.java 148행). maxConnections="-1" 이면 latch 를 만들지 않아 연결 수를 세지 않습니다(2426–2428행).

예제 4: maxConnections=2 에서 세 번째 연결

maxConnections="2" keepAliveTimeout="3000" 으로 띄웠습니다. 연결 둘을 열어 요청을 하나씩 보내고 놀려 둔 채, 세 번째 연결을 열어 요청을 보냅니다. 세 번째 연결이 기다리는 동안 run.sh 가 Acceptor 의 스택을 찍습니다.

=== 4. maxConnections=2 keepAliveTimeout=3000: two idle connections, then a new one ===
--- <Connector port="8080" ... maxConnections="2" keepAliveTimeout="3000">
 0.05s idle conn 1: thread=http-nio-8080-exec-2
 0.11s idle conn 2: thread=http-nio-8080-exec-3
 0.11s new conn connected (TCP handshake done)
--- Acceptor while the third connection waits
"http-nio-8080-Acceptor" #46 [551] daemon prio=5 os_prio=0 cpu=15.92ms elapsed=4.31s tid=0x00007f78506925d0 nid=551 waiting on condition  [0x00007f77d25f4000]
	at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method)
	at java.util.concurrent.locks.LockSupport.park(java.base@21.0.12.1/LockSupport.java:221)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:788)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:1133)
	at org.apache.tomcat.util.threads.LimitLatch.countUpOrAwait(LimitLatch.java:133)
	at org.apache.tomcat.util.net.AbstractEndpoint.countUpOrAwaitConnection(AbstractEndpoint.java:2454)
	at org.apache.tomcat.util.net.Acceptor.run(Acceptor.java:133)
	at java.lang.Thread.runWith(java.base@21.0.12.1/Thread.java:1596)
	at java.lang.Thread.run(java.base@21.0.12.1/Thread.java:1583)
 3.12s new conn response: thread=http-nio-8080-exec-6
 3.14s idle conn 1 again: connection closed by server
 3.14s idle conn 2 again: connection closed by server

시각과 스레드 번호는 실행마다 다릅니다.

  1. 세 번째 연결의 connect() 는 0.11초에 바로 성공했습니다. TCP 연결은 운영체제가 맺어 커널 대기열(acceptCount)에 넣었기 때문입니다. 클라이언트는 요청까지 보냈습니다.
  2. 그런데 Acceptor 는 accept() 가 아니라 LimitLatch.countUpOrAwait() 에서 잠들어 있습니다. 스택의 Acceptor.java:133 은 3절에서 본 endpoint.countUpOrAwaitConnection(); 줄입니다. 열린 연결이 이미 2개라 문이 닫혔습니다.
  3. 응답은 3.12초에 왔습니다. keepAliveTimeout 3초가 지나자 Poller 의 timeout() 이 놀던 연결 둘을 닫았고, 연결 수가 내려가 Acceptor 가 깨어나 세 번째 연결을 accept() 했습니다. 이미 도착해 있던 요청은 그때 처리됐습니다.
  4. 그 뒤 놀던 연결로 다시 요청하자 "connection closed by server" — 서버가 정말 닫았습니다.

예제 2와 이 예제를 함께 보면, keep-alive 연결은 스레드는 차지하지 않지만 연결 수(maxConnections)는 차지합니다. 놀고 있는 연결이 많은 서비스에서 막히는 것은 maxThreads 보다 maxConnections 쪽일 수 있습니다. httpd 의 event MPM 도 열린 연결이 일정 수를 넘으면 그 자식 프로세스가 듣기 소켓을 잠시 끄는 장치(connections_above_limit())를 두는데(httpd 8편), Tomcat 에서는 Acceptor 하나가 latch 앞에서 멈추는 것으로 같은 일을 합니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
<Connector protocol="HTTP/1.1"> Connector → Http11NioProtocol → NioEndpoint 세 객체. 스레드·연결 속성 대부분은 setProperty() 를 거쳐 AbstractEndpoint 로 간다
main 의 엔드포인트 NIO 하나. APR 은 10.1.x 부터, NIO2 는 12.0.0-M1 에서 빠졌다
Acceptor (1개) maxConnections 자리를 확보하고 블로킹 accept(), 소켓을 논블로킹으로 바꿔 Poller 큐에 넣는다
Poller (1개) 큐의 이벤트를 Selector 에 반영하고 select(), 준비된 연결의 관심사를 빼고 SocketProcessor 를 풀에 넘긴다. 타임아웃도 정리
작업 스레드 exec-N SocketProcessor → ConnectionHandler → Http11Processor → CoyoteAdapter → 서블릿
keep-alive 요청 사이에는 SocketState.OPEN 으로 스레드를 풀에 돌려주고 Poller 가 지켜본다. 다음 요청은 아무 스레드나 받는다
keepAliveTimeout 기본은 connectionTimeout(server.xml 20초, 속성이 없으면 60초). Poller 의 timeout() 이 닫는다
Tomcat ThreadPoolExecutor JDK 코드를 가져와 고친 것. 기본 크기만큼 미리 띄우고, TaskQueue 로 큐보다 스레드를 먼저 늘린다
maxThreads 풀의 최대 크기. 넘친 일은 TaskQueue 에 쌓인다
maxConnections LimitLatch. 넘치면 Acceptor 가 멈추고 새 접속은 커널 대기열에서 기다린다. keep-alive 연결도 센다
acceptCount bind() 의 backlog. 커널의 대기열 크기

다음에 볼 것

작업 스레드가 Http11Processor.service() 에 들어서는 데까지 왔습니다. 이제 그 스레드가 소켓에서 바이트를 읽어 GET /hello HTTP/1.1 과 헤더들을 Coyote 의 Request 객체로 바꾸는 과정을 봅니다. 4절에서 "요청 줄을 읽어 본다"고만 하고 넘어간 parseRequestLine() 과, 문자열을 되도록 늦게 만드는 MessageBytes 가 주인공입니다. 6편: HTTP/1.1 요청 읽기 — 바이트가 coyote Request가 되기까지에서 이어집니다.

httpd 연재의 8편: MPM은 같은 질문 — "연결은 어떤 스레드가 받고, keep-alive 연결은 누가 붙잡나" — 에 대한 C 서버의 답입니다. httpd 는 그 답을 prefork·worker·event 라는 갈아 끼우는 모듈로 여러 개 두었고, Tomcat 은 main 에서 NIO 하나로 정리했습니다. Tomcat 의 NIO 커넥터는 keep-alive 연결을 스레드에서 떼어 내는 event MPM 과 같은 쪽이지만, 프로세스는 하나이고 듣기 소켓을 받는 스레드(Acceptor)와 연결을 지켜보는 스레드(Poller)를 나눴다는 점이 다릅니다. 요청을 처리하는 도중에도 스레드를 놓아주는 방법은 12편: 비동기 서블릿과 논블로킹 I/O에서 봅니다.

같은 NIO 모양을 Kafka 브로커는 Acceptor·Processor·요청 처리 스레드로 나누되 소켓 읽기·쓰기를 Processor 가 맡게 했고(Kafka 4편), nginx 는 지켜보는 스레드와 처리하는 스레드를 나누지 않고 워커 스레드 하나가 둘 다 합니다(nginx 7편).

다른 글