Thundering Herd 문제와, 이 문제가 발생하게 되는 시나리오를 한 번만 짚고 가자.
Thundering Herd 문제
여러 워커가 동일한 이벤트를 대기하고 하고 있을 때, 이벤트 발생 시 실제 이벤트를 처리하는 워커는 하나 뿐임에도 대기 중인 모든 워커가 깨어나게 되는 문제문제 발생 시나리오
여러 워커들이 마스터 프로세스로부터 동일한 리스닝 소켓을 상속받아 가지고 있고, 이 리스닝 소켓으로 들어오는 연결 요청 이벤트를 대기하고 있는 상태.
오늘은 Nginx가 어떻게 이 문제를 해결하는지, 그 해결 전략들에 대해 한 번 알아보자.
첫 번째는 뮤텍스(accept_mutex
)를 사용하는 방법인데, 이 뮤텍스의 사용 여부는 nginx.conf
에서 설정할 수 있다.
events {
accept_mutex off; # (default: 1.11.3 버전 전까지는 on, 이후 off)
}
아이디어는 간단하다.
// 실제 코드는 아닙니다.
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);
}
}
이제 락을 획득한 하나의 워커만이 이벤트를 대기하고 처리하게 되니, 리스닝 소켓에 이벤트가 준비돼도 하나의 워커만이 해당 이벤트를 받게 된다.
accept_mutex
세부일단은 뮤텍스 구현을 위해 필요한 구조체들을 보자. /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의 내장 원자적 연산 라이브러리, 혹은 시스템이 제공하는 원자적 연산을 이용해 락을 사용하지만, 어떠한 원자적 연산도 전혀 지원되지 않는 환경이라면 파일 락을 기본적인 동기화 메커니즘으로 사용한다.
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;
}
// 아래에 추가적인 내용이 있기는 하지만 생략
}
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;
}
락 해제는 더 간단하다. 지금 내가 락을 획득한 상태인 경우 해제시켜 주기만 하면 된다.
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);//세마포어 관련 함수인데 쓰지 않아서 즉시 리턴함
}
}
락을 획득한 후에는 이벤트를 대기하다 준비가 완료되면 처리한다.
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 문제가 발생하더라도 뮤텍스를 사용하지 않는 편이 낫다.
락 획득에 실패한 워커들이 기존 연결의 이벤트들을 처리할 수 있도록 코드를 수정해줄 필요가 있다.
수정 방향 자체는 간단하다.
accept_mutex
를 가진 프로세스는 리스닝 소켓을 자신의 이벤트 목록에 등록지난 코드에서 문제가 되었던 지점은 아래와 같다.
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 문제를 해결합니다.