이 글에서 다루는 것
"nginx 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서 마스터가 워커를 띄우고, 워커가 ngx_worker_process_cycle() 의 무한 반복에 들어서는 데까지 봤습니다. 그 반복의 몸통은 사실상 한 줄, ngx_process_events_and_timers(cycle); 입니다. 이번 글은 그 한 줄 안으로 들어갑니다.
2편의 지도에서 이 글은 B. 프로세스와 이벤트 구역의 이벤트 루프 상자를 확대합니다.
초록 상자가 이 글입니다. 6편이 워커를 띄웠고, 8편부터는 이 루프가 부르는 HTTP 핸들러를 봅니다.
답하려는 질문은 네 가지입니다.
- 워커는 스레드 하나로 어떻게 수천 개의 연결을 번갈아 처리하나 — 루프 한 바퀴에서 무슨 일이 어떤 순서로 일어나나
events { use epoll; worker_connections 1024; }는 소스의 어디가 되나 — epoll 은 어떻게 끼워지고,worker_connections는 무엇의 개수인가client_header_timeout·keepalive_timeout같은 타임아웃은 누가 재고, 어떻게epoll_wait와 맞물리나- 워커가 여럿일 때 새 연결은 누가 받나 —
accept_mutex,reuseport는 무엇을 바꾸나
| 항목 | 내용 |
|---|---|
| 대상 | 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행
노란 상자는 타이머 트리를 만지는 단계, 회색은 설정에 따라 건너뛰는 단계입니다. 기다리는(잠드는) 곳은 초록 상자 하나뿐입니다.
순서에 담긴 생각은 이렇습니다.
- 잘 시간을 먼저 정합니다. 가장 가까운 타이머가 3초 뒤라면
epoll_wait는 최대 3초만 자야, 아무 일이 없어도 3초 뒤에 깨어나 그 타이머를 처리할 수 있습니다. 타이머가 하나도 없으면 무한정 잡니다(5절). - accept 뮤텍스는 기본이 off 라 보통 건너뜁니다(7절).
- posted next 큐는 "다음 바퀴에 처리해 달라"는 것들입니다. 있으면
timer = 0으로epoll_wait가 잠들지 않고 바로 돌아오게 합니다(6절). - 기다립니다.
ngx_process_events는 함수가 아니라 함수 포인터 매크로입니다(2절). epoll 에서는epoll_wait를 부르고, 깨어나면 준비된 이벤트마다 핸들러를 부릅니다(4절). - ~ 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 를 적지 않으면 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행
data는 쓰는 쪽 마음대로입니다. HTTP 에서는 처음에ngx_http_connection_t, 요청이 생기면 그 요청을 가리킵니다(8편). 빈 칸일 때는 다음 빈 칸을 가리킵니다(아래).recv/send는 운영체제별 I/O 함수 포인터입니다. TLS 연결이면 OpenSSL 을 거치는 함수로 바뀝니다.number는 디버그 로그의*2같은 연결 번호,queue·reusable·close는 "급하면 닫아도 되는 연결" 목록에 쓰입니다(아래).
이벤트 구조체 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 이벤트 설계의 요점입니다.
타이머 노드와 큐 노드가 이벤트 구조체 안에 박혀 있어서, 타이머를 걸거나 큐에 넣을 때 메모리를 따로 할당하지 않습니다.
트리나 큐에서 꺼낸 노드로부터 이벤트를 되찾을 때는 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 필드에 적어 둔 것이 알뜰한 점입니다.
빌리고 돌려줄 때 맨 앞만 만지므로, 방금 돌려준 칸이 다음에 가장 먼저 다시 쓰입니다. 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
- 쉬는 워커인데 빈 칸이 6이 아니라 4입니다. 듣기 소켓과 마스터와의 채널이 칸을 하나씩 차지합니다.
- 노는 연결 4개로 빈 칸이 0이 되었고, 넷 모두
reusable입니다. 요청을 기다리는 연결은ngx_http_init_connection()이ngx_reusable_connection(c, 1)로 표시합니다(src/http/ngx_http_request.c 367행). - 새 연결은 200 을 받았고, 대신 가장 먼저 연 idle1 이 닫혔습니다.
ngx_drain_connections()가warn을 남기고(1420–1423행) 목록 맨 뒤를 닫았습니다. 새 연결의 curl 이 끝나 칸을 돌려준 뒤라 빈 칸이 1입니다. - 백엔드를 기다리는 요청 둘은 칸 넷(클라이언트 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(빈 응답)였습니다. - 기다리던 두 요청은 4초 뒤 정상으로 200 을 받았습니다. pid(
13)는 실행마다 다릅니다.
헷갈리기 쉬운 곳
worker_connections는 워커마다입니다. 한 워커에 연결이 몰리면(7절) 다른 워커에 칸이 남아도 그 워커는 모자랄 수 있습니다.- 연결 칸과 파일 디스크립터 한도는 다른 이야기입니다. 칸이 남아도 열린 파일 수 한도(
ulimit -n,worker_rlimit_nofile)에 걸리면accept4()가EMFILE로 실패합니다. 그래서 기동 때worker_connections가 그 한도보다 크면 경고를 남깁니다(src/event/ngx_event.c 520–531행).
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행).
- 연결 소켓은 HTTP 가
ngx_handle_read_event(rev, 0)로 등록합니다(src/http/ngx_http_request.c 369행). 엣지 트리거 방식이면 아직 등록되지 않았고(!active) 준비되지도 않은(!ready) 이벤트만NGX_CLEAR_EVENT로 넣습니다(src/event/ngx_event.c 282–294행). 한 번 넣으면 연결이 닫힐 때까지 그대로 둡니다. - 듣기 소켓은
ngx_add_event(rev, NGX_READ_EVENT, 0)처럼EPOLLET없이, 곧 레벨 트리거로 넣습니다(src/event/ngx_event.c 909행. 워커가 여럿이면 7절의EPOLLEXCLUSIVE가 붙지만EPOLLET는 여전히 없습니다). 대기열에 연결이 남아 있으면 다음epoll_wait가 또 알려 주므로 한 번에 하나만 받아도 놓치지 않습니다.
엣지 트리거에서는 알림이 한 번뿐이므로 받은 쪽이 소켓을 다 비울 때까지 읽어야 합니다(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행
data.ptr에서 아래 1비트를 떼어instance로, 나머지를 연결 주소로 되돌립니다.- 준비된 이벤트에
ready = 1을 세우고 핸들러를 그 자리에서 부릅니다. 듣기 소켓이면ngx_event_accept, HTTP 연결이면 요청을 읽는 함수입니다. NGX_POST_EVENTS가 있으면(accept 뮤텍스를 쥔 바퀴, 7절) 부르지 않고 큐에 넣습니다.rev->accept비트로 accept 큐와 일반 큐를 나눕니다.
instance 비트: 같은 바퀴에서 닫히고 다시 쓰인 칸
c->fd == -1 검사만으로는 왜 모자랄까요? epoll_wait 는 이벤트 여러 개를 배열 하나로 한꺼번에 돌려주고, 워커는 그것을 앞에서부터 차례로 처리합니다. 그 사이에 칸의 주인이 바뀔 수 있습니다.
빈 목록은 맨 앞에서 빌려 주므로 방금 닫힌 칸이 바로 다음 손님에게 갑니다. 커널도 가장 작은 빈 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로 잡니다. 타이머가 하나도 없습니다(epoll timer: -1을 찍는 곳은 epoll 모듈 797–798행). 첫epoll_wait가 오래 걸린 것은 strace 를 붙인 뒤 접속까지 기다린 시간입니다. - 듣기 소켓(fd 5)이 깨어나
accept4(…, SOCK_NONBLOCK)로 받습니다. 받는 순간 논블로킹입니다. - 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절의 엣지 트리거 등록입니다. - 다음
epoll_wait의 타임아웃이7000입니다. 트리의 최솟값이 방금 건 타이머이기 때문입니다. - 1초 뒤 요청이 오자 읽고(
recvfrom한 번, 27 < 1024 라 더 읽지 않음),writev로 응답하고, 헤더 타이머를 지우고(event timer del) keep-alive 타이머 3000ms 를 겁니다(src/http/ngx_http_request.c 3571행). 그래서 다음epoll_wait는3000입니다. - 3초 동안 아무 일도 없어
epoll_wait가= 0으로 돌아오고, 같은 바퀴의ngx_event_expire_timers()가 타이머를 빼고 keep-alive 핸들러를timedout = 1로 부릅니다. 핸들러가 연결을 닫습니다(close(3)).timer delta: 3019가 그 3초입니다. - 트리가 다시 비어
-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 는 실행마다 다릅니다.
헷갈리기 쉬운 곳
- 타이머는 워커마다 따로입니다. 트리는 전역 변수
ngx_event_timer_rbtree하나지만 프로세스마다 따로 있는 메모리라 워커끼리 섞이지 않습니다. - graceful 종료는 타이머 트리를 봅니다. 워커 반복문에서
ngx_exiting이면ngx_event_no_timers_left()가 "취소할 수 없는(cancelable이 아닌) 타이머가 남았나"로 나갈지 정합니다(src/event/ngx_event_timer.c 99–126행). 6편에서 본 그대로입니다.
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번) |
위 표의 세 큐를 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행
multi_accept off(기본)이면available = 0이라 한 번만 돌고 연결 하나만 받습니다. 듣기 소켓은 레벨 트리거라 남은 연결은 다음 바퀴에 또 알림이 옵니다.on이면EAGAIN이 날 때까지 받습니다.ngx_accept_disabled는 빈 칸이 전체의 1/8 보다 적으면 양수가 됩니다. "이 워커는 꽤 찼다"는 표시로, 아래 accept 뮤텍스와 EPOLLEXCLUSIVE 재등록에서 씁니다.ls->handler는 듣기 소켓을 만든 모듈이 꽂은 함수입니다. HTTP 는ngx_http_init_connection입니다(src/http/ngx_http.c 1882행). 여기서부터는 8편입니다.
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행
노란 갈래는 공유 메모리의 잠금을 쓰는 옛 방식, 파란 두 갈래는 커널에 맡기는 방식입니다.
(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.
- (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. - (b) 듣기 소켓이 채널보다 늦게, 배타 없이(
00002001) 등록됩니다. 기동 때는 넣지 않고 뮤텍스를 잡은 첫 바퀴에 넣기 때문입니다. "accept mutex locked" 512번 — 바퀴마다 잡기를 시도합니다. 모든 이벤트를 큐로 미루므로 "post event" 가 603번입니다. 분포는 104 : 196 으로 기울었습니다. 예제를 다듬는 동안 돌린 실행에서는 226 : 74, 274 : 26 처럼 더 기운 적도, 다시 확인하려고 돌린 실행에서는 300 : 0(한 워커가 전부)인 적도 있었고, 어느 쪽으로 기우는지도 실행마다 달랐습니다. - (c) 두 워커의 듣기 소켓 fd 가 6 과 7 로 다릅니다. 서로 다른 소켓을 하나씩 맡았다는 뜻입니다. 분포는 141 : 159.
pid 와 숫자는 실행마다 다릅니다. 연결이 아주 짧고 같은 컴퓨터 안의 curl 이라 실제 서비스의 분포를 말해 주지는 않습니다. 소스가 말해 주는 것은 세 갈래가 서로 다른 장치로 일을 나눈다는 데까지입니다.
헷갈리기 쉬운 곳
- 옛 튜닝 글의 "accept_mutex 를 끄세요"는 이미 기본값입니다. 2016년(1.11.3) 전에는 기본 on 이었습니다. 1편에서도 짚었습니다.
worker_processes 1이면 세 갈래 모두 의미가 없습니다. accept 뮤텍스는 워커가 둘 이상일 때만 켜지고(645행), EPOLLEXCLUSIVE 도worker_processes > 1조건이 있습니다(893–894행). 예제 2의 워커 하나짜리 설정에서 듣기 소켓은 평범한 레벨 트리거로 등록됩니다.
이 글에서 뺀 것
- 다른 이벤트 방식 모듈:
src/event/modules/의ngx_kqueue_module.c(BSD·macOS),ngx_poll_module.c,ngx_select_module.c,ngx_devpoll_module.c,ngx_eventport_module.c,ngx_iocp_module.c(Windows). 이 빌드의 모듈 목록(objs/ngx_modules.c)에 든 이벤트 방식 모듈은 epoll 하나입니다 - 쓰기 이벤트 쪽
ngx_handle_write_event()와send_lowat: src/event/ngx_event.c 345–425행 - 스레드 풀·파일 AIO 가 끝났음을 루프에 알리는 eventfd(
ngx_epoll_notify_init·ngx_epoll_notify_handler: src/event/modules/ngx_epoll_module.c 383–459행,ngx_epoll_notify: 765행, 파일 AIO 용ngx_epoll_eventfd_handler: 939행부터) EPOLLRDHUP지원을 기동 때 시험하는ngx_epoll_test_rdhup(): 같은 파일 464–526행EMFILE때 accept 를 잠시 끄는 경로와ngx_enable_accept_events/ngx_disable_accept_events: src/event/ngx_event_accept.c 364–426행- UDP 의
ngx_event_recvmsg():src/event/ngx_event_udp.c - upstream 쪽 논블로킹 connect
ngx_event_connect_peer():src/event/ngx_event_connect.c— 11편 - 연결을 닫는
ngx_close_connection()(타이머 지우기 → epoll 에서 빼기 → posted 에서 빼기 → 칸 돌려주기 →close()): src/core/ngx_connection.c 1286–1370행
정리
| 개념 | 한 줄 요약 |
|---|---|
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 의 소스는 이 연재에서 읽지 않았습니다).