[Tomcat] 아파치 톰캣9 Socket I/O 동작방식 이해

2022. 4. 20. 21:24[ 백엔드 개발 ]/[ Spring ]

아파치 Tomcat의 BIO Connector / NIO Connector 개념

 

 

1) BIO Connector

- Response를 보내고 끝내는 것이 아니라 TCP Connection이 만료될 때까지 Thread가 활성 상태로 남아있으며 소켓이 닫히면 Pool로 반환되는 구조

- Connection이 만료되기 전까지 Thread가 활성상태로 남아 있기 때문에 Idle Thread 발생

- '최대 동시접속 클라이언트 수'와 '쓰레드 풀의 쓰레드 수'가 같음

 

2) NIO Connector

- Java의 I/O 라이브러리인 NIO를 활용하여 Tomcat에서 만들어진 Connector

- Poller라는 단일 쓰레드가 Selector를 이용하여 처리가 가능한 순간에만 Thread를 활성화시킴으로써 Idle Thread를 줄일 수 있음

 

 


 

 

NIO Connector의 구조

- NIO Connector는 아파치 톰캣 9 버전 이상부터 default로 사용된다.

- Acceptor에서 소켓 accpet 후 PollerEvent Queue에 Publish 한다. 

- Poller는 새로운 Channel들을 Selector에 등록한다.

- Poller는 PollerEvent Queue를 Subscribe하여 새로운 소켓 Channel을 획득하고 Selector 기반으로 활성 가능 상태의 Channel들만 Multiplexing 하여 2개 이상의 Channel을 한 번에 processing 한다.

- Processing 된 Channel은 Woker Thread Pool에 있던 각 Worker Thread에게 할당되어 작업이 처리되고 바로 Pool에 반환된다.

 

 


 

NIO Connector의 동작 플로우

아래는 실제 톰캣의 Acceptor, PollerEvent, Poller의 코드를 확인해보면서 요청이 들어오는 과정을 확인해본 순서이다. NIO Connector는 톰캣 9버전 이상부터 사용된다.

 

* 아래 코드는 tomcat 9.0.65 버전 기준

 

1. Acceptor

Acceptor

 

 

- 위 코드에서 볼 수 있듯이 shutdown 커맨드가 발생하지 않는 한, loop를 돌면서 새로운 소켓 커넥션이 발생하는지 확인한다. (line 76)

- Acceptor는 Socket 연결 과정 중 서버에서 진행되는 단계인 'accept'를 진행한다. (line 129)

- stop이나 pause 상태가 아니면 소켓에 대한 설정을 진행한다. (line 149)

 

 

setSocketOptions

 

- Acceptor가 호출한 setSocketOptions 메소드에서는 Acceptor가 전달한 새로운 소켓인 SocketChannel 객체를 인자로 받고 톰캣의 NioChannel 객체로 감싼다. (line 493)

- socket Channel에 대한 설정을 하는 부분도 찾아볼 수 있는데, configureBlocking 메소드를 통해 소켓I/O 과정을 Non-Blocking으로 설정하고 있음을 확인할 수 있다. (line 500)

- 마지막 단계로 캡슐화한 SocketChannel(NioChannel) 객체를 Poller에 등록한다. (line 508)

 

 

- Channel의 Key를 이용하여 해당 Channel에 대해 OP_READ 연산을 등록한다. 현재 과정은 새로운 소켓을 등록하고 요청을 받기 위한 과정이기 때문에 READ 연산을 사용하는 것으로 생각된다. (line 753)

- Poller의 register에서는 캡슐화된 NioChannel 객체를 받고 PollerEvent 객체로 한번 더 캡슐화하여 EventQueue에 담는다. (line 755)

- 메소드 설명을 보니, 새롭게 열린 소켓(Channel)을 Poller에 등록하는 과정이라고 한다. 즉, Poller가 Selector를 관리하고 있으므로 소켓 Channel을 Selector에 등록하는 것으로 추측된다.

 

 

2. Poller

 

- 위 Acceptor에 의해 캡슐화된 PollerEvent 객체가 register 메소드 호출로 인해 Poller의 events라는 SynchronizedQueue에 들어간 상태이다. (line 621)

- Poller 쓰레드는 위와 같이 loop를 돌면서 새로운 이벤트를 확인한다. 따라서 Acceptor는 events Queue의 Producer이고 Poller 쓰레드는 events Queue의 Consumer이다. (line 799)

- Poller는 위와 같이 Selector를 가지고 있으며(line 620) Selector에는 여러 Channel이 등록되어 있고, select 또는 selectNow 메소드를 통해 새로 추가된 소켓 Channel의 Key 개수를 획득하는 구조이다. (line 803, 805)

 

 

cf. Select Method

Selector는 JDK 1.4 NIO부터 추가되었으며 Selector에 Channel을 등록하면 새로운 이벤트가 Channel에서 발생할 경우 해당 Channel에 대한 접근이 가능해진다. 이는 Selector가 Single Thread 기반으로 multiplexing을 하는 방식으로 동작한다.

 

이때 등록한 Channel에서 어떤 타입의 이벤트를 알림 받을지에 따라 아래와 같이 3가지 타입으로 분류된다.

- accept: 새로운 TCP 커넥션이 발생

- read: 연결된 커넥션에서 read 요청이 발생했고 I/O 준비가 완료되었을 경우 발생

