BIO와 NIO, Selector와 ServerSocket, SocketChannel

아무튼 간에·2023년 1월 31일
0

Netty

목록 보기
1/4

Netty 환경에서 Http request 로직을 작성하던 중 NioEventLoopGroup를 이해하기 위해 검색하다가 보게 된 이 도움이 되어 한글로 옮긴 내용입니다.

* BIO: Blocking I/O
* NIO: Non-blocking I/O


BIO의 단점

BIO는 아래와 같은 단점이 존재합니다.

  • 서버 스레드는 새 커넥션을 수락할 때마다 아무것도 하지 못하고 대기 상태에 들어가게 됩니다.

  • 클라이언트는 순서가 될 때까지 큐에서 대기하거나, 동시성이 높은 상황에서 엄청나게 많은 단일 클라이언트-단일 스레드 모델 스레드를 생성하는 수 밖에 없습니다.

  • 동시성이 높은 상황에선 스레드 컨텍스트를 스위칭 하는 건 비용이 높아질 수 있습니다.

이 글에서는 Non-blocking 라이브러리(줄여서 NIO)를 소개하고 BIO를 구현했을 때 발생하는 문제를 해결하도록 기존의 Echo Server를 고쳐보도록 하겠습니다.


Non-blocking I/O

NIO는 자바 1.4부터 추가된 라이브러리입니다. New I/O라고도 불리기도 했는데 이제 별로 new는 아니기 때문에 Non-blocking I/O으로 보게 됐습니다.


동작원리

이 글에선 위에 언급한 단점들을 해결하기 위해 NIO로 구현한 Echo Server를 작성할 건데,
그 전에 NIO 라이브러리로 어떻게 컨트롤하는지 보겠습니다.

BIO를 구현한 Echo Server에서는 serverSocket.accept();가 커넥션이 수락될 때까지 서버 스레드를 블록킹 합니다. 그렇다면 NIO는 어떻게 할까요? Selector가 트릭을 씁니다!

+-------+ +-------+ +-------+
|Channel| |Channel| |Channel| ...
+---+---+ +---+---+ +---+---+
    |         |         |
    |         |         |
    |         |         |
    +---------+---------+
              |
              | Register
              v
          +---+----+
          |Selector|
          +---+----+
              |
              |
    +---------+---------+
    |         |         |
  +-+-+     +-+-+     +-+-+
  |Key|     |Key|     |Key| ...
  +---+     +---+     +---+

  ------------------------>

       Iterate Over Keys

           +------+
           |Thread|
           +------+

위 그림은 새 커넥션을 기다릴 때 서버 스레드가 blocking으로부터 자유로울 수 있도록 해주는 Selector의 동작 방식을 보여줍니다.

Selector는 꼭 멀티플렉서처럼 동작합니다.

  • 우선, 읽기/쓰기/수락 등의 이벤트를 위해 ChannelSelector에 등록하면 이 레지스트리의 대표키 값이 리턴됩니다.

  • 그리고 이벤트(ex. Channel이 읽기/쓰기/수락 가능)가 발생하면 Selector는 키 값들의 상태를 변경합니다.

  • 이 키 값들을 순회하며 작업을 수행할 준비가 된 Channel을 찾습니다.


구현 예시

public class EchoServer {
  public void start() throws Exception {
    try (final ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {
      final Selector selector = Selector.open();

      serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8080));
      serverSocketChannel.configureBlocking(false);
      serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

      while (true) {
        if (selector.select(1000L) == 0) {
          // no connection yet, do some other staff
          continue;
        }
        // handle the selected keys (selector.selectedKeys())
      }
    }
  }
}

여기엔 BIO 구현과는 다른 중요한 차이점들이 있는데요,

  • SelectorServerSocket을 등록할 수 있게 하려면, ServerSocket 대신 반드시 ServerSocketChannel를 사용해야 합니다. Static 메소드인 ServerSocketChannel.open()이 이를 도와줍니다. (Static method ServerSocketChannel.open() creates one for us.)

  • ServerSocketChannelSelector에 등록됐다고 해도 ServerSocket의 디폴트 설정에 의해 blocking(synchronous) 방식으로 동작할 겁니다. 비동기(asynchronous) 방식으로 돌려주기 위해선 Selector에 등록(register())하기 전에 methodserverSocketChannel.configureBlocking(false) 세팅을 해주어야 합니다. 이 세팅을 하지 않으면 java.nio.channels.IllegalBlockingModeException 오류가 발생합니다.

  • 서버 소켓 채널을 셀렉터에 등록하고, 채널이 새로운 연결(들)을 수락 요청(SelectionKey.OP_ACCEPT)을 알립니다.


