[Netty] Blocking I/O, Non-Blocking I/O, 동기, 비동기 처리에 대한 고찰

2023. 5. 5. 21:11[ 백엔드 개발 ]/[ Java,Kotlin ]

Netty에서 제공되는 I/O는 모두 Non-Blocking 기반이다. 예를 들어, Bootstrap에 대한 connect, bind 또는 writeAndFlush 등의 I/O 메소드는 모두 Non-blocking I/O 기반으로 동작한다.

 

Non-Blocking I/O

Non-Blocking I/O는 쓰레드가 실행 중에 I/O 연산을 마주했을 때 system call을 하고 바로 빠져나와(Non-Blocking I/O) 다음 작업을 처리하는 방식이다. 따라서 Non-blocking I/O기반의 작업은 I/O가 완료되기 전까지 다른 작업을 수행할 수 있다는 게 핵심이다. 이러한 개념에 비추어 봤을 때, I/O 시간이 오래걸리는 것에 대해서 Non-blocking을 기반으로 하는 것이 좋다. I/O 시간이 짧은 것에 대해서 굳이 Non-blocking을 기반으로 하는 것은 비효율적이다. 기본적으로 I/O라는 작업 자체가 커널의 도움 없이 실행될 수 없는 연산이며 context를 커널로 넘겨야 하는데, 이 부분에서 비용이 크게 든다. 만약 Blocking 기반으로 할 경우 user -> kernel -> user 순으로 CPU의 컨텍스트가 바뀌면 되지만, Non-Blocking I/O를 기반으로 할 경우 user -> kernel -> user 순으로 컨텍스트가 바뀐 뒤, I/O 연산 완료 여부를 조회하기 위해 다시 kernel 모드로 바뀌어야 한다. 즉, I/O 연산 자체에 비용이 크지 않다면 굳이 context switching을 여러번 해가면서 까지 Non-Blocking I/O를 사용할 이유가 없다고 생각한다. 

 

 

동기, 비동기 처리

Non-blokcing I/O의 결과를 어떻게 확인할 것인가에 따라 비동기 방식과 동기 방식이 있다. 두가지 방식 모두 Future 객체에 대해 설정한다. 

 

* 동기, 비동기 용어는 범용적인 용어이며 해당 포스팅에서는 I/O 쓰레드 관점에 한정된 의미로 사용

 

1) 비동기 방식으로 처리

- Future에 addListener를 통해 리스너를 등록함으로써 Non-Blocking I/O 결과를 콜백 기반의 비동기 방식을 구현할 수 있다.

- 장점: Non-Blocking I/O를 수행 중이던 쓰레드는 '다른 작업을 하다가' I/O 연산에 대한 이벤트(완료 또는 실패)를 받고 콜백을 수행하게 된다. 그리고 이런 흐름 자체를 netty가 알아서 call해준다. 즉, 개발자는 콜백 메소드만 구현하면 된다.

- 단점: 디버깅이 어렵다, 작업 실패에 대한 특별한 처리가 필요한 경우(예를 들어, I/O 실패에 대한 원상 복구하는 연산 등이 필요할 때) 복잡한 연산이 요구된다. 즉, 그 '다른 작업'이라는 게 콜백 연산과 타이트하게 연관 있을 경우 처리가 복잡하다는 것이다. 다르게 말해서, 그 연산들 간의 관계('순서')가 중요할 경우에는 비동기 방식을 적용하는거 자체가 어렵고 이를 구현하는 거 자체가 비용이라고 생각한다.

 

[example]

bootstrap.connect(/*ServerAddress*/).addListener(
    future -> {
        if (future.isSuccess()) {
            // process success case
        } else {
            // process failure case
        }
    }
);

- 위 예제코드에서 등록한 리스너는 어떤 시점에 실행될 지 모른다. 즉, connect를 하는 흐름과 리스너가 실행되는 흐름의 순서는 보장되지 않는다. 따라서 non-blocking + Asynchronous 기반 프로세싱이다.

 

 

2) 동기 방식으로 처리

Future에 sync, await, get과 같은 blocking API를 통해 non-blocking I/O 결과를 조회할 수 있다. 해당 API들이 blocking일 수밖에 없는 이유는 동기 방식으로 Non-Blocking I/O를 기다리겠다는 가정이 있기 때문이다. 동기 방식은 비동기와 다르게 그 I/O 연산의 결과와 그 이후에 실행될 로직들 간의 '순서'가 중요한건데, sync, await, get과 같은 API를 Non-Blocking으로 할 수는 없을 것이다.

 

[example]

