namalzi

← 글 목록

nginx 소스 파헤치기 7: 이벤트 루프 — epoll, 타이머, posted 이벤트, accept

nginx · 게시

워커 하나가 스레드 하나로 수천 연결을 돌리는 ngx_process_events_and_timers() 한 바퀴를 소스로 따라갑니다. epoll 을 함수 포인터 표(ngx_event_actions)로 끼우는 법, worker_connections 만큼 미리 만든 연결 배열과 빈 목록, EPOLLET 와 낡은 이벤트를 거르는 instance 비트, 레드블랙 트리 타이머가 epoll_wait 의 타임아웃이 되는 길, posted 큐, 그리고 accept_mutex(기본 off)·EPOLLEXCLUSIVE·reuseport 가운데 누가 새 연결을 받는지를 strace·gdb·디버그 로그로 확인합니다.

이 글에서 다루는 것

"nginx 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서 마스터가 워커를 띄우고, 워커가 ngx_worker_process_cycle() 의 무한 반복에 들어서는 데까지 봤습니다. 그 반복의 몸통은 사실상 한 줄, ngx_process_events_and_timers(cycle); 입니다. 이번 글은 그 한 줄 안으로 들어갑니다.

2편의 지도에서 이 글은 B. 프로세스와 이벤트 구역의 이벤트 루프 상자를 확대합니다.

연재 지도에서 이 글의 위치. A 준비 구역에 기동·cycle(4편), 모듈·디렉티브(5편), 메모리 풀·자료구조(3편)가 있고, B 프로세스와 이벤트 구역에 마스터·워커(6편)와 이벤트 루프(7편, 이 글)가 있으며, C HTTP 요청 하나 구역에 요청 읽기(8편), phase·location(9편), 필터·정적 파일(10편), upstream·proxy(11편), 부하 분산·stream(12편)이 있다. 이벤트 루프 상자만 초록색이다. 초록 상자가 이 글입니다. 6편이 워커를 띄웠고, 8편부터는 이 루프가 부르는 HTTP 핸들러를 봅니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 nginx
버전 nginx 1.31.9 (master, 개발판)
커밋 9a7439fd79 (2026-10-23)
주로 읽는 파일 src/event/ngx_event.c, src/event/modules/ngx_epoll_module.c, src/event/ngx_event_timer.[ch], src/event/ngx_event_posted.[ch], src/event/ngx_event_accept.c, src/core/ngx_connection.[ch]

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

src/event 폴더에는 이벤트 방식 모듈(epoll·kqueue·poll·select …), OpenSSL 연동, UDP, 프록시 버퍼링(event pipe), QUIC 까지 들어 있습니다. 이 글은 리눅스의 epoll 위에서 TCP 연결 하나가 받아지고, 기다려지고, 시간이 다 되어 닫히는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

이벤트 루프(event loop)는 "할 일이 생긴 것이 있는지 운영체제에 묻고, 생긴 것만 처리하고, 다시 묻는" 반복문입니다. nginx 의 워커는 이 반복문 하나로 삽니다. 2편에서 그 모양을 한 장으로 봤고, 이 글은 그 한 바퀴의 단계를 하나씩 엽니다.

논블로킹 소켓은 읽을 데이터가 없을 때 recv() 가 기다리지 않고 바로 EAGAIN("지금은 없음")을 돌려주는 소켓입니다. 스레드 하나가 여러 연결을 돌리려면 한 연결 때문에 멈춰 서면 안 되므로, nginx 의 연결 소켓은 모두 논블로킹입니다.

epoll 은 리눅스가 주는 "소켓 수천 개를 한꺼번에 지켜보다가 준비된 것만 알려 주는" 기능입니다. epoll_create 로 지켜보기 목록을 만들고, epoll_ctl 로 소켓을 넣고 빼고, epoll_wait 로 "준비된 것이 생길 때까지(또는 정한 시간까지)" 잠듭니다. 알림 방식이 둘 있습니다. 레벨 트리거는 읽을 것이 남아 있는 동안 매번 알려 주고, 엣지 트리거(EPOLLET)는 읽을 수 없던 상태에서 읽을 수 있는 상태로 바뀔 때 한 번 알려 줍니다.

이벤트(ngx_event_t)는 "이 소켓에 읽을 것(또는 쓸 자리)이 생기면 불러 줄 함수"를 담은 구조체입니다. 핵심 필드는 handler(부를 함수 포인터)와 data(보통 그 연결)입니다. 연결(ngx_connection_t) 하나에 읽기 이벤트 c->read 와 쓰기 이벤트 c->write 가 붙어 있고, 같은 구조체가 타이머로도 쓰입니다.

타이머는 "이 이벤트를 N 밀리초 뒤에 시간 초과로 불러 달라"는 예약입니다. 워커의 모든 타이머는 레드블랙 트리(넣기·빼기·가장 작은 값 찾기가 모두 빠른 균형 이진 트리) 하나에 모여 있습니다. 트리의 구현은 3편에서 봤습니다.

posted 이벤트는 "지금 바로 부르지 말고, 이번 바퀴가 끝나기 전에 불러 달라"고 큐에 넣어 둔 이벤트입니다(6절).

연결 칸: 이 글에서 cycle->connections 배열의 원소 하나를 이렇게 부르겠습니다. nginx 는 연결 구조체를 그때그때 malloc 하지 않고, 워커가 시작할 때 worker_connections 개를 한꺼번에 만들어 두고 빌려 씁니다(3절).

1. 워커의 for 문 한 줄 — ngx_process_events_and_timers()

6편에서 본 워커의 for ( ;; ) 는 종료 플래그(ngx_exiting, ngx_terminate, ngx_quit …) 검사 사이에 ngx_process_events_and_timers(cycle); 한 줄을 두고 있습니다(src/os/unix/ngx_process_cycle.c 740–778행). master_process off; 일 때의 ngx_single_process_cycle()(322행)과 캐시 관리자 프로세스(1184행)도 같은 함수를 부릅니다. 이벤트 루프는 nginx 의 일하는 프로세스가 모두 쓰는 엔진입니다.

한 바퀴의 여덟 단계

함수 전체입니다. 70줄 남짓입니다.

ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
    ngx_uint_t  flags;
    ngx_msec_t  timer, delta;

    if (ngx_timer_resolution) {
        timer = NGX_TIMER_INFINITE;
        flags = 0;

    } else {
        timer = ngx_event_find_timer();
        flags = NGX_UPDATE_TIME;

        /* ... Windows 전용 블록 (#if (NGX_WIN32)) ... */
    }

    if (ngx_use_accept_mutex) {
        if (ngx_accept_disabled > 0) {
            ngx_accept_disabled--;

        } else {
            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
                return;
            }

            if (ngx_accept_mutex_held) {
                flags |= NGX_POST_EVENTS;

            } else {
                if (timer == NGX_TIMER_INFINITE
| timer > ngx_accept_mutex_delay)
                {
                    timer = ngx_accept_mutex_delay;
                }
            }
        }
    }

    if (!ngx_queue_empty(&ngx_posted_next_events)) {
        ngx_event_move_posted_next(cycle);
        timer = 0;
    }

    delta = ngx_current_msec;

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

    delta = ngx_current_msec - delta;

    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
                   "timer delta: %M", delta);

    ngx_event_process_posted(cycle, &ngx_posted_accept_events);

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

    ngx_event_expire_timers();

    ngx_event_process_posted(cycle, &ngx_posted_events);
}

src/event/ngx_event.c 195–264행

이벤트 루프 한 바퀴의 여덟 단계. 1. ngx_event_find_timer로 트리에서 가장 이른 타이머까지 남은 밀리초를 구한다(없으면 NGX_TIMER_INFINITE). 2. accept_mutex가 켜져 있을 때만 ngx_trylock_accept_mutex를 시도해, 잡으면 flags에 NGX_POST_EVENTS를 더하고 못 잡으면 timer를 accept_mutex_delay 이하로 줄인다. 3. posted next 큐가 비어 있지 않으면 posted 큐로 옮기고 timer를 0으로. 4. ngx_process_events, 곧 ngx_epoll_process_events가 epoll_wait에서 잠든다. 깨어나면 ngx_time_update로 시각 캐시를 고치고, 준비된 이벤트마다 handler를 바로 부르거나 posted 큐에 넣는다. 5. posted accept 큐 처리. 6. 뮤텍스를 쥐었으면 푼다. 7. ngx_event_expire_timers로 시간이 다 된 타이머의 handler를 timedout=1로 부른다. 8. posted 큐 처리. 그리고 다음 바퀴. 노란 상자는 타이머 트리를 만지는 단계, 회색은 설정에 따라 건너뛰는 단계입니다. 기다리는(잠드는) 곳은 초록 상자 하나뿐입니다.

