namalzi

← 글 목록

Tomcat 소스 파헤치기 12: 비동기 서블릿과 논블로킹 I/O — 스레드를 놓아주는 법

Tomcat · 게시

startAsync()를 부른 서블릿은 응답을 끝내지 않고 돌아가고, 요청 처리 스레드는 풀로 돌아갑니다. 그 사이 요청을 붙들고 있는 AsyncStateMachine의 상태 전이, complete()와 dispatch()가 컨테이너 스레드로 돌아오는 길, 1초마다 도는 타임아웃 검사, ReadListener가 Poller와 함께 동작하는 방식을 Tomcat 소스와 스레드 이름을 찍는 예제로 따라갑니다.

이 글에서 다루는 것

"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행). 답하려는 질문은 네 가지입니다.

항목 내용
대상 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를 돌려주고, 애플리케이션은 그것을 다른 스레드에 넘겨 둡니다. 일이 끝나면 그 스레드가 다음 둘 중 하나를 부릅니다.

서블릿이 비동기를 쓰려면 @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초 동안 컨테이너 스레드가 잠들어 있습니다. 스레드는 아무 일도 하지 않지만 풀의 한 자리를 차지하므로, 풀이 다 차면 다른 요청은 큐에서 기다립니다. 비동기 서블릿은 기다리는 동안 컨테이너 스레드를 풀에 돌려줍니다.

동기와 비동기 서블릿의 비교. 왼쪽 비동기 서블릿에서는 exec-1이 service()를 부르고 startAsync()를 부른 뒤 service()가 바로 돌아가 exec-1이 풀로 돌아간다. 다른 스레드(직접 만든 스레드나 라이브러리 콜백)가 일을 하고 complete()나 dispatch()를 부르면 컨테이너 스레드 하나가 응답을 마무리한다. 오른쪽 동기 서블릿에서는 exec-1이 service()를 부르고 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 하나뿐입니다. 비동기 요청 셋이 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행

하는 일은 세 가지입니다.

  1. 비동기를 써도 되는지 확인합니다. 안 되면 IllegalStateException을 던지고, 어느 클래스가 막았는지 경고 로그를 남깁니다.
  2. AsyncContextImpl을 만들고 setStarted()를 부릅니다. 요청마다 하나만 만들어 두고 비동기를 여러 번 시작해도 다시 씁니다.
  3. 타임아웃을 커넥터의 asyncTimeout으로 정합니다. server.xml의 <Connector asyncTimeout="...">이 이 값이고, 기본값은 30000밀리초입니다(Connector.java 169행). 애플리케이션이 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()는 요청에 들어 있는 플래그 하나를 읽을 뿐인데, 이 플래그는 요청이 지나온 길에서 하나씩 깎입니다.

            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행).

요청의 isAsyncSupported 플래그가 깎이는 길과 startAsync()의 두 갈래. CoyoteAdapter.service()가 Engine 파이프라인의 밸브 전부를, Engine·Host·Context의 기본 밸브가 다음 컨테이너 파이프라인의 밸브 전부를, ApplicationFilterChain이 필터마다 그리고 서블릿을 부르기 직전에 본다. 하나라도 비동기를 지원하지 않으면 플래그는 false가 되고 다시 true로 돌아가지 않는다. 서블릿이 req.startAsync()를 부를 때 플래그가 false면 IllegalStateException이 나서 HTTP 500이 되고, 경고 로그에 getNonAsyncClassNames()가 사슬을 다시 훑어 모은 목록(예제의 [NoAsyncServlet])이 찍힌다. true면 요청마다 하나인 AsyncContextImpl을 만들어 setStarted()와 setTimeout(asyncTimeout, 기본 30000밀리초)을 부르고, action(ASYNC_START)로 Coyote 프로세서의 AsyncStateMachine에 알린다. 비동기 상태는 Catalina가 아니라 그 상태 기계에 있다. 빨간 칸은 아래 "헷갈리기 쉬운 곳"의 실제 출력, 초록 칸은 위 두 코드 조각입니다.

헷갈리기 쉬운 곳: 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행). 이 글에서 따라갈 길만 추리면 이렇습니다.

AsyncStateMachine의 주요 상태 전이. DISPATCHED에서 startAsync()로 STARTING이 된다. STARTING에서 service()가 끝나면 STARTED, service() 안에서 complete()를 부르면 MUST_COMPLETE, dispatch()를 부르면 MUST_DISPATCH가 된다. STARTED에서 complete()는 COMPLETING, dispatch()는 DISPATCHING, 타임아웃은 TIMING_OUT으로 간다. TIMING_OUT에서 onTimeout()이 complete()를 부르면 COMPLETING, 아무도 끝내지 않으면 ERROR가 되고, ERROR는 오류 페이지 뒤 complete()로 COMPLETING이 된다. MUST_DISPATCH는 service()가 끝나면 DISPATCHING이 된다. MUST_COMPLETE와 COMPLETING은 onComplete()를 부르고 DISPATCHED로, DISPATCHING은 서블릿을 다시 부르기 직전에 DISPATCHED로 돌아간다. 노란 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행

