이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 다섯 번째 글입니다. 2편에서 Tomcat 을 Catalina(서블릿 컨테이너)와 Coyote(커넥터)로 나눠 보았고, 3편에서 catalina.sh start 뒤에 컴포넌트들이 init()·start() 되는 순서를, 4편에서 웹앱마다 따로 있는 클래스로더를 봤습니다. 이번에는 Tomcat 의 입구로 갑니다. 브라우저가 8080 번 포트에 접속한 순간부터 그 연결이 서블릿을 부를 스레드에 닿기까지입니다.
답하려는 질문은 네 가지입니다.
- server.xml 의
<Connector port="8080" protocol="HTTP/1.1">한 줄은 소스에서 어떤 객체들이 되는가 jstack에 보이는http-nio-8080-Acceptor,-Poller,-exec-1은 각각 무슨 일을 하는가- keep-alive 로 열어 둔 연결은 다음 요청을 기다리는 동안 스레드를 붙잡고 있는가
maxThreads,maxConnections,acceptCount는 코드의 어디에서 걸리고, 넘치면 어떻게 되는가
| 항목 | 내용 |
|---|---|
| 대상 | 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> 한 줄이 객체 셋이 되기까지
파란 상자가 설정에서 이어지는 객체, 노란 상자가 엔드포인트가 만드는 스레드입니다. 회색 상자는 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행
순서가 그대로 의존 관계입니다.
- 작업 스레드 풀을 먼저 만듭니다. 바깥에서 받은
Executor가 없을 때만 자기 풀을 만듭니다(createExecutor(), 5절). - 연결 수 제한 장치(
LimitLatch)를 만듭니다(6절). - Poller 스레드 하나를 띄웁니다.
poller는 필드 하나이고 이 메서드는new Poller()를 한 번만 부르므로, 커넥터 하나에 Poller 는 하나입니다. - 마지막으로 Acceptor 스레드 하나를 띄웁니다. 받은 연결을 넘길 Poller 와 풀이 준비된 뒤라야 접속을 받기 시작할 수 있기 때문입니다.
startAcceptorThread()는getName() + "-Acceptor"라는 이름으로 스레드를 만듭니다(AbstractEndpoint.java2329–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 프레임만 남겼습니다. 읽을 것이 셋입니다.
- 스레드 이름이 1절에서 본 대로
http-nio-8080뒤에 붙습니다. Acceptor 하나, Poller 하나입니다. - 아직 요청이 하나도 없는데 작업 스레드가 10개입니다.
minSpareThreads의 기본값이 10이고(AbstractEndpoint.java1459행), Tomcat 의 풀은 만들어지는 순간 그만큼을 미리 띄웁니다(5절). - Poller 는
EPoll.wait에서, 작업 스레드는TaskQueue.take()에서 기다립니다. Poller 는 소켓에 무슨 일이 생기기를, 작업 스레드는 큐에 일이 들어오기를 기다리는 것입니다. 이 둘 사이를 잇는 것이 3절의 주제입니다.
위 코드의 순서와 예제 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 이 받았는지는 실행마다 다릅니다. 줄 번호는 모두 기준 커밋의 소스와 맞는 것을 확인했습니다. 스택을 세 토막으로 읽으면 연재의 지도가 됩니다.
TaskThread~NioEndpoint$SocketProcessor.doRun— 이 글의 범위입니다. 풀의 작업 스레드가 큐에서SocketProcessor라는 일감을 꺼내 실행했습니다.ConnectionHandler.process~CoyoteAdapter.service— 프로토콜 처리기(Coyote)입니다.Http11Processor가 바이트를 요청으로 읽는 과정은 6편,CoyoteAdapter는 7편에서 봅니다.StandardEngineValve~doGet— Catalina 의 컨테이너들입니다. 밸브는 8편, 필터 체인은 9편입니다.WsFilter는 WebSocket 지원(WsServerContainer)이 웹앱에 등록해 두는 필터입니다(WsServerContainer.java98행, 15편).
위 출력의 스택을 먼저 불린 쪽부터 위에서 아래로 놓았습니다(출력은 아래가 먼저). 괄호 안은 그 부분을 자세히 보는 편입니다.
헷갈리기 쉬운 곳: 스택에 Acceptor 도 Poller 도 없습니다
요청은 Acceptor 가 받은 연결로 들어왔고 Poller 가 "데이터가 왔다"고 알려서 처리됐지만, 서블릿을 부른 스레드의 스택에는 둘 다 없습니다. 둘은 일감(SocketProcessor)을 만들어 큐에 넣고 자기 일로 돌아가는 스레드라서, 그 일감을 실행하는 작업 스레드의 스택에는 흔적이 남지 않습니다. 같은 이유로 서블릿 안에서 오래 걸리는 일을 해도 Acceptor 와 Poller 는 멈추지 않습니다.
3. 연결 하나의 여정
노란 상자는 기다리는 곳(대기열·큐), 파란 상자는 그 사이에서 일하는 코드입니다.
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행). 한 바퀴에 하는 일은 셋입니다.
countUpOrAwaitConnection()— 연결 수를 하나 올립니다. 이미maxConnections개면 하나가 닫힐 때까지 여기서 멈춥니다(6절의 예제 4).serverSocketAccept()— 듣기 소켓에서accept()를 부릅니다. 듣기 소켓은 블로킹으로 열어 두었으므로(serverSock.configureBlocking(true),NioEndpoint.java497행) 새 접속이 없으면 여기서 기다립니다.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 의 한 바퀴는 세 단계입니다.
events()— 다른 스레드들이 넣어 둔PollerEvent를 꺼내 Selector 에 반영합니다.OP_REGISTER면sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper)로 새 채널을 등록하고, 그 밖의 것이면 이미 등록된 키의 관심사(interest ops, "이 채널의 무엇을 지켜볼지")에 읽기나 쓰기를 더합니다(1009–1061행).select()— 지켜보는 채널 가운데 준비된 것이 생길 때까지 최대selectorTimeout(기본 1000ms, 316행) 기다립니다. 예제 1의 Poller 가EPoll.wait에 있던 자리가 1093행 바로 이 줄입니다. Poller 가 이렇게 기다리는 동안 큐에 이벤트가 들어오면addEvent()가selector.wakeup()으로 이 기다림을 깨웁니다(968–973행.wakeupCounter로 Poller 가select()에 들어가 있는 때에만 부릅니다).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 객체 하나뿐입니다.
초록 상자가 이 설계의 목적입니다. 기다리는 동안 스레드는 풀에 있습니다.
시간이 다 된 연결은 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
시각은 실행마다 조금씩 다릅니다.
- 작업 스레드는 정말 2개뿐입니다.
minSpareThreads(기본 10)는getMinSpareThreads()에서maxThreads를 넘지 못하게 잘립니다(Math.min(...),AbstractEndpoint.java1483–1485행). - 스레드 2개로 연결 5개를 받았고, 그 5개가 열려 있는 동안 새 연결도 바로(0.01초 안에) 처리됐습니다. keep-alive 시간은 server.xml 의
connectionTimeout="20000"을 그대로 따르므로(아래 "헷갈리기 쉬운 곳") 연결 다섯은 20초 동안 열려 있을 수 있었지만, 스레드는 하나도 붙잡지 않았습니다. - 같은 연결 1의 두 요청을 다른 스레드가 받았습니다(
exec-2→exec-1). 연결과 스레드가 묶여 있지 않다는 뜻입니다. 요청 사이에 연결은 Poller 에게 돌아가고, 다음 데이터가 오면 그때 노는 스레드 아무에게나 갑니다.
httpd 로 같은 실험을 했을 때, 연결을 붙잡는 worker MPM 에서는 새 요청이 KeepAliveTimeout 만큼(5.7초) 기다렸고 event MPM 에서는 0.001초였습니다(httpd 8편). Tomcat 의 NIO 커넥터는 event MPM 쪽입니다.
헷갈리기 쉬운 곳
keepAliveTimeout을 적지 않으면connectionTimeout을 씁니다. 필드가Integer이고null이면getConnectionTimeout()을 돌려줍니다(AbstractEndpoint.java1351–1364행). 배포판 server.xml 의 8080 커넥터에는connectionTimeout="20000"이 적혀 있으므로(conf/server.xml70–72행) keep-alive 도 20초입니다. 그 속성조차 없으면AbstractHttp11Protocol생성자가 넣는DEFAULT_CONNECTION_TIMEOUT60000ms 가 됩니다(AbstractHttp11Protocol.java78–81행,http11/Constants.java29행). httpd 의KeepAliveTimeout기본값 5초보다 훨씬 깁니다. 11.0.x 의DEFAULT_CONNECTION_TIMEOUT도 60000 입니다.maxKeepAliveRequests(기본 100) 는 연결 하나로 받을 요청의 수입니다. 소스 주석도 "as in Apache HTTPD server" 라고 적어 두었습니다(1628행). 요청마다decrementKeepAlive()로 하나씩 줄여 0이 되면 그 요청의 응답 뒤에 연결을 닫습니다(Http11Processor.java393–398행).- httpd event 의 리스너와 Tomcat 의 Poller 는 하는 일이 같지 않습니다. event 의 리스너 스레드 하나는 듣기 소켓과 기다리는 연결을 한 pollset 에서 함께 지켜보고, 타임아웃은 기다리는 시간이 같은 연결끼리 묶은 큐로 앞에서부터 정리합니다. Tomcat 은 듣기 소켓을 블로킹
accept()하는 Acceptor 에게 따로 맡기고, Poller 의timeout()은 등록된 키 전체를 훑습니다.
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행
- 이름:
TaskThreadFactory가"http-nio-8080-exec-"뒤에 1부터 올라가는 번호를 붙입니다(TaskThreadFactory.java47–54행). - 크기: 기본 크기(corePoolSize)가
minSpareThreads(기본 10), 최대 크기가maxThreads(기본 200, 1499행), 기본 크기를 넘는 스레드가 놀다가 사라지기까지의 시간이threadsMaxIdleTime(기본 60000ms, 1564행)입니다. 뒤에 볼<Executor>에서는 같은 뜻의 속성 이름이maxIdleTime입니다. 큐 크기maxQueueSize는 기본Integer.MAX_VALUE, 사실상 무한입니다(1535행). - 가상 스레드:
useVirtualThreads="true"면 풀 대신 요청마다 JDK 21 의 가상 스레드를 하나씩 만드는VirtualThreadExecutor를 씁니다(이름은…-virt-로 시작). 이 글의 예제에서는 쓰지 않았습니다.
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행
노란 상자는 큐에 쌓이는 경우, 초록 상자는 스레드가 늘어나는 경우입니다.
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. 한계 넷 — 어디서 기다리고, 어디서 막히나
지금까지 본 것을 "요청이 기다릴 수 있는 곳"으로 다시 줄 세우면 이렇습니다.
노란 상자가 기다리는 자리입니다. 숫자는 각 속성의 기본값입니다.
| 속성 | 기본값 | 걸리는 곳 | 넘치면 |
|---|---|---|---|
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
시각과 스레드 번호는 실행마다 다릅니다.
- 세 번째 연결의
connect()는 0.11초에 바로 성공했습니다. TCP 연결은 운영체제가 맺어 커널 대기열(acceptCount)에 넣었기 때문입니다. 클라이언트는 요청까지 보냈습니다. - 그런데 Acceptor 는
accept()가 아니라LimitLatch.countUpOrAwait()에서 잠들어 있습니다. 스택의Acceptor.java:133은 3절에서 본endpoint.countUpOrAwaitConnection();줄입니다. 열린 연결이 이미 2개라 문이 닫혔습니다. - 응답은 3.12초에 왔습니다.
keepAliveTimeout3초가 지나자 Poller 의timeout()이 놀던 연결 둘을 닫았고, 연결 수가 내려가 Acceptor 가 깨어나 세 번째 연결을accept()했습니다. 이미 도착해 있던 요청은 그때 처리됐습니다. - 그 뒤 놀던 연결로 다시 요청하자 "connection closed by server" — 서버가 정말 닫았습니다.
예제 2와 이 예제를 함께 보면, keep-alive 연결은 스레드는 차지하지 않지만 연결 수(maxConnections)는 차지합니다. 놀고 있는 연결이 많은 서비스에서 막히는 것은 maxThreads 보다 maxConnections 쪽일 수 있습니다. httpd 의 event MPM 도 열린 연결이 일정 수를 넘으면 그 자식 프로세스가 듣기 소켓을 잠시 끄는 장치(connections_above_limit())를 두는데(httpd 8편), Tomcat 에서는 Acceptor 하나가 latch 앞에서 멈추는 것으로 같은 일을 합니다.
헷갈리기 쉬운 곳
- 연결 수는 실제보다 하나 많게 읽힐 수 있습니다. Acceptor 는
accept()전에 숫자를 올리므로, 한도에 닿기 전까지getConnectionCount()(JMX 의 ThreadPool MBean 에 보이는 값)는 실제로 처리 중인 연결보다 하나(Acceptor 수만큼) 큽니다. 소스 주석이 그렇게 적고 있습니다(1063–1065행). 한도에 닿으면 이 차이는 없어집니다. 예제 4에서 Acceptor 는 두 번째 연결을 받은 직후 세 번째 자리를 잡으려고 숫자를 3으로 올려 보았다가, 한도(2)를 넘었으므로 되돌리고 기다리기 시작했습니다(위의tryAcquireShared()). 그래서 문이 닫혀 있는 동안의 값은 실제 연결 수와 같은 2입니다. acceptCount와maxConnections를 헷갈리기 쉽습니다. 앞쪽은 "Tomcat 이 아직 받지 않은 연결"을 운영체제가 쌓아 두는 크기, 뒤쪽은 "Tomcat 이 받아서 들고 있는 연결"의 수입니다. 둘 다 넘친 접속은 Tomcat 이 거절 응답을 보내는 것이 아니라, 받지 않은 채로 둡니다.
이 글에서 뺀 것
- TLS 핸드셰이크(
SecureNioChannel,SocketProcessor.doRun()의handshake분기):NioEndpoint.java2182–2230행,java/org/apache/tomcat/util/net/SecureNioChannel.java - sendfile(큰 정적 파일을 커널이 바로 보내기):
Poller.processSendfile()NioEndpoint.java1211행부터 - 블로킹 읽기·쓰기를 Poller 로 흉내 내는
readBlocking/writeBlocking, 비동기 I/O 의readOperation/writeOperation:NioEndpoint.NioSocketWrapper(1446행부터). 비동기 서블릿은 12편에서 봅니다 - 커넥터를 멈출 때 Acceptor 를 깨우는
unlockAccept(),pause():AbstractEndpoint.java2011행, 2343행 - Unix 도메인 소켓으로 듣기(
unixDomainSocketPath):NioEndpoint.java394–498행 - 스레드 풀이 웹앱을 내린 뒤 스레드를 갈아 끼우는
threadRenewalDelay(TaskQueue.take()·stopCurrentThreadIfNeeded()):TaskQueue.java127–156행 - AJP 커넥터(
AjpNioProtocol):java/org/apache/coyote/ajp/
정리
| 개념 | 한 줄 요약 |
|---|---|
<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편).