서버 스레드가 왜 block이지 않아도 되는지에 대한 주요한 이유가 여기 있습니다.

selector.select()는 작업할 준비가 된 키들을 조회하고 서버 스레드에 대한 블록킹 없이 채널 수를 즉각적으로 리턴하려고 합니다. 그러면 우리는 리턴된 채널 수를 통해 읽기, 쓰기, 수락 작업이나 다른 작업 수행을 결정하면 되는 거지요.

참고
SocketChannel에는 ServerSocketChannelSocketChannel 두가지 종류가 있다.

  • ServerSocketChannel
    - 서버측에서 사용된다.
    - 서버측의 포트와 바인딩 (bind()) 한 뒤에, 연결요청 (connect()) 이 오면 SocketChannel을 생성하여 클라이언트와 통신한다.
  • SocketChannel
    - 클라이언트, 서버측에서 모두 사용되며 데이터 read/write 등의 기능을 한다.


자, 그럼 서버 스레드는 더이상 block될 필요가 없다는 걸 확인했으니 위에서 첫번째로 언급한 단점은 해결되었습니다. 그럼 나머지 2개의 문제는 어떨까요? 너무 많은 스레드 생성 없이도 클라이언트의 커넥션을 핸들링할 수 있을까요?

스레드를 많이 생성해야 하는 이유는 서버 스레드가 많은 클라이언트들을 그닥 빠르게 처리하지 못해서이고, 처리 속도(process)가 느린 건 프로세스들이 읽기/쓰기 작업을 포함하고 있는 경우가 있어서 입니다. 그 읽기/쓰기는 역시 또 blocking 작업들이죠.

그래서 우리는 다시 한번 NIO 라이브러리를 사용해서 non-blocking 클라이언트 소켓을 만들어볼겁니다.

public class EchoServer {
  public void start() throws Exception {
    // .....
    while (true) {
      if (selector.select(1000L) == 0) {	// 블록킹
        continue;
      }
      for (final Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); iterator.hasNext(); iterator.remove()) {
        final SelectionKey key = iterator.next();
        if (key.isAcceptable()) {
          final ServerSocketChannel server = (ServerSocketChannel) key.channel();
          final SocketChannel client = server.accept();
          client.configureBlocking(false);
          client.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE, ByteBuffer.allocate(1024));
          LOGGER.info("client connected: " + client);
        }
        if (key.isReadable()) {
          readData(key);
        }
        if (key.isWritable()) {
          writeData(key);
        }
      }
    }
  }
}

ServerSocketChannelaccept() 메소드는 또 다른 SocketChannel을 리턴합니다. 이 SocketChannelSelector에 등록시킬 수 있습니다. 결국 non-blocking 방식으로 작업하고 등록시키기 위해선 또 Selector를 이용해야한다는 걸 알 수 있습니다.

한 가지 눈여겨볼 만한 건 이 채널(SocketChannel client)은 SelectionKey.OP_ACCEPT 요청 대신 SelectionKey.OP_READSelectionKey.OP_WRITE으로 요청했다는 겁니다. 이는 클라이언트 커넥션의 읽기/쓰기 가능(readability/writability) 여부를 요청한 겁니다.


요약

Selector는 소켓을 멀티플렉싱하는 중요한 역할을 합니다. 멀티플렉싱을 함으로써 서버 스레드는 새 커넥션을 기다리고 클라이언트 커넥션을 핸들링하는 동안 다른 작업을 할 수 있게 되죠. 모든 I/O 작업을 하나의 스레드에 넣는 건 단점들을 해결하는 데에 있어서 꽤 중요한 관점이고, Netty의 스레드 모델에 적용하고 이를 이해하는 것 또한 앞으로의 Netty 소스 코드 공부에 큰 도움이 될 겁니다.

후략...

profile
armton garnet

0개의 댓글