gRedis 의 동작 방식을 공부했다. Redis 가 싱글 스레드임에도 비동기적으로 멀티플렉싱 IO 를 어쩌구 저쩌구 하는 설명을 여기저기서 많이 보았는데, 사실 이런 단어들은 나한테 별로 직관적으로 와닿는 설명이 아니라서... 좀 더 아랫단의 동작을 분석해서 Redis 가 어떻게 빠르게 동작할 수 있는지를 알고 싶었다. 코드 수준까지 분석하는 건 역시 힘들고 핵심적인 사항만 정리한다.
1. Redis 의 네트워크 I/O
나는 지금까지 I/O 작업이라고 하면 디스크에 읽고 쓰는 것을 주로 생각해왔다. 그런데 Redis 에서 주로 거론되는 I/O 작업은 디스크의 입출력을 이야기 하는 것이 아니라 네트워크의 I/O 를 이야기한다. I/O 는 디스크에서만 일어나는 게 아니라 네트워크를 통해 들어온 요청, 응답을 처리하는 과정에서도 일어난다. 네트워크의 요청과 응답 또한 프로세스 레벨에서 그냥 수행할 수 있는 것이 아니라 시스템 콜을 이용해 운영체제를 경유하여 처리하기 때문이다.
네트워크의 요청은 소켓이라는 파일을 통해 이루어지는데, 소켓은 프로세스에서 임의로 접근하는 것이 아니라 운영체제에 시스템 콜 함수를 보냄으로써 처리가 가능하다. 주요한 API 함수는 다음과 같다. (사실 이런건 소켓 프로그래밍 해보면서 알 수 있는데...)
- socket(): 새로운 소켓 생성
- accept(): 클라이언트의 연결 요청을 수락하고 새로운 소켓을 반환한다.
- read(): 소켓에서 데이터를 읽어들인다.
- write() / send(): 소켓에 데이터를 쓰고, 전송한다.
- select() / epoll_wait(): 읽거나 쓰기가 가능한 상태가 될 때 까지 소켓을 감시한다.
2. Blocking / Non-Blocking
디스크 I/O 가 Blocking / Non-Blocking 으로 구분되듯이, 네트워크 I/O 또한 Blocking / Non-Blocking 으로 구분된다. 예를 들어, 특정 스레드가 특정 소켓에 대해 read() 를 요청하면 (시스템 콜), 커널은 해당 소켓에 데이터가 들어오기를 기다리고 있다가 데이터가 도착하면, 소켓에 들어온 데이터를 유저의 메모리 공간에 복사하고 해당 함수를 반환하게 된다. 운영체제가 read() 함수를 처리하는 이 시간동안 메서드를 실행한 스레드는 아무런 작업을 하지 못하고 대기하게 된다. 즉 Blocking 상태가 되는 것이다.
반대로 Non-blocking I/O 도 생각할 수 있다. 스레드가 운영체제에 accept() 같은 메서드를 통해 시스템 콜을 보냈을 때, 만약 커널에서 요청이 준비되지 않은 경우 이를 그대로 반환하여 스레드가 지속적으로 작업을 수행 할 수 있게 하는 것이다. 하지만 디스크 I/O 에서 인터럽트를 통해 운영체제에게 I/O 가 준비되었다고 알려주는 것과는 다르게 이 경우에는 스레드에서 주기적으로 폴링을 하여 I/O 작업을 시작할 수 있는 지를 확인해야 한다.
3. select 과 epoll
상기의 Blocking / Non-blocking 모델의 예시에서는 어플리케이션은 각각의 소켓을 따로 따로 관리해야 했는데 select() 과 epoll 을 이용하면 그러한 번거로움을 극복할 수 있다. 각각의 소켓에 대한 요청을 하지 않고 한 번에 여러 개의 소켓 상태를 하나의 메서드로 확인함으로써, 더 효율적인 I/O 작업이 가능해지는 것이다. 그런데 두 시스템 콜은 동작 방식에 있어서 큰 차이가 있다.
select()
어플리케이션은 클라이언트가 연결될 때마다 해당 소켓의 FD 를 리스트에 넣어 관리한다. 그러면서 주기적으로 select() 을 통해 연결된 FD 리스트를 운영체제로 전달한다. 운영체제는 해당 리스크를 전부 순회하면서 I/O 작업이 준비된 FD 가 있는지 확인하고 준비를 마친 소켓의 리스트를 한번에 반환한다. 모든 소켓의 상태를 한번의 메서드 호출로 확인할 수 있으므로 더 효율적인 I/O 작업을 가능하게 한다. 그러나 운영체제는 호출 될 때마다 전달된 모든 FD 의 상태를 순회하여야 하므로 O(N) 의 작업이 일어나게 되는데, 좀 아쉬운(?) 시간 복잡도라고 할 수 있다. + select 의 경우 한번에 확인할 수 있는 FD 의 수도 1024 개로 제한된다.
epoll
epoll 은 FD의 관리를 어플리케이션이 직접하지 않고 운영체제에 맡김으로써 이러한 문제를 해결한다. 클라이언트와 연결이 형성되고 소켓이 생성되면 어플리케이션에서는 epoll_ctl() 를 통해 해당 소켓의 FD 를 운영체제에 알린다. 이후 운영체제에서는 등록된 FD 들을 감시하고 있다가, 요청이 들어오거나 응답할 준비가 되면 커널에 있는 epoll 큐에 해당 이벤트를 쌓아둔다. 이렇게 되면 어플리케이션이 FD 를 인자로 넘겨줄 필요 없이 epoll_wait() 요청만으로 커널은 곧바로 큐에서 해당 이벤트를 반환할 수 있으므로 훨씬 더 효율적인 이벤트 관리를 할 수 있게 된다. 이로써 네트워크 I/O 시간도 줄일 뿐만 아니라 운영체제 자원도 절약해준다.
4. Even Loop 와 Multiplexing
이렇게 여러 개의 소켓(또는 요청 / 응답) 을 한번의 메서드 콜로 관리하는 것을 Multipexing (다중화) 이라고 한다. Redis 는 Multiplexing 을 이용하여 요청과 응답을 매우 효율적으로 수행한다. Redis 의 동작구조를 공부해보면 가장 먼저 Event Loop 라는 키워드가 나오는데, Event Loop 가 Multiplexing 을 수행하는 주체로, 커널의 epoll 과 소통하며 클라이언트 연결, 요청/응답 등 네트워크 I/O 를 효율적으로 수행하며, Redis 에서 수행하는 명령어를 바로바로 스케줄링 해주는 역할을 수행한다. 즉, 이 녀석 덕분에 Redis 의 메인스레드는 끊임없이 요청을 처리할 수 있게 되는 것이다.
참고로 레디스 6.0 이상의 버전에서는 소켓 읽기/쓰기 같은 작업은 별도의 스레드에서 병렬처리 한다. 그러나 여전히 이벤트 루프의 멀티플렉싱, 이벤트 큐 관리 등은 메인 스레드에서 수행하며, 명령어 처리 또한 순차적으로 진행되기 때문에 동시성 문제에서는 자유롭다.