순서에 담긴 생각은 이렇습니다.

  1. 잘 시간을 먼저 정합니다. 가장 가까운 타이머가 3초 뒤라면 epoll_wait 는 최대 3초만 자야, 아무 일이 없어도 3초 뒤에 깨어나 그 타이머를 처리할 수 있습니다. 타이머가 하나도 없으면 무한정 잡니다(5절).
  2. accept 뮤텍스는 기본이 off 라 보통 건너뜁니다(7절).
  3. posted next 큐는 "다음 바퀴에 처리해 달라"는 것들입니다. 있으면 timer = 0 으로 epoll_wait 가 잠들지 않고 바로 돌아오게 합니다(6절).
  4. 기다립니다. ngx_process_events 는 함수가 아니라 함수 포인터 매크로입니다(2절). epoll 에서는 epoll_wait 를 부르고, 깨어나면 준비된 이벤트마다 핸들러를 부릅니다(4절).
  5. ~ 8. 뒷정리. posted accept 큐 → 뮤텍스 풀기 → 시간이 다 된 타이머 → posted 큐. accept 를 먼저 처리하고 바로 뮤텍스를 푸는 이유는 7절에서, 타이머가 epoll 뒤에 있는 이유는 5절에서 봅니다.

핸들러에는 약속이 하나 있습니다. 절대 기다리지 않는다입니다. 읽을 것이 없으면 EAGAIN 을 받고 "읽을 수 있게 되면 다시 불러 달라"고 epoll 에 맡긴 채 바로 돌아옵니다. 한 핸들러가 디스크나 계산으로 오래 붙잡으면 그동안 같은 워커의 다른 연결이 모두 멈춥니다. 스레드를 나누지 않은 대가입니다.

헷갈리기 쉬운 곳: delta 는 이제 로그에만 쓰입니다

delta 는 epoll_wait 앞뒤의 시각 차이인데, 이 판에서는 디버그 로그 timer delta: … 에만 쓰입니다. 옛 판은 if (delta) { ngx_event_expire_timers(); } 처럼 시각이 바뀌었을 때만 타이머를 검사했습니다. 이미 지난 타이머가 남아 있으면 epoll_wait(…, 0) 이 바로 돌아오는데 시각은 그대로라 타이머가 처리되지 않고 루프가 헛도는 문제가 있어서, 커밋 c108f04e8(2021-03-26, "Events: fixed expiration of timers in the past.", 1.19.9 부터)이 조건을 없앴습니다. 옛 판을 설명한 글의 if (delta) 는 이 판과 다릅니다.

timer_resolution 을 쓰면 1번이 달라집니다. 타이머를 보지 않고 무한정 자는 대신 setitimer() 로 그 간격마다 SIGALRM 을 받아 epoll_wait 를 깨웁니다(src/event/ngx_event.c 696–719행). 기본은 꺼져 있습니다.

2. epoll 을 끼우는 자리 — ngx_event_actions 함수 포인터 표

ngx_event.c 에는 epoll_wait 라는 글자가 없습니다. 이벤트 코어는 "넣는다·뺀다·기다린다"를 함수 포인터 표로만 부르고, 표를 채우는 것은 운영체제별 이벤트 모듈(src/event/modules/)입니다. httpd 8편에서 APR 의 pollset 이 운영체제 차이를 감싸던 것과 같은 자리입니다.

typedef struct {
    ngx_int_t  (*add)(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags);
    ngx_int_t  (*del)(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags);

    ngx_int_t  (*enable)(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags);
    ngx_int_t  (*disable)(ngx_event_t *ev, ngx_int_t event, ngx_uint_t flags);

    ngx_int_t  (*add_conn)(ngx_connection_t *c);
    ngx_int_t  (*del_conn)(ngx_connection_t *c, ngx_uint_t flags);

    ngx_int_t  (*notify)(ngx_event_handler_pt handler);

    ngx_int_t  (*process_events)(ngx_cycle_t *cycle, ngx_msec_t timer,
                                 ngx_uint_t flags);

    ngx_int_t  (*init)(ngx_cycle_t *cycle, ngx_msec_t timer);
    void       (*done)(ngx_cycle_t *cycle);
} ngx_event_actions_t;

src/event/ngx_event.h 149–166행

이벤트 모듈의 컨텍스트 ngx_event_module_t(ngx_module_t 의 ctx 가 가리키는 모듈 종류별 구조체, 5편)는 name, create_conf, init_conf 와 이 표 actions 를 품습니다(src/event/ngx_event.h 417–424행). epoll 모듈이 채운 값입니다.

static ngx_event_module_t  ngx_epoll_module_ctx = {
    &epoll_name,
    ngx_epoll_create_conf,               /* create configuration */
    ngx_epoll_init_conf,                 /* init configuration */

    {
        ngx_epoll_add_event,             /* add an event */
        ngx_epoll_del_event,             /* delete an event */
        ngx_epoll_add_event,             /* enable an event */
        ngx_epoll_del_event,             /* disable an event */
        ngx_epoll_add_connection,        /* add an connection */
        ngx_epoll_del_connection,        /* delete an connection */
#if (NGX_HAVE_EVENTFD)
        ngx_epoll_notify,                /* trigger a notify */
#else
        NULL,                            /* trigger a notify */
#endif
        ngx_epoll_process_events,        /* process the events */
        ngx_epoll_init,                  /* init the events */
        ngx_epoll_done,                  /* done the events */
    }
};

src/event/modules/ngx_epoll_module.c 179–200행

enable 자리에 add 와 같은 함수가, disable 자리에 del 과 같은 함수가 들어 있습니다. "잠시 끄기"를 따로 지원하는 kqueue 와 달리 epoll 에서는 넣기·빼기와 같은 일이기 때문입니다.

이벤트 모듈을 끼우는 길. events 블록의 use epoll 지시, 또는 그것을 적지 않았을 때 ngx_event_core_init_conf가 epoll_create를 시험해 보고 고른 결과가 ecf->use(= ngx_epoll_module.ctx_index)가 된다. 워커마다 ngx_event_process_init이 use와 같은 이벤트 모듈의 actions.init을 부르고, ngx_epoll_init이 epoll_create와 event_list 할당을 한 뒤 전역 ngx_event_actions에 ngx_epoll_module_ctx.actions를 복사한다. 이 전역 표의 함수 포인터 10개를 ngx_add_event, ngx_process_events 같은 매크로로 부르고, 결국 ngx_epoll_add_event가 epoll_ctl을, ngx_epoll_process_events가 epoll_wait를 부른다. kqueue·poll·select 모듈도 같은 표를 자기 함수로 채운다. 설정 읽기(위의 둘)는 마스터에서, 그 아래는 각 워커에서 일어납니다. 노란 상자가 워커마다 하나 있는 전역 변수입니다.

고르기는 설정을 읽을 때입니다. events {} 에 use 를 적지 않으면 ngx_event_core_init_conf() 가 epoll_create(100) 을 실제로 한 번 불러 보고, 되면 epoll 모듈을 고릅니다(kqueue 가 있는 시스템이면 kqueue, 아무것도 없으면 select — src/event/ngx_event.c 1272–1304행). 고른 모듈의 ctx_index 가 ecf->use 가 됩니다.

꽂기는 각 워커가 시작할 때입니다. 이벤트 코어 모듈의 init_process 콜백인 ngx_event_process_init() 이 모듈 목록에서 ctx_index == ecf->use 인 이벤트 모듈을 찾아 module->actions.init(cycle, ngx_timer_resolution) 을 부릅니다(675–692행). ngx_epoll_init() 은 epoll_create(cycle->connection_n / 2) 로 epoll 인스턴스를 만들고(330행), epoll_events(기본 512) 개짜리 결과 배열 event_list 를 할당한 뒤, 표를 꽂고 "이 방식의 성질" 플래그를 세웁니다.

    ngx_event_actions = ngx_epoll_module_ctx.actions;

#if (NGX_HAVE_CLEAR_EVENT)
    ngx_event_flags = NGX_USE_CLEAR_EVENT
#else
    ngx_event_flags = NGX_USE_LEVEL_EVENT
#endif
NGX_USE_GREEDY_EVENT
NGX_USE_EPOLL_EVENT;

src/event/modules/ngx_epoll_module.c 369–377행

이후 코어와 HTTP 모듈은 이 표를 매크로로 부릅니다.

#define ngx_process_events   ngx_event_actions.process_events
#define ngx_done_events      ngx_event_actions.done

#define ngx_add_event        ngx_event_actions.add
#define ngx_del_event        ngx_event_actions.del
#define ngx_add_conn         ngx_event_actions.add_conn
#define ngx_del_conn         ngx_event_actions.del_conn

#define ngx_notify           ngx_event_actions.notify

src/event/ngx_event.h 371–379행

그래서 1절의 ngx_process_events(cycle, timer, flags) 는 이 빌드에서 ngx_epoll_process_events(cycle, timer, flags) 입니다. ngx_event_flags 는 코어가 "이 방식에서는 어떻게 해야 하나"를 고를 때 봅니다. 예를 들어 NGX_USE_CLEAR_EVENT(엣지 트리거)이면 4절의 ngx_handle_read_event() 가 한 번 등록한 이벤트를 다시 건드리지 않습니다.

events {} 에서 보던 것이 소스의 어디인가

기본값은 모두 ngx_event_core_init_conf() 끝의 ngx_conf_init_* 줄에서 확인했습니다(src/event/ngx_event.c 1330–1340행).

