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

Park Yeongseo·7일 전
0

Nginx

목록 보기
8/9
post-thumbnail

Thundering Herd 문제와, 이 문제가 발생하게 되는 시나리오를 한 번만 짚고 가자.

Thundering Herd 문제
여러 워커가 동일한 이벤트를 대기하고 하고 있을 때, 이벤트 발생 시 실제 이벤트를 처리하는 워커는 하나 뿐임에도 대기 중인 모든 워커가 깨어나게 되는 문제

문제 발생 시나리오
여러 워커들이 마스터 프로세스로부터 동일한 리스닝 소켓을 상속받아 가지고 있고, 이 리스닝 소켓으로 들어오는 연결 요청 이벤트를 대기하고 있는 상태.

오늘은 Nginx가 어떻게 이 문제를 해결하는지, 그 해결 전략들에 대해 한 번 알아보자.

1. 뮤텍스를 이용한 레이스 컨디션 해소

첫 번째는 뮤텍스(accept_mutex)를 사용하는 방법인데, 이 뮤텍스의 사용 여부는 nginx.conf에서 설정할 수 있다.

events {
	accept_mutex off; # (default: 1.11.3 버전 전까지는 on, 이후 off)
}

아이디어는 간단하다.

  1. 공유 메모리에 뮤텍스를 하나 둔다.
  2. 락을 획득한 워커만이 이벤트를 대기하고 처리한다.
  3. 이벤트 처리가 끝난 워커는 락을 반납한다.
// 실제 코드는 아닙니다.
void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
    if (ngx_use_accept_mutex) {
        
        if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
            return;
        }

        if (!ngx_accept_mutex_held) {
	        return;
        } 
    }


    (void) ngx_process_events(cycle, timer, flags);
    
    if (ngx_accept_mutex_held) {
	    ngx_shmtx_unlock(&ngx_accept_mutex);
    }
    
}

이제 락을 획득한 하나의 워커만이 이벤트를 대기하고 처리하게 되니, 리스닝 소켓에 이벤트가 준비돼도 하나의 워커만이 해당 이벤트를 받게 된다.

2. accept_mutex 세부

락에 대한 자세한 내용들은 OS 시리즈Locks(3)Locks(4)를 참고하시면 좋을 것 같습니다.

일단은 뮤텍스 구현을 위해 필요한 구조체들을 보자. /src/core/ngx_shmtx.h에 정의되어 있다. ngx_shmtx_sh_t는 실제 락 상태를 공유 메모리에 저장하기 위한 구조체고 , ngx_shmtx_t는 각 워커가 락을 제어하기 위해 이용하는 구조체다.

// /src/core/ngx_shmtx.h

typedef struct {
    ngx_atomic_t   lock;//뮤텍스 상태. = 0: 해제, > 0: 락을 가진 프로세스의 PID
#if (NGX_HAVE_POSIX_SEM)
    ngx_atomic_t   wait;//세마포어 사용시, 대기 중인 프로세스 수 추적에 사용
#endif
} ngx_shmtx_sh_t;


typedef struct {
#if (NGX_HAVE_ATOMIC_OPS)
    ngx_atomic_t  *lock;//위 ngx_shmtx_sh_t.lock의 포인터
#if (NGX_HAVE_POSIX_SEM)
    ngx_atomic_t  *wait;//위 ngx_shmtx_sh_t.wait의 포인터
    ngx_uint_t     semaphore;//세마포어 사용 여부 플래그
    sem_t          sem;//세마포어 객체
#endif
#else
    ngx_fd_t       fd;//파일락 사용 시 사용할 FD
    u_char        *name;//파일 락 경로명
#endif
    ngx_uint_t     spin;//스핀락 횟수. -1: 스핀 사용하지 않음
} ngx_shmtx_t;

일반적으로는 Nginx의 내장 원자적 연산 라이브러리, 혹은 시스템이 제공하는 원자적 연산을 이용해 락을 사용하지만, 어떠한 원자적 연산도 전혀 지원되지 않는 환경이라면 파일 락을 기본적인 동기화 메커니즘으로 사용한다.

(2-1) accept_mutex의 생성

뮤텍스 생성 자체는 /src/core/ngx_cycle.c:ngx_init_cycle()에서 각 모듈에 대한 전역 초기화를 진행할 때 이루어진다. ngx_shmtx_sh_t 구조체는 공유 메모리에 생기고, ngx_shmtx_t 구조체는 일단 만들어놨다가 나중에 워커들을 fork()로 생성할 때 각 워커가 복사해 가지게 한다.

// /src/core/ngx_cycle.c
ngx_cycle_t *
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
	// ...
	if (ngx_init_modules(cycle) != NGX_OK) {
        /* fatal */
        exit(1);
    }
	// ...
}