두 가지가 일어납니다.

  1. 프로세서가 소켓에 붙은 채 남습니다. OPEN이었다면 release(processor)로 프로세서를 재활용했을 것입니다(1389행). LONG은 그러지 않고 1428행에서 wrapper.setCurrentProcessor(processor)로 붙여 둡니다. 요청의 모든 상태(Request, Response, 상태 기계)는 이 프로세서 객체 안에 있습니다. 질문 "요청은 누가 붙잡고 있나"의 답이 이것입니다. 스레드가 아니라 소켓에 붙은 프로세서 객체가 요청을 붙잡고 있습니다.
  2. 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의 줄 순서는 실행마다 조금씩 다릅니다.

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행). 그다음은 그림과 같이 진행됩니다.

complete()가 컨테이너 스레드로 돌아오는 길. my-worker 스레드가 AsyncContextImpl.complete()를 부르면 coyote Request.action()에 ActionCode.ASYNC_COMPLETE가 가고, AsyncStateMachine.asyncComplete()가 STARTED를 COMPLETING으로 바꾸고 true를 돌려준다. processSocketEvent(OPEN_READ, true)가 소켓 작업을 실행기에 넘기고, http-nio-8080-exec-N이 SocketProcessor를 실행한다. AbstractProcessorLight.process()는 isAsync()이므로 dispatch()를 부르고, CoyoteAdapter.asyncDispatch()는 아직 비동기 상태라 할 일이 없다. asyncPostProcess()가 onComplete()를 부르고 DISPATCHED로 바꾸고 ASYNC_END를 돌려준다. 한 번 더 dispatch()와 asyncDispatch()를 지나며 finishResponse, 접근 로그, recycle을 하고, keep-alive면 SocketState.OPEN으로 다음 요청을 기다린다. 회색 상자부터 실행기에 넘기는 상자까지는 애플리케이션 스레드(my-worker)에서, 노란 상자부터 아래는 모두 같은 컨테이너 스레드에서 일어납니다.

  1. 첫 바퀴: 상태가 COMPLETING이므로 isAsync()가 아직 참입니다. asyncDispatch()는 리스너도, 디스패치도 없으니 별 일 없이 돌아옵니다. 루프가 asyncPostProcess()를 부르고, COMPLETING 가지(위 코드)가 onComplete()를 부르고 상태를 DISPATCHED로 돌려 ASYNC_END를 돌려줍니다. 예제 2에서 onComplete() 다음 줄에 COMPLETING → DISPATCHED가 찍힌 순서와 같습니다.
  2. 둘째 바퀴: 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()가 불렸습니다.

헷갈리기 쉬운 곳

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행

타임아웃의 길. 1초마다 도는 유틸리티 실행기의 작업이 waitingProcessors를 훑어 프로세서마다 timeoutAsync(now)를 부른다. 시작한 지 asyncTimeout이 지났으면 processSocketEvent(TIMEOUT, true)로 exec 스레드에 넘기고, CoyoteAdapter.asyncDispatch()가 AsyncContextImpl.timeout()을 불러 STARTED를 TIMING_OUT으로 바꾸고 onTimeout()을 부른다. 리스너가 complete()나 dispatch()를 부르면 COMPLETING이나 DISPATCHING이 되어 평소처럼 끝나고, 아무도 끝내지 않으면 setErrorState()로 ERROR가 되어 500 오류 페이지를 보내고 complete()한다. 노란 상자가 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.

밀리초 값과 스레드 번호는 실행마다 다릅니다.

헷갈리기 쉬운 곳

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행

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절에서 본 세 번째 가지). 연결은 다시 "스레드 없이 기다리는" 상태가 됩니다.

논블로킹 읽기의 순환. service() 안에서 setReadListener()를 부르면 읽을 것이 없을 때 읽기 관심을 등록하고, service()가 돌아가 스레드는 풀로 간다. Poller가 요청 처리 스레드를 잡지 않고 소켓을 지켜보다가 데이터가 도착하면 SocketEvent.OPEN_READ를 exec 스레드에 넘긴다. AbstractProcessor.dispatch()가 STARTED를 READ_WRITE_OP로 바꾸고, CoyoteAdapter.asyncDispatch()가 onDataAvailable()을 부른다. isReady()가 false면 다시 등록하고 Poller로 돌아가며, 본문을 다 읽었으면 onAllDataRead()를 부르고 complete()한다. 노란 상자에서 기다리는 동안에는 이 요청을 맡은 컨테이너 스레드가 없습니다.

예제 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

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

쓰기 쪽: 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행). 이 글에서는 쓰기 예제를 따로 돌리지 않았습니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
비동기 서블릿 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() 길이 그대로 쓰입니다.

다른 글