디렉티브 기본값 (이 판) 소스에서
use 고를 수 있는 것 가운데 epoll(리눅스) ecf->use = 이벤트 모듈의 ctx_index
worker_connections 512 (DEFAULT_CONNECTIONS, 13행) cycle->connection_n — 3절의 배열 크기
multi_accept off ev->available — accept 핸들러가 한 번에 몇 개 받나(7절)
accept_mutex off ngx_use_accept_mutex (7절)
accept_mutex_delay 500ms 뮤텍스를 못 잡은 워커가 자는 최대 시간(1절 2번)
epoll_events 512 (ngx_epoll_init_conf, epoll 모듈 1047행) epoll_wait 한 번에 받는 이벤트 수의 상한

배포판 nginx.conf 에 흔히 적힌 worker_connections 1024; 와 달리 적지 않았을 때의 값은 512 라는 것은 2편에서도 짚었습니다. accept_mutex 의 기본값이 off 가 된 것은 커밋 46dd747c9(2016-07-15, 1.11.3 부터)입니다.

헷갈리기 쉬운 곳: 표는 워커마다 따로 꽂힙니다

ngx_event_actions 는 전역 변수지만 마스터가 채워 fork() 로 물려주는 것이 아닙니다. 마스터는 init_process 를 부르지 않으므로 마스터의 표는 비어 있고 epoll 인스턴스도 없습니다. 워커마다 자기 epoll 인스턴스와 자기 연결 배열을 갖는다는 것이 다음 절의 출발점입니다(6편의 워커 초기화 순서 6번).

3. 연결 칸 — worker_connections 는 미리 만든 배열의 크기다

구조체 둘

연결 구조체에서 이벤트 루프가 쓰는 필드만 남기면 이렇습니다.

struct ngx_connection_s {
    void               *data;
    ngx_event_t        *read;
    ngx_event_t        *write;

    ngx_socket_t        fd;

    ngx_recv_pt         recv;
    ngx_send_pt         send;
    /* ... */

    ngx_pool_t         *pool;

    /* ... */

    ngx_queue_t         queue;

    ngx_atomic_uint_t   number;

    /* ... */

    unsigned            reusable:1;
    unsigned            close:1;
    /* ... */
};

src/core/ngx_connection.h 125–206행

이벤트 구조체 ngx_event_s 는 앞부분이 1비트 플래그로 빽빽하고, 뒤에 handler, 타이머 노드 timer(ngx_rbtree_node_t), 큐 노드 queue(ngx_queue_t)가 있습니다(src/event/ngx_event.h 19–121행). 이 글에 자주 나올 비트는 이렇습니다.

필드 뜻
active 이 이벤트가 지금 epoll 에 등록되어 있다
ready 지금 읽을(쓸) 수 있다 — epoll 이 알려 주면 1, recv() 가 다 비우면 0
accept 듣기 소켓의 읽기 이벤트다
instance 낡은 이벤트를 거르는 1비트 (4절)
timer_set / timedout 타이머 트리에 걸려 있다 / 시간이 다 되어 불렸다 (5절)
posted posted 큐에 들어 있다 (6절)

그리고 이 구조체 하나가 세 곳에 동시에 걸릴 수 있다는 것이 nginx 이벤트 설계의 요점입니다.

ngx_event_t 하나가 걸리는 세 곳. 커널 안의 epoll은 fd마다 연결 주소에 instance 비트를 더한 data.ptr을 들고 있다가 epoll_wait로 돌려준다. ngx_connection_t는 c->read로 이벤트를, 이벤트는 ev->data로 연결을 서로 가리킨다. 이벤트 안에 박힌 ev->timer(ngx_rbtree_node_t, key는 만료 시각 ms)는 ngx_add_timer로 워커에 하나뿐인 ngx_event_timer_rbtree에 걸리고, ev->queue(ngx_queue_t)는 ngx_post_event로 ngx_posted_events, ngx_posted_accept_events, ngx_posted_next_events 큐에 걸린다. 타이머 노드와 큐 노드가 이벤트 구조체 안에 박혀 있어서, 타이머를 걸거나 큐에 넣을 때 메모리를 따로 할당하지 않습니다.

트리나 큐에서 꺼낸 노드로부터 이벤트를 되찾을 때는 offsetof 로 거꾸로 계산하는 매크로 ngx_rbtree_data(node, ngx_event_t, timer), ngx_queue_data(q, ngx_event_t, queue) 를 씁니다(src/core/ngx_rbtree.h 50–51행, src/core/ngx_queue.h 106–107행). 3편에서 본 "노드를 구조체 안에 박는" 방식입니다.

워커가 시작할 때 한꺼번에 만든다

ngx_event_process_init() 은 epoll 을 꽂은 뒤 connection_n(= worker_connections) 개의 연결과 읽기·쓰기 이벤트를 세 배열로 만들고 빈 칸 목록을 잇습니다.

    cycle->connections =
        ngx_alloc(sizeof(ngx_connection_t) * cycle->connection_n, cycle->log);
    /* ... read_events, write_events 도 같은 크기로 ngx_alloc ... */

    rev = cycle->read_events;
    for (i = 0; i < cycle->connection_n; i++) {
        rev[i].closed = 1;
        rev[i].instance = 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/event/ngx_event.c 750–796행

뒤에서부터 거꾸로 돌며 c[i].data = next 로 이으므로, 다 돌고 나면 free_connections 가 c[0] 을 가리키는 c[0] → c[1] → … → c[n-1] → NULL 의 빈 칸 목록(단일 연결 리스트)이 됩니다. 다음 칸 포인터를 따로 두지 않고 빈 칸에서는 쓰지 않는 data 필드에 적어 둔 것이 알뜰한 점입니다.

연결 배열과 빈 목록. 워커마다 하나인 ngx_cycle_t의 connection_n이 worker_connections다. connections, read_events, write_events 세 배열을 한 번에 할당하고, c[i].read와 c[i].write가 i번째 이벤트를, 이벤트의 data가 다시 연결을 가리킨다. 빈 칸은 free_connections에서 시작하는 단일 연결 리스트로, 다음 칸은 c->data에 적는다. ngx_get_connection은 맨 앞을 떼어 내고 ngx_free_connection은 맨 앞에 되돌린다(둘 다 O(1)). 쓰는 중인 연결은, 예제 2에서는 0번이 듣기 소켓, 1번이 마스터와의 채널이고 나머지가 클라이언트와 upstream 연결이다. 빌리고 돌려줄 때 맨 앞만 만지므로, 방금 돌려준 칸이 다음에 가장 먼저 다시 쓰입니다. 4절의 instance 비트가 필요한 이유가 여기서 생깁니다.

빌리는 함수 ngx_get_connection() 의 가운데입니다.

    c = ngx_cycle->free_connections;

    if (c == NULL) {
        ngx_log_error(NGX_LOG_ALERT, log, 0,
                      "%ui worker_connections are not enough",
                      ngx_cycle->connection_n);

        return NULL;
    }

    ngx_cycle->free_connections = c->data;
    ngx_cycle->free_connection_n--;

    /* ... 연결 구조체를 0 으로 지우고 read/write/fd/log 를 다시 채운다 ... */

    instance = rev->instance;

    ngx_memzero(rev, sizeof(ngx_event_t));
    ngx_memzero(wev, sizeof(ngx_event_t));

    rev->instance = !instance;
    wev->instance = !instance;

src/core/ngx_connection.c 1225–1258행

이벤트를 0으로 지우면서 instance 비트만은 이전 값을 뒤집어 남깁니다. 왜 그런지는 4절에서 봅니다. 돌려주는 ngx_free_connection() 의 핵심은 세 줄입니다 — c->data = free_connections; free_connections = c; free_connection_n++;. 그 뒤에 poll·devpoll 처럼 fd 표를 쓰는 방식에서만 할당되는 cycle->files 를 비우는 줄이 붙습니다(1273–1282행).

칸을 빌리는 곳은 이 빌드에서 여섯입니다. 듣기 소켓(ngx_event_process_init), 마스터와의 채널(ngx_add_channel_event, 6편), 새 클라이언트(ngx_event_accept), upstream 으로 나가는 연결(ngx_event_connect_peer, 11편), UDP(ngx_event_recvmsg), 그리고 resolver 를 설정했을 때 DNS 서버로 나가는 연결(src/core/ngx_resolver.c)입니다(ngx_get_connection 을 grep 해서 확인 — QUIC 쪽에도 있지만 이 빌드에서는 켜지 않았습니다). 그러니 worker_connections 는 "클라이언트 수"가 아니라 워커 하나가 동시에 쥘 수 있는 연결 칸 전부입니다. 리버스 프록시는 요청 하나에 칸 둘(클라이언트 + upstream)을 씁니다.

칸이 모자랄 때: 노는 연결부터 닫는다

ngx_get_connection() 은 맨 처음에 ngx_drain_connections() 를 부릅니다(1223행).

    if (cycle->free_connection_n > cycle->connection_n / 16
| cycle->reusable_connections_n == 0)
    {
        return;
    }

    /* ... 1초에 한 번 "... worker_connections are not enough, reusing connections" 경고 ... */

    c = NULL;
    n = ngx_max(ngx_min(32, cycle->reusable_connections_n / 8), 1);

    for (i = 0; i < n; i++) {
        /* ... */

        q = ngx_queue_last(&cycle->reusable_connections_queue);
        c = ngx_queue_data(q, ngx_connection_t, queue);

        /* ... */

        c->close = 1;
        c->read->handler(c->read);
    }

src/core/ngx_connection.c 1411–1442행

빈 칸이 전체의 1/16 이하로 줄면, "아직 요청을 안 보낸 연결"이나 "keep-alive 로 다음 요청을 기다리는 연결"처럼 ngx_reusable_connection(c, 1) 로 표시된 연결을 몇 개 골라 c->close = 1 을 세우고 그 읽기 핸들러를 부릅니다. 핸들러가 close 를 보고 연결을 닫으면 칸이 생깁니다. 새 연결은 목록 앞에 넣고(1393–1394행) 여기서는 뒤에서 꺼내므로, 가장 오래 논 연결부터 닫힙니다.

예제 1: worker_connections 6 에서 칸이 바닥나면

이 예제는 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9 로 도커 컨테이너(Debian bookworm, --with-debug)에서 실행했습니다. 예제 폴더는 ex/u07-connections 입니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# worker_connections 를 6 으로 아주 작게 두고, 연결 자리가 바닥났을 때 무슨 일이 생기는지 본다
worker_processes 1;
error_log /tmp/u07/error.log warn;
pid /tmp/u07/nginx.pid;

events {
    worker_connections 6;
}

http {
    access_log off;

    server {
        listen 8080;
        location / {
            return 200 "ok\n";
        }
        location /slow {
            proxy_pass http://127.0.0.1:9000;   # 4초 걸리는 백엔드
        }
    }
}

run.sh 는 4초 뒤에 답하는 작은 python3 백엔드(slow.py)를 띄우고 두 가지를 합니다. ① bash 의 /dev/tcp 로 연결 4개를 1→4 순서로 열기만 하고(요청은 보내지 않음) 새 연결로 curl, ② /slow 요청 2개를 띄워 둔 채 새 연결로 curl. 중간중간 gdb 로 워커에 잠깐 붙어 ngx_cycle->connection_n, free_connection_n, reusable_connections_n 을 찍습니다.

=== idle worker: connection_n, free_connection_n, reusable_connections_n ===
$1 = 6
$2 = 4
$3 = 0
=== 1) four idle connections (opened in order 1..4), then a new one ===
$1 = 6
$2 = 0
$3 = 4
new connection: HTTP 200
$1 = 6
$2 = 1
$3 = 3
idle1: closed by server
idle2: still open
idle3: still open
idle4: still open
=== 2) two requests waiting for the slow backend, then a new one ===
$1 = 6
$2 = 0
$3 = 0
new connection: HTTP 000
curl exit code: 56
slow2: HTTP 200
slow1: HTTP 200
=== error.log ===
[warn] 13#13: 6 worker_connections are not enough, reusing connections
[alert] 13#13: 6 worker_connections are not enough
  1. 쉬는 워커인데 빈 칸이 6이 아니라 4입니다. 듣기 소켓과 마스터와의 채널이 칸을 하나씩 차지합니다.
  2. 노는 연결 4개로 빈 칸이 0이 되었고, 넷 모두 reusable 입니다. 요청을 기다리는 연결은 ngx_http_init_connection() 이 ngx_reusable_connection(c, 1) 로 표시합니다(src/http/ngx_http_request.c 367행).
  3. 새 연결은 200 을 받았고, 대신 가장 먼저 연 idle1 이 닫혔습니다. ngx_drain_connections() 가 warn 을 남기고(1420–1423행) 목록 맨 뒤를 닫았습니다. 새 연결의 curl 이 끝나 칸을 돌려준 뒤라 빈 칸이 1입니다.
  4. 백엔드를 기다리는 요청 둘은 칸 넷(클라이언트 2 + upstream 2)을 쓰고 reusable 이 아닙니다. 닫을 연결이 없으니 ngx_get_connection() 이 alert 를 남기고 NULL 을 돌려줍니다. ngx_event_accept() 는 이미 accept4() 로 받은 소켓을 그냥 닫으므로(src/event/ngx_event_accept.c 144–151행) curl 은 응답 없이 끊깁니다. curl 의 종료 코드는 실행마다 56(받다가 끊김)이거나 52(빈 응답)였습니다.
  5. 기다리던 두 요청은 4초 뒤 정상으로 200 을 받았습니다. pid(13)는 실행마다 다릅니다.