// example1: utilize non-block I/O
ChannelFuture future = bootstrap.connect(/*ServerAddress*/);

// ** Do something here to utilize non-block I/O of connect method ** //

boolean ret = future.awaitUninterruptibly(/*MaxWaitTime*/, /*TimeUnit*/);

if (ret && future.isSuccess()) {
    // process success case
} else {
    // process failure case
}
//

// example2: based on blocking I/O
ChannelFuture future = bootstrap.connect(/*ServerAddress*/).sync();

if (future.isSuccess()) {
    // process success case
} else {
    // process failure case
}
//

- 위 코드는 connect를 하는 흐름과 그 후 처리될 로직의 순서를 따져가며 프로그래밍된 코드이다. 따라서 example1은 non-blocking I/O + Synchronous 기반 프로세싱이고, example2는 blocking I/O + Synchronous 기반 프로세싱이다. (물론 connect API 자체가 non-blocking I/O 기반이지만 sync()를 걸었기 때문에 좀 더 high level 관점에서 봤을 때 blocking I/O 처럼 보인 다는 의미)

 

 

* 두 방식은 좋고 나쁨의 문제가 아니라 쓰레드의 실행 컨텍스트를 따져보고 결정해야 한다. 예를들어, 클라이언트가 connect를 했고 이 connect를 실행하는 쓰레드가 해당 I/O 연산에 대한 결과를 계속해서 컨텍스트로 가져가면서 무언가 작업을 하는 것이 요구된다면(I/O의 결과와 그 후의 로직의 순서가 중요하다면) 비동기 방식으로 처리하기 어렵다.

 

 

아래 메소드들은  모두 I/O 연산이 완료 여부를 기다리는 'blocking API'이다.

[checked exception을 throw 하는 API]

1) await(): I/O가 완료되기를 무한정 기다리고 I/O 결과를 boolean 값으로 반환

2) sync(): I/O가 완료되기를 무한정 기다리고 I/O 결과를 Future 객체로 반환

3) get(): Java 표준 API, 무한 block하며 사용자 지정 타입으로 I/O 결과를 반환

 

[예외를 throw하지 않는 API]

1) syncUninterruptibly()

- Future의 작업이 완료되지 않으면 무한정 대기

- sync와 같으며 차이점은 예외를 throw 하지 않고 Future에 저장된다는 점

 

2) awaitUninterruptibly()

- syncUninterruptibly와 다른 점은 Future의 작업이 완료되지 않더라도 interrupted되면 블로킹에서 바로 해제되며 이때 awaitUninterruptibly 메소드가 예외를 발생시키진 않음.

- syncUninterruptibly와 마찬가지로 예외 여부도 Future에 저장됨.

 

3) awaitUninterruptibly(최대시간)

- awaitUninterruptibly와 같으며 Future의 작업이 완료되기를 최대 시간만큼만 block 된다는 차이점이 있음

- 지정한 시간 동안 작업이 완료되지 않으면 interrupted되고 블로킹에서 바로 해제.

- 따라서 Future에 대한 성공, 실패 여부를 반드시 파악해야 함

 

 

[awaitUninterruptibly(최대시간) 활용]

        ChannelFuture future = bootstrap.connect(/* server address */);

        boolean ret = future.awaitUninterruptibly(/* max wait time */, /* time unit */);
        
        if (ret && future.isSuccess()) {
            // I/O 성공에 대한 처리를 현재 쓰레드의 순서에 맞게 처리
        } else {
            // I/O 실패에 대한 처리를 현재 쓰레드의 순서에 맞게 처리
        }

 

 

 

[고찰]

- 필자가 생각하기에는 동기, 비동기 중 어떤 방식으로 구현할지는 netty의 non-blocking I/O를 실행하는 쓰레드의 컨텍스트를 어떻게 가져가고 처리할지, 그 쓰레드의 역할이 무엇인지, I/O를 호출하는 흐름과 I/O 성공 및 실패에 대한 결과가 서로 연관이 크고 둘 간의 순서가 중요한지 등을 따져보고 결정해야 한다고 생각한다. 결국 필요에 따라 다른 것이지 좋고 나쁨의 문제가 아니다.

- Netty는 기본적으로 모든 I/O가 Non-Blocking I/O 기반으로 동작하고 addListener와 같은 콜백 기반 비동기 방식, sync와 같은 동기 방식도 제공한다. 결국 상황에 맞게 비동기, 동기 방식을 적절하게 사용해야 Netty를 효율적으로 사용하는 거라 생각한다.

 

 

 

 

* 틀린 내용은 알려주시면 감사하겠습니다.