Redis는 기본적으로 싱글 스레드 기반으로 동작하는 인메모리 데이터베이스이다. Redis는 내부적으로 싱글 스레드를 고수하고 있다. 보통은 싱글스레드가 느려서 멀티스레드나 멀리 프로세싱 등을 고려하기 마련인데, Redis는 싱글스레드로 동작하면서도 속도가 매우 빠르고, 여러 클라이언트로부터 요청을 받을 수 있다.
Redis가 싱글스레드를 유지하면서 누릴 수 있는 가장 큰 이점은 명령어 실행을 Atomic 하게 유지할 수 있다는 점이다. 여러 스레드가 동시에 작업을 수행하는 경우라면 Lock을 통해서 이를 해결해야 하지만, 싱글 스레드라면 굳이 이럴 필요가 없다. Redis6.0부터는 Threaded I/O 기능이 나오기는 했지만, 그럼에도 불구하고 명령어 실행은 하나의 스레드에서만 동작하는 것이 이러한 장점을 유지하기 위함일 것이다.
그러면 Redis는 실제로 요청을 어떻게 받아서 처리하는지 코드를 통해서 살펴보자.
Redis 서버 시작부터 요청 처리까지
Redis는 기본적으로 프로토콜에 따라 리스너를 분리해서 관리한다. 가장 기본적으로 tcp 리스너가 있고, tls_port가 정의되면 해당 포트로 tls 리스너를 생성한다. 코드를 보면 initServer() 함수를 수행하기 전에, connTypeInitialize 함수를 호출하는데, 여기서 각 타입별로 커넥션 타입을 미리 구조체에 저장해 놓는다.
커넥션 타입 데이터를 보면 다음과 같이 인터페이스에 함수를 어떻게 매핑할지에 대한 정보가 저장되어 있다. 아래는 일반적인 TCP 연결을 위한 리스너를 설정할 때 필요한 함수 매핑 정보 중 일부이다.
그 후에 소켓 설정을 위해서 initListeners() 함수를 호출한다. initListener() 함수를 보면 인덱스를 나눠서 설정이 있는 경우에 서버 리스너 목록에 Listener 객체를 추가한다. 참고로 클러스터 모드를 사용하면, 클러스터 간 통신을 위해서 별도의 리스너를 하나 더 추가한다.(참고로, 별도로 포트를 설정하지 않으면, (TCP 혹은 TLS 포트 + 10000) 포트를 사용한다.)
이렇게 사용할 리스너를 모두 추가하고 나면, 소켓을 여는 작업을 수행한다. TCP Socket 타입을 기준으로 코드를 살펴보면, bind 옵션으로 넘어온 주소에 모두 socket을 연다. 그 후 소켓에 해당하는 파일 디스크립터에 NonBlocking 설정을 추가한다.
이렇게 필요한 소켓을 모두 만들고 나면, 해당 파일 디스크립터와 그에 매핑된 핸들러 함수를 이벤트 루프에 추가한다. 참고로, 소켓을 만들기 전에 기본적인 서버 세팅을 위햇 initServer() 함수를 호출하는데, 이때 aeCreateEventLoop 함수가 이벤트 루프를 만들어준다.
다음 코드가 이벤트 루프에 파일 디스크립터와 핸들러 함수를 추가하는 부분이다.
이렇게 이벤트 루프에 등록을 마치고 나면, 비로소 요청을 받을 준비가 끝난다. 준비를 마친 Redis는 main() 함수 마지막에 aeMain(server.el) 함수를 실행하여 이벤트 루프에 들어오는 요청을 처리한다.
이벤트 루프를 처리하는 방식은 운영체제에 따라서 epoll, kqueue, evport, 그리고 select 등으로 나뉜다. 이는 커널에서 이벤트를 받아 처리하는 동작 방식을 의미하는데, 기본적으로 리눅스에서는 epoll을 사용한다.
epoll은 Linux의 고성능 I/O 멀티플렉싱 메커니즘으로, 커널 레벨에서 다수의 파일 디스크립터를 효율적으로 모니터링하여 이벤트 발생 시 애플리케이션에 알리는 역할을 한다. O(1) 시간 복잡도로 동작하며, 대규모 동시 연결 처리에 최적화되어 있어 Redis에서 사용하기 적합한 구조이다.
Redis 서버가 실행되고 요청을 처리하는 전체 과정을 자세히 설명하면 다음과 같다. Redis6.0부터 io를 위한 별도 스레드를 수행할 수 있지만, 먼저 해당 기능을 사용하지 않는다고 가정하고 살펴보자.
전체 요청 처리 과정
1. 먼저 6379(혹은 사용자 설정 포트)에 연결을 리스닝하는 파일디스크립터를 epoll 이벤트에 등록한다.
2. Redis는 이벤트 루프를 실행하면서 epoll_wait 함수를 호출하여 등록한 모든 파일 디스크립터에 대한 이벤트를 기다린다.
3. 클라이언트가 연결을 시도하면, 커널은 해당 파일디스크립터에 연결이 발생했음을 의미하고, Redis가 epoll_wait 하는 과정에서 이를 인지한다.
4. Redis는 커넥션이 연결되면 리스너 타입에 맞는 accept_handler 함수를 호출한다. TCP의 경우 connSocketAcceptHandler() 함수가 호출된다.
5. connSocketAcceptHandler 함수에서 Redis는 accept() 시스템 콜을 통해 클라이언트의 연결 요청을 수락한다. 그러면 해당 클라이언트 요청에 대한 별도의 파일 디스크립터와 커넥션 객체를 만들고, 이를 다시 epoll에 등록한다.
6. 다시 aeProcessEvents 가 호출될 때 만약 클라이언트로부터 전달받은 데이터가 있으면, connSocketEventHandler 함수를 호출합니다. 해당 함수는 내부적으로 readQueryFromClient 함수를 호출한다.
7. Redis는 read 시스템 콜을 사용해서 소켓으로부터 들어온 데이터를 버퍼로 옮긴다. 그 후 해당 쿼리를 파싱 한 후, processCommand를 호출한다.
8. processCommand는 명령어에 해당하는 실제 함수를 호출한다. Redis는 초기화 과정에서 각 명령어에 대한 실제 매핑 함수를 배열에 저장해놓고 있기 때문에, 빠르게 lookup이 가능하다.
9. 명령어에 해당하는 함수를 수행하고 나면, 응답을 생성한다.
10. 응답을 출력 버퍼에 넣은 후, 해당 클라이언트를 clients_pending_write 리스트에 추가한다.
11. 메인이벤트 루프는 주기적으로 clients_pending_write 리스트에 있는 클라이언트를 확인한다. 그리고 바로 즉시 쓰기를 수행한다. Redis 소켓들은 비동기로 동작하기 때문에 한 번에 바로 보내지 못하면 대기해야 한다. 만약 한 번에 데이터를 다 보내지 못했다면, 해당 클라이언트의 write_handler로 sendReplyToClient 함수를 매핑해 준다.
12. 2번 과정과 동일하게 메인이벤트 루프가 실행될 때 epoll_wait 함수를 호출하는데, 이때 Write를 하지 못한 소켓들은 나머지 쓰기 작업을 수행한다.
I/O Threads를 사용하면 어떻게 동작할까?
아무런 설정을 하지 않으면 io-threads의 기본값은 1로 설정되어 있다. I/O thread를 사용하고 싶다면 해당 값을 수정하면 된다. 만약 설정에서 io-threads를 1보다 큰 값으로 설정하면, 내부적으로는 I/O전용 스레드를 생성한다. 구체적으로는 initThreadedIO 함수에서 pthread_create 함수를 호출하여 새롭게 스레드를 생성한다. 로직을 보면 io_threads_num 값이 1인 경우에 아무런 작업을 하지 않고, 함수를 빠져나온다.
IOThreadMain 함수가 바로 각 I/O 스레드가 수행하는 함수이다. I/O 전용 스레드는 자신에게 작업이 할당될 때까지 대기한다. 메인 스레드는 메인이벤트 루프를 실행할 때 before_sleep 함수를 호출하는데, 여기서 다음과 같이 두 가지 함수를 수행한다.
handleClientsWithPendingReadsUsingThreads: I/O 스레드에 읽기 작업 분배
handleClientsWithPendingWritesUsingThreads: I/O 스레드에 쓰기 작업 분배
각 함수에서는 대기 중인 읽기/쓰기 작업을 라운드 로빈 형식으로 각 I/O 스레드에 전달한다. 다만, 대기 중인 I/O 작업이 너무 적은 경우에는 별도로 할당하지 않고 그냥 메인 스레드가 처리한다. 한 가지 흥미로운 점은 I/O 스레드는 작업을 Start/Stop 하는 건 handleClientsWithPendingWritesUsingThreads에만 구현되어 있다는 점이다. 즉, 쓰기 작업에 대해 I/O 작업 분배를 결정할 때, I/O 스레드를 활성화할지 안 할지가 결정된다.
위 로직에서도 볼 수 있지만, I/O 스레드가 메인스레드와 따로 동작하는 구조는 아니다. 왜냐하면 메인 스레드는 I/O 스레드가 작업을 모두 수행할 때까지 대기한다. I/O 스레드에서 모든 작업을 완료하면, io_threads_op를 IO_THREADS_OP_IDLE로 설정한다. 이 의미는 더 이상 I/O 스레드에서 수행하고 있지 않음을 의미한다.
io_threads_op 변수가 굉장히 중요한 이유는 해당 변수 값에 따라 I/O 스레드에서 명령어를 수행할지 안 할지가 결정되기 때문이다. 아래 코드는 읽기 작업을 수행하는 코드의 일부이다.
코드를 보면 io_threads_op라는 변숫값을 통해 I/O Thread 임을 확인한다. 만약 I/O 스레드에서 읽기 작업을 수행했다면 명령어 수행만 남았음을 표시하고, 실제로 명령어는 수행하지 않는다. 즉, 미리 버퍼에 데이터를 넣고, 명령어를 파싱 하는 과정만 수행한다.
요컨대, Redis는 여전히 싱글스레드 모델을 사용하고 있다. Redis는 내부적으로 이벤트 루프를 실행하는데 epoll에 파일 디스크립터를 등록하고 이벤트가 발생할 시, 작업을 수행한다. 연결 요청이 발생하면 클라이언트 요청에 대한 별도의 파일 디스크립터를 생성하고 이를 epoll에 등록한다. 각 파일 디스크립터에 이벤트가 발생하면, Read인지 Write인지에 따라서 별도의 작업을 수행한다.
만약 I/O 스레드를 별도로 사용한다면, Read/Write에 대한 작업을 메인 스레드가 각 I/O 전용 스레드에 할당한다. I/O 전용 스레드가 수행하는 작업은 Read 아니면 Write로 구분되어 있으며 io_threads_op 변수를 통해서 I/O 스레드가 요청을 처리하는지 여부를 확인한다.