헷갈리기 쉬운 곳

4. epoll 에 등록하고 깨어나기 — EPOLLET 와 instance 비트

등록: 연결 주소에 1비트를 더해 커널에 맡긴다

    if (e->active) {
        op = EPOLL_CTL_MOD;
        events |= prev;

    } else {
        op = EPOLL_CTL_ADD;
    }

#if (NGX_HAVE_EPOLLEXCLUSIVE && NGX_HAVE_EPOLLRDHUP)
    if (flags & NGX_EXCLUSIVE_EVENT) {
        events &= ~EPOLLRDHUP;
    }
#endif

    ee.events = events | (uint32_t) flags;
    ee.data.ptr = (void *) ((uintptr_t) c | ev->instance);

src/event/modules/ngx_epoll_module.c 606–621행

epoll 은 fd 마다 등록이 하나뿐이라, 읽기 이벤트를 넣을 때 쓰기 이벤트가 이미 등록되어 있으면(e->active) EPOLL_CTL_MOD 로 둘을 합칩니다. 그리고 커널에 맡기는 사용자 데이터 data.ptr 에 연결 구조체의 주소와 이벤트의 instance 비트를 OR 한 값을 넣습니다. 구조체 주소는 정렬되어 있어 맨 아래 비트가 늘 0이므로, 그 자리를 1비트 저장소로 쓰는 것입니다.

flags 가 트리거 방식을 정합니다. epoll 빌드에서 NGX_CLEAR_EVENT 는 EPOLLET 입니다(src/event/ngx_event.h 331행).

엣지 트리거에서는 알림이 한 번뿐이므로 받은 쪽이 소켓을 다 비울 때까지 읽어야 합니다(NGX_USE_GREEDY_EVENT). 매번 EAGAIN 이 나올 때까지 recv() 를 한 번 더 부르면 시스템 콜이 늘어나므로, nginx 는 EPOLLRDHUP 를 쓸 수 있는 커널에서 요청한 것보다 적게 읽혔으면 다 비운 것으로 보고 ready 를 내립니다.

            if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
                && ngx_use_epoll_rdhup)
            {
                if ((size_t) n < size) {
                    if (!rev->pending_eof) {
                        rev->ready = 0;
                    }

                    rev->available = 0;
                }

                return n;
            }

src/os/unix/ngx_recv.c 157–169행

5절 예제 2의 strace 에서 recvfrom(3, …, 1024, …) = 27 다음에 EAGAIN 을 확인하는 두 번째 recvfrom 이 없는 것이 이 때문입니다.

깨어남: ngx_epoll_process_events()

epoll_wait(ep, event_list, (int) nevents, timer) 가 돌아오면 가장 먼저 ngx_time_update() 로 시각 캐시를 고칩니다(timer_resolution 을 쓰면 SIGALRM 이 왔을 때만 — src/event/modules/ngx_epoll_module.c 800–806행). nginx 는 시각이 필요할 때마다 시계를 읽지 않고, 이 함수가 바퀴마다 한 번 채우는 ngx_current_msec(밀리초, CLOCK_MONOTONIC 기준)와 로그·HTTP 헤더용 시각 문자열을 씁니다(src/core/ngx_times.c 94–107행). 한 바퀴 안의 핸들러는 모두 같은 "지금"을 보고, 핸들러가 오래 걸리면 그 "지금"이 실제보다 늦어집니다.

epoll_wait 가 EINTR(시그널에 끊김)로 실패하면 info 수준으로 epoll_wait() failed (4: Interrupted system call) 를 남기고 돌아갑니다(808–824행). 예제를 다듬는 동안 워커에 gdb 를 붙였을 때 이 줄이 찍혔습니다. 다음이 이벤트마다 도는 반복입니다(쓰기 쪽은 줄였습니다).

    for (i = 0; i < events; i++) {
        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) {

            /*
             * the stale event from a file descriptor
             * that was just closed in this iteration
             */

            ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
                           "epoll: stale event %p", c);
            continue;
        }

        revents = event_list[i].events;

        /* ... 디버그 로그, EPOLLERR|EPOLLHUP 이면 읽기·쓰기 둘 다 깨운다 ... */

        if ((revents & EPOLLIN) && rev->active) {

            /* ... EPOLLRDHUP 이면 pending_eof = 1 ... */

            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);
            }
        }

        /* ... 쓰기 이벤트(EPOLLOUT)도 같은 모양: wev->ready = 1, wev->handler(wev) ... */
    }

src/event/modules/ngx_epoll_module.c 836–933행

instance 비트: 같은 바퀴에서 닫히고 다시 쓰인 칸

c->fd == -1 검사만으로는 왜 모자랄까요? epoll_wait 는 이벤트 여러 개를 배열 하나로 한꺼번에 돌려주고, 워커는 그것을 앞에서부터 차례로 처리합니다. 그 사이에 칸의 주인이 바뀔 수 있습니다.