- write: 연결된 커넥션에서 write 요청이 발생했고 I/O 준비가 완료되었을 경우 발생

- connect: 새로운 TCP 커넥션이 닫힐 경우

 

Java NIO Selector의 selectNow()
Java NIO Selector의 select()
Java NIO Selector의 selectedKey()

1) select(): blocking기반의 select이며 I/O 준비가 완료된 Channel의 Key들을 select한다.

2) selectNow(): Non-Blocking기반의 select이며 I/O 준비가 완료된 Channel이 없다면 0을 반환한다.

3) selectedKeys(): Non-Blocking I/O가 준비된 Channel들의 Key집합을 반환한다. 결국 Non-Blocking I/O를 할 때에는 selectedKeys메소드를 통해 I/O 준비가 완료된 Channel들을 알 수 있게 되는 것이다. I/O가 준비된 Channel을 selected-key Set을 순회하며 I/O 연산을 진행한다. 반환되는 Set은 Thread-Safe하지 않다고 한다. 따라서 하나의 쓰레드(Poller)에서만 해당 Set에 접근해야 한다.

 

 

 

Poller의 run()

- 위에서 selectNow(), select()를 통해 key 개수를 획득했고 이때 keyCount가 0보다 크다면 실제로 소켓 Channel을 가져오는 연산을 진행한다. (line 830)

- selectedKeys() 메소드를 통해 I/O가 준비된 Channel들의 Key Set을 얻어온다. 여기서 Selector 기반의 (Java 레벨에서) Multiplexing이 진행된다.

- 그리고 아래 833 Line의 while문에서는 수집한 key들에 대해 event를 활성화한다. (아래 processKey 메소드 설명 참고) (line 831~833)

 

cf. SelectionKey

XXXChannel.register() 메소드를 통해 Selector에 Channel을 등록하면, Selector는 selectedKeys() 메소드를 통해 Channel에 대한 Key를 반환하는데, 여기서 Key는 'SelectionKey' 클래스로 표현한다. 즉, Selector에 등록된 Channel들은 각각 Key를 가지는 구조이다.

 

[SelectionKey에 포함된 정보]

1) Key를 의미하는 Channel

2) Channel이 등록된 Selector

3) 어떤 타입의 I/O 연산을 감지할 지

    - OP_READ = 1 << 0
    - OP_WRITE = 1 << 2
    - OP_CONNECT = 1 << 3
    - OP_ACCEPT = 1 << 4

4) Channel이 attatch된 Buffer

 

SelectionKey는 단 1곳의 Buffer에만 attached 될 수 있다. (Non-Blocking I/O를 하기 위해 Channel은 반드시 read/write할 Buffer를 필요로 함) 위 코드의 line 836의 attachment() 메소드는 해당 Channel(SelectionKey)이 attach된 Buffer를 반환한다. (Buffer를 포함하여 Wrapping된 객체를 반환하는 것 같다.)

 

위와 같이 SelectionKey 객체로 Channel 관련 정보를 한번에 묶음으로써 쉽게 관리할 수 있다. 예를들어 아래는 각각 SelectionKey를 기반으로 Channel, attachment를 가져오는 과정이다.

 

1) key.channel();

2) key.attachment();

 

 

processKey()

Poller의 processKey()

processKey() 메소드에서는 소켓 Channel에 대해 실제 I/O 연산을 진행하는 부분이다. 첫 번째 블록에서는 Channel의 SelectionKey가 OP_READ 모드일 경우에 해당한다.Blocking, Non-Blocking I/O를 모두 지원하고 있음을 확인할 수 있다. write 모드일 경우도 같은 방식으로 동작하며 이는 Channel의 Buffer에 write할 경우에 해당하기 때문에 요청에 대한 응답을 처리할 때 사용될 것이다.

 

 

 

 

 

 

(스프링 프레임워크까지 호출되는 과정, response 후 wroker thread pool 반환과정은 추후 보충하겠습니다.)

 

 

 

 

요약

- 톰캣의 NIO Connector는 Selector를 활용하여 Java 레벨에서 Multiplexing 할 때, 데이터 처리가 가능할 때에만 Worker Thread를 사용하기 때문에 Idle 상태의 쓰레드를 줄일 수 있다.

- PollerEvent Queue는 소켓 Connection 당 하나의 PollerEvent(Socket Channel)가 담기게 된다. 즉, 동시에 서비싱 가능한 최대 클라이언트 수와 Worker Thread Pool의 사이즈는 같지만, Worker Thread Pool에 활성 가능한 쓰레드가 없더라도 PollerEvent Queue에서 새로운 클라이언트에 대한 소켓 Connection을 캐시와 비슷한 형태로 가지고 있기 때문에 Connection을 refuse하지 않고 받아 놓을 수 있다.

 

 

cf. 용어 정리

- Acceptor, Connector, Poller, PollerEvent는 Tomcat에서 만들어진 컴포넌트이며, Selector. Channel, NIO, BIO는 Java에서 만들어진 컴포넌트이다.

 

 

(* 틀린 내용이 있으면 댓글로 알려주세요 :D)

 

 

 

Reference

- NIO/BIO Connector, https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests

- Java의 Channel 기반 I/O에 대한 포스팅: https://jh-labs.tistory.com/353

- BIO, NIO에 대한 전반적인 설명: https://jh-labs.tistory.com/334

- Java NIO and non-blocking sockets, https://youtu.be/VhSu1pRIEqQ