이 포스팅에서는 Lettuce를 이해하기 위한 Netty를 이해하기 위한 Non-blocking I/O를 이해하기 위한 Multiplexing I/O를 정리하고자 한다.
해당 글은 네이버 클라우드 플랫폼의 [네이버클라우드 기술&경험] IO Multiplexing (IO 멀티플렉싱) 기본 개념부터 심화까지 -1부- 를 기반으로 이해한 내용들을 정리하고자 한다.
I/O 작업은 user space에서 직접 수행할 수 없기 때문에, user process에서 Kernel에 I/O요청을 보내고, 응답을 받는 구조이다.
여기서 I/O모델은 응답을 어떤 순서로 받는지(synchronous/asynchronous), 어떤 타이밍에 받는지(blocking/non-blocking)에 따라 여러 모델로 분류되는 것이다.
모든 I/O 작업이 순서가 보장되며, 각 작업의 완료 여부와 다음 작업 요청 타이밍을 user space에서 판단하게 된다. 이 때문에 특정 단계들이 순차적으로 이루어져야 하는 Pipeline 구성에서 특히 효율적이다.
Kernel에 I/O 작업을 요청해두고 작업이 끝나지 않아도 다른 작업을 처리할 수 있는 방식이나, 작업의 완료시점이나 순서를 보장하지 않으며 작업이 완료되면 kernel이 이벤트/콜백 형태로 직접 통보한다.
작업 별 지연이 크거나, 각 작업들이 서로 독립적일 때 효율적이다.
요청한 작업이 모두 완료될 때까지 대기하다가 완료될 때 응답과 결과를 반환받는다.
작업 요청을 한 이후, 결과는 필요할 때 응답받는 방식이다. 응답을 받을때까지 대기하지 않으며, 중간중간 필요할 때 요청에 대한 상태 확인은 해볼 수 있다.
여기서 동기와 Blocking, 비동기와 Non-blocking의 차이에 대해 헷갈릴 수 있는데, 이들은 전부 독립적인 개념이다.
예를 들어, 동기 작업(작업의 순서가 정해져 있음)에서도 Blocking여부에 따라 동기 + Blocking, 동기 + Non-blocking으로 나뉘어 스레드의 활용 여부를 나눌 수 있다.
그럼 이제 그래서 Multiplexing이 뭐냐? 라고 하실 수 있는데, 이는 구현 방식에 따라 여러 의견이 있으므로 밑에서 간단하게나마 설명해 보겠다.
먼저, 다중화의 의미부터 한번 생각해 보자.
간단하게 생각하면, 하나를 여러개 처럼 보이게 동작한다는 의미이며, 여기서는 하나의 프로세스가 여러개의 파일을 관리한다는 기법으로 볼 수 있을 것이다.
파일은 프로세스가 Kernel 공간에 접근할 수 있게 해주는 인터페이스이며, 만약 Server - Client 환경이라면 하나의 서버에서 여러 파일(Socket)에 접속할 수 있게 구성된다.
프로세스가 파일이나 소켓에 접근할 때는 파일 디스크립터(FD)라는 추상적인 정수 값을 사용한다. 문제는 서버 프로그램처럼 여러 클라이언트와 동시에 통신해야 하는 환경에서는 수백, 수천 개의 FD를 어떤 방식으로 관리하냐는 것이다.
I/O 작업을 요청하고 응답을 받을 때, 이 FD들을 어떤 방식으로 감시하고, 어떤 상태에서 대기하는지에 따라 구현 방식이 크게 달라진다.
그 결과로 등장한 것이 바로 다양한 I/O 멀티플렉싱 기법이며, 운영체제별로 select, poll, epoll (Linux), kqueue (BSD, macOS), IOCP (Windows)등의 방법이 존재한다.
각 방식은 FD를 감시하는 방법, 이벤트 감지 방식, 성능 특성에서 차이를 가지며 대규모 네트워크 서버 구조의 핵심을 이루는 기술들이다.
Netty에서는 host의 OS에 맞춰 최적화된 방식을 사용하고 있으며, 이제 이 방식들에 대해 간단히 알아보고 글을 마치도록 하겠다.
대상 FD를 배열에다 쭉 넣어놓고 순차 검색하는 고전적인 방법이다.
때문에 대상 FD가 늘어날수록 효율은 점점 안좋아질 것이며, 1024개의 제한을 두고있다.
select()에서 timeout과 signal 처리 로직을 개선한 pselect()라는 함수도 존재한다. Linux kernel 2.6.16부터 추가되었으며, timeval 구조체로 timeout을 관리하는 select()와 달리 pselect()의 timeout은 timespec 구조체로 구현되어 나노초까지 정밀하게 컨트롤할 수 있다. 또한 수행 도중 signal에 의한 interrept가 발생하면 hang 상태로 빠질 수 있었던 select()와 달리 sigmask라는 인자가 추가되어 signal에 의해 비정상 동작이 일어나지 못하게 block 시켜둘 수 있다는 개선점이 있다.
관리 가능한 최대 FD 수가 1024로 제한적이었던 select와 달리 무한 개의 FD를 검사할 수 있다. 처리 방식은 select와 비슷하게, 하나 이상의 FD에서 이벤트가 발생하면 Blocking 해제 후 해당 FD로 들어온 데이터에 대한 I/O 작업을 수행한다. 매번 최대 FD까지 loop를 도는 select와 달리 poll은 실제 FD 개수(nfds) 만큼만 loop를 돌게끔 구현할 수 있어 FD 수가 적은 경우 select보다 효율적일 수 있지만 select와 마찬가지로 O(n)의 시간 복잡도를 가집니다.
다만 감시하는 FD의 수만큼은 loop를 돌아야 하고, select는 한 이벤트 전달에 3bit만 사용되는데 반해 poll은 64bit 가량의 메모리를 사용하기 때문에 어느 정도 FD 수가 많아지면 성능이 select보다 떨어질 수 있다. FD가 무한인 것 외엔 구현 환경에 따라 효율이 달라지므로 어느 게 더 성능이 좋다 판단하긴 어렵다.
epoll은 Linux kernel 2.5.44부터 도입된 고성능 I/O 멀티플렉싱 방식이다. select나 poll과 같은 기존 방식보다 훨씬 효율적으로 많은 FD를 감시할 수 있으며, 이는 FD 목록을 다루는 차이에서 발생한다.
select나 poll은 FD 목록을 user space에서 관리하므로, user process에서 FD 목록을 만들어 Kernel로 전송하고, Kernel이 이 목록에서 변경사항이 있는 FD 목록을 다시 user process로 보낸다. 여기서 Kernel이 한번, user process에서 한번 전체 FD 목록을 스캔하게 되어 총 두번의 전체 스캔이 이루어진다.
하지만 epoll은 FD를 Kernel에서 직접 관리하며, 상태가 변한 FD 목록을 event list에 넣어 원할 때 return해준다.
kqueue는 크게 Kernel 내부의 event queue와 filter라는 이벤트 소스로 동작한다. 대략적인 동작 방식은 epoll과 비슷하지만, filter라는 이벤트 소스로 인해 훨씬 다양한 이벤트를 kevent()라는 syscall로 처리할 수 있다는 장점이 있다.
커널이 특정 필터(EVFILT_READ 등)를 트리거하면, 해당 FD 또는 이벤트 객체를 event queue에 삽입하고, 사용자가 kevent()를 호출하면 ready 상태 이벤트들을 반환하는 방식으로 작동한다.
원래 socket은 unix에서 만든 network interface였고, 이를 windows에서 사용하기 위해 만든 것이 Windows socket API, winsock이라고 한다.
version 1에서는 unix socket과 호환성을 제공하면서 TCP/IP를 지원하는 것이 주 목적이었다면, version 2에서는 보다 다양한 protocol을 지원하도록 확장되며 multi-thread를 접목시켰고, 그 결과 I/O 성능이 대폭 향상되었다.
IOCP란 Input Output Completion Port의 약자로, Windows에서 지원하는 I/O 다중화 모델이다. 여러 socket을 하나의 IOCP 객체로 처리하며 해당 객체 하위에서 돌아가는 thread도 여러 개가 동시 대기하는 구조이며, IOCP는 kernel로 부터 반환받는 결과에 따라 미리 생성돼있던 thread를 깨우거나 대기 상태로 유지시킨다.
여기서 epoll, select, poll등은 user process에서 직접 read를 통해 작업이 끝난것을 읽어야 하지만 IOCP는 작업이 끝남을 user process에게 알려주는 진짜 비동기임을 알 수 있다.
물론 IOCP 이전에도 Overlapped I/O 같은 비동기 모델이 있었지만, 이는 완료 통지를 event로 전달받고 있었기에 각 socket에 대한 event 처리를 일정 단위의 thread로 처리해야 했다. 또한, 연결 요청 처리와 I/O 처리의 반복 호출로 context switching이 빈번하다는 문제점을 가지고 있었다.
그래서 이를 보완해서 나온것이 바로 IOCP이며, IOCP는 I/O 작업의 완료를 Completion Routine(완료 루틴) 형태로 통지받을 수 있도록 설계되어 있다.
Completion Routine은 흔히 말하는 콜백(callback) 함수의 개념으로, WSASend나 WSARecv와 같은 비동기 I/O 요청이 완료되는 시점에 커널에 의해 자동으로 호출된다. 즉, 애플리케이션은 I/O 요청 자체를 커널에 위임하고, 실제 작업이 끝났을 때 커널이 직접 콜백을 호출하여 완료를 알려주는 구조다.
특히 IOCP는 다음과 같은 중요한 장점을 갖는다.
소켓 개수와 무관하게, 고정된 수의 워커 스레드로 동작한다.
(보통 CPU 코어 수에 맞춘 소수의 스레드)
하나의 IOCP 객체(Completion Port)가 수천~수만 개의 소켓을 효율적으로 관리할 수 있다.
스레드가 소켓별로 개별 대기하지 않기 때문에 context switching 부담이 크게 줄어든다.
이러한 구조 덕분에 IOCP는 무한한 소켓을 처리할 수 있는 모델이라고 평가되며,
여기서 명명하는 Overlapped I/O 모델과 IOCP는 둘 다 Overlapped 즉 Asynchronos 한 I/O 모델이라는 공통점이 있으나 I/O 작업이 완료된 상태에서의 확인 방법의 차이가 있다고 볼 수 있다.