낡은 이벤트가 생기는 순서. 처음에 연결 칸 c[7]에는 fd 12의 연결 A(예를 들어 어떤 요청의 upstream 연결)가 있고 instance는 0이다. epoll_wait 결과의 0번은 클라이언트 연결 X가 끊겼다는 이벤트로, X의 핸들러가 요청을 끝내며 A도 닫는다. A의 fd는 -1이 되고 칸은 빈 목록 맨 앞으로 간다. 1번은 듣기 소켓의 새 손님으로, ngx_event_accept가 ngx_get_connection으로 같은 칸 c[7]을 다시 받는다. 이제 그 칸은 fd 15의 새 연결 B이고 instance는 1로 뒤집혔다. 2번은 A의 칸 주소에 비트 0이 붙은, A를 닫기 전에 커널이 담아 둔 이벤트다. c->fd는 15라 -1 검사로는 거를 수 없지만 rev->instance 1과 비트 0이 달라 stale event로 건너뛴다. 빈 목록은 맨 앞에서 빌려 주므로 방금 닫힌 칸이 바로 다음 손님에게 갑니다. 커널도 가장 작은 빈 fd 번호를 다시 주므로 fd 숫자 비교로도 가릴 수 없습니다.

ngx_get_connection() 이 칸을 빌려 줄 때마다 instance 를 뒤집으므로, 칸이 다시 쓰였으면 커널이 들고 있던 옛 비트와 지금 비트가 다릅니다. 칸이 닫히고 아직 다시 쓰이지 않았다면 c->fd == -1 이 거릅니다. 소스의 주석은 "the stale event from a file descriptor that was just closed in this iteration" 한 줄뿐이고, 위 그림의 시나리오는 이 코드에서 읽어 낸 것입니다(실제로 일으켜 보지는 않았습니다).

5. 타이머 — 레드블랙 트리가 epoll_wait 의 타임아웃이 된다

client_header_timeout, client_body_timeout, send_timeout, keepalive_timeout, proxy_read_timeout … 많은 타임아웃 디렉티브가 모두 같은 장치, 이벤트에 타이머를 거는 것으로 구현됩니다. 연결의 읽기 이벤트에 client_header_timeout 만큼의 타이머를 걸어 두고, 그 전에 데이터가 오면 타이머를 지우고, 오지 않으면 같은 핸들러가 timedout = 1 로 불립니다. 핸들러는 처음에 if (rev->timedout) 를 보고 연결을 닫습니다.

걸기: 300ms 안쪽의 변경은 무시한다

    key = ngx_current_msec + timer;

    if (ev->timer_set) {

        /*
         * Use a previous timer value if difference between it and a new
         * value is less than NGX_TIMER_LAZY_DELAY milliseconds: this allows
         * to minimize the rbtree operations for fast connections.
         */

        diff = (ngx_msec_int_t) (key - ev->timer.key);

        if (ngx_abs(diff) < NGX_TIMER_LAZY_DELAY) {
            /* ... 디버그 로그 ... */
            return;
        }

        ngx_del_timer(ev);
    }

    ev->timer.key = key;

    /* ... "event timer add: ..." 디버그 로그 ... */

    ngx_rbtree_insert(&ngx_event_timer_rbtree, &ev->timer);

    ev->timer_set = 1;

src/event/ngx_event_timer.h 56–86행

키는 "만료 시각"(ngx_current_msec + timer)이고, 이벤트 안에 박힌 노드 ev->timer 를 트리에 넣습니다. 이미 걸린 타이머를 고칠 때 차이가 NGX_TIMER_LAZY_DELAY(300ms, 19행) 미만이면 트리를 건드리지 않습니다. 데이터를 조금씩 주고받는 빠른 연결은 매번 타이머를 다시 거는데, 그때마다 트리에서 빼고 넣는 비용을 아끼는 것입니다. 타임아웃이 최대 300ms 어긋날 수 있다는 뜻이기도 합니다.

트리에 넣는 ngx_rbtree_insert_timer_value() 는 키를 (ngx_rbtree_key_int_t) (node->key - temp->key) < 0 처럼 빼서 부호로 비교합니다. 32비트 밀리초는 49일마다 0으로 넘어가는데, 타이머들이 서로 몇 분 안쪽에 몰려 있으므로 뺄셈 비교는 넘어감을 견딥니다(src/core/ngx_rbtree.c 129–139행).

가장 가까운 것 찾기, 다 된 것 터뜨리기

    if (ngx_event_timer_rbtree.root == &ngx_event_timer_sentinel) {
        return NGX_TIMER_INFINITE;
    }

    root = ngx_event_timer_rbtree.root;
    sentinel = ngx_event_timer_rbtree.sentinel;

    node = ngx_rbtree_min(root, sentinel);

    timer = (ngx_msec_int_t) (node->key - ngx_current_msec);

    return (ngx_msec_t) (timer > 0 ? timer : 0);

src/event/ngx_event_timer.c 38–49행

ngx_event_find_timer() 는 트리가 비면 NGX_TIMER_INFINITE((ngx_msec_t) -1, ngx_event_timer.h 17행)를 돌려주고, 이것이 그대로 epoll_wait 의 마지막 인자 -1(무한정 기다림)이 됩니다. 아니면 가장 왼쪽 노드(최솟값)까지 남은 시간입니다.

    for ( ;; ) {
        root = ngx_event_timer_rbtree.root;

        if (root == sentinel) {
            return;
        }

        node = ngx_rbtree_min(root, sentinel);

        /* node->key > ngx_current_msec */

        if ((ngx_msec_int_t) (node->key - ngx_current_msec) > 0) {
            return;
        }

        ev = ngx_rbtree_data(node, ngx_event_t, timer);

        /* ... "event timer del: ..." 디버그 로그 ... */

        ngx_rbtree_delete(&ngx_event_timer_rbtree, &ev->timer);

        /* ... */

        ev->timer_set = 0;

        ev->timedout = 1;

        ev->handler(ev);
    }

src/event/ngx_event_timer.c 61–95행

ngx_event_expire_timers() 는 최솟값이 아직 미래이면 멈추고, 아니면 빼고 timedout = 1 로 핸들러를 부르기를 반복합니다. 타이머용 핸들러가 따로 있지 않고 같은 이벤트 핸들러가 불리며, 그 함수는 timedout 으로 "데이터가 와서"인지 "시간이 다 돼서"인지를 가립니다.

이 단계가 1절에서 epoll 이벤트 처리 뒤에 있는 데에도 뜻이 있습니다. 타임아웃 직전에 데이터가 도착했다면 데이터 핸들러가 먼저 돌며 타이머를 지우거나 다시 걸 기회를 얻습니다.

예제 2: epoll_wait 의 타임아웃이 타이머를 따라 바뀐다

1편에서 기본값(-1 → 60000 → 75000)으로 본 것을, 이번에는 눈에 띄는 값을 주고 타이머 트리와 함께 봅니다. 예제 폴더는 ex/u07-epoll-timers 입니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# 워커 하나, 디버그 로그. 타이머 두 개(요청 헤더 기다리기 7초, keep-alive 3초)를 눈에 띄는 값으로 둔다
worker_processes 1;
error_log /tmp/u07/error.log debug;
pid /tmp/u07/nginx.pid;

events {
    worker_connections 64;
}

http {
    access_log off;
    client_header_timeout 7s;   # 연결 뒤 요청 헤더가 다 올 때까지 기다리는 시간
    keepalive_timeout 3s;       # 응답 뒤 다음 요청을 기다리는 시간

    server {
        listen 8080;
        location / {
            return 200 "hello\n";
        }
    }
}

쉬고 있는 워커에 strace -tt -p <워커 pid> -e trace=epoll_wait,epoll_ctl,accept4,recvfrom,writev,close 를 붙인 뒤, nc 로 접속만 하고 1초 뒤에 요청을 보내고 5초 더 연결을 열어 둡니다.

