이 글에서 이벤트 기반 동시성에 대해 다룬 적이 있습니다. 이번에는 좀 더 구체적으로, 이벤트 큐로 활용되는 시스템 콜들이 어떻게 동작하는지를 자세하게 다뤄봅시다.
요청마다 스레드, 혹은 프로세스를 만드는 다중처리 모듈(MPM, Multi Processing Module) 방식에서 일어날 수 있는 문제에는 크게 세 가지가 있다.
이벤트 기반 동시성 프로그래밍은 "이벤트 큐"를 통해 단일 스레드에서의 비동기 처리를 가능하게 하는 방법으로, 자바스크립트의 node.js, 자바의 Netty, 그리고 오늘 다룰 Nginx가 대표적인 이벤트-드리븐 웹 서버들이다.
이벤트라 부르는 것에는 여러 가지가 있는데, 대충 다음과 같은 것들이 있다.
이벤트 드리븐 웹 서버에서는 일종의 큐 역할을 하는 자료 구조에 이러한 이벤트들을 담고, 이벤트가 준비가 되는대로 하나씩 꺼내 필요한 처리를 함으로써 동시성(concurrency)을 달성한다.
동시성(병행성, concurrency)
여러 작업을 잘게 나눠 번갈아 실행함으로써, 작업들이 꼭 동시에 실행되는 것처럼 보이게 함.
ex) 시분할 시스템, 멀티 스레드, 이벤트 기반 동시성
다시 멀티 스레드 웹 서버와 비교해보자. 멀티 스레드 웹 서버에서는 연결-요청-응답-연결 끊기와 같은 일련의 작업을 하나의 스레드에 배정한다. 여러 요청이 있으면 여러 스레드가 짧은 시간동안 서로 돌아가면서 CPU를 점유해, 마치 여러 스레드가 동시에 돌아가는 듯한, 여러 요청이 동시에 처리되는 듯한 환상을 만들어낸다.
이벤트 드리븐 웹 서버의 경우, 연결-요청-응답-연결 끊기의 일련의 작업을 각각의 이벤트로 분할하고 하나의 스레드에서 처리한다. 여러 요청이 들어오는 경우에는, (물론 이벤트가 준비되는 순서에 따라 이벤트 처리 순서가 달라질 수 있겠지만) 연결(1)-연결(2)-요청(1)-요청(2)-응답(1)-연결 끊기(1)-응답(2)-연결 끊기(2)와 같은 순서로 각 이벤트를 처리함으로써 여러 요청이 동시에 처리되는 듯한 환상을 만들어낸다.
이제 대표적인 이벤트 드리븐 웹서버인 Nginx와, Nginx가 어떻게 이벤트 드리븐 웹 서버를 구현하고 있는지 확인해보자.
Nginx는 2004년 러시아에서 만들어진 오픈 소스 웹 서버다.
Nginx가 제공하는 기능은 정말 많은데, 몇 가지를 추리자면 다음 정도가 있다.
리버스 프록시(Reverse Proxy)
일반적으로 말하는 프록시가 클라이언트가 자신을 숨기는 것이라면, 리버스 프록시는 서버가 자신을 숨기는 것을 말한다. 리버스 프록시 서버는 클라이언트의 요청을 대신 받아 백엔드로 전달함으로써 실제 요청을 처리하는 백엔드 서버를 숨기는데, 이렇게 함으로써 얻을 수 있는 장점에는 다음과 같은 것들이 있다.
- 보안 강화: 클라이언트가 직접 백엔드 서버에 접근하지 못하게 막을 수 있고, DDoS 같이 악의적인 트래픽을 걸러낼 수도 있다.
- 클라이언트와의 연결은 HTTPS, 백엔드 서버와의 연결은 HTTP를 사용해 백엔드 서버의 부담을 줄일 수 있다.
- 리버스 프록시 서버를 하나의 게이트웨이로 두고, 뒤에 여러 개의 백엔드 서버를 둬 트래픽을 분산해 부하를 줄일 수 있다(로드 밸런싱).
Nginx에서 제공하는 부하 분산 옵션
- 라운드 로빈
- 가중 라운드 로빈
- 각 백엔드 서버에 가중치를 두는 라운드 로빈- IP Hash
- 클라이언트 IP + 포트를 해싱해, 한 클라이언트가 항상 한 백엔드 서버와 연결을 맺도록 할 수 있음- Least Connection
- 현재 연결된 클라이언트의 수가 가장 적은 서버를 선택- Least time, 스티키 쿠키, 스티키 런, 스티키 라우팅 (Nginx Plus 유료 결제 필요)
Nginx에서는 독자적인 이벤트 큐를 구현해 사용하지는 않고, select
, poll
, epoll
과 같은, OS가 제공하는 I/O 멀티플렉싱 시스템 콜(사실 epoll
자체는 시스템 콜이 아니라 자료 구조긴 함)을 활용해 비동기 이벤트 처리를 한다. 이 시스템 콜들은 간단히 말해, 등록된 파일 디스크립터(들)을 감시하다가 읽기/쓰기가 가능해지면 알림을 주는 역할을 한다.
리눅스/UNIX에서 "모든 것이 파일"이기 때문에, 소켓도 일종의 파일로 취급되고 파일 디스크립터로 관리할 수 있으며, epoll
등에 등록해 OS가 해당 소켓의 네트워크 I/O 상태를 감시하다, 읽거나 쓸 준비가 되면 이를 알려주게 할 수 있다.
//socket()은 소켓을 만들고, 쓰이지 않은 가장 낮은 파일 디스크립터 번호를 반환한다.
int socket(int domain, int type, int protocol);
select
, poll
, epoll
중 어떤 걸 사용할지는 설정을 할 때 정할 수 있다.
select()
읽을/쓸/예외 발생을 확인할 파일 스크립터들을 각 파일 디스크립터 집합(fd_set
)에 넣어 select()
에 인자로 전달한다.
//return > 0: 준비된 파일 디스크립터 수, 0: 타임아웃, -1: 오류 발생
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
fd_set
은 아래와 같이 정수형 배열을 가지는 구조체로, 각 파일 디스크립터를 비트마스크로 관리한다.
struct fd_set{
unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(long))];
};
내부적으로 select()
는 타임아웃이 나거나, 전달한 fd_set
에 준비된 파일 디스크립터가 생길 때까지 0~nfds-1
의 파일 디스크립터를 반복해서 순차적으로 확인하는 방식을 사용한다.
select()
가 0보다 큰 값은 반환하는 경우, 아래의 매크로를 통해 원하는 fd_set
의 어떤 파일 디스크립터가 준비된 상태인지를 확인할 수 있다.
int FD_ISSET(int fd, fd_set *set);
select()
는 이해하기 쉽기는 하지만, 단점이 너무 너무 많다.
- 모니터링할 수 있는 최대 파일 디스크립터 수인
FD_SETSIZE
는 보통 1024로 고정되어 있다.select()
는 모니터링을 하기 위해 주어진 범위의 FD를 순차적으로 확인한다. 비효율적이다.select()
후, 어떤 FD가 준비된 상태인지 확인하기 위해서도 직접 FD를 순차적으로 확인해야 한다.
poll()
poll()
은 select()
와 비슷하지만, FD_SETSIZE
같은 제한이 없어서 더 많은 파일 디스크립터를 확인할 수 있다.
//return > 0: 준비된 파일 디스크립터 수, 0: 타임아웃, -1: 오류 발생
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
pollfd
구조체에는 확인할 FD를 기입하고, 어떤 이벤트를 확인할 것인지를 events
에 비트 필드로 넣을 수 있다. 예를 들어 읽을 준비가 됐는지를 확인하려면 POLLIN
, 쓸 준비가 됐는지를 확인하려면 POLLOUT
을 쓰면 되고, 둘 중 하나라도 준비됐는지 확인하고 싶으면 POLLIN | POLLOUT
으로 쓰면 된다.
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
revents
는 실제로 발생한 이벤트들이 채워지는 필드다. events
에 설정해놓은 이벤트를 확인하고, 준비가 됐다면 해당 필드의 비트를 1로 만든다. 혹은 어떤 에러가 발생하는 경우에도 그 에러에 해당하는 비트를 1로 만든다.
파일 디스크립터들이 준비됐는지 확인하는 것도 select()
와 비슷하다. 구조체 배열fds
에 들어있는 nfds_t
개의 파일 디스크립터들을 순차적으로 확인하면서, revents
가 0이 아니게 된 것이 있는지를 확인하는 방법이다.
poll()
은select()
와 달리 모니터링할 수 있는 FD 수에 제한이 없기는 하지만, 마찬가지로 순차적으로 확인해야하기 때문에 비효율적이다.
epoll
요즘 리눅스에서의 고성능 서버의 경우에는 select()
나 poll()
대신 epoll
을 사용한다고 한다. epoll
은 커널에서 관리되는 자료 구조로, 그 핵심 컨셉은 커널에 두 리스트를 담은 epoll
인스턴스를 만들어 사용하는 것이다.
epoll
set): 사용자 프로세스에서 모니터링하고 싶은 파일 디스크립터 집합epoll
인스턴스를 만들고 제어하기 위해서는 아래의 시스템 콜들을 사용한다.
int epoll_create(int size);
epoll_create()
는 새 epoll
인스턴스를 만들고 해당 인스턴스의 파일 디스크립터를 반환한다. 이때 size
는 해당 epoll
인스턴스에 추가할 파일 디스크립터의 수를 알려주기 위해 쓰인다. 반환된 FD는 이후 epoll
관련 시스템 콜들을 호출할 때 쓰이고, 더 이상 필요가 없어지면 close()
로 닫으면 되고, 해당 epoll
인스턴스를 가리키는 모든 파일 디스크립터가 닫히면 커널이 알아서 인스턴스를 삭제하고 자원을 반납한다.
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl()
은 epoll
인스턴스를 제어하는 데 쓰인다. 어떤 epoll
인스턴스(epfd
)에 어떤 작업을 할지(op
), 어떤 FD(fd
)에 대한 것인지, 또 어떤 이벤트인지를 명시해야 한다. 성공 시 0을, 에러 발생 시 -1을 반환한다.
op
로 가능한 작업으로는 다음의 세 가지가 있다.
EPOLL_CTL_ADD
: 인스턴스에 모니터링할 새 파일 디스크립터와 이벤트를 추가. EPOLL_CTL_MOD
: 인스턴스의 파일 디스크립터의 interest list를 변경.EPOLL_CTL_DEL
: 인스턴스에서 해당 파일 디스크립터를 삭제. event
는 무시됨.epoll_event
는 다음과 같은 구조체다.
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
data
멤버는 커널이 저장해놓고, 나중에 epoll_wait()
로 해당 파일 디스크립터가 준비됐을 때 반환하게 되고, events
멤버는 모니터링할 이벤트를 비트 필드로 나타낸 것이다. 여러 가지 이벤트들이 있지만 poll()
과 비슷하게, 읽기 작업이 준비됐는지를 확인하려면 EPOLLIN
, 쓰기 작업이 준비됐는지 확인하려면 EPOLLOUT
, 둘 중 하나라도 준비됐는지 확인하려면 EPOLLIN | EPOLLOUT
과 같이 쓸 수 있다.
여기서 한 가지 중요한 이벤트 플래그 EPOLLET
을 짚고 넘어 가자. epoll
에는 다음과 같은 두 가지 모드가 있다.
man-page에 나오는 예로, 다음과 같은 상황을 생각해보자.
rfd
를 epoll
에 등록epoll_wait()
을 호출하고 rfd
가 읽기 준비 됐음을 확인rfd
로부터 1kB의 데이터를 읽음epoll_wait()
호출ET 모드인 경우, 준비가 되지 않은 상태에서 준비가 된 상태로 변한 3번에서만 이벤트가 전달되어 1kB만을 읽을 수 있게 되지만, LT 모드인 경우 rfd
가 준비된 상태를 유지하기 때문에 5번 이후 남은 데이터도 읽을 수 있게 된다.
LT는 직관적이고 간편하지만, 단순히 말하면 그냥 좀 더 빠른 poll()
이고, 불필요한 이벤트가 계속 발생할 수도 있다.
이와 달리 ET는 상태가 변경되는 순간에만 이벤트를 발생시키기 때문에 이벤트가 발생하는 빈도가 적고, 성능을 최적화하는 데에도 쓸 수 있다. 다만 얼마나 데이터를 읽었는지 계속해서 상태를 추적해야 하기 때문에 복잡하고, 논 블로킹 I/O를 사용하지 않으면 미처리된 데이터를 다시 처리하지 못하게 될 수도 있다.
기본적으로는 LT를 사용하는데, ET를 쓰고 싶은 경우 이벤트에 EPOLLET
플래그를 추가하면 된다.
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
마지막 epoll_wait()
는 epoll
인스턴스의 FD 모니터링을 시작하기 위한 시스템 콜로, 최대 maxevents
개의 이벤트를 반환한다. 여기의 events
는 ready list로, epoll_ctl()
로 interest list에 넣은 FD 중 준비가 된 것들이 들어가게 된다. 이때 epoll_event
의 data
멤버는 epoll_ctl()
로 넣은 것과 동일하고, events
멤버는 실제로 모니터링된 결과가 비트 필드로 주어진다.
epoll
의 상태 추적은 어떻게 이루어질까?select()
나 poll()
은 파일 디스크립터 중 어느 것이 준비됐는지를 확인하기 위해 주기적으로 모든 FD에게 준비 여부를 물어보는 방식을 택했기 때문에 비효율적이었다.
epoll
의 경우는 다음과 같이 동작한다.
epoll_create()
로 epoll
인스턴스 생성eventpoll
구조체가 생성되고, 파일 추적 및 관리를 위한 레드-블랙 트리의 루트 노드와 준비 리스트가 생긴다.epoll_ctl()
로 FD를 epoll
인스턴스에 등록epitem
구조체를 생성하고, (파일 구조, FD 번호)를 키로 레드-블랙 트리에 삽입epitem
을 연결epitem
를 연결리스트로 유지함.sock_poll()
)를 호출. 플래그를 반환 받음epitem
의 콜백 함수(ep_poll_callback()
)를 실행epitem
은 자신이 소속된 인스턴스의 ready list에 추가됨.epoll_wait()
호출 시결국 epoll
의 경우, 커널이 각 FD에 직접 준비 여부를 물어보는 대신, 콜백 함수를 이용해 준비된 FD가 스스로 각 인스턴스에 준비 여부를 알리도록 하기 때문에 훨씬 빠르다.
분량 조절 실패로 Nginx의 구조와 어떻게 Nginx가 구성되는지는 다음 시간에...