한 번 이해한 것 같지만 다시 생각하면 쉽게 헷갈리는 블록킹, 논블록킹 / 동기, 비동기의 개념에 대해서 정리해본다.
자세한 글을 시작하기 전에 많이 들어봤던 용어들을 아래 표로 간략하게 정리할 수 있다.
Blocking | 작업 완료까지 대기 |
Non-Blocking | 작업 완료까지 대기하지 않음 |
Synchronous (동기) | 호출하는 측에서 작업 완료 상태를 수시로 체크 |
Asynchronous (비동기) | 작업이 완료되면 콜백, 신호 등을 통해 상태를 호출된 측에서 전달받음 |
하지만 이는 대략적인 개념일 뿐, 실제로는 2가지씩 함께 작용하기 때문에 정확한 이해가 필요하다.
그래서, 일반적으로 포괄할 수 있는 IBM의 Linux 관점 설명을 토대로 각 요소를 조합한 4가지 케이스를 살펴보자.
동기 - 블록킹 (Synchronous blocking I/O)
가장 흔히 볼 수 있는 I/O 형태로, 운영체제 기준에서 사용자 공간의 애플리케이션은 애플리케이션의 blocking을 유발하는 시스템 호출을 수행한다. 이는 곧 애플리케이션이 시스템 호출의 완료(데이터가 전송되거나 에러가 발생)까지 대기하는 상태를 말한다. 호출하는 동안 애플리케이션은 CPU를 활용하지 않고 그저 응답을 기다리는 상태가 되기 때문에 처리(processing)의 관점에서는 효율적이다.
위 그림은 애플리케이션에 가장 흔하게 사용하는 sync + blocking I/O의 Flow이다. 그림에서도 보이듯 일반적인 애플리케이션에서 적용하기 쉽고, 효율적이다. Read()라는 시스템 호출이 발생하면, 애플리케이션은 대기하며, 커널 쪽으로 context switch한다. 커널의 read가 완료되면, 응답을 반환하며 데이터를 커널 공간에서 사용자 공간으로 전달한다. 그 결과 Read 호출이 반환되어 애플리케이션의 대기상태가 해제된다. 애플리케이션의 측면에서 보면 Read에 굉장히 오랜 시간이 소요되지만, 실제로는 커널의 다른 작업과 다중화되어 처리되는 동안 대기상태를 유지한다.
식당을 예로 들어보자. (손님 - 사용자 / 카운터 - 애플리케이션 / 주방 - 커널)
- 1번 손님이 카운터에 주문하면, 카운터는 주방에 주문을 전달한다.
- 카운터는 요리가 완성될 때까지 2번, 3번 손님의 주문을 받지 않고 기다린다.
앞 사람의 주문이 끝날 때까지 손님들은 계속 줄을 서서 기다린다.
- 1번 손님의 주문이 완성되면 주방에서 카운터로, 카운터가 손님에게 요리를 전달해 주문이 끝나며, 다음 2번 손님의 주문을 받는다.
이런 처리방식은 자칫하면 비효율적으로 보이지만 그 방식이 직관적이며, 요청(주문)의 처리 속도만 충분히 빠르다면 순서대로 빠르게 응답을 보일 수 있다는 장점이 있다.
비동기 - 논 블록킹 (Asynchronous non-blocking I/O)
이 형태는 중복 처리 I/O 모델이다. read라는 시스템 호출을 수행하면, read 작업의 시작을 알리는 read 요청은 즉시 반환된다. 그러면 애플리케이션은 read 작업이 백그라운드에서 완료되는 동안 다른 작업을 처리할 수 있고, read 요청의 완료 응답을 받으면, 신호나 스레드를 활용한 콜백을 통해 데이터를 전달하여 I/O 작업을 완료한다.
단일 프로세스가 여러 I/O 요청에 대해 중복 연산과 I/O 프로세싱을 할 수 있는 것은 I/O 속도와 처리 속도의 차이를 활용하기 때문에 가능하다. 이 때문에, 느린 I/O 요청을 기다리는 동안 CPU는 다른 작업을 수행하거나, I/O를 시작하면서 이미 완료된 I/O에서 동작할 수 있다.
다시 한번 식당을 예로 들어보자.
- 카운터가 1번 손님의 주문을 주방에 전달하고 주방이 "오케이! 요리 시작"이라고 카운터에 얘기한다.
- 카운터가 1번 손님의 요리가 완성될 때까지 기다리지 않고 2번, 3번 손님의 주문을 받아 주방에 전달한다.
이때, 주문을 마친 손님들은 자유롭게 활동할 수 있다.
- 요리가 완성되면 주방에서 "1번 손님 요리 나가요!"라고 카운터에 알리고, 카운터는 해당 손님에게 완성된 요리를 전달한다.
이 방식은 굉장히 효율적으로 보이지만, 구성하기 쉽지 않다. 처리(요리)에 필요한 시간에 따라 1번보다 3번 응답(요리)이 먼저 나올 수도 있고, 실행 순서가 복잡하기 때문에 누락이나 오류가 발생하면 어느 부분에서 놓쳤는지 확인이 힘들기 때문이다. 하지만 복잡한 만큼 적은 수의 리소스로도 빠른 처리속도와 효율성을 보여준다.
위에서 살펴본 2가지 경우는 일반적으로 흔히 볼 수 있는 조합이었다. 다음은 잘 보기 힘든 케이스들을 살펴보자.
동기 - 논 블록킹 (Synchronous non-blocking I/O)
이 형태에서는 애플리케이션이 Blocking 상태로 대기하지 않는다. 이는 애플리케이션이 커널로 전달한 I/O를 성공적으로 시작했다는 메시지를 반환하는 것이 아니라, Read() 요청이 아직 완료되지 않았다는 에러 코드를 반환한다.
논 블록킹의 의미는 I/O 명령이 즉시 완료되지 않아서, 애플리케이션이 대기 상태의 완료를 위해 무수히 많은 호출을 할 필요가 있다는 뜻으로도 볼 수 있다. 대부분 애플리케이션에서 데이터를 활용할 수 있을 때까지 busy-wait를 해야 하거나 커널에서 명령이 수행되는 동안에 자주 다른 작업을 시도해야 해서 아주 비효율적이다. 그림에서도 볼 수 있듯, 이 방식은 데이터가 커널에서 가용 상태가 되기까지 확인하기 위해 Read() 작업을 호출하는 것이 전체 데이터의 처리량 저하를 일으킬 수 있고, 결과적으로 입출력에 지연이 발생할 수 있다.
여기서 Busy-wait은 시스템이 공유 자원을 사용하기 위해 지속적으로 확인하는 것을 말하는데, 공유 자원에 동시에 하나의 스레드만 접근해야 race condition을 방지할 수 있다. 이 방법보다는 sleep 형태를 사용하는 것을 권장한다고 하며 뮤텍스, 세마포어 등 OS 적으로 알아야 할 부분이 있으니 다음에 자세히 알아보자.
비동기 - 블록킹 (Asynchronous blocking I/O)
IBM의 비동기 블록킹 방식의 설명에서 I/O Multiplexing을 기준으로 설명하는 게 의견이 갈린다고 한다. 환경마다 구현 방법 때문에 굳이 비동기 - 블록킹으로 국한되지 않고 다른 모델이 될 수 있다고 하니 대략적인 형태만 참고하는 정도로 넘어가는 것이 좋겠다. 다음에, select, poll epoll 등 자세한 I/O multiplexing에 대해서 알아보도록 하자
그림대로 보면 동기적으로 시스템 호출하고, select()를 통해서 애플리케이션을 block 상태로 대기시켜, 커널에서 요청이 완료되는 경우 select가 이를 인식하여 block 대기 상태를 해제하는 형태다.
하지만 일반적으로 적용하기 힘들어서 간단하게 생각해보았다. 개념적인 측면만 고려했을 때,
애플리케이션이 커널로 Read()를 요청하면 지속해서 커널에 완료 여부를 확인하지 않고 애플리케이션은 block 상태로 대기한다. 커널에서 Read 작업이 완료되면 애플리케이션으로 신호와 데이터를 전달하고, 이때 애플리케이션이 block 상태에서 해제되는 형태라고 이해했다. (아마 block으로 대기하는 것과 완료 작업을 인지하는 방법 때문에 의견이 갈리는 듯하다)
간단하게 놓고 보면 비동기 - 블록킹 방식은 동기 - 블록킹 방식과 형태가 유사하기도 하고, 블록킹 때문에 비동기의 장점을 활용할 수 없어서 사용할 이유를 찾기 힘든 것 같다. 또한 이해하기 쉬웠던 예시로는, homoefficio님의 글에서 볼 수 있었던 node.js와 mysql의 경우였는데, 나도 채팅 애플리케이션을 설계하면서 Netty와 MySQL을 연결하는 경우 비동기 - 블록킹 형태를 피하려고 추가적인 리소스가 필요하다고 느꼈던 기억이 새록새록 했다.
정리하면서 예시로 들었던 부분 중에 식당과 손님의 경우는 애플리케이션 / 커널 단위에서도 적절하지 않을 수 있을 것 같다. 소켓 통신을 위해 공부하다 보니 클라이언트 디바이스를 손님처럼 생각하고 카운터를 서버 I/O 스레드로 생각했을 때 저런 이미지가 생겼는데 조금 더 고민해봐야겠다.
알고 있던 내용을 좀 더 확실하게 정리하기 위해 이것저것 다시 찾아보니, 공부할 것과 정리하고 싶은 것들이 계속해서 늘어난다. 미리 정리해두려고 했던 내용들이 있지만, 이번 기회를 통해 정리가 필요한 리스트에 추가된 주제들은 I/O multiplexing, Busy waiting, Java에서 비동기-논블록킹 형태 이렇게 3가지였다. 기술 블로그를 쓰는 습관이 잘 들지 않았는데 부지런히 움직여서 쌓인 내용들을 얼른 정리해야겠다..
참고 링크 :
https://developer.ibm.com/articles/l-async/
https://luminousmen.com/post/asynchronous-programming-blocking-and-non-blocking
https://homoefficio.github.io/2017/02/19/Blocking-NonBlocking-Synchronous-Asynchronous/