(sleep 1; printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\n'; sleep 5) | nc 127.0.0.1 8080
=== strace (worker 11) ===
03:22:08.624393 epoll_wait(7, [{events=EPOLLIN, data={u32=3852001504, u64=94506017415392}}], 512, -1) = 1
03:22:10.039805 accept4(5, {sa_family=AF_INET, sin_port=htons(47908), sin_addr=inet_addr("127.0.0.1")}, [112 => 16], SOCK_NONBLOCK) = 3
03:22:10.066303 epoll_ctl(7, EPOLL_CTL_ADD, 3, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data={u32=3852001985, u64=94506017415873}}) = 0
03:22:10.067509 epoll_wait(7, [{events=EPOLLIN, data={u32=3852001985, u64=94506017415873}}], 512, 7000) = 1
03:22:10.802872 recvfrom(3, "GET / HTTP/1.1\r\nHost: x\r\n\r\n", 1024, 0, NULL, NULL) = 27
03:22:10.810558 writev(3, [{iov_base="HTTP/1.1 200 OK\r\nServer: nginx/1"..., iov_len=147}, {iov_base="hello\n", iov_len=6}], 2) = 153
03:22:10.815907 epoll_wait(7, [], 512, 3000) = 0
03:22:13.820774 close(3)                = 0
03:22:13.822107 epoll_wait(7,  <detached ...>

같은 실행의 디버그 로그에서 타이머에 관한 줄만 뽑으면 이렇습니다(날짜·시각 열은 지웠습니다).

[debug] 11#11: timer delta: 4182
[debug] 11#11: epoll timer: -1
[debug] 11#11: accept on 0.0.0.0:8080, ready: 0
[debug] 11#11: *2 accept: 127.0.0.1:47908 fd:3
[debug] 11#11: *2 event timer add: 3: 7000:4051177
[debug] 11#11: timer delta: 1416
[debug] 11#11: epoll timer: 7000
[debug] 11#11: *2 event timer del: 3: 4051177
[debug] 11#11: *2 set http keepalive handler
[debug] 11#11: *2 event timer add: 3: 3000:4047939
[debug] 11#11: timer delta: 762
[debug] 11#11: epoll timer: 3000
[debug] 11#11: timer delta: 3019
[debug] 11#11: *2 event timer del: 3: 4047939
[debug] 11#11: *2 http keepalive handler
[debug] 11#11: *2 close http connection: 3
[debug] 11#11: epoll timer: -1
[debug] 11#11: timer delta: 2488
[debug] 11#11: epoll timer: -1
  1. 쉬는 워커는 -1 로 잡니다. 타이머가 하나도 없습니다(epoll timer: -1 을 찍는 곳은 epoll 모듈 797–798행). 첫 epoll_wait 가 오래 걸린 것은 strace 를 붙인 뒤 접속까지 기다린 시간입니다.
  2. 듣기 소켓(fd 5)이 깨어나 accept4(…, SOCK_NONBLOCK) 로 받습니다. 받는 순간 논블로킹입니다.
  3. HTTP 가 타이머를 겁니다 — event timer add: 3: 7000:4051177(fd 3, 7000ms, 만료 시각 키). client_header_timeout 입니다(src/http/ngx_http_request.c 366행). 이어서 epoll_ctl(… EPOLLIN|EPOLLRDHUP|EPOLLET …) — 4절의 엣지 트리거 등록입니다.
  4. 다음 epoll_wait 의 타임아웃이 7000 입니다. 트리의 최솟값이 방금 건 타이머이기 때문입니다.
  5. 1초 뒤 요청이 오자 읽고(recvfrom 한 번, 27 < 1024 라 더 읽지 않음), writev 로 응답하고, 헤더 타이머를 지우고(event timer del) keep-alive 타이머 3000ms 를 겁니다(src/http/ngx_http_request.c 3571행). 그래서 다음 epoll_wait 는 3000 입니다.
  6. 3초 동안 아무 일도 없어 epoll_wait 가 = 0 으로 돌아오고, 같은 바퀴의 ngx_event_expire_timers() 가 타이머를 빼고 keep-alive 핸들러를 timedout = 1 로 부릅니다. 핸들러가 연결을 닫습니다(close(3)). timer delta: 3019 가 그 3초입니다.
  7. 트리가 다시 비어 -1 로 돌아갑니다.

예제 2를 시간 순서로 그린 그림. 처음 타이머 트리는 비어 있어 쉬는 워커는 epoll_wait(7, …, -1) 로 잔다. 접속이 오면 accept4(5, …) = 3 으로 받고, HTTP 가 client_header_timeout 타이머 event timer add: 3: 7000 을 건다. 그래서 다음 epoll_wait 의 타임아웃은 7000 이다. 27바이트 요청이 오면 recvfrom 과 writev 로 읽고 답한 뒤, 헤더 타이머를 지우고(event timer del) keepalive_timeout 타이머 event timer add: 3: 3000 을 건다. 다음 epoll_wait(…, 3000) 은 3초 동안 아무 일이 없어 0 을 돌려주고(timer delta: 3019), ngx_event_expire_timers() 가 timedout = 1 로 keep-alive 핸들러를 불러 close(3) 한다. 트리가 다시 비어 epoll_wait(…, -1) 로 돌아간다. epoll_wait 의 마지막 인자가 트리의 가장 가까운 타이머를 따라갑니다. 값은 위 strace 와 디버그 로그의 실제 출력입니다.

data.u64 에는 4절의 이야기가 보입니다. 듣기 소켓의 값 94506017415392 는 짝수(instance 0), 새 연결의 값 94506017415873 은 홀수(instance 1)입니다. 같은 실행 끝에 gdb 로 워커를 들여다봤습니다.

=== gdb: where do the epoll data pointers point? ===
$1 = 240
$2 = (ngx_connection_t *) 0x55f3e598e0e0
$3 = 5
$4 = 1

sizeof(ngx_connection_t) 는 240, 연결 배열은 0x55f3e598e0e0(= 94506017415392)에서 시작하고, connections[0].fd 는 5 — 듣기 소켓은 0번 칸입니다. 새 연결의 값에서 1을 빼면 시작 + 480 = 2번 칸이고(1번 칸은 마스터와의 채널), connections[2].read->instance 는 1입니다. 2번 칸은 기동 확인용 curl 이 먼저 썼다가 돌려준 칸이라(그때 1 → 0), 이번에 다시 빌려 주며 0 → 1 로 뒤집혔습니다. 주소·시각·포트·pid 는 실행마다 다릅니다.

헷갈리기 쉬운 곳

6. posted 이벤트 — 지금 말고, 이번 바퀴 끝에

핸들러 안에서 다른 이벤트의 핸들러를 곧바로 부르면 콜 스택이 깊어지고, 부르는 쪽이 아직 정리 중인 상태를 불린 쪽이 건드릴 수 있습니다. 그래서 nginx 는 "이따가 불러 달라"를 큐로 표현합니다.

#define ngx_post_event(ev, q)                                                 \
                                                                              \
    if (!(ev)->posted) {                                                      \
        (ev)->posted = 1;                                                     \
        ngx_queue_insert_tail(q, &(ev)->queue);                               \
                                                                              \
        ngx_log_debug1(NGX_LOG_DEBUG_CORE, (ev)->log, 0, "post event %p", ev);\
                                                                              \
    } else  {                                                                 \
        ngx_log_debug1(NGX_LOG_DEBUG_CORE, (ev)->log, 0,                      \
                       "update posted event %p", ev);                         \
    }

src/event/ngx_event_posted.h 17–28행

이미 들어 있으면(posted) 두 번 넣지 않습니다. 큐를 비우는 ngx_event_process_posted() 는 while (!ngx_queue_empty(posted)) 로 앞에서부터 꺼내 ngx_delete_posted_event(ev); ev->handler(ev); 를 할 뿐입니다(src/event/ngx_event_posted.c 18–36행). 처리 중에 같은 큐에 새로 들어온 것도 이번 호출 안에서 처리됩니다. 큐는 셋입니다.

큐 누가 넣나 언제 처리되나
ngx_posted_accept_events accept 뮤텍스를 쥔 바퀴의 ngx_epoll_process_events() (듣기 소켓 이벤트) epoll_wait 직후, 뮤텍스를 풀기 전 (1절 5번)
ngx_posted_events 위와 같은 바퀴의 나머지 이벤트, 그리고 "이미 읽을 것이 있는" 연결을 HTTP·upstream 코드가 직접 타이머 처리 뒤, 바퀴 맨 끝 (1절 8번)
ngx_posted_next_events 일부러 한 바퀴 쉬고 싶을 때 — 보낼 것이 남은 write 필터, auth_delay 다음 바퀴. 있으면 epoll_wait 를 timer = 0 으로 불러 잠들지 않는다 (1절 3번)

세 posted 큐가 한 바퀴의 어디서 채워지고 비워지는지. write 필터가 보낼 것이 남았는데 이번엔 그만 보낼 때 ngx_post_event(c->write, &ngx_posted_next_events) 로 ngx_posted_next_events 에 넣는다. 다음 바퀴가 시작되면 이 큐가 비어 있지 않을 때 ngx_event_move_posted_next() 가 ready = 1 을 다시 세워 ngx_posted_events 로 옮기고 timer = 0 으로 둔다. 그다음 ngx_process_events() 가 epoll_wait(…, timer) 를 부른다. 이 바퀴에 accept 뮤텍스를 쥐지 않았으면(flags 에 NGX_POST_EVENTS 가 없으면) 핸들러를 그 자리에서 바로 부르고, 쥐었으면 듣기 소켓 이벤트는 ngx_posted_accept_events 에, 나머지 이벤트는 ngx_posted_events 에 넣는다. 이어서 ngx_event_process_posted(ngx_posted_accept_events), 쥐었으면 accept 뮤텍스 풀기, ngx_event_expire_timers() 를 차례로 하고, 바퀴 맨 끝에서 ngx_event_process_posted(ngx_posted_events) 를 부른다. HTTP·upstream 코드도 rev->ready 면 ngx_post_event(rev, &ngx_posted_events) 로 이 큐에 넣는다. 위 표의 세 큐를 1절의 한 바퀴 위에 놓은 것. 순서는 ngx_event.c 의 ngx_process_events_and_timers(), 큐를 고르는 곳은 ngx_epoll_module.c 894–931행입니다.

ngx_posted_next_events 는 공정성을 위한 장치입니다. 큰 응답 하나를 한 바퀴에 끝까지 보내면 그동안 다른 연결이 기다려야 하므로, write 필터는 보낼 것이 남았는데 이번엔 그만 보낼 때 쓰기 이벤트를 이 큐에 넣습니다.

    if (chain && c->write->ready && !c->write->delayed) {
        ngx_post_event(c->write, &ngx_posted_next_events);
    }

src/http/ngx_http_write_filter_module.c 334–336행

다음 바퀴가 시작되면 ngx_event_move_posted_next() 가 이 큐의 이벤트에 ready = 1 을 다시 세우고 통째로 ngx_posted_events 로 옮깁니다(src/event/ngx_event_posted.c 39–60행). 그 바퀴의 epoll_wait(…, 0) 이 다른 연결의 이벤트를 먼저 거둬 처리하고, 맨 끝에서 남은 응답을 이어 보냅니다. 응답을 언제 얼마나 보내는지는 10편에서 봅니다.

헷갈리기 쉬운 곳: 엣지 트리거와 posted 큐

엣지 트리거는 "읽을 수 있게 바뀌었을 때 한 번"만 알려 주므로, 데이터가 이미 와 있는 상태에서 핸들러를 바꾸면 epoll 은 다시 알려 주지 않습니다. 그래서 HTTP 코드 곳곳에 if (rev->ready) { ngx_post_event(rev, &ngx_posted_events); } 가 있습니다 — 예를 들어 keep-alive 로 넘어간 직후 다음 요청이 이미 와 있을 때입니다(src/http/ngx_http_request.c 3573–3575행). epoll 이 다시 알려 주지 않을 일을 nginx 가 스스로 큐에 넣는 것입니다.

7. accept — 워커가 여럿일 때 새 연결은 누가 받나

accept 핸들러

듣기 소켓의 읽기 이벤트 핸들러는 ngx_event_accept() 입니다.

    if (!(ngx_event_flags & NGX_USE_KQUEUE_EVENT)) {
        ev->available = ecf->multi_accept;
    }

    /* ... */

    do {
        socklen = sizeof(ngx_sockaddr_t);

#if (NGX_HAVE_ACCEPT4)
        if (use_accept4) {
            s = accept4(lc->fd, &sa.sockaddr, &socklen, SOCK_NONBLOCK);
        } else {
            s = accept(lc->fd, &sa.sockaddr, &socklen);
        }
#else
        s = accept(lc->fd, &sa.sockaddr, &socklen);
#endif

        /* ... 실패 처리: EAGAIN 이면 return, EMFILE 이면 accept 를 잠시 끈다 ... */

        ngx_accept_disabled = ngx_cycle->connection_n / 8
                              - ngx_cycle->free_connection_n;

        c = ngx_get_connection(s, ev->log);

        /* ... 연결 풀 만들기, 주소 복사, recv/send 함수 꽂기, 연결 번호 ... */

        ls->handler(c);

        /* ... */

    } while (ev->available);

src/event/ngx_event_accept.c 47–318행

epoll 빌드에서는 받은 연결을 여기서 epoll 에 넣지 않습니다. ngx_add_conn 은 NGX_USE_EPOLL_EVENT 가 아닐 때만 부르고(302–307행), epoll 등록은 HTTP 가 읽기 이벤트를 필요로 할 때 합니다(4절). 예제 2의 strace 에서 accept4 다음 epoll_ctl 이 하나뿐이었던 것이 그 결과입니다.

같은 듣기 소켓, 워커 여럿 — 세 갈래

listen 8080; 의 소켓은 마스터가 열고 워커들은 fork() 로 같은 소켓을 물려받습니다(6편). 손님 하나에 모든 워커를 깨우면 하나만 accept4() 에 성공하고 나머지는 EAGAIN 으로 헛걸음합니다(흔히 "thundering herd" 라 부릅니다). 이 판의 ngx_event_process_init() 에서 듣기 소켓을 epoll 에 넣는 부분은 세 갈래입니다.

#if (NGX_HAVE_REUSEPORT)

        if (ls[i].reuseport) {
            if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
                return NGX_ERROR;
            }

            continue;
        }