// /src/ore/ngx_module.c
ngx_int_t
ngx_init_modules(ngx_cycle_t *cycle)
{
    ngx_uint_t  i;

    for (i = 0; cycle->modules[i]; i++) {
        if (cycle->modules[i]->init_module) {
            if (cycle->modules[i]->init_module(cycle) != NGX_OK) {
                return NGX_ERROR;
            }
        }
    }

    return NGX_OK;
}

// /src/event/ngx_event.c
static ngx_int_t
ngx_event_module_init(ngx_cycle_t *cycle)
{
    //...

    ngx_accept_mutex_ptr = (ngx_atomic_t *) shared;
    ngx_accept_mutex.spin = (ngx_uint_t) -1;

    if (ngx_shmtx_create(&ngx_accept_mutex, (ngx_shmtx_sh_t *) shared,
                         cycle->lock_file.data)
        != NGX_OK)
    {
        return NGX_ERROR;
    }
	
    // ...
}

// /src/core/ngx_shmtx.c
ngx_int_t
ngx_shmtx_create(ngx_shmtx_t *mtx, ngx_shmtx_sh_t *addr, u_char *name)
{
    mtx->lock = &addr->lock;//포인터 설정

    if (mtx->spin == (ngx_uint_t) -1) {//즉시 반환
        return NGX_OK;
    }

	// 아래에 추가적인 내용이 있기는 하지만 생략
}

(2-2) 락 획득

accept_mutex의 락 획득은 간단하다. 일단 락 획득 시도를 해보고, 획득에 성공하면 리스닝 소켓들을 내 이벤트 목록에 등록하고, 내가 이 락을 가지고 있다는 표시를 해주는 게 끝이다(ngx_accept_mutex_held = 1;).

// /src/event/ngx_event_accept.c
ngx_int_t
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
    if (ngx_shmtx_trylock(&ngx_accept_mutex)) {

		// 이렇게 되는 경우는 없을 것 같지만 방어적 프로그래밍을 위해서 있는 듯함
        if (ngx_accept_mutex_held && ngx_accept_events == 0) {
            return NGX_OK;
        }

        if (ngx_enable_accept_events(cycle) == NGX_ERROR) {
            ngx_shmtx_unlock(&ngx_accept_mutex);
            return NGX_ERROR;
        }

        ngx_accept_events = 0;
        ngx_accept_mutex_held = 1;

        return NGX_OK;
    }

    if (ngx_accept_mutex_held) {
        if (ngx_disable_accept_events(cycle, 0) == NGX_ERROR) {
            return NGX_ERROR;
        }

        ngx_accept_mutex_held = 0;
    }

    return NGX_OK;
}

// /src/core/ngx_shmtx.c
ngx_uint_t
ngx_shmtx_trylock(ngx_shmtx_t *mtx)
{
    return (*mtx->lock == 0 && ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid));
}

ngx_atomic_cmp_set(lock, old, new)는 compare-and-swap 연산이다. 세부 구현은 원자적 연산 라이브러리가 있거나, 시스템에서 원자적 연산을 지원하는 경우에는 해당 구현을 곧바로 사용하고, 그렇지 않으면 직접 구현해서 사용한다.

// /src/core/os/unix/ngx_atomic.h

/**
	원자적 연산이 지원되는 경우 이를 이용하고
*/
#if (NGX_HAVE_LIBATOMIC)

#define ngx_atomic_cmp_set(lock, old, new)  \
    AO_compare_and_swap(lock, old, new)

#elif (NGX_HAVE_GCC_ATOMIC)
#define ngx_atomic_cmp_set(lock, old, set)  \
    __sync_bool_compare_and_swap(lock, old, set)

// ... 기타 시스템에서 원자적 연산을 지원하는 경우

#endif

/** 
	그렇지 않으면 아래와 같이 직접 CAS를 구현해서 사용
*/
#if !(NGX_HAVE_ATOMIC_OPS)

#define NGX_HAVE_ATOMIC_OPS  0

static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
    ngx_atomic_uint_t set)
{
    if (*lock == old) {
        *lock = set;
        return 1;
    }

    return 0;
}

락 획득에 성공 시에는 ngx_enable_accept_events()를 호출해, 사용할 수 있는 리스닝 소켓들 중 활성화되지 않은 것들(누군가의 이벤트 목록에 등록된 상태가 아닌 것들)을 모두 자신의 이벤트 목록에 등록한다.

// /src/event/ngx_event_accept.c
ngx_int_t
ngx_enable_accept_events(ngx_cycle_t *cycle)
{
    ngx_uint_t         i;
    ngx_listening_t   *ls;
    ngx_connection_t  *c;

    ls = cycle->listening.elts;
    for (i = 0; i < cycle->listening.nelts; i++) {

        c = ls[i].connection;

        if (c == NULL || c->read->active) {
            continue;
        }

        if (ngx_add_event(c->read, NGX_READ_EVENT, 0) == NGX_ERROR) {
            return NGX_ERROR;
        }
    }

    return NGX_OK;
}

