실제로 어떻게 이벤트 처리가 이루어지는지 살펴보기 전에 간단하게 epoll
시스템 콜들을 리뷰하고 가자.
epoll
시스템 콜 리뷰int epoll_create(int size);
epoll_create()
는 새 epoll 인스턴스를 만들고 그 FD를 반환한다.
size
: 모니터링할 파일 디스크립터의 수에 해당한다.int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl()
은 생성된 epoll
인스턴스 제어에 쓰이며, 성공 시 0, 실패 시 -1을 반환한다.
epfd
: 위 epoll_create()
로 반환된 FDop
: 추가/수정/삭제 중 어떤 것을 할지fd
: op
를 적용할 FDevents
: epoll
이벤트 구조체여기서 epoll_event
는 다음과 같은 구조체다.
struct epoll_event {
uint32_t events; // epoll 이벤트 플래그
epoll_data_t data; // 사용자가 사용할 데이터
};
대표적인 epoll
이벤트에는 EPOLLIN
, EPOLLOUT
, EPOLLERR
, EPOLLET
등이 있는데, 여기서 특히 EPOLLET
은 epoll
의 모드를 설정하기 위해 쓰인다. 기본은 LT모드지만, Nginx에서는 ET모드를 주로 사용한다.
typedef union epoll_data {
void *ptr; // 이벤트 관련 구조체를 정의/사용하는 경우
int fd; // 단순 FD만 전달하면 되는 경우
uint32_t u32; // 작은 정수 데이터 전달이 필요한 경우 (1)
uint64_t u64; // 작은 정수 데이터 전달이 필요한 경우 (2)
} epoll_data_t;
data
에 들어가는 epoll_data_t
구조체는 위와 같이 정의되어 있고, 사용자가 준비된 이벤트 처리를 위해 사용하게 될 정보들을 넣는 용도로 사용한다.
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
마지막으로 epoll_wait()
는 해당 epoll
인스턴스에서의 이벤트 발생을 대기하기 위해 쓰인다. 성공 시 준비된 이벤트의 개수를 반환하고, 에러인 경우 -1, 타임아웃인 경우 0을 반환한다.
epfd
: 대기할 epoll
인스턴스의 FDevents
: 준비된 이벤트들이 들어간다maxevents
: 한 번에 반환받을 최대 이벤트 수timeout
: 타임아웃 시간이때 events
배열에는 epoll_ctl
로 넣었던 바로 그 구조체가 담겨 반환된다.
ngx_listening_t
구조체ngx_listening_t
구조체는 서버가 바인딩해서 클라이언트 연결을 기다리는 리스닝 소켓을 표현한다. Nginx는 리스닝 소켓에 대한 정보를 이 구조체를 통해 관리하며, 새로운 연결 수락 시에도 이 구조체를 참조한다.``
// /src/core/ngx_connection.h
struct ngx_listening_s {
ngx_socket_t fd; // 리스닝 소켓 FD
struct sockaddr *sockaddr; // 바인딩된 주소
socklen_t socklen; // 주소 길이
size_t addr_text_max_len;
ngx_str_t addr_text;
int type;
int backlog; // 리스닝 소켓 백로그
int rcvbuf; // 수신 버퍼 크기
int sndbuf; // 송신 버퍼 크기
/* handler of accepted connection */
ngx_connection_handler_pt handler; // 연결 수락 후 호출할 핸들러
//.. 그 외 엄청나게 많은 필드들
};
이 ngx_listening_t
구조체는 아래의 ngx_connection_t
구조체로 래핑되어, 새로운 연결 이벤트를 받을 수 있도록 epoll
인스턴스에 등록된다.
ngx_connection_t
구조체ngx_connection_t
은 연결을 추상화한 구조체로, 연결과 관련한 모든 정보들이 담겨 있다. Nginx에서는 주로 이 구조체의 포인터를 epoll_data.ptr
에 넣어 이후 처리에 이용한다.
말 그대로 연결과 관련된 모든 정보들을 담은 구조체라 필드도 수십개가 있지만, 몇 가지만 추려보면 다음 정도가 있다.
// /src/core/ngx_connection.h
struct ngx_connection_s {
void *data; // 연결 관련 사용자 정의 데이터 포인터
ngx_event_t *read; // 읽기 이벤트 관련 구조체 (핸들러)
ngx_event_t *write; // 쓰기 이벤트 관련 구조체 (핸들러)
ngx_socket_t fd; // 소켓 FD
ngx_listening_t *listening; // 연결 수락에 쓰일/쓰인 리스닝 소켓
int type; // 연결 유형 플래그
// 아래는 연결 수립 이후 클라이언트의 정보들
struct sockaddr *sockaddr;
socklen_t socklen;
ngx_str_t addr_text;
//.. 그 외 겁내 많은 필드들
};
data
필드에는 소켓의 종류에 따라 여러 가지 구조체 포인터가 들어간다.
ngx_listening_t
의 포인터ngx_http_request_t
등의 포인터각 워커마다 만들 수 있는 연결 소켓의 개수는 nginx.conf
에서 다음과 같이 설정할 수 있고,
events {
worker_connections 1234;
}
각 워커 프로세스에서 ngx_events_module
의 init_process
를 호출할 때, 다음과 같이 그 수만큼의 ngx_connection_t
구조체를 생성/초기화 해놓고 사용한다.
// /src/event/ngx_event.c
static ngx_int_t
ngx_event_process_init(ngx_cycle_t *cycle)
{
// ...
// worker_connections 크기의 ngx_connection_t 구조체 배열을 만들고
cycle->connections =
ngx_alloc(sizeof(ngx_connection_t) * cycle->connection_n, cycle->log);
if (cycle->connections == NULL) {
return NGX_ERROR;
}
c = cycle->connections;
// 각 커넥션 구조체에 넣을 read 이벤트 구조체를 만들고 초기화
cycle->read_events = ngx_alloc(sizeof(ngx_event_t) * cycle->connection_n,
cycle->log);
if (cycle->read_events == NULL) {
return NGX_ERROR;
}
rev = cycle->read_events;
for (i = 0; i < cycle->connection_n; i++) {
rev[i].closed = 1;
rev[i].instance = 1;
}
// 각 커넥션 구조체에 넣을 write 이벤트 구조체를 만들고 초기화
cycle->write_events = ngx_alloc(sizeof(ngx_event_t) * cycle->connection_n,
cycle->log);
if (cycle->write_events == NULL) {
return NGX_ERROR;
}
wev = cycle->write_events;
for (i = 0; i < cycle->connection_n; i++) {
wev[i].closed = 1;
}
i = cycle->connection_n;
next = NULL;
// 커넥션 객체에 넣어줌
do {
i--;
c[i].data = next;
c[i].read = &cycle->read_events[i];
c[i].write = &cycle->write_events[i];
c[i].fd = (ngx_socket_t) -1;
next = &c[i];
} while (i);
cycle->free_connections = next;//사용 가능한 커넥션 구조체 연결리스트의 헤드
cycle->free_connection_n = cycle->connection_n;//남아있는 사용 가능 커넥션
// ...
}
새 클라이언트 연결 등의 이유로 커넥션 구조체가 필요한 경우 /src/core/ngx_connection.c:ngx_get_connection()
을 호출해, 위에서 만들어 놓은 ngx_connection_t
풀에서 하나를 가져와 사용한다.
ngx_event_t
구조체ngx_event_t
는 개별 I/O 이벤트를 추상화한 구조체로, 이벤트가 발생했을 때 어떤 처리를 할지를 정의하는 핸들러가 있다. 이벤트의 각 상태는 1비트를 이용해 나타내고, ngx_connection_t
와 마찬가지로 많은 필드가 있지만, 몇 가지만 추려보면 다음 정도가 있다.
struct ngx_event_s {
void *data; //이벤트 발생한 연결
unsigned write:1; //쓰기 이벤트 여부(1: 쓰기, 0: 읽기)
unsigned accept:1; //accept 이벤트 여부(리스닝 소켓용)
unsigned instance:1;
unsigned active:1;
unsigned disabled:1;
unsigned ready:1;
unsigned complete:1;
unsigned eof:1;
unsigned error:1;
unsigned timedout:1;
unsigned timer_set:1;
unsigned closed:1;
ngx_queue_t queue;
ngx_event_handler_pt handler; //이벤트 핸들러
//.. 그 외 쥰내 많은 필드들
};
epoll
이벤트 처리epoll
이벤트의 경우 /src/event/ngx_event.c:ngx_process_events_and_timers()
에서 ngx_epoll_process_events()
를 호출해 이벤트를 대기/처리 한다. 아래 코드를 몇 개의 블럭으로 나눠, Nginx에서 어떻게 epoll
이벤트를 처리하는지 살펴보자.
일단은 epoll_wait()
를 호출해 이벤트 발생을 기다리고, 반환 시 에러/타임아웃 발생 여부를 확인한다.
// /src/event/modules/ngx_epoll_module.c
static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
/**
변수 선언
*/
// epoll_wait()를 호출해 이벤트 발생 대기
events = epoll_wait(ep, event_list, (int) nevents, timer);
// 에러 여부 확인
err = (events == -1) ? ngx_errno : 0;
// 타이머 업데이트
if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
ngx_time_update();
}
// 에러처리
if (err) {
if (err == NGX_EINTR) {//인터럽트로 인한 대기 종료인 경우
if (ngx_event_timer_alarm) {//타이머로 인한 종료인 경우
ngx_event_timer_alarm = 0;
return NGX_OK;
}
level = NGX_LOG_INFO;
} else {
level = NGX_LOG_ALERT;
}
ngx_log_error(level, cycle->log, err, "epoll_wait() failed");
return NGX_ERROR;
}
// events == 0이면 타임아웃인 경우여야 하지만,
if (events == 0) {
if (timer != NGX_TIMER_INFINITE) {
return NGX_OK;
}
// 타임아웃이 아님에도 0이 반환되면 에러를 반환
return NGX_ERROR;
}
//...
epoll_wait()
에 대한 오류 처리가 끝나고 나면 각 이벤트를 처리한다.
오래된 이벤트(stale event)
epoll
로부터 전달된, 이미 닫힌/무효화된 연결에 대한 이벤트
다음과 같은 시나리오를 생각해보자.
conn_A
)를 반납한다.epoll
은 빠르기는 하지만, 완전히 실시간은 아니다. 이전 A와의 연결에서 발생한 이벤트가 이제서야 전달됐다고 해보자. 워커프로세스는 이 오래된 A의 이벤트를 B의 이벤트로 생각하고 잘못 처리하게 될 것이다.
Nginx에서는 이러한 오래된 이벤트 처리를 위해 epoll_event.data.ptr
에 담긴 포인터의 최하위 비트를 사용한다.
메모리 정렬
현대 컴퓨터 시스템에서는 성능 향상/오류 방지 등을 위해, 데이터를 메모리에 저장할 때 특정 주소 단위로 배치하며, 그 주소 단위는 보통 그 자료형에 따라 정해진다.
- 1바이트 데이터(char): 아무 데나 상관 없음
- 2바이트 데이터(short 등): 2의 배수 주소 (
0x00, 0x02, 0x04, ...
)- 4바이트 데이터(int 등): 4의 배수 주소 (
0x00, 0x04, 0x08, ...
)- 8바이트 데이터(long long, double 등): 8의 배수 주소 (
0x00, 0x08, 0x10, ...
)
포인터 변수는 32비트 주소 체계의 경우 4바이트 단위로 정렬될 것이고, 64비트 주소 체계라면 8바이트 단위로 정렬된다. 어떤 주소 체계든 최하위 비트는 항상 0이 되고, Nginx에서는 이 비트 자리를 오래된 이벤트를 걸러내는 데 사용한다.
ngx_event_t
구조체에는 1비트짜리 instance
필드가 있다. ngx_get_connection()
로 커넥션 구조체 풀에서 하나를 가져올 때, 이 instance
필드는 토글된다.// /src/core/ngx_connections.c
ngx_connection_t *
ngx_get_connection(ngx_socket_t s, ngx_log_t *log)
{
/**
사용 가능한 커넥션 구조체를 풀에서 가져오는 부분
*/
instance = rev->instance;
ngx_memzero(rev, sizeof(ngx_event_t));
ngx_memzero(wev, sizeof(ngx_event_t));
rev->instance = !instance;
wev->instance = !instance;
/**
기타 필드 설정
*/
return c;
}
epoll
인스턴스에 이벤트를 등록할 때에는 커넥션 구조체 포인터의 최하위 비트를 이벤트 instance
값으로 설정한 다음 epoll_event.data.ptr
에 넣는다.// /src/event/modules/ngx_epoll_module.c
static ngx_int_t
ngx_epoll_add_event(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags)
{
// ...
ee.events = events | (uint32_t) flags;
ee.data.ptr = (void *) ((uintptr_t) c | ev->instance); // << 여기
if (epoll_ctl(ep, op, c->fd, &ee) == -1) {
ngx_log_error(NGX_LOG_ALERT, ev->log, ngx_errno,
"epoll_ctl(%d, %d) failed", op, c->fd);
return NGX_ERROR;
}
// ...
return NGX_OK;
}
instance
값을 비교한다. 만약 두 값이 일치하지 않으면 이젠 유효하지 않은 연결에서 발생했던 이벤트임을 알 수 있다.// /src/event/modules/ngx_epoll_module.c
static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
/**
앞에서 본 부분
*/
// 각 이벤트들에 대해
for (i = 0; i < events; i++) {
// epoll_event 구조체 -> data 필드 -> ptr 필드에 담긴 포인터
c = event_list[i].data.ptr;
// 인스턴스 값
instance = (uintptr_t) c & 1;
// 실제 커넥션 구조체의 주소
c = (ngx_connection_t *) ((uintptr_t) c & (uintptr_t) ~1);
rev = c->read;
// 이미 닫힌 연결이거나, 인스턴스 값이 일치하지 않으면 건너 뛴다.
if (c->fd == -1 || rev->instance != instance) {
ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
"epoll: stale event %p", c);
continue;
}
// ...
오래된 이벤트가 아님을 확인했다면 발생한 이벤트에 따른 처리를 해준다.
/**
앞에서 본 부분
*/
revents = event_list[i].events;
if (revents & (EPOLLERR|EPOLLHUP)) {
ngx_log_debug2(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
"epoll_wait() error on fd:%d ev:%04XD",
c->fd, revents);
revents |= EPOLLIN|EPOLLOUT;
}
EPOLLERR
나 EPOLLHUP
이벤트가 발생하는 경우에는 EPOLLIN
, EPOLLOUT
비트를 1로 만든 후 다음으로 이어간다.
EPOLLHUP
Hang up. 상대방이 더 이상 데이터를 송/수신할 수 없는 상태인 경우 발생 (ex. 연결 종료)Q. 에러 발생/연결 종료 이벤트가 발생했는데 왜
EPOLLIN | EPOLLOUT
을 ?
A1.EPOLLERR
,EPOLLHUP
플래그만으로는 충분하지 않고,read()
,write()
를 호출하고 반환값과 에러 코드를 확인해야만 정확히 알 수 있다.
A2. 읽기/쓰기 이벤트 핸들러를 재활용하면 별도의 에러/연결 종료 클린 업 핸들러를 만들지 않아도 된다.
이후는 읽기/쓰기 이벤트에 따라 핸들러를 호출하고 처리하는 부분들이다.
/**
앞에서 본 부분
*/
if ((revents & EPOLLIN) && rev->active) {
#if (NGX_HAVE_EPOLLRDHUP)
if (revents & EPOLLRDHUP) {
rev->pending_eof = 1;
}
#endif
rev->ready = 1;
rev->available = -1;
// 이벤트 지연 플래그가 설정된 경우
if (flags & NGX_POST_EVENTS) {
queue = rev->accept ? &ngx_posted_accept_events
: &ngx_posted_events;
// 지연 이벤트 큐에 추가
ngx_post_event(rev, queue);
} else {//그렇지 않으면 즉시 핸들러를 호출해 처리
rev->handler(rev);
}
}
wev = c->write;
if ((revents & EPOLLOUT) && wev->active) {
// stale event 확인
if (c->fd == -1 || wev->instance != instance) {
continue;
}
wev->ready = 1;
#if (NGX_THREADS)
wev->complete = 1;
#endif
// 이벤트 지연 플래그가 설정된 경우
if (flags & NGX_POST_EVENTS) {
// 지연 이벤트 큐에 추가
ngx_post_event(wev, &ngx_posted_events);
} else {//그렇지 않으면 즉시 핸들러를 호출해 처리
wev->handler(wev);
}
}
}
return NGX_OK;
}
다음 글에서는 이벤트 처리 최적화를 위한 큐 & 이벤트 지연 처리 전략에 대해