#endif

        if (ngx_use_accept_mutex) {
            continue;
        }

#if (NGX_HAVE_EPOLLEXCLUSIVE)

        if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
            && ccf->worker_processes > 1)
        {
            ngx_use_exclusive_accept = 1;

            if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
                == NGX_ERROR)
            {
                return NGX_ERROR;
            }

            continue;
        }

#endif

        if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
            return NGX_ERROR;
        }

src/event/ngx_event.c 875–911행

새 연결을 누가 받나, 세 갈래. 마스터가 연 listen 8080 소켓을 fork로 모든 워커가 물려받는다. (a) 기본값 accept_mutex off에서는 워커마다 자기 epoll에 같은 소켓을 EPOLLEXCLUSIVE로 등록하고, 커널은 기다리는 워커 가운데 하나나 몇 개만 깨운다. accept 핸들러가 16번 불릴 때마다 지웠다 다시 등록한다. (b) accept_mutex on에서는 바퀴마다 공유 메모리의 잠금 ngx_trylock_accept_mutex를 시도해, 잡은 워커만 듣기 소켓을 epoll에 넣고 accept는 posted accept 큐로 미뤘다 처리한다. (c) listen 8080 reuseport에서는 마스터가 워커 수만큼 소켓을 따로 열고(SO_REUSEPORT), 커널이 연결마다 소켓 하나를 고르며 워커는 자기 소켓만 epoll에 넣는다. 노란 갈래는 공유 메모리의 잠금을 쓰는 옛 방식, 파란 두 갈래는 커널에 맡기는 방식입니다.

(a) 기본값 — accept_mutex off, epoll, 워커 2개 이상이면 듣기 소켓을 EPOLLEXCLUSIVE 로 등록합니다. 같은 fd 를 지켜보는 epoll 인스턴스 여럿 가운데 일부만 깨우라는 리눅스 4.5 의 플래그입니다(이 설명은 리눅스 문서 기준이고 커널 소스로 확인한 것은 아닙니다). 커밋 5c2dd3913(2016-07-15, 1.11.3 부터)으로, accept_mutex 기본값을 끈 바로 그날 들어왔습니다. 그런데 커널이 대개 처음 등록한 epoll 을 깨워 연결이 한 워커에 몰리는 문제가 있어, 커밋 96c342e56(2021-12-30, "Events: fixed balancing between workers with EPOLLEXCLUSIVE.", 1.21.6 부터)이 ngx_reorder_accept_events() 를 더했습니다. accept 핸들러 끝에서 불리는 이 함수는 듣기 소켓 칸의 requests 필드를 카운터로 빌려 씁니다.

    if (c->requests++ % 16 != 0
        && ngx_accept_disabled <= 0)
    {
        return;
    }

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

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

src/event/ngx_event_accept.c 458–474행

16번에 한 번(또는 이 워커가 꽤 찼으면 매번) 듣기 소켓을 epoll 에서 뺐다가 다시 넣어 줄의 맨 뒤로 갑니다.

(b) accept_mutex on 이면 듣기 소켓을 처음에 epoll 에 넣지 않습니다(위의 continue). 대신 매 바퀴 1절의 2번에서 ngx_trylock_accept_mutex() 가 공유 메모리의 잠금을 시도합니다(src/event/ngx_event_accept.c 326–361행). 잠금은 원자적 비교-교환 한 번입니다 — *mtx->lock == 0 && ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid)(src/core/ngx_shmtx.c 65행). 잡은 워커는 ngx_enable_accept_events() 로 듣기 소켓을 epoll 에 넣고, 놓친 워커는 쥐고 있었다면 ngx_disable_accept_events() 로 빼고 accept_mutex_delay(500ms) 안에 다시 시도합니다. 잡은 바퀴는 NGX_POST_EVENTS 로 모든 이벤트를 큐로 미루고, epoll_wait 직후 accept 큐만 먼저 처리한 뒤 곧바로 잠금을 풉니다(1절 5·6번). 잠금을 쥔 채 일반 요청까지 처리하면 그동안 다른 워커가 새 연결을 받지 못하기 때문입니다. ngx_accept_disabled > 0 인 워커는 잠금 시도를 몇 바퀴 쉬어(1절 코드의 ngx_accept_disabled--) 덜 찬 워커에게 양보합니다. 이 갈래는 master_process on 이고 워커가 둘 이상일 때만 켜집니다(src/event/ngx_event.c 645–652행).

(c) listen 8080 reuseport 이면 설정을 읽을 때 ngx_clone_listening() 이 듣기 소켓 정의를 워커 수만큼 복제하고 각각에 worker 번호를 붙입니다(src/core/ngx_connection.c 99–131행). 각 소켓은 SO_REUSEPORT 로 같은 포트에 묶이고, 워커는 ls[i].worker != ngx_worker 인 소켓을 건너뛰어(804–806행) 자기 몫만 epoll 에 넣습니다. 어느 소켓으로 보낼지는 커널이 연결마다 고릅니다. 잠금도 재등록도 없습니다.

예제 3: 워커 2개, 연결 300개 — 누가 받았나

예제 폴더는 ex/u07-accept 입니다. 같은 설정 틀(nginx.conf.in)의 @ACCEPT_MUTEX@·@LISTEN@ 을 바꿔 세 가지를 차례로 띄우고, 매번 curl 300번(요청마다 새 연결, 동시에 10개씩)을 보낸 뒤 access 로그에 남긴 $pid 로 워커별 개수를 셉니다. 설정의 핵심은 이 몇 줄입니다.