락 획득에 실패한 경우에는 ngx_disable_accept_events()를 호출해, 활성화된 리스닝 소켓들을 내 이벤트 목록에서 제거한다. 불필요한 리소스 경쟁이 일어나는 일을 막기 위함이다.

// /src/event/ngx_event_accept.c

static ngx_int_t
ngx_disable_accept_events(ngx_cycle_t *cycle, ngx_uint_t all)
{
    ngx_uint_t         i;
    ngx_listening_t   *ls;
    ngx_connection_t  *c;

    ls = cycle->listening.elts;
    for (i = 0; i < cycle->listening.nelts; i++) {

        c = ls[i].connection;

        if (c == NULL || !c->read->active) {
            continue;
        }

#if (NGX_HAVE_REUSEPORT)

        /*
         * do not disable accept on worker's own sockets
         * when disabling accept events due to accept mutex
         */

        if (ls[i].reuseport && !all) {
            continue;
        }

#endif

        if (ngx_del_event(c->read, NGX_READ_EVENT, NGX_DISABLE_EVENT)
            == NGX_ERROR)
        {
            return NGX_ERROR;
        }
    }

    return NGX_OK;
}

(2-3) 락 해제

락 해제는 더 간단하다. 지금 내가 락을 획득한 상태인 경우 해제시켜 주기만 하면 된다.

void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
	//...
    
    if (ngx_accept_mutex_held) {
	    ngx_shmtx_unlock(&ngx_accept_mutex);
    }
}


// /src/core/ngx_shmtx.c
void
ngx_shmtx_unlock(ngx_shmtx_t *mtx)
{
	// spin == -1이므로 X 
    if (mtx->spin != (ngx_uint_t) -1) {
        ngx_log_debug0(NGX_LOG_DEBUG_CORE, ngx_cycle->log, 0, "shmtx unlock");
    }

	// 이번에는 락의 값이 내 PID와 동일한지 확인하고 0(잠금 해제)으로 설정해준다
    if (ngx_atomic_cmp_set(mtx->lock, ngx_pid, 0)) {
        ngx_shmtx_wakeup(mtx);//세마포어 관련 함수인데 쓰지 않아서 즉시 리턴함
    }
}

3. 이벤트 처리

락을 획득한 후에는 이벤트를 대기하다 준비가 완료되면 처리한다.

void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
	//학 획득

    (void) ngx_process_events(cycle, timer, flags);

	//락 해제
}

ngx_process_events/src/event/ngx_event.h에 다음과 같이 매크로 정의가 되어 있는데, 간단히 선택된 이벤트 모델의 모듈에 정의된 process_events 함수를 호출하기 위함이라 생각하면 좋을 것 같다.

// /src/event/ngx_event.h

#define ngx_process_events   ngx_event_actions.process_events

epoll을 쓴다고 가정하면, Nginx의 epoll 관련 모듈의 다음 함수를 실행한다.

static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
	// ...

    events = epoll_wait(ep, event_list, (int) nevents, timer);

	// 준비된 이벤트들에 대한 처리
}

이렇게 accept_mutex를 사용해 항상 하나의 워커만이 대기/처리하게 함으로써 Thundering Herd 문제를 해결할 수 있게 됐다.

하지만 이렇게만 하면 경쟁하지 않아도 되는 이벤트까지도 동시에 처리할 수 없게 된다는 문제가 발생하고, 이렇게 할 바에는 차라리 Thundering Herd 문제가 발생하더라도 뮤텍스를 사용하지 않는 편이 낫다.

락 획득에 실패한 워커들이 기존 연결의 이벤트들을 처리할 수 있도록 코드를 수정해줄 필요가 있다.

3. 기존 연결 이벤트의 병렬 처리

수정 방향 자체는 간단하다.

  1. accept_mutex를 가진 프로세스는 리스닝 소켓을 자신의 이벤트 목록에 등록
  2. 나머지 프로세스는 자신의 이벤트 목록에서 리스닝 소켓을 제거하고, 기존의 연결 소켓 이벤트는 처리

지난 코드에서 문제가 되었던 지점은 아래와 같다.

void
ngx_process_events_not_real_just_for_practice(ngx_cycle_t *cycle)
{
    if (ngx_use_accept_mutex) {
        
        if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
            return;
        }

        if (!ngx_accept_mutex_held) {
	        return;// <-----------바로 여기
        } 
	}
	
	(void) ngx_process_events(cycle, timer, flags);

	if (ngx_accept_mutex_held) {
	    ngx_shmtx_unlock(&ngx_accept_mutex);
    }
}

단순히 표시된 부분의 return;을 삭제해주기만 해도 리스닝 소켓으로 들어오는 새 연결 요청 이벤트를 처리하면서 기존의 연결 소켓 이벤트까지도 모두 병렬 처리할 수 있게 된다.

다음 글에서는 REUSEPORT 소켓을 이용해 Thundering Herd 문제를 해결합니다.

0개의 댓글

관련 채용 정보