[Nginx] 4-4. 이벤트 루프와 처리 (4)

Park Yeongseo·2025년 4월 29일
0

Nginx

목록 보기
10/10
post-thumbnail

실제로 어떻게 이벤트 처리가 이루어지는지 살펴보기 전에 간단하게 epoll 시스템 콜들을 리뷰하고 가자.

1. 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()로 반환된 FD
  • op: 추가/수정/삭제 중 어떤 것을 할지
  • fd: op를 적용할 FD
  • events: epoll 이벤트 구조체

여기서 epoll_event는 다음과 같은 구조체다.

struct epoll_event {
   uint32_t     events; // epoll 이벤트 플래그
   epoll_data_t data;   // 사용자가 사용할 데이터
};

대표적인 epoll 이벤트에는 EPOLLIN, EPOLLOUT, EPOLLERR, EPOLLET 등이 있는데, 여기서 특히 EPOLLETepoll의 모드를 설정하기 위해 쓰인다. 기본은 LT모드지만, Nginx에서는 ET모드를 주로 사용한다.

  • Edge Triggered(ET): 해당 파일 디스크립터에 상태 변화가 생기는 경우에만 전달
  • Level Triggered(LT): 해당 파일 디스크립터가 준비 상태면 계속 전달
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 인스턴스의 FD
  • events: 준비된 이벤트들이 들어간다
  • maxevents: 한 번에 반환받을 최대 이벤트 수
  • timeout: 타임아웃 시간

이때 events 배열에는 epoll_ctl로 넣었던 바로 그 구조체가 담겨 반환된다.

2. Nginx의 이벤트/연결 처리 관련 구조체 소개

(1) 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 인스턴스에 등록된다.

(2) 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_moduleinit_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 풀에서 하나를 가져와 사용한다.

(3) 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; //이벤트 핸들러

	//.. 그 외 쥰내 많은 필드들
};

3. epoll 이벤트 처리

epoll 이벤트의 경우 /src/event/ngx_event.c:ngx_process_events_and_timers()에서 ngx_epoll_process_events()를 호출해 이벤트를 대기/처리 한다. 아래 코드를 몇 개의 블럭으로 나눠, Nginx에서 어떻게 epoll 이벤트를 처리하는지 살펴보자.

(1) 이벤트 에러 처리

일단은 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()에 대한 오류 처리가 끝나고 나면 각 이벤트를 처리한다.

(2) 오래된 이벤트(stale event) 처리

오래된 이벤트(stale event)
epoll로부터 전달된, 이미 닫힌/무효화된 연결에 대한 이벤트

다음과 같은 시나리오를 생각해보자.

  1. 클라이언트 A의 연결 소켓에 이벤트가 발생
  2. 이벤트가 전달되기 전 A가 연결을 종료
    1. 해당 연결 소켓의 FD가 닫힌다
    2. 사용했던 커넥션 구조체(conn_A)를 반납한다.
  3. 클라이언트 B가 서버에 연결한다.
    1. A와의 연결에서 사용됐던 FD와 커넥션 구조체가 재사용됐다고 가정

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에서는 이 비트 자리를 오래된 이벤트를 걸러내는 데 사용한다.

  1. 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;
}
  1. 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;
}
  1. 이벤트를 확인할 때에는 포인터의 최하위 비트와, 안에 담긴 이벤트 구조체의 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;
        }

	// ...

(3) 이벤트 처리

오래된 이벤트가 아님을 확인했다면 발생한 이벤트에 따른 처리를 해준다.

		/**
		  앞에서 본 부분
		*/
		
        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;
        }
		

EPOLLERREPOLLHUP이벤트가 발생하는 경우에는 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;
}

다음 글에서는 이벤트 처리 최적화를 위한 큐 & 이벤트 지연 처리 전략에 대해

profile
블로그 이전 준비 중

0개의 댓글