worker_processes 2;
error_log /tmp/u07/error.log @LOGLEVEL@;

events {
    worker_connections 64;
    accept_mutex @ACCEPT_MUTEX@;
}

http {
    # 어느 워커가 받았는지 남긴다 ($pid = 요청을 처리한 워커의 pid)
    log_format w '$pid';
    access_log /tmp/u07/access.log w;

    server {
        listen @LISTEN@;

(파일 첫 줄의 "이 글을 위해 쓴 예제" 주석과 pid, location 은 줄였습니다.) 디버그 로그(@LOGLEVEL@ = debug)의 epoll add event 줄 가운데 EPOLLET(80000000)가 없는 것 — 듣기 소켓과 채널의 등록 — 을 워커·fd 마다 첫 줄만 뽑고, 그 fd 가 무엇인지 /proc/<pid>/fd 와 /proc/net/tcp 로 알아내 옆에 적었습니다. 이 줄을 찍는 곳은 epoll 모듈 623–625행입니다.

=== (a) default: accept_mutex off ===
-- epoll_ctl without EPOLLET (first per worker and fd):
[debug] 12#12: epoll add event: fd:6 op:1 ev:10000001   <- listening socket :8080
[debug] 12#12: epoll add event: fd:7 op:1 ev:00002001   <- channel to master
[debug] 13#13: epoll add event: fd:6 op:1 ev:10000001   <- listening socket :8080
[debug] 13#13: epoll add event: fd:9 op:1 ev:00002001   <- channel to master
-- requests per worker:
    154 12
    146 13
-- debug log counts:
    22  epoll add event: fd:[0-9]* op:[0-9]* ev:1
     0  accept mutex locked
     0  accept mutex lock failed
     0  post event
=== (b) accept_mutex on ===
-- epoll_ctl without EPOLLET (first per worker and fd):
[debug] 366#366: epoll add event: fd:7 op:1 ev:00002001   <- channel to master
[debug] 366#366: epoll add event: fd:6 op:1 ev:00002001   <- listening socket :8080
[debug] 367#367: epoll add event: fd:9 op:1 ev:00002001   <- channel to master
[debug] 367#367: epoll add event: fd:6 op:1 ev:00002001   <- listening socket :8080
-- requests per worker:
    104 366
    196 367
-- debug log counts:
     0  epoll add event: fd:[0-9]* op:[0-9]* ev:1
   512  accept mutex locked
     6  accept mutex lock failed
   603  post event
=== (c) listen 8080 reuseport ===
-- epoll_ctl without EPOLLET (first per worker and fd):
[debug] 720#720: epoll add event: fd:6 op:1 ev:00002001   <- listening socket :8080
[debug] 720#720: epoll add event: fd:8 op:1 ev:00002001   <- channel to master
[debug] 721#721: epoll add event: fd:7 op:1 ev:00002001   <- listening socket :8080
[debug] 721#721: epoll add event: fd:10 op:1 ev:00002001   <- channel to master
-- requests per worker:
    141 720
    159 721
-- debug log counts:
     0  epoll add event: fd:[0-9]* op:[0-9]* ev:1
     0  accept mutex locked
     0  accept mutex lock failed
     0  post event

ev: 는 epoll 에 넘긴 이벤트 비트의 16진수입니다. 00000001 = EPOLLIN, 00002000 = EPOLLRDHUP, 10000000 = EPOLLEXCLUSIVE, 80000000 = EPOLLET.

  1. (a) 두 워커 모두 같은 fd 6 을 10000001(EPOLLIN|EPOLLEXCLUSIVE)로 등록했습니다. EPOLLRDHUP 가 빠진 것은 배타 등록일 때 ngx_epoll_add_event() 가 그 비트를 지우기 때문입니다(4절의 614–618행). 등록 순서도 소스대로 듣기 소켓(init_process)이 채널(ngx_add_channel_event)보다 먼저입니다. ev:1… 로 시작하는 등록이 22번 — 처음 두 번에 더해 300번 남짓의 accept 를 16번마다 다시 등록한 것입니다. 분포는 154 : 146.
  2. (b) 듣기 소켓이 채널보다 늦게, 배타 없이(00002001) 등록됩니다. 기동 때는 넣지 않고 뮤텍스를 잡은 첫 바퀴에 넣기 때문입니다. "accept mutex locked" 512번 — 바퀴마다 잡기를 시도합니다. 모든 이벤트를 큐로 미루므로 "post event" 가 603번입니다. 분포는 104 : 196 으로 기울었습니다. 예제를 다듬는 동안 돌린 실행에서는 226 : 74, 274 : 26 처럼 더 기운 적도, 다시 확인하려고 돌린 실행에서는 300 : 0(한 워커가 전부)인 적도 있었고, 어느 쪽으로 기우는지도 실행마다 달랐습니다.
  3. (c) 두 워커의 듣기 소켓 fd 가 6 과 7 로 다릅니다. 서로 다른 소켓을 하나씩 맡았다는 뜻입니다. 분포는 141 : 159.

pid 와 숫자는 실행마다 다릅니다. 연결이 아주 짧고 같은 컴퓨터 안의 curl 이라 실제 서비스의 분포를 말해 주지는 않습니다. 소스가 말해 주는 것은 세 갈래가 서로 다른 장치로 일을 나눈다는 데까지입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
ngx_process_events_and_timers() 타이머로 잘 시간 정하기 → (뮤텍스) → epoll_wait → 핸들러 → posted accept → 타이머 만료 → posted. 기다리는 곳은 epoll_wait 하나
ngx_event_actions 이벤트 방식의 함수 포인터 표. 워커마다 ngx_epoll_init() 이 epoll 모듈의 표를 복사해 꽂고, ngx_add_event·ngx_process_events 매크로로 부른다
worker_connections 워커마다 미리 만드는 연결·읽기·쓰기 이벤트 배열의 크기(기본 512). 듣기 소켓·채널·upstream 연결도 칸을 쓴다
빈 칸 목록 c->data 로 이은 단일 연결 리스트. 맨 앞에서 빌리고 맨 앞에 돌려준다. 모자라면 노는(reusable) 연결부터 닫는다
EPOLLET 연결 소켓은 엣지 트리거로 한 번 등록, 듣기 소켓은 레벨 트리거. 짧게 읽히면 다 비운 것으로 보고 ready = 0
instance 비트 data.ptr 의 맨 아래 비트. 칸을 빌릴 때마다 뒤집어, 같은 바퀴에서 닫혔다 다시 쓰인 칸의 낡은 이벤트를 거른다
타이머 이벤트에 박힌 rbtree 노드, 키는 만료 시각. 최솟값까지 남은 시간이 epoll_wait 의 타임아웃(없으면 -1). 같은 핸들러가 timedout = 1 로 불린다
시각 캐시 epoll_wait 직후 ngx_time_update() 가 한 번 채우는 ngx_current_msec·시각 문자열
posted 큐 accept 큐(뮤텍스 바퀴), 일반 큐(바퀴 끝), next 큐(다음 바퀴, 공정성)
누가 accept 하나 기본 = EPOLLEXCLUSIVE + 16번마다 재등록, accept_mutex on = 공유 메모리 잠금, reuseport = 워커마다 소켓

다음에 볼 것

이벤트 루프가 듣기 소켓에서 연결을 받아 ls->handler(c), 곧 ngx_http_init_connection() 을 부르는 데까지 왔습니다. 그 함수는 예제 2에서 본 대로 client_header_timeout 타이머를 걸고 읽기 이벤트를 epoll 에 맡긴 채 바로 돌아옵니다. 데이터가 오면 이 루프가 부를 핸들러 ngx_http_wait_request_handler() 부터, 바이트가 요청 줄과 헤더로 해석되어 ngx_http_request_t 가 되는 과정이 8편: HTTP 요청 읽기 — 연결이 ngx_http_request_t가 되기까지에서 이어집니다.

같은 문제를 다른 서버는 이렇게 풀었습니다. httpd 8편: MPM의 event MPM 은 리스너 스레드 하나가 듣기 소켓과 keep-alive 연결을 한 pollset 으로 지켜보다가 요청이 오면 작업 스레드에게 넘깁니다. Tomcat 5편: 커넥터와 NIO 엔드포인트의 Poller 도 셀렉터 하나로 많은 연결을 지켜보다 작업 스레드 풀에 넘깁니다. nginx 의 워커에는 그 "넘기기"가 없습니다 — 지켜보는 스레드가 곧 처리하는 스레드라 스레드 사이의 큐도 잠금도 없지만, 그만큼 핸들러가 절대 기다리지 않아야 합니다. httpd 의 prefork 가 쓰던 accept 뮤텍스(같은 8편)는 nginx 의 accept_mutex 와 같은 생각이고, nginx 에서는 기본으로 꺼져 커널(EPOLLEXCLUSIVE·SO_REUSEPORT)에 맡겨졌습니다. Spring 24편: WebFlux가 기본으로 올라타는 Reactor Netty 도 이벤트 루프 방식의 서버입니다(Netty 의 소스는 이 연재에서 읽지 않았습니다).

다른 글