1. 스레드 풀
톰캣은 멀티 스레딩을 지원한다. 즉, 동시에 여러 HTTP 요청들을 처리해준다. 그리고 이때 스레드 풀을 사용해서 스레드를 효율적으로 관리한다. 따라서 스레드 풀에 대해 먼저 알아보자.
자바는 One-to-One Threading Model로 스레드를 생성한다. 즉, 자바에서 유저 스레드를 생성하면 해당 스레드는 운영체제 스레드와 1:1로 매핑된다. 따라서 자바에서 스레드를 생성할 때마다 OS 작업이 수행되기 때문에 스레드 생성 비용이 많이 든다. 그리고 스레드가 무한정으로 생성되면 그만큼 메모리를 차지하게 되고 컨텍스트 스위칭이 빈번히 발생한다. 따라서 스레드 풀이란 개념이 등장한다. 스레드 풀은 일정량의 스레드를 미리 만들어 두고 필요한 시점에 꺼내 사용하는 방식이다.
자바의 스레드 풀
자바는 java.util.concurrent 패키지에서 스레드 풀을 의미하는 Executors와 ExecutorService 인터페이스를 제공한다. 그리고 기본 구현체인 ThreadPoolExecutor의 생성자는 다음 속성들을 사용한다.
- corePoolSize: 스레드 풀에서 관리되는 기본 스레드 수
- maximumPoolSize: 스레드 풀에서 관리되는 최대 스레드 수
- keepAliveTime, TimeUnit unit: 기본 스레드 수를 초과해서 만들어진 초과 스레드가 생존할 수 있는 대기 시간, 이 시간 동안 처리하는 작업이 없으면 초과 스레드는 제거된다.
- BlockingQueue workQueue: 작업을 보관할 블로킹 큐
자바의 스레드 풀에 대한 더 자세한 내용은 다음을 참고하자. (https://olsohee.tistory.com/238)
톰캣의 스레드 풀 vs 자바의 스레드 풀
톰캣은 자바 기반의 WAS로, 자체적으로 스레드 풀을 관리한다. 톰캣에서 스레드 풀을 생성하고 관리하는 것과, 자바에서 스레드 풀을 생성하고 관리하는 것은 별개다.
- 톰캣의 스레드 풀은 웹 서버 및 서블릿 컨테이너로서 동시에 여러 HTTP 요청을 처리하기 위해 사용된다. 톰캣은 스레드 풀을 통해 멀티 스레딩을 지원한다.
- 반면, 자바의 스레드 풀은 HTTP 요청을 처리하는 톰캣 스레드 풀과는 별개로, 자바 애플리케이션에서 병렬 처리를 위해 사용된다.
즉, 우리가 스프링 애플리케이션을 사용할 때 HTTP 요청에 대한 관리를 위해 톰캣의 스레드 풀 설정을 해주면 되는 것이고, 자바의 스레드 풀의 경우에는 개발자가 명시적으로 스레드를 생성하고 관리할 때 사용되는 것이지 내부적으로 자동 생성되거나 관리되지는 않는다.
그리고 톰캣은 자바로 작성된 애플리케이션이자 WAS이자 서블릿 컨테이너이다. 즉, 자바 언어로 구현되어 있으면서, HTTP 요청을 처리하고 스레드를 생성하는 등 WAS, 서블릿 컨테이너의 역할을 하는 소프트웨어인 것이다. 즉, 톰캣도 자바 언어로 작성되어 있기 때문에 자바의 스레드 풀을 통해 자체적인 스레드 풀을 구현했다.
2. 톰캣의 NIO Connector
톰캣에서 Connector는 port listen을 통해 소켓 커넥션을 얻고, 소켓 커넥션으로부터 데이터를 획득하여 HttpServletRequest 객체로 변환하고, 서블릿 객체에 전달하는 역할을 한다.
Connector의 종류로는 BIO Connector와 NIO Connector가 있다. 현재 톰캣은 기본으로 NIO Connector를 사용하고, BIO Connector는 deprecated되었다.
- BIO Connector
- 소켓 커넥션을 처리할 때 자바의 기본 I/O 기술을 사용한다.
- Acceptor가 소켓 객체를 받고, Woker 스레드를 할당해준다.
- 따라서 준비되지 않은 채널(데이터가 오지 않은 채널)들에게도 스레드가 할당된다. 따라서 스레드가 효율적으로 사용되지 못한다. 이러한 문제를 해결하기 위해 NIO Connector가 등장했다.
- NIO Connector
- NIO Connector는 내부적으로 Java NIO를 사용한다.
- BIO Connector와 달리 새로운 소켓 연결이 발생하면 바로 스레드를 할당하지 않고, 하나의 스레드(Poller)가 여러 소켓들을 받고, Selector를 이용해서 여러 소켓들 중 당장 처리할 수 있는 소켓에만 스레드를 할당한다.
- 즉, Selector가 여러 채널들을 감시하는데(select()), 채널들 중 준비된 채널이 있으면 바로 해당 채널에 Worker 스레드를 할당한다.
- 따라서 하나의 selector 스레드가 여러 채널들을 감시함으로써 블로킹되는 시간을 짧게 할 수 있다. (감시하는 여러 채널들 중 하나라도 준비가 되었으면 select() 메서드로 인한 블로킹이 풀리기 때문)
- 그리고 당장 처리할 수 있는 소켓에만 Worker 스레드를 할당하기 때문에 스레드를 더욱 효율적으로 사용할 수 있다.
3. NIO Connector의 스레드 할당 과정
* 커넥션
커넥션은 맥락에 따라 다르다. DB에서의 커넥션은 JDBC의 java.sql.Connection 객체가 될 수 있으며, 톰캣에서의 커넥션은 java.nio.channels.SocketChannel 객체를 의미한다.
톰캣 버전 8부터는 기본적으로 NIO Connector가 사용되는데, 요청이 톰캣으로 도착해서 스레드를 할당받는 과정은 다음과 같다.
- 스프링부트 애플리케이션이 시작되면, 톰캣의 Connector 객체가 생성되고 Connector는 소켓을 열고 바인드한다. 이때 생성되는 소켓 객체는 ServerSocketChannel이다. 이 소켓을 통해 클라이언트의 연결 요청을 기다린다. (소켓 API: socket(), bind())
- Acceptor는 클라이언트의 연결 요청을 기다리다가 요청이 들어오면, ServerSocketChannel의 accept() 메서드를 통해 연결 요청을 수락하고 새로운 소켓(SocketChannel)을 생성한다(소켓 API: accept()). 이제 이 새로 생성된 채널을 통해 데이터를 주고 받는다.
- Acceptor는 생성한 SocketChannel을 NioChannel로 래핑하고, NioChannel를 PollerEvent로 래핑하여 Poller의 이벤트 큐로 넘겨준다. 이 이벤트 큐는 Poller가 관리하고 있는 채널들을 담는 큐이다. 즉, Acceptor는 이벤트 큐의 producer이고, Poller는 이벤트 큐의 consumer가 된다.
- Poller는 Selector를 통해 이벤트 큐에 준비된 채널이 있는지 확인한다.
- Selector는 Events 큐를 감시해서 특정 소켓에 데이터가 준비되면, 해당 채널을 반환한다. 즉, 준비된 채널이 있는지 확인한다. 이때 select() 메서드가 사용되는데, 준비된 채널이 있을 때까지 대기하며 블로킹된다.
- 이때 "준비된 채널"이라는 것은 소켓이 연결된 후에, 클라이언트가 보낸 데이터가 해당 채널 소켓의 버퍼에 도착하여 읽을 수 있는 상태가 된 것을 의미한다.
- 준비된 채널을 반환받은 Poller는 준비된 채널을 SocketProcessor로 래핑하여 스레드 풀의 work queue에 추가한다.
- 유휴 스레드는 work queue에서 작업(SocketProcessor)을 꺼내서 처리한다. (= 특정 작업에 스레드가 할당된 것!)
- SocketProcessor는 Adapter와 Mapper를 사용해서 요청에 맞는 서블릿을 찾아 요청을 전달한다.
즉, 3-way handshake로 연결이 확립된 후에 클라이언트가 전송한 데이터가 서버의 TCP 버퍼에 도착하면, Selector가 해당 소켓 채널이 준비 완료 상태임을 감지하여, 유휴 스레드가 할당된다.
* HTTP의 keep-alive 옵션을 지정하면?
클라이언트가 keep-alive 헤더를 포함해서 보내면, 서버는 이 연결을 일정 시간 동안 유지한다. 즉, 서버가 SocketChannel을 닫지 않고, 일정 시간 동안 유지한다. 따라서 클라이언트가 후속 요청을 보내면, 새로운 연결을 설정할 필요 없이, 즉 새로운 SocketChannel 객체를 생성할 필요 없이 기존의 SocketChannel을 통해 데이터를 주고받을 수 있다.
톰캣의 스레드 풀(ThreadPoolExecutor)과 스레드(Worker)
톰캣은 자바의 ThreadPoolExecutor를 직접 사용하는 것이 아니라, 톰캣의 커스터마이즈된 스레드 풀을 사용한다. 톰캣의 스레드 풀은 org.apache.tomcat.util.threads.ThreadPoolExecutor로, 자바의 기본 ThreadPoolExecutor를 확장하여 톰캣의 요구 사항에 맞게 조정된 형태이다.
그리고 스레드(worker thread)는 다음과 같이 org.apache.tomcat.util.threads.ThreadPoolExecutor 내부의 Woker라는 클래스이며, Runnable 인터페이스를 구현하고 있다(즉, 자바의 스레드!). 이 스레드는 work queue에서 작업을 가져와서 실행한다.
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
@Override
public void run() {
runWorker(this);
}
//...
}
4. 톰캣 설정
server:
tomcat:
max-connections: 8192
accept-count: 100
threads:
max: 200
min-spare: 10
- max-connections: 톰캣이 최대로 동시에 처리할 수 있는 커넥션의 개수이다. 즉, 서버는 max-connections 수만큼 소켓을 열고있을 수 있다. (연결 요청을 받기 위한 서버 소켓과는 별개)
- accept-count: max-connections을 넘는 연결 요청이 들어왔을 때 연결 요청이 대기하는 큐 사이즈이다.
- 너무 크게 설정하면, 대기열이 커지면서 메모리 문제를 유발할 수 있다.
- 너무 작게 설정하면, 요청이 몰렸을 때 들어오는 요청들을 거절해 버릴 수 있다.
- max-connections와 accept-count 이상의 요청이 들어왔을 때 그 이후 요청은 거절될 수 있다.
- threads.max: 스레드 풀에서 사용할 최대 스레드 개수
- 요청 수에 비해 너무 많게 설정하면, 놀고 있는 스레드가 많아져서 비효율적이다.
- 너무 적게 설정하면, 동시 처리율이 떨어지고 요청들이 대기하게 된다.
- threads.min-spare: 스레드 풀에서 최소한으로 유지할 스레드 개수
Reference
- https://www.youtube.com/watch?v=prniILbdOYA
- https://www.youtube.com/watch?v=um4rYmQIeRE&list=WL&index=38&t=395s
- https://giron.tistory.com/155
- https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat#connector%EB%93%A4%EC%9D%98-%EA%B3%B5%ED%86%B5%EC%A0%81%EC%9D%B8-%EC%97%AD%ED%95%A0
- https://sihyung92.oopy.io/spring/1