이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 열두 번째 글입니다. 11편에서는 세션을 봤습니다. 지금까지 본 요청은 모두 같은 모양이었습니다. 요청 처리 스레드 하나가 요청을 받아 밸브와 필터 체인을 지나 서블릿의 service()를 부르고, service()가 돌아오면 응답을 마무리합니다. 처음부터 끝까지 스레드 하나가 요청 하나를 붙잡고 있습니다.
이번 글은 이 규칙을 깨는 방법을 봅니다. 서블릿 3.0의 비동기 서블릿(startAsync())과 3.1의 논블로킹 I/O(ReadListener, WriteListener)입니다. 소스에도 그 구분이 주석으로 남아 있습니다(AbstractProcessor.action()의 "Servlet 3.0 asynchronous support", "Servlet 3.1 non-blocking I/O", 575행·658행). 답하려는 질문은 네 가지입니다.
startAsync()를 부르고service()에서 돌아오면, 응답은 아직 안 끝났는데 스레드는 어디로 가고 요청은 누가 붙잡고 있는가- 다른 스레드에서 부른
complete()나dispatch()는 어떤 길로 Tomcat의 요청 처리 스레드에 다시 닿는가 - 비동기 요청을 아무도 끝내지 않으면 언제, 누가 끊는가
ReadListener의onDataAvailable()은 누가, 언제 부르는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | core/AsyncContextImpl, coyote/AsyncStateMachine, coyote/AbstractProcessor·AbstractProcessorLight, coyote/AbstractProtocol, connector/CoyoteAdapter, connector/Request, coyote/Request |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
비동기 처리에는 경쟁 조건(두 스레드가 거의 동시에 같은 요청을 건드리는 경우)을 막는 코드가 아주 많습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
컨테이너 스레드(container thread)는 Tomcat이 요청을 처리하라고 만든 스레드 풀의 스레드입니다. http-nio-8080-exec-1처럼 이름이 붙어 있고, server.xml의 <Connector>에 적는 maxThreads가 이 풀의 크기입니다. 연결을 받는 Acceptor, 소켓을 지켜보는 Poller와 함께 5편에서 봤습니다. Tomcat 소스의 주석은 이 스레드들을 "container thread", 애플리케이션이 직접 만든 스레드를 "non-container thread"(비컨테이너 스레드)라고 나눠 부릅니다(예: AsyncStateMachine.java 337–349행).
비동기 서블릿은 service() 안에서 request.startAsync()를 불러 "응답은 나중에 끝내겠다"고 알리는 서블릿입니다. startAsync()는 AsyncContext를 돌려주고, 애플리케이션은 그것을 다른 스레드에 넘겨 둡니다. 일이 끝나면 그 스레드가 다음 둘 중 하나를 부릅니다.
complete()— 응답을 여기서 끝낸다.dispatch()— 이 요청을 컨테이너 스레드에서 서블릿으로 다시 보낸다(서블릿이 한 번 더 불린다).
서블릿이 비동기를 쓰려면 @WebServlet(asyncSupported = true)(web.xml에서는 <async-supported>true</async-supported>)처럼 미리 밝혀 둬야 합니다.
ActionCode 는 Catalina(서블릿 컨테이너)가 Coyote(커넥터)에게 무언가를 시킬 때 쓰는 이름표입니다(2편에서 둘을 나눈 이유를 봤습니다). Catalina는 Coyote의 내부를 직접 부르지 않고 coyoteRequest.action(ActionCode.ASYNC_COMPLETE, null)처럼 이름표만 넘깁니다. 받는 쪽은 프로세서(HTTP/1.1이면 6편의 Http11Processor)이고, 실제 switch는 그 부모인 AbstractProcessor.action()에 있습니다. ActionCode는 enum이고, 소스 주석은 "서블릿 컨테이너에서 coyote 커넥터로 가는 콜백"이라고 설명합니다(coyote/ActionCode.java 19–26행).
SocketState 는 프로세서가 소켓 하나를 처리하고 돌아올 때 "이 연결을 이제 어떻게 할지"를 알리는 값입니다. 이 글에서는 셋만 알면 됩니다.
| 값 | 뜻 |
|---|---|
OPEN |
요청 하나를 다 끝냈다. keep-alive면 다음 요청을 기다린다 |
LONG |
요청을 처리하는 도중이다. 프로세서를 소켓에 붙여 둔 채로 돌아간다 |
ASYNC_END |
비동기 처리가 방금 끝났다. 한 번 더 돌아 마무리하라 |
논블로킹 I/O는 "읽을 데이터가 없으면 기다리지 말고 바로 돌아와라"라는 방식입니다. 서블릿에서는 ServletInputStream.setReadListener()로 리스너를 걸어 두면, 데이터가 왔을 때 컨테이너가 onDataAvailable()을 불러 줍니다. 쓰기 쪽은 WriteListener.onWritePossible()입니다. 이 둘은 비동기 모드에서만 쓸 수 있습니다.
1. 왜 스레드를 놓아주나
요청 하나가 2초 걸리는 외부 API를 기다린다고 해 봅시다. 동기 서블릿이라면 그 2초 동안 컨테이너 스레드가 잠들어 있습니다. 스레드는 아무 일도 하지 않지만 풀의 한 자리를 차지하므로, 풀이 다 차면 다른 요청은 큐에서 기다립니다. 비동기 서블릿은 기다리는 동안 컨테이너 스레드를 풀에 돌려줍니다.
빨간 상자가 동기 서블릿이 스레드를 낭비하는 곳, 초록 상자가 비동기 서블릿이 스레드를 돌려주는 곳입니다.
예제 1: maxThreads="1"에서 동기와 비동기
정말 그런지 극단적인 설정으로 확인했습니다. server.xml의 8080 커넥터에 maxThreads="1"을 붙여 요청 처리 스레드를 하나만 두고, 2초 걸리는 일을 하는 서블릿을 동기와 비동기로 하나씩 만들어 각각 세 요청을 동시에 보냈습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 2초 걸리는 일을 동기와 비동기로 한 번씩. maxThreads="1" 인 커넥터에서 세 요청을 동시에 보낸다.
public class SlowServlets {
// ... now() 는 이 클래스가 처음 쓰인 때부터 잰 단조 시계(nanoTime) 값을 "t=…ms" 로 돌려준다
// 동기: 컨테이너 스레드가 2초 동안 잠든다
@WebServlet("/sync")
public static class Sync extends HttpServlet {
// ... doGet() 은 Thread.sleep(2000) 한 뒤 아래 Async 와 같은 모양의 한 줄을 쓴다
}
// 비동기: service() 는 바로 돌아가고, 2초 뒤에 스케줄러 스레드 하나가 응답을 끝낸다
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public static class Async extends HttpServlet {
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "my-scheduler"));
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
String begin = now();
String t = Thread.currentThread().getName();
AsyncContext ac = req.startAsync();
scheduler.schedule(() -> {
try {
ac.getResponse().getWriter().println("async " + req.getParameter("n") + ": service() " + begin
+ " on " + t + ", response " + now() + " on " + Thread.currentThread().getName());
} catch (IOException e) {
e.printStackTrace();
}
ac.complete();
}, 2, TimeUnit.SECONDS);
}
// ... destroy() 에서 scheduler 를 닫는다
}
}
[예제 ex/u12-maxthreads/SlowServlets.java에서 줄임]
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. run.sh는 curl 세 개를 백그라운드로 동시에 띄우고, 끝나면 jstack으로 http-nio-8080으로 시작하는 스레드를 셉니다.
<Connector port="8080" protocol="HTTP/1.1" maxThreads="1"
===== 3 concurrent requests to /u12/sync =====
sync 2: service() t= 0ms on http-nio-8080-exec-1, response t= 2002ms on http-nio-8080-exec-1
sync 3: service() t= 2005ms on http-nio-8080-exec-1, response t= 4008ms on http-nio-8080-exec-1
sync 1: service() t= 4011ms on http-nio-8080-exec-1, response t= 6015ms on http-nio-8080-exec-1
===== 3 concurrent requests to /u12/async =====
async 2: service() t= 6073ms on http-nio-8080-exec-1, response t= 8079ms on my-scheduler
async 1: service() t= 6081ms on http-nio-8080-exec-1, response t= 8082ms on my-scheduler
async 3: service() t= 6083ms on http-nio-8080-exec-1, response t= 8083ms on my-scheduler
===== http-nio-8080 threads (jstack) =====
"http-nio-8080-Acceptor"
"http-nio-8080-exec-1"
"http-nio-8080-Poller"
요청 순서와 밀리초 값은 실행마다 다릅니다.
- 동기: 세 요청이
exec-1하나를 차례로 씁니다. 두 번째 요청의service()는 첫 요청이 끝난 2초 뒤에야 시작하고, 세 번째는 4초 뒤에 시작합니다. 마지막 요청은 6초 만에 응답을 받았습니다. - 비동기: 세 요청의
service()가 몇 밀리초 간격으로 같은exec-1에서 차례로 불렸습니다.service()가 바로 돌아가니 스레드가 곧 비어서 다음 요청을 받을 수 있었던 것입니다. 2초를 기다린 것은 스레드가 아니라 스케줄러의 예약이고, 응답은 세 개 모두 2초 만에my-scheduler가 썼습니다.
스레드 목록에는 exec-1 하나뿐입니다. 비동기 요청 셋이 2초 동안 동시에 "처리 중"이었지만 그동안 그 요청들을 맡은 컨테이너 스레드는 하나도 없었다는 뜻입니다. 그러면 그 2초 동안 요청은 어디에 있었을까요. 2절부터 따라갑니다.
헷갈리기 쉬운 곳: 비동기는 일을 빨리 끝내 주지 않습니다
비동기 요청 하나의 응답 시간은 여전히 2초입니다. 비동기가 바꾸는 것은 기다리는 동안 컨테이너 스레드를 쓰지 않는다는 것뿐입니다. 위 예제에서 my-scheduler 대신 Thread.sleep(2000)을 하는 스레드 풀에 일을 넘겼다면, 그 풀이 동기 서블릿의 컨테이너 스레드와 같은 처지가 됩니다. 비동기가 효과를 보는 것은 기다림 자체를 스레드 없이 할 수 있을 때(타이머, 논블로킹 클라이언트의 콜백, 메시지 큐의 알림)입니다.
이 예제를 만들다 겪은 일도 하나 적어 둡니다. 처음에는 응답 시각을 LocalTime.now()(벽시계)로 찍었는데, 이 도커 환경에서는 벽시계가 흔들려 Thread.sleep(2000) 앞뒤의 시각 차이가 1.2초로 찍히기도 했습니다. 경과 시간은 System.nanoTime()(단조 시계)으로 재야 합니다.
2. startAsync() — 무엇이 준비되나
서블릿이 받는 HttpServletRequest는 RequestFacade로 감싼 connector.Request입니다(7편). startAsync()는 여기에 있습니다.
@Override
public AsyncContext startAsync(ServletRequest request, ServletResponse response) {
if (!isAsyncSupported()) {
IllegalStateException ise = new IllegalStateException(sm.getString("request.asyncNotSupported"));
log.warn(sm.getString("coyoteRequest.noAsync", StringUtils.join(getNonAsyncClassNames())), ise);
throw ise;
}
if (asyncContext == null) {
asyncContext = new AsyncContextImpl(this);
}
asyncContext.setStarted(getContext(), request, response,
request == getRequest() && response == getResponse().getResponse());
asyncContext.setTimeout(getConnector().getAsyncTimeout());
return asyncContext;
}
java/org/apache/catalina/connector/Request.java 1582–1599행
하는 일은 세 가지입니다.
- 비동기를 써도 되는지 확인합니다. 안 되면
IllegalStateException을 던지고, 어느 클래스가 막았는지 경고 로그를 남깁니다. AsyncContextImpl을 만들고setStarted()를 부릅니다. 요청마다 하나만 만들어 두고 비동기를 여러 번 시작해도 다시 씁니다.- 타임아웃을 커넥터의
asyncTimeout으로 정합니다.server.xml의<Connector asyncTimeout="...">이 이 값이고, 기본값은 30000밀리초입니다(Connector.java169행). 애플리케이션이setTimeout()으로 다시 바꿀 수 있습니다(5절).
setStarted()의 첫 줄이 Coyote에 비동기 시작을 알립니다.
synchronized (asyncContextLock) {
this.request.getCoyoteRequest().action(ActionCode.ASYNC_START, this);
this.context = context;
context.incrementInProgressAsyncCount();
this.servletRequest = request;
this.servletResponse = response;
this.hasOriginalRequestAndResponse = originalRequestResponse;
this.event = new AsyncEvent(this, request, response);
// ... 등록돼 있던 AsyncListener 들의 onStartAsync() 를 부른다
}
java/org/apache/catalina/core/AsyncContextImpl.java 407–430행
ASYNC_START는 AbstractProcessor.action()에서 asyncStateMachine.asyncStart(...)로 이어집니다(576–579행). 여기서 이 글의 주인공이 등장합니다. 비동기 상태는 Catalina의 AsyncContextImpl이 아니라 Coyote 쪽 프로세서가 가진 AsyncStateMachine에 있습니다. AsyncContextImpl의 메서드들은 대부분 action()으로 상태 기계에 일을 넘기는 얇은 껍데기입니다. complete()도 check() 한 번 하고 ActionCode.ASYNC_COMPLETE를 보내는 것이 전부입니다(148–156행).
isAsyncSupported()는 사슬 전체를 봅니다
1번의 isAsyncSupported()는 요청에 들어 있는 플래그 하나를 읽을 뿐인데, 이 플래그는 요청이 지나온 길에서 하나씩 깎입니다.
CoyoteAdapter.service()가 Engine의 파이프라인 전체를 봅니다(345행).StandardPipeline.isAsyncSupported()는 밸브를 하나씩 따라가다 비동기를 지원하지 않는 밸브가 하나라도 있으면false입니다(100–109행).- Engine·Host·Context의 기본 밸브가 다음 컨테이너의 파이프라인을 같은 방식으로 봅니다(예:
StandardHostValve90–92행). - 마지막으로 필터 체인이 필터마다, 그리고 서블릿을 부르기 직전에 봅니다.
if (request.isAsyncSupported() && !servletSupportsAsync) {
request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
}
java/org/apache/catalina/core/ApplicationFilterChain.java 128–130행
필터는 108–110행에서 같은 일을 합니다. 한 번 false가 되면 다시 true로 돌아가지 않습니다. 그래서 요청이 지나는 밸브·필터·서블릿 가운데 하나라도 asyncSupported가 아니면 startAsync()는 실패합니다. 실패했을 때 경고 로그에 찍히는 클래스 목록은 getNonAsyncClassNames()가 서블릿, 필터 체인, 그리고 Wrapper부터 Engine까지의 파이프라인을 다시 훑어 모읍니다(1602–1624행).
빨간 칸은 아래 "헷갈리기 쉬운 곳"의 실제 출력, 초록 칸은 위 두 코드 조각입니다.
헷갈리기 쉬운 곳: asyncSupported를 빠뜨린 서블릿
asyncSupported를 적지 않은 서블릿에서 startAsync()를 불러 봤습니다(예제 2의 /noasync, 코드는 @WebServlet("/noasync")에 req.isAsyncSupported()를 찍는 줄과 req.startAsync() 한 줄이 전부입니다).
===== curl http://127.0.0.1:8080/u12/noasync =====
HTTP 500
[http-nio-8080-exec-6] isAsyncSupported=false
24-Oct-2026 13:56:00.002 WARNING [http-nio-8080-exec-6] org.apache.catalina.connector.Request.startAsync Unable to start async because the following classes in the processing chain do not support async [NoAsyncServlet]
시각과 스레드 번호는 실행마다 다릅니다. 응답은 500이고, 로그에 막은 클래스가 [NoAsyncServlet]으로 찍힙니다. 실무에서 흔한 것은 서블릿은 켰는데 앞에 걸린 필터 하나가 asyncSupported가 아닌 경우입니다. 그때는 이 목록에 그 필터의 클래스 이름이 나오므로, 목록부터 보면 됩니다. Spring MVC도 비동기를 시작하기 전에 같은 것을 확인하고 "서블릿과 관련된 모든 필터에서 async support를 켜라"는 메시지를 냅니다(StandardServletAsyncWebRequest 141–145행).
3. AsyncStateMachine — service()가 돌아온 뒤 요청은 어디에 있나
상태 열세 개
AsyncStateMachine의 상태는 enum 하나에 모여 있습니다.
private enum AsyncState {
DISPATCHED(false, false, false, false),
STARTING(true, true, false, false),
STARTED(true, true, false, false),
MUST_COMPLETE(true, true, true, false),
COMPLETE_PENDING(true, true, false, false),
COMPLETING(true, false, true, false),
TIMING_OUT(true, true, false, false),
MUST_DISPATCH(true, true, false, true),
DISPATCH_PENDING(true, true, false, false),
DISPATCHING(true, false, false, true),
READ_WRITE_OP(true, true, false, false),
MUST_ERROR(true, true, false, false),
ERROR(true, true, false, false);
java/org/apache/coyote/AsyncStateMachine.java 131–144행
괄호 안의 네 값은 차례로 isAsync, isStarted, isCompleting, isDispatching입니다(146–156행). 예를 들어 request.isAsync()는 결국 지금 상태의 첫 번째 값을 읽습니다. DISPATCHED만 false이므로, DISPATCHED가 아니면 모두 "비동기 중" 입니다. 이름이 헷갈리는데, DISPATCHED는 "디스패치했다"가 아니라 "보통 요청처럼 컨테이너 스레드에 맡겨진 상태"라는 뜻으로, 비동기가 아닌 평소 상태입니다.
각 상태의 뜻과 허용되는 전이는 클래스 머리의 주석에 큰 그림으로 그려져 있습니다(30–124행). 이 글에서 따라갈 길만 추리면 이렇습니다.
노란 STARTED가 "스레드 없이 기다리는" 상태입니다. *_PENDING, READ_WRITE_OP, MUST_ERROR는 그림에서 뺐습니다(4·6절에서 글로 봅니다).
STARTING과 STARTED를 나누는 이유
startAsync()가 만드는 상태는 STARTED가 아니라 STARTING입니다.
synchronized void asyncStart(AsyncContextCallback asyncCtxt) {
if (state == AsyncState.DISPATCHED) {
generation.incrementAndGet();
updateState(AsyncState.STARTING);
// Note: In this instance, caller is responsible for calling
// asyncCtxt.incrementInProgressAsyncCount() as that allows simpler
// error handling.
this.asyncCtxt = asyncCtxt;
lastAsyncStart = System.currentTimeMillis();
} else {
throw new IllegalStateException(sm.getString("asyncStateMachine.invalidAsyncState", "asyncStart()", state));
}
}
java/org/apache/coyote/AsyncStateMachine.java 241–253행
STARTING은 "비동기를 시작했지만 service()가 아직 안 끝났다"입니다. 이 구분이 필요한 이유는 service()를 부른 컨테이너 스레드가 아직 요청을 쥐고 있기 때문입니다. 이때 다른 스레드가 complete()를 불러 응답을 끝내 버리면, service()에서 돌아온 컨테이너 스레드가 이미 끝난 요청을 다시 만지게 됩니다. 그래서 상태 기계는 service()가 끝날 때까지 기다렸다가 진짜 일을 합니다(4절의 *_PENDING).
lastAsyncStart는 5절의 타임아웃이 기준으로 삼는 시각이고, generation은 비동기를 시작할 때마다 하나씩 올라가는 번호입니다. 소스 주석은 이전 비동기 회차의 이벤트가 지금 회차에 잘못 처리되는 것을 막기 위한 것이라고 설명합니다(178–184행).
service()가 끝나면: STARTED와 SocketState.LONG
service()가 돌아오면 CoyoteAdapter.service()는 비동기인지 봅니다.
if (postParseSuccess) {
// check valves if we support async
request.setAsyncSupported(connector.getService().getContainer().getPipeline().isAsyncSupported());
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
}
if (request.isAsync()) {
async = true;
// ... (ReadListener 가 이미 다 읽었으면 onAllDataRead(), 오류가 있었으면 오류 처리)
} else {
request.finishRequest();
response.finishResponse();
}
java/org/apache/catalina/connector/CoyoteAdapter.java 343–377행
비동기면 finishResponse()를 부르지 않습니다. finally에서도 async가 참이면 접근 로그를 남기지 않고, 요청·응답 객체를 재활용(recycle())하지도 않습니다(394행, 423–427행). 응답이 아직 안 끝났으니 당연합니다.
6편에서 본 Http11Processor.service()의 요청 루프는 !isAsync()가 조건에 들어 있어(268행) 비동기가 되면 루프를 빠져나오고, 마지막에 이렇게 돌려줍니다.
if (getErrorState().isError() || (protocol.isPaused() && !isAsync())) {
return SocketState.CLOSED;
} else if (isAsync()) {
return SocketState.LONG;
java/org/apache/coyote/http11/Http11Processor.java 474–477행
이 값을 받은 AbstractProcessorLight.process()는 비동기면 asyncPostProcess()를 한 번 부릅니다(4절에서 이 루프 전체를 봅니다). 상태 기계의 그 메서드가 STARTING을 STARTED로 바꿉니다.
synchronized SocketState asyncPostProcess() throws IOException {
if (state == AsyncState.COMPLETE_PENDING) {
clearNonBlockingListeners();
updateState(AsyncState.COMPLETING);
return SocketState.ASYNC_END;
} else if (state == AsyncState.DISPATCH_PENDING) {
clearNonBlockingListeners();
updateState(AsyncState.DISPATCHING);
return SocketState.ASYNC_END;
} else if (state == AsyncState.STARTING || state == AsyncState.READ_WRITE_OP) {
updateState(AsyncState.STARTED);
return SocketState.LONG;
} else if (state == AsyncState.MUST_COMPLETE || state == AsyncState.COMPLETING) {
if (processor.getErrorState().isIoAllowed() && processor.flushBufferedWrite()) {
return SocketState.LONG;
}
asyncCtxt.fireOnComplete();
updateState(AsyncState.DISPATCHED);
asyncCtxt.decrementInProgressAsyncCount();
return SocketState.ASYNC_END;
// ... MUST_DISPATCH, DISPATCHING, STARTED 처리, 그 밖의 상태면 IllegalStateException
java/org/apache/coyote/AsyncStateMachine.java 285–324행
asyncPostProcess()는 "service()(또는 리스너 호출)가 막 끝났다"는 시점에 불려, 그동안 쌓인 일을 정리하는 메서드입니다. 이 절에서 볼 것은 세 번째 가지입니다. STARTING이면 STARTED로 바꾸고 LONG을 돌려줍니다.
LONG을 받은 연결 처리기: 소켓에 붙여 두고, 기다리는 목록에 올린다
LONG은 5편에서 본 엔드포인트와 프로세서 사이의 AbstractProtocol.ConnectionHandler.process()까지 올라갑니다.
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) {
java/org/apache/coyote/AbstractProtocol.java 1378–1386행
두 가지가 일어납니다.
- 프로세서가 소켓에 붙은 채 남습니다.
OPEN이었다면release(processor)로 프로세서를 재활용했을 것입니다(1389행).LONG은 그러지 않고 1428행에서wrapper.setCurrentProcessor(processor)로 붙여 둡니다. 요청의 모든 상태(Request,Response, 상태 기계)는 이 프로세서 객체 안에 있습니다. 질문 "요청은 누가 붙잡고 있나"의 답이 이것입니다. 스레드가 아니라 소켓에 붙은 프로세서 객체가 요청을 붙잡고 있습니다. waitingProcessors집합에 올라갑니다. 5절의 타임아웃 검사가 이 집합을 훑습니다.
longPoll()은 비동기 프로세서에 대해서는 아무것도 하지 않습니다.
protected void longPoll(SocketWrapperBase<?> socket, Processor processor) {
if (!processor.isAsync()) {
// This is currently only used with HTTP
// Either:
// - this is an upgraded connection
// - the request line/headers have not been completely
// read
socket.registerReadInterest();
}
}
java/org/apache/coyote/AbstractProtocol.java 1505–1514행
비동기 요청을 기다리는 동안에는 소켓을 Poller에 읽기로 등록하지도 않습니다. 이 연결은 클라이언트가 아니라 애플리케이션이 complete()나 dispatch()를 불러 줄 때 다시 움직이기 때문입니다. 그러면 컨테이너 스레드는 ConnectionHandler.process()에서 돌아와 풀로 돌아갑니다.
예제 2: 상태 바뀜과 스레드 이름 찍기
AsyncStateMachine.updateState()는 상태를 바꿀 때마다 trace 로그를 남깁니다(526–531행). 그래서 conf/logging.properties에 한 줄을 더하면 상태 전이를 볼 수 있습니다.
org.apache.coyote.AsyncStateMachine.level = FINEST
여기에 스레드 이름을 찍는 서블릿을 걸었습니다. service()에서 startAsync()를 부르고, 직접 만든 스레드 my-worker가 1초 뒤에 응답을 쓰고 complete()를 부릅니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
static void say(String msg) {
System.out.println("[" + Thread.currentThread().getName() + "] " + msg);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// ... (dispatch() 로 다시 들어왔을 때의 처리 — 4절)
say("service() start, dispatcherType=" + req.getDispatcherType());
AsyncContext ac = req.startAsync();
say("startAsync() returned, timeout=" + ac.getTimeout() + "ms");
ac.addListener(new AsyncListener() {
@Override public void onComplete(AsyncEvent e) { say("AsyncListener.onComplete()"); }
// ... onTimeout, onError 도 같은 모양, onStartAsync 는 비어 있음
});
String mode = req.getParameter("mode");
// 컨테이너 풀이 아닌, 직접 만든 스레드에서 오래 걸리는 일을 한다
Thread worker = new Thread(() -> {
try {
say("worker: working for 1s ...");
Thread.sleep(1000);
if ("dispatch".equals(mode)) {
req.setAttribute("result", "42");
say("worker: calling dispatch()");
ac.dispatch();
} else {
ac.getResponse().setContentType("text/plain");
ac.getResponse().getWriter().println("completed by " + Thread.currentThread().getName());
say("worker: calling complete()");
ac.complete();
}
say("worker: done");
} catch (Exception e) {
e.printStackTrace();
}
}, "my-worker");
worker.start();
say("service() end");
}
}
[예제 ex/u12-async-threads/AsyncServlet.java에서 줄임]
run.sh는 catalina.out에서 서블릿이 찍은 줄과 상태 기계의 로그만 골라, 로그 쪽은 날짜와 클래스 이름을 떼고 state:를 붙여 보여 줍니다.
===== curl http://127.0.0.1:8080/u12/async?mode=complete =====
completed by my-worker
[http-nio-8080-exec-2] service() start, dispatcherType=REQUEST
[http-nio-8080-exec-2] state: Changing async state from [DISPATCHED] to [STARTING]
[http-nio-8080-exec-2] startAsync() returned, timeout=30000ms
[http-nio-8080-exec-2] service() end
[my-worker] worker: working for 1s ...
[http-nio-8080-exec-2] state: Changing async state from [STARTING] to [STARTED]
[my-worker] worker: calling complete()
[my-worker] state: Changing async state from [STARTED] to [COMPLETING]
[my-worker] worker: done
[http-nio-8080-exec-3] AsyncListener.onComplete()
[http-nio-8080-exec-3] state: Changing async state from [COMPLETING] to [DISPATCHED]
스레드 번호와, 같은 순간에 일어난 my-worker와 exec-2의 줄 순서는 실행마다 조금씩 다릅니다.
startAsync()는DISPATCHED → STARTING이고, 타임아웃은 커넥터 기본값30000ms입니다.STARTING → STARTED는service() end보다 뒤에 찍힙니다.service()가 돌아온 뒤asyncPostProcess()가 바꾼다는 것을 그대로 보여 줍니다.complete()를 부른 것은my-worker이고 상태도my-worker에서COMPLETING이 됩니다. 그런데onComplete()는 새 컨테이너 스레드exec-3에서 불립니다.exec-2도my-worker도 아닙니다. 이 길이 4절의 주제입니다.
4. complete()와 dispatch() — 컨테이너 스레드로 돌아오는 길
asyncComplete(): 상태를 바꾸고 "다시 실행해 달라"고 알린다
my-worker가 부른 complete()는 ActionCode.ASYNC_COMPLETE가 되어 AbstractProcessor.action()에 닿습니다.
case ASYNC_COMPLETE: {
clearDispatches();
if (asyncStateMachine.asyncComplete()) {
processSocketEvent(SocketEvent.OPEN_READ, true);
}
break;
}
case ASYNC_DISPATCH: {
if (asyncStateMachine.asyncDispatch()) {
processSocketEvent(SocketEvent.OPEN_READ, true);
}
break;
}
java/org/apache/coyote/AbstractProcessor.java 580–592행
상태 기계가 true를 돌려주면 processSocketEvent()로 이 소켓을 다시 처리해 달라고 엔드포인트에 부탁합니다. asyncComplete()가 언제 true인지가 핵심입니다.
synchronized boolean asyncComplete() {
Request request = processor.getRequest();
if ((request == null || !request.isRequestThread()) &&
(state == AsyncState.STARTING || state == AsyncState.READ_WRITE_OP)) {
updateState(AsyncState.COMPLETE_PENDING);
return false;
}
clearNonBlockingListeners();
boolean triggerDispatch = false;
if (state == AsyncState.STARTING || state == AsyncState.MUST_ERROR) {
// Processing is on a container thread so no need to transfer
// processing to a new container thread
updateState(AsyncState.MUST_COMPLETE);
} else if (state == AsyncState.STARTED) {
updateState(AsyncState.COMPLETING);
// A dispatch to a container thread is always required.
// If on a non-container thread, need to get back onto a container
// thread to complete the processing.
// If on a container thread the current request/response are not the
// request/response associated with the AsyncContext so need a new
// container thread to process the different request/response.
triggerDispatch = true;
// ... READ_WRITE_OP, TIMING_OUT, ERROR 는 COMPLETING 으로만 바꾸고 false
java/org/apache/coyote/AsyncStateMachine.java 327–367행
경우가 셋으로 나뉩니다.
complete()를 부른 때 |
바뀌는 상태 | 다시 실행 요청 | 마무리는 누가 |
|---|---|---|---|
service()가 끝나기 전, 그 스레드에서 |
STARTING → MUST_COMPLETE |
안 함 | service()에서 돌아온 그 스레드의 asyncPostProcess() |
service()가 끝나기 전, 다른 스레드에서 |
STARTING → COMPLETE_PENDING |
안 함 | 같음. COMPLETE_PENDING → COMPLETING으로 바꾸고 ASYNC_END |
service()가 끝난 뒤(STARTED) |
STARTED → COMPLETING |
함 | 실행기가 새로 고른 컨테이너 스레드 |
isRequestThread()는 지금 스레드가 이 요청을 처리 중인 컨테이너 스레드인지를 스레드 ID로 비교합니다(coyote/Request.java 1098–1112행). 첫 두 줄은 아직 요청을 쥔 스레드가 있으니 "그 스레드가 나올 때 처리하라"고 상태만 바꿔 둡니다. 예제 2는 세 번째 줄입니다. my-worker가 1초를 잔 사이에 service()는 이미 끝나 STARTED였습니다.
processSocketEvent(): 실행기에 소켓 작업을 넣는다
processSocketEvent(OPEN_READ, true)는 SocketWrapperBase.processSocket()을 거쳐 5편에서 본 AbstractEndpoint.processSocket()에 닿습니다. 두 번째 인자 dispatch가 true이므로 지금 스레드에서 처리하지 않고 실행기(컨테이너 스레드 풀)에 넘깁니다.
Executor executor = getExecutor();
if (dispatch && executor != null) {
executor.execute(sc);
} else {
sc.run();
}
java/org/apache/tomcat/util/net/AbstractEndpoint.java 2145–2150행
이벤트 이름이 OPEN_READ라서 "소켓에서 읽을 것이 생겼다"처럼 보이지만, 여기서는 그저 "이 소켓의 프로세서를 다시 돌려라"라는 뜻입니다. 클라이언트에게서 받은 바이트는 없습니다. 이렇게 해서 비컨테이너 스레드(my-worker)의 호출이 컨테이너 스레드(exec-3)의 일로 바뀝니다. my-worker는 일을 넘기기만 하고 바로 돌아가서 worker: done을 찍습니다.
새 컨테이너 스레드에서: 두 바퀴 도는 process()
exec-3이 실행하는 SocketProcessor는 다시 ConnectionHandler.process()로 들어가고, 소켓에 붙어 있던 그 프로세서를 꺼내(그리고 waitingProcessors에서 빼고, 1256행) process()를 부릅니다.
do {
// ... (등록된 논블로킹 dispatch 들이 있으면 하나씩 처리, DISCONNECT 면 아무것도 안 함)
} else if (isAsync() || isUpgrade() || state == SocketState.ASYNC_END) {
state = dispatch(status);
state = checkForPipelinedData(state, socketWrapper);
} else if (status == SocketEvent.OPEN_WRITE) {
// ... (OPEN_READ 면 service() — 6편의 보통 요청 길)
if (isAsync() && state != SocketState.CLOSED) {
state = asyncPostProcess();
// ... (trace 로그)
}
// ...
} while (state == SocketState.ASYNC_END || dispatches != null && state != SocketState.CLOSED);
java/org/apache/coyote/AbstractProcessorLight.java 52–103행
같은 OPEN_READ라도 비동기 중이면 service()(요청 줄을 새로 읽는 길)가 아니라 dispatch()로 갑니다. dispatch()는 CoyoteAdapter.asyncDispatch()를 부릅니다(AbstractProcessor.java 292행). 그다음은 그림과 같이 진행됩니다.
회색 상자부터 실행기에 넘기는 상자까지는 애플리케이션 스레드(my-worker)에서, 노란 상자부터 아래는 모두 같은 컨테이너 스레드에서 일어납니다.
- 첫 바퀴: 상태가
COMPLETING이므로isAsync()가 아직 참입니다.asyncDispatch()는 리스너도, 디스패치도 없으니 별 일 없이 돌아옵니다. 루프가asyncPostProcess()를 부르고,COMPLETING가지(위 코드)가onComplete()를 부르고 상태를DISPATCHED로 돌려ASYNC_END를 돌려줍니다. 예제 2에서onComplete()다음 줄에COMPLETING → DISPATCHED가 찍힌 순서와 같습니다. - 둘째 바퀴:
ASYNC_END라서 루프를 한 번 더 돕니다. 이번에는isAsync()가 거짓이므로asyncDispatch()의 끝부분이 응답을 마무리합니다.
if (!request.isAsync()) {
request.finishRequest();
response.finishResponse();
}
java/org/apache/catalina/connector/CoyoteAdapter.java 248–251행
finally 블록에서 접근 로그를 남기고 요청·응답을 재활용합니다(278–298행). 동기 요청이라면 CoyoteAdapter.service()가 했을 일을 여기서 대신 하는 것입니다. 프로세서는 dispatchEndRequest()로 keep-alive면 OPEN을 돌려주고(Http11Processor.java 1155–1170행), 연결은 보통 요청을 마친 연결처럼 다음 요청을 기다립니다.
dispatch(): 같은 길에 파이프라인 한 번이 더 붙는다
dispatch()도 ASYNC_DISPATCH로 같은 길을 탑니다. STARTED → DISPATCHING, 실행기에 넘기기, asyncDispatch()까지 같습니다. 다른 점은 asyncDispatch() 안에서 이 가지를 탄다는 것입니다.
if (request.isAsyncDispatching()) {
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
if (response.isError()) {
Throwable t = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
asyncConImpl.setErrorState(t, true);
}
}
java/org/apache/catalina/connector/CoyoteAdapter.java 240–246행
Engine의 파이프라인을 처음부터 다시 부릅니다. 요청은 8편의 밸브들을 다시 지나 Wrapper의 기본 밸브에 닿고, 거기서 필터 체인 대신 AsyncContextImpl의 디스패치를 실행합니다.
if (request.isAsyncDispatching()) {
request.getAsyncContextInternal().doInternalDispatch();
} else {
filterChain.doFilter(request.getRequest(), response.getResponse());
}
java/org/apache/catalina/core/StandardWrapperValve.java 161–165행
doInternalDispatch()는 dispatch() 때 만들어 둔 AsyncRunnable을 실행합니다. 이것은 먼저 ActionCode.ASYNC_DISPATCHED로 상태를 DISPATCHED로 돌리고, ApplicationDispatcher.dispatch()로 대상 서블릿을 부릅니다(AsyncContextImpl.java 716–728행). ApplicationDispatcher.dispatch()는 9편의 forward와 비슷하게 요청을 감싸고, 디스패처 종류를 DispatcherType.ASYNC로 적습니다(ApplicationDispatcher.java 419–444행). 서블릿이 끝난 뒤 다시 startAsync()를 부르지 않았다면 onComplete()를 부릅니다(453–459행).
예제 2의 서블릿은 DispatcherType.ASYNC로 다시 불리면 Tomcat 클래스의 스택 프레임만 골라 찍습니다(org.apache.catalina., org.apache.coyote.로 시작하는 클래스와 서블릿 자신).
===== curl http://127.0.0.1:8080/u12/async?mode=dispatch =====
dispatched back, result=42
[http-nio-8080-exec-4] service() start, dispatcherType=REQUEST
[http-nio-8080-exec-4] state: Changing async state from [DISPATCHED] to [STARTING]
[http-nio-8080-exec-4] startAsync() returned, timeout=30000ms
[http-nio-8080-exec-4] service() end
[my-worker] worker: working for 1s ...
[http-nio-8080-exec-4] state: Changing async state from [STARTING] to [STARTED]
[my-worker] worker: calling dispatch()
[my-worker] state: Changing async state from [STARTED] to [DISPATCHING]
[my-worker] worker: done
[http-nio-8080-exec-5] state: Changing async state from [DISPATCHING] to [DISPATCHED]
[http-nio-8080-exec-5] service() again, dispatcherType=ASYNC
at AsyncServlet.doGet:28
at ApplicationFilterChain.doFilter:132
at ApplicationDispatcher.invoke:512
at ApplicationDispatcher.dispatch:443
at AsyncContextImpl$AsyncRunnable.run:723
at AsyncContextImpl.doInternalDispatch:456
at StandardWrapperValve.invoke:162
at StandardContextValve.invoke:77
at AuthenticatorBase.invoke:540
at StandardHostValve.invoke:115
at ErrorReportValve.invoke:82
at AbstractAccessLogValve.invoke:777
at StandardEngineValve.invoke:71
at CoyoteAdapter.asyncDispatch:241
at AbstractProcessor.dispatch:292
at AbstractProcessorLight.process:65
at AbstractProtocol$ConnectionHandler.process:1317
[http-nio-8080-exec-5] AsyncListener.onComplete()
HttpServlet.service()처럼 jakarta.servlet 패키지의 프레임과, ConnectionHandler.process() 아래의 org.apache.tomcat.util.net 프레임(엔드포인트의 SocketProcessor, 스레드 풀)은 거른 것입니다. 아래에서 위로 읽으면 이 절의 길 그대로입니다. ConnectionHandler.process() → AbstractProcessorLight.process() 65행의 dispatch() → CoyoteAdapter.asyncDispatch() 241행의 파이프라인 호출 → Engine·Host·Context의 밸브들(접근 로그 밸브, 오류 보고 밸브, 인증 밸브도 다시 지납니다) → StandardWrapperValve 162행의 doInternalDispatch() → AsyncRunnable → ApplicationDispatcher → 필터 체인 → 서블릿입니다.
DISPATCHING → DISPATCHED가 service() again보다 먼저 찍힌 것도 볼 만합니다. 상태 기계는 서블릿을 다시 부르기 직전에 이미 비동기를 끝낸 상태로 돌려 둡니다. 그래서 다시 불린 서블릿이 startAsync()를 한 번 더 부를 수 있습니다(비동기를 여러 번 이어 가는 경우). 부르지 않았으므로 마지막 줄에서 onComplete()가 불렸습니다.
헷갈리기 쉬운 곳
complete()를 불러도 그 자리에서 응답이 끝나지 않습니다. 예제 2에서my-worker는complete()를 부르고 바로worker: done을 찍었지만,onComplete()와finishResponse()는 그 뒤 다른 스레드(exec-3)에서 일어났습니다.complete()다음 줄에서 "이제 응답이 클라이언트에 갔다"고 가정하면 안 됩니다.AsyncContext.start(Runnable)은 컨테이너 풀을 씁니다. 비컨테이너 스레드를 따로 만들기 싫어ac.start(...)를 쓰면, 상태 기계의asyncRun()이 그 일을processor.execute()로 커넥터의 스레드 풀에 넣습니다(AsyncStateMachine.java475–490행,AsyncContextImpl.start()307–318행). 그 안에서 오래 기다리면 1절의 동기 서블릿과 똑같이maxThreads를 잡아먹습니다.dispatch()는 한 비동기 회차에 한 번만 됩니다. 두 번째 부르면IllegalStateException입니다(267–269행). 인자 없는dispatch()가 원래 경로를 만드는 방법은 11.0.x 브랜치와 다릅니다. 11.0.x는getRequestURI()에서 컨텍스트 경로를 떼어 만들고, main은getServletPath()와getPathInfo()를 이어 붙인 뒤 다시 URL 인코딩합니다(221–252행). 그 밖에 이 글에서 본AsyncStateMachine·AbstractProcessorLight는 두 브랜치가 같습니다.
5. 타임아웃 — 아무도 끝내지 않으면
비동기 요청을 끝내는 것은 애플리케이션의 책임입니다. 하지만 버그로 complete()를 빠뜨리면 연결과 프로세서가 영영 붙잡혀 있게 됩니다. 그래서 Tomcat은 비동기 요청마다 타임아웃을 둡니다.
1초마다 도는 검사
검사는 요청마다 타이머를 거는 방식이 아닙니다. 프로토콜 핸들러가 시작할 때 유틸리티 실행기(Tomcat이 이런 잔일에 쓰는 별도 스레드 풀)에 1초마다 도는 작업 하나를 겁니다.
timeoutFuture = getUtilityExecutor().scheduleAtFixedRate(() -> {
long now = System.currentTimeMillis();
for (Processor processor : waitingProcessors) {
processor.timeoutAsync(now);
}
}, 1, 1, TimeUnit.SECONDS);
java/org/apache/coyote/AbstractProtocol.java 1000–1005행
훑는 대상은 3절에서 LONG일 때 올려 둔 waitingProcessors입니다. 프로세서 쪽 판정은 이렇습니다.
@Override
public void timeoutAsync(long now) {
if (now < 0) {
doTimeoutAsync();
} else {
long asyncTimeout = getAsyncTimeout();
if (asyncTimeout > 0) {
long asyncStart = asyncStateMachine.getLastAsyncStart();
if ((now - asyncStart) > asyncTimeout) {
doTimeoutAsync();
}
} else if (!asyncStateMachine.isAvailable()) {
// Timeout the async process if the associated web application
// is no longer running.
doTimeoutAsync();
}
}
}
java/org/apache/coyote/AbstractProcessor.java 753–770행
asyncStart는 3절의asyncStart()가 적어 둔lastAsyncStart입니다.asyncTimeout이 0 이하면 타임아웃이 없습니다. 웹앱이 내려간 경우만 끊습니다.now < 0은 커넥터를 멈출 때 기다리는 프로세서를 모두 끊으려고 부르는 경우입니다(1078–1082행).doTimeoutAsync()는processSocketEvent(SocketEvent.TIMEOUT, true)로, 4절과 같이 실행기에 소켓 작업을 넣습니다(773–783행).
노란 상자가 3절에서 프로세서를 올려 둔 집합입니다. 타임아웃 처리도 결국 컨테이너 스레드에서 일어납니다.
컨테이너 스레드에서: onTimeout(), 그리고 아무도 안 끝내면 500
TIMEOUT 이벤트를 받은 asyncDispatch()는 맨 앞에서 이 가지를 탑니다.
if (status == SocketEvent.TIMEOUT) {
if (!asyncConImpl.timeout()) {
asyncConImpl.setErrorState(null, false);
}
java/org/apache/catalina/connector/CoyoteAdapter.java 136–139행
AsyncContextImpl.timeout()은 ASYNC_TIMEOUT으로 상태를 STARTED → TIMING_OUT으로 바꾸고, 등록된 AsyncListener마다 onTimeout()을 부른 뒤, 아직 TIMING_OUT인지 묻습니다.
AtomicBoolean result = new AtomicBoolean();
request.getCoyoteRequest().action(ActionCode.ASYNC_TIMEOUT, result);
if (result.get()) {
// ... (trace 로그)
ClassLoader oldCL = context.bind(null);
try {
for (AsyncListenerWrapper listener : listeners) {
try {
listener.fireOnTimeout(event);
} catch (Throwable t) {
// ... (경고 로그)
}
}
request.getCoyoteRequest().action(ActionCode.ASYNC_IS_TIMINGOUT, result);
} finally {
context.unbind(oldCL);
}
}
return !result.get();
java/org/apache/catalina/core/AsyncContextImpl.java 196–218행
리스너가 onTimeout() 안에서 complete()나 dispatch()를 불렀다면 상태는 이미 COMPLETING이나 DISPATCHING이므로 true(처리됨)를 돌려줍니다. 아무도 부르지 않아 여전히 TIMING_OUT이면 false이고, asyncDispatch()는 setErrorState(null, false)를 부릅니다. 이 메서드는 ASYNC_ERROR로 상태를 ERROR로 바꾸고, 응답 상태를 500으로 정한 뒤 Host의 기본 밸브에 오류 페이지를 맡기고, 그래도 아직 ERROR면 직접 complete()를 부릅니다.
AtomicBoolean result = new AtomicBoolean();
request.getCoyoteRequest().action(ActionCode.ASYNC_IS_ERROR, result);
if (result.get()) {
// No listener called dispatch() or complete(). This is an error.
// SRV.2.3.3.3 (search for "error dispatch")
// ... 응답 상태를 500 (SC_INTERNAL_SERVER_ERROR) 으로 정하고,
// ... Host 의 기본 밸브 StandardHostValve.throwable() 로 오류 페이지를 만든다
request.getCoyoteRequest().action(ActionCode.ASYNC_IS_ERROR, result);
if (result.get()) {
// Still in the error state. The error page did not call
// complete() or dispatch(). Complete the async processing.
complete();
}
java/org/apache/catalina/core/AsyncContextImpl.java 547–574행
예제 3: 2초 타임아웃
setTimeout(2000)을 걸고 응답을 끝내지 않는 서블릿입니다. ?handle=none이면 onTimeout()에서 아무것도 하지 않고, ?handle=complete면 응답을 쓰고 complete()를 부릅니다. 두 경우 모두 4초 뒤에 늦게 complete()를 부르는 작업 스레드를 띄웁니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
@WebServlet(urlPatterns = "/timeout", asyncSupported = true)
public class TimeoutServlet extends HttpServlet {
// ... say(start, msg) 는 "[스레드 이름] +경과ms 메시지" 를 찍는다 (경과 시간은 nanoTime 으로 잰다)
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
long start = System.nanoTime();
String handle = req.getParameter("handle");
AsyncContext ac = req.startAsync();
ac.setTimeout(2000);
say(start, "startAsync(), setTimeout(2000)");
ac.addListener(new AsyncListener() {
@Override public void onTimeout(AsyncEvent e) throws IOException {
say(start, "onTimeout()");
if ("complete".equals(handle)) {
e.getAsyncContext().getResponse().setContentType("text/plain");
e.getAsyncContext().getResponse().getWriter().println("timed out, handled in onTimeout()");
e.getAsyncContext().complete();
}
}
@Override public void onComplete(AsyncEvent e) { say(start, "onComplete()"); }
// ... onError, onStartAsync
});
// 응답을 끝내는 것을 깜빡했거나, 일이 타임아웃보다 오래 걸리는 작업 스레드
new Thread(() -> {
try {
Thread.sleep(4000);
say(start, "worker: late complete()");
ac.complete();
} catch (Exception e) {
say(start, "worker: " + e);
}
}, "my-worker").start();
}
}
[예제 ex/u12-async-timeout/TimeoutServlet.java에서 줄임]
===== curl http://127.0.0.1:8080/u12/timeout?handle=none =====
<title>HTTP Status 500 – Internal Server Error</title>
HTTP 500
[http-nio-8080-exec-2] state: Changing async state from [DISPATCHED] to [STARTING]
[http-nio-8080-exec-2] +4ms startAsync(), setTimeout(2000)
[http-nio-8080-exec-2] state: Changing async state from [STARTING] to [STARTED]
[http-nio-8080-exec-3] state: Changing async state from [STARTED] to [TIMING_OUT]
[http-nio-8080-exec-3] +2875ms onTimeout()
[http-nio-8080-exec-3] state: Starting to process call to asyncError()
[http-nio-8080-exec-3] state: Changing async state from [TIMING_OUT] to [ERROR]
[http-nio-8080-exec-3] state: Changing async state from [ERROR] to [COMPLETING]
[http-nio-8080-exec-3] +2880ms onComplete()
[http-nio-8080-exec-3] state: Changing async state from [COMPLETING] to [DISPATCHED]
[my-worker] +4015ms worker: late complete()
[my-worker] +4016ms worker: java.lang.IllegalStateException: The request associated with the AsyncContext has already completed processing.
===== curl http://127.0.0.1:8080/u12/timeout?handle=complete =====
timed out, handled in onTimeout()
HTTP 200
[http-nio-8080-exec-4] state: Changing async state from [DISPATCHED] to [STARTING]
[http-nio-8080-exec-4] +1ms startAsync(), setTimeout(2000)
[http-nio-8080-exec-4] state: Changing async state from [STARTING] to [STARTED]
[http-nio-8080-exec-5] state: Changing async state from [STARTED] to [TIMING_OUT]
[http-nio-8080-exec-5] +2961ms onTimeout()
[http-nio-8080-exec-5] state: Changing async state from [TIMING_OUT] to [COMPLETING]
[http-nio-8080-exec-5] +2962ms onComplete()
[http-nio-8080-exec-5] state: Changing async state from [COMPLETING] to [DISPATCHED]
[my-worker] +4002ms worker: late complete()
[my-worker] +4002ms worker: java.lang.IllegalStateException: The request associated with the AsyncContext has already completed processing.
밀리초 값과 스레드 번호는 실행마다 다릅니다.
handle=none:TIMING_OUT → ERROR → COMPLETING → DISPATCHED. 위에서 읽은setErrorState()의 길 그대로이고, 클라이언트는 Tomcat의 기본 오류 페이지(HTTP Status 500)를 받았습니다.Starting to process call to asyncError()는 상태 기계의asyncError()가 남기는 trace 로그입니다(445행).handle=complete:onTimeout()안의complete()가TIMING_OUT → COMPLETING을 만들어ERROR를 거치지 않고, 응답은200입니다.- 둘 다
onTimeout()이 새 컨테이너 스레드(exec-3,exec-5)에서 불렸습니다. 타임아웃 검사를 한 유틸리티 스레드가 직접 부르지 않습니다.
헷갈리기 쉬운 곳
- 2초 타임아웃이 2.9초에 왔습니다. 검사가 1초마다 돌고, 조건이 "지났나"(
>)이므로 실제로는 설정값보다 최대 1초쯤 늦게 끊깁니다. 두 실행 모두 2초와 3초 사이였습니다. 타임아웃을 정밀한 타이머로 쓰면 안 됩니다. - 타임아웃 뒤의
complete()는 예외입니다. 4초 뒤에 깨어난my-worker의complete()는IllegalStateException("이미 처리를 끝낸 요청")을 받았습니다. 요청이 끝나면AsyncContextImpl이 재활용되고, 그 뒤의 호출은check()에서 막힙니다(658–673행). 작업 스레드 쪽에서 이 예외를 처리하거나,onTimeout()에서 작업을 취소해야 합니다. setTimeout(0)이나 음수는 "끝없이"입니다. 위의asyncTimeout > 0조건 때문입니다. Spring WebFlux를 서블릿 컨테이너 위에서 돌릴 때 쓰는ServletHttpHandlerAdapter가startAsync()바로 뒤에asyncContext.setTimeout(-1)을 부르는 것이 이 경우입니다(ServletHttpHandlerAdapter163–164행). 이때는 끊는 책임이 전부 애플리케이션에 있습니다.
6. 논블로킹 I/O — ReadListener와 WriteListener
비동기 서블릿은 "응답을 나중에 끝내는" 방법이었습니다. 그런데 응답을 만드는 도중에도 스레드가 묶이는 곳이 있습니다. 요청 본문을 읽는 InputStream.read()는 데이터가 아직 안 왔으면 올 때까지 블로킹(스레드가 멈춰 기다림)합니다. 느린 클라이언트가 큰 파일을 올리면 그동안 컨테이너 스레드가 묶입니다. 서블릿 3.1의 논블로킹 I/O는 이 읽기·쓰기 자체를 이벤트로 바꿉니다.
setReadListener(): 읽을 수 있으면 바로, 없으면 등록
ServletInputStream의 구현은 CoyoteInputStream이고, setReadListener()는 InputBuffer를 거쳐 Coyote의 Request에 닿습니다(CoyoteInputStream.java 127–130행, InputBuffer.java 245–247행).
AtomicBoolean result = new AtomicBoolean(false);
action(ActionCode.ASYNC_IS_ASYNC, result);
if (!result.get()) {
throw new IllegalStateException(sm.getString("request.notAsync"));
}
this.listener = listener;
// The container is responsible for the first call to
// listener.onDataAvailable(). If isReady() returns true, the container
// needs to call listener.onDataAvailable() from a new thread. If
// isReady() returns false, the socket will be registered for read and
// the container will call listener.onDataAvailable() once data arrives.
// Must call isFinished() first as a call to isReady() if the request
// has been finished will register the socket for read interest and that
// is not required.
if (!isFinished() && isReady()) {
// ... (중복 등록을 막는 플래그 설정)
action(ActionCode.DISPATCH_READ, null);
if (!isRequestThread()) {
// Not on a container thread so need to execute the dispatch
action(ActionCode.DISPATCH_EXECUTE, null);
}
}
java/org/apache/coyote/Request.java 216–246행
- 비동기 모드가 아니면 예외입니다. 그래서
startAsync()를 먼저 불러야 합니다. - 첫
onDataAvailable()은 컨테이너가 부릅니다. 지금 읽을 데이터가 있으면DISPATCH_READ로 "읽기 이벤트를 하나 처리할 것"을 프로세서에 적어 두고(addDispatch,AbstractProcessor.java674–677행), 4절의process()루프가 그것을 꺼내 처리합니다. 읽을 데이터가 없으면isReady()안에서NB_READ_INTEREST→registerReadInterest()로 소켓을 Poller에 읽기로 등록합니다(Request.isReady()254–277행,AbstractProcessor.isReadyForRead()987–997행).
3절에서 비동기 프로세서는 longPoll()이 Poller에 등록하지 않는다고 했습니다. 논블로킹 읽기는 그 대신 리스너 쪽에서 필요할 때만 등록합니다. 그 결과 데이터가 오면 Poller가 평소처럼 OPEN_READ 이벤트를 만들어 실행기에 넘깁니다. 이번에는 진짜로 읽을 바이트가 있는 OPEN_READ입니다.
데이터가 오면: asyncOperation()과 onDataAvailable()
컨테이너 스레드에서 4절과 같은 process() → dispatch()로 들어오면, dispatch()의 앞부분이 이것을 알아봅니다.
} else if (status == SocketEvent.OPEN_READ && request.getReadListener() != null) {
if (!dispatchNonBlockingRead()) {
// The notification raced with async completion on another
// thread. Nothing to notify; returning LONG lets
// asyncPostProcess() complete the cycle as usual.
if (getLog().isTraceEnabled()) {
getLog().trace(sm.getString("abstractProcessor.lateReadNotification",
request.requestURI()));
}
return SocketState.LONG;
}
java/org/apache/coyote/AbstractProcessor.java 257–267행
dispatchNonBlockingRead()는 상태 기계의 asyncOperation()을 부릅니다(742–744행). STARTED면 READ_WRITE_OP로 바꾸고 true입니다(261–279행). 그다음 CoyoteAdapter.asyncDispatch()가 리스너를 부릅니다.
} else if (readListener != null && status == SocketEvent.OPEN_READ) {
Context context = request.getContext();
ClassLoader oldCL = null;
try {
oldCL = context.bind(null);
// If data is being read on a non-container thread a
// dispatch with status OPEN_READ will be used to get
// execution back on a container thread for the
// onAllDataRead() event. Therefore, make sure
// onDataAvailable() is not called in this case.
if (!request.isFinished()) {
req.onDataAvailable();
}
if (request.isFinished() && req.sendAllDataReadEvent()) {
readListener.onAllDataRead();
}
java/org/apache/catalina/connector/CoyoteAdapter.java 195–210행
context.bind(null)은 리스너를 부르는 동안 스레드의 컨텍스트 클래스로더를 그 웹앱의 것으로 바꾸는 일입니다(4편). 본문을 다 읽었으면 같은 스레드에서 이어서 onAllDataRead()를 부릅니다. sendAllDataReadEvent()는 이 이벤트가 한 번만 가도록 하는 깃발입니다(coyote/Request.java 306–308행). 리스너가 돌아오면 asyncPostProcess()가 READ_WRITE_OP를 다시 STARTED로 돌리고 LONG을 돌려줍니다(3절에서 본 세 번째 가지). 연결은 다시 "스레드 없이 기다리는" 상태가 됩니다.
노란 상자에서 기다리는 동안에는 이 요청을 맡은 컨테이너 스레드가 없습니다.
예제 4: 1초에 5바이트씩 보내는 클라이언트
15바이트 본문을 5바이트씩 1초 간격으로 보내는 클라이언트(JDK 소켓으로 직접 짠 것)와, ReadListener로 읽는 서블릿입니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
@WebServlet(urlPatterns = "/upload", asyncSupported = true)
public class UploadServlet extends HttpServlet {
// ... say(msg) 는 "[스레드 이름] 메시지" 를 찍는다
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
AsyncContext ac = req.startAsync();
ServletInputStream in = req.getInputStream();
in.setReadListener(new ReadListener() {
private final byte[] buf = new byte[1024];
private int total = 0;
@Override
public void onDataAvailable() throws IOException {
// isReady() 가 false 를 돌려주면 여기서 손을 뗀다 — 다음 데이터가 오면 컨테이너가 다시 부른다
while (in.isReady()) {
int n = in.read(buf);
if (n == -1) {
break;
}
total += n;
say("onDataAvailable(): read " + n + " bytes [" + new String(buf, 0, n) + "]");
}
say("onDataAvailable(): isReady()=false, returning");
}
@Override
public void onAllDataRead() throws IOException {
say("onAllDataRead(): total " + total + " bytes");
ac.getResponse().setContentType("text/plain");
ac.getResponse().getWriter().println("read " + total + " bytes");
ac.complete();
}
// ... onError() 는 로그를 찍고 complete()
});
say("doPost() end (listener set)");
}
}
[예제 ex/u12-nonblocking-read/UploadServlet.java에서 줄임]
===== slow POST /u12/upload (15 bytes, 5 bytes per second) =====
client: sending aaaaa
client: sending bbbbb
client: sending ccccc
client: HTTP/1.1 200
client: body = read 15 bytes
----- server side (catalina.out) -----
[http-nio-8080-exec-2] doPost() end (listener set)
[http-nio-8080-exec-3] onDataAvailable(): read 5 bytes [aaaaa]
[http-nio-8080-exec-3] onDataAvailable(): isReady()=false, returning
[http-nio-8080-exec-4] onDataAvailable(): read 5 bytes [bbbbb]
[http-nio-8080-exec-4] onDataAvailable(): isReady()=false, returning
[http-nio-8080-exec-5] onDataAvailable(): read 5 bytes [ccccc]
[http-nio-8080-exec-5] onDataAvailable(): isReady()=false, returning
[http-nio-8080-exec-5] onAllDataRead(): total 15 bytes
스레드 번호는 실행마다 다릅니다.
doPost()는 헤더만 받은 상태에서 리스너를 걸고 바로 끝났습니다. 그때는 본문이 한 바이트도 없었으므로 첫onDataAvailable()도 데이터가 온 뒤에 불렸습니다.- 조각이 올 때마다 다른 컨테이너 스레드(
exec-3,-4,-5)가onDataAvailable()을 불렀습니다. 조각 사이 1초 동안은 어느 스레드도 이 요청을 기다리지 않았습니다. 같은 일을 블로킹read()로 했다면 스레드 하나가 3초 동안 묶였을 것입니다. - 마지막 조각을 읽은 뒤의
isReady()도false입니다. 본문이 끝났을 때InputBuffer.isReady()는false를 돌려주고(282–291행), 같은 스레드(exec-5)의asyncDispatch()가 이어서onAllDataRead()를 불렀습니다. 위 코드의 두if가 차례로 실행된 것입니다.
쓰기 쪽: WriteListener
쓰기도 같은 모양입니다. CoyoteOutputStream.setWriteListener()는 Coyote의 Response.setWriteListener()로 가고(CoyoteOutputStream.java 198–201행), 지금 쓸 수 있으면 DISPATCH_WRITE로 첫 onWritePossible()을 예약합니다(coyote/Response.java 851–873행). 앞서 쓴 데이터가 아직 소켓으로 다 나가지 않았으면 isReady()가 false를 돌려주며 쓰기 관심을 등록하고(Http11OutputBuffer.isReady() 521–527행), 쓸 수 있게 되면 OPEN_WRITE 이벤트로 asyncDispatch()가 onWritePossible()을 부릅니다(CoyoteAdapter.java 167–175행). 이 글에서는 쓰기 예제를 따로 돌리지 않았습니다.
헷갈리기 쉬운 곳
isReady()가false면 반드시 돌아와야 합니다.isReady()가false를 돌려주는 순간 Tomcat은 "다음 데이터가 오면 다시 불러 주겠다"고 등록해 둔 상태입니다. 이때 기다리지 않고read()를 부르면IllegalStateException입니다(checkNonBlockingRead(),CoyoteInputStream.java133–137행). 그래서 예제의while (in.isReady())모양이 정석입니다.- 리스너는 비동기 안에서만 의미가 있습니다.
startAsync()없이setReadListener()를 부르면IllegalStateException이고,complete()나dispatch()를 하면 상태 기계가 리스너를 지웁니다(clearNonBlockingListeners(), 520–523행). - 리스너는 처음 스레드에서 거는 것이 좋습니다. 비컨테이너 스레드에서 걸면 위 코드처럼
DISPATCH_EXECUTE로 따로 실행을 맡겨야 하고,executeDispatches()의 주석은 이 경우 소켓 잠금 순서까지 신경 써야 한다고 적고 있습니다(1026–1054행). Spring의 서블릿 어댑터도 "Tomcat은 ReadListener 등록이 처음 스레드에서 일어나기를 기대한다"는 주석과 함께 생성자에서 리스너를 겁니다(ServletServerHttpRequest105–108행,ServletServerHttpResponse88–89행).
이 글에서 뺀 것
- 비동기 처리 중의 I/O 오류(
SocketEvent.ERROR)와onError():CoyoteAdapter.java140–161행,AsyncStateMachine.asyncError()440–472행 MUST_ERROR(서블릿이 끝나기 전에 다른 스레드에서 I/O 오류)와 그 전이:AsyncStateMachine.java60–65행- 타임아웃 이벤트가 늦게 도착했을 때 걸러 내는 세대 검사(
checkAsyncTimeoutGeneration()):AbstractProtocol.java1244–1252행,AbstractProcessor.java786–789행 - 비동기 응답을 다 쓰지 못했을 때 남은 출력을 내보내는
flushBufferedWrite():AsyncStateMachine.java297–300행 - 웹앱을 내릴 때 진행 중인 비동기 요청을 세는
incrementInProgressAsyncCount():AsyncContextImpl.java585–602행 - HTTP/2 스트림에서의 비동기와 논블로킹 I/O(
coyote/http2/StreamProcessor): 15편에서 업그레이드와 함께 봅니다 - 가상 스레드 실행기(
useVirtualThreads):AbstractEndpoint.java1963–1964행
정리
| 개념 | 한 줄 요약 |
|---|---|
| 비동기 서블릿 | service()에서 startAsync()를 부르고 돌아가면 컨테이너 스레드는 풀로 돌아간다. 응답은 나중에 complete()나 dispatch()로 끝낸다 |
asyncSupported |
밸브·필터·서블릿 사슬에 하나라도 없으면 startAsync()가 IllegalStateException. 로그에 막은 클래스가 나온다 |
AsyncContextImpl |
애플리케이션이 쓰는 껍데기. 대부분 ActionCode로 Coyote에 넘긴다 |
AsyncStateMachine |
프로세서마다 하나 있는 상태 기계. DISPATCHED만 비동기가 아닌 상태 |
STARTING → STARTED |
service()가 끝난 뒤 asyncPostProcess()가 바꾸고 SocketState.LONG을 돌려준다 |
LONG |
프로세서를 소켓에 붙여 두고 waitingProcessors에 올린다. 요청을 붙잡는 것은 스레드가 아니라 이 객체 |
complete()·dispatch() |
STARTED면 processSocketEvent(OPEN_READ, true)로 실행기에 넘겨 새 컨테이너 스레드가 마무리한다 |
dispatch() 뒤 서블릿을 다시 부르기 |
asyncDispatch()가 Engine 파이프라인을 다시 부르고, Wrapper 밸브가 doInternalDispatch()로 서블릿을 DispatcherType.ASYNC로 부른다 |
| 타임아웃 | 커넥터 asyncTimeout(기본 30초). 1초마다 도는 검사라 최대 1초쯤 늦다. 아무도 안 끝내면 500 오류 페이지 |
ReadListener |
읽을 것이 없으면 Poller에 등록하고 돌아간다. 데이터가 오면 컨테이너 스레드가 onDataAvailable(), 다 읽으면 onAllDataRead() |
다음에 볼 것
여기까지 요청이 서블릿에 닿아 응답이 되는 길을, 동기와 비동기 모두 봤습니다. 다음 글은 서블릿을 직접 쓰지 않는 쪽으로 갑니다. .jsp 파일 하나는 처음 요청될 때 자바 소스로 바뀌고, 컴파일되어 서블릿이 됩니다. 13편: Jasper — JSP가 서블릿이 되기까지에서 JspServlet부터 work/ 폴더에 생기는 .java 파일까지 따라갑니다.
앞 연재에서 닮은 것을 다룬 글이 둘 있습니다. httpd 8편의 event MPM은 keep-alive 연결을 작업 스레드에서 떼어 내 전용 스레드가 지켜보게 합니다. "할 일 없이 기다리는 연결에 스레드를 붙여 두지 않는다"는 발상이 같고, 이 글의 waitingProcessors와 SocketState.LONG이 event MPM의 연결 상태와 큐에 해당합니다. 다른 점은, event MPM은 그것을 요청과 요청 사이에 하고, 비동기 서블릿은 요청 도중에 애플리케이션이 직접 한다는 것입니다.
Spring 24편의 WebFlux는 Tomcat 위에서도 돕니다. 그때 쓰는 ServletHttpHandlerAdapter는 요청마다 startAsync()를 부르고 타임아웃을 끄며(5절), 요청 본문과 응답 본문을 이 글의 ReadListener·WriteListener로 읽고 씁니다(6절). 리액티브 스트림의 "데이터가 오면 알려 달라"가 Tomcat 안에서는 이 글의 Poller와 상태 기계로 구현되는 셈입니다. Spring MVC의 DeferredResult·Callable 반환값도 WebAsyncManager가 StandardServletAsyncWebRequest를 거쳐 startAsync()를 부르고(StandardServletAsyncWebRequest 158행), 결과가 준비되면 dispatch()로 DispatcherServlet을 한 번 더 지나게 합니다(169행). 4절의 dispatch() 길이 그대로 쓰입니다.