namalzi

← 글 목록

nginx 소스 파헤치기 3: 메모리 풀과 기본 자료구조 — ngx_pool_t, ngx_str_t, ngx_buf_t와 ngx_chain_t

nginx · 게시

nginx 의 C 코드에도 free() 는 거의 없습니다. 요청 하나, 연결 하나처럼 수명이 같은 메모리를 풀에 모아 한꺼번에 버리기 때문입니다. ngx_pool_t 의 블록 사슬과 failed 카운트, 큰 할당을 따로 다루는 large 목록, cleanup, NUL 로 끝나지 않는 ngx_str_t, 배열·리스트·큐·해시, 그리고 응답을 담는 ngx_buf_t 와 ngx_chain_t 를 실제 소스와 디버그 로그·gdb 로 확인합니다.

이 글에서 다루는 것

"nginx 소스 파헤치기" 연재의 세 번째 글입니다. 2편에서 마스터와 워커, 모듈, cycle, 설정 문맥으로 nginx 전체 지도를 그렸습니다. 이 글부터는 그 지도의 상자를 하나씩 확대합니다. 첫 상자는 2편 지도의 "A. 준비" 줄에 있는 메모리 풀·자료구조입니다.

2편의 전체 지도를 줄인 그림. A 준비 줄에 기동·cycle(4편), 모듈·디렉티브(5편), 메모리 풀·자료구조(3편)가 있고, B 프로세스와 이벤트 줄에 마스터·워커(6편)와 이벤트 루프(7편), C HTTP 요청 하나 줄에 요청 읽기(8편)부터 부하 분산(12편)까지가 있다. 이 글이 다루는 메모리 풀·자료구조 상자만 노란색이고, 이 상자에서 C 줄 전체로 "모든 상자가 풀에서 메모리를 받는다"는 점선이 이어진다. 노란 상자가 이 글입니다. 나머지 상자들이 쓰는 메모리와 자료구조가 모두 여기서 나옵니다.

이 상자를 가장 먼저 여는 이유가 있습니다. 4편부터 읽을 nginx 코드는 거의 모든 줄이 ngx_palloc(r->pool, …), ngx_str_t, ngx_array_push(), ngx_chain_t 중 하나를 씁니다. 이것을 모르고 읽으면 "이 메모리는 누가 언제 돌려주지?", "왜 문자열을 printf("%s") 로 찍지 않지?" 같은 질문에 계속 걸립니다.

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

항목 내용
대상 nginx
버전 nginx 1.31.9 (master, 개발판)
커밋 9a7439fd79 (2026-10-23)
주로 읽는 파일 src/core/ngx_palloc.[ch], src/os/unix/ngx_alloc.c, src/core/ngx_string.[ch], ngx_array.[ch], ngx_list.[ch], ngx_queue.h, ngx_hash.[ch], ngx_rbtree.h, ngx_buf.[ch], src/http/ngx_http_request.c

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 소스를 같은 커밋으로 받는 방법과 예제를 돌리는 도커 환경은 1편에 있습니다. 풀과 자료구조 코드(ngx_palloc·ngx_array·ngx_list·ngx_queue·ngx_hash·ngx_rbtree·ngx_buf 의 .c·.h)만 합쳐도 3,193줄이고 문자열 함수 ngx_string.[ch] 가 2,369줄 더 있습니다. 이 글은 그중에서도 4편부터 읽을 코드를 따라가는 데 필요한 큰 줄기만 골랐습니다.

먼저 알아 둘 말

풀(pool)은 메모리를 빌려 주는 "통"입니다. 풀에서 받은 메모리는 하나씩 돌려주지 않고, 풀을 없앨 때(ngx_destroy_pool()) 한꺼번에 돌아갑니다. nginx 의 자료형 이름은 ngx_pool_t 입니다. httpd 4편의 APR 풀과 같은 생각인데, 만듦새는 꽤 다릅니다(8절에서 비교합니다).

블록(block)은 풀이 malloc 계열 함수로 한 번에 받아 오는 덩어리입니다. 풀은 블록 안을 잘게 나눠 줍니다. nginx 소스에는 따로 "블록"이라는 자료형이 없고, 블록마다 맨 앞에 ngx_pool_t 구조체(의 앞부분)가 들어 있습니다. 이 글에서는 편의상 블록이라고 부릅니다.

large(큰 할당)는 블록에 넣기에는 너무 큰 요청입니다. 풀은 이것을 블록에 넣지 않고 malloc() 으로 따로 받은 뒤 목록에 달아 둡니다.

cleanup 은 풀이 없어질 때 불러 달라고 풀에 맡겨 두는 함수입니다. 열어 둔 파일을 닫는 일처럼 "메모리를 돌려주는 것 말고 해야 할 뒷정리"를 여기에 둡니다. 함수 포인터와 void * 가 낯설다면 httpd 2편에 풀어 두었습니다.

ngx_str_t 는 nginx 의 문자열입니다. 길이(len)와 시작 주소(data) 두 필드뿐이고, C 문자열처럼 끝에 '\0'(NUL)이 있다고 기대하면 안 됩니다.

버퍼와 체인 — ngx_buf_t 는 "보낼 데이터가 어디에 있나"를 적는 구조체(메모리라면 시작·끝 주소, 파일이라면 파일과 위치), ngx_chain_t 는 그 버퍼들을 잇는 한 방향 연결 리스트입니다. 응답은 이 체인에 실려 필터를 지나 나갑니다.

정렬(alignment)은 데이터를 CPU 가 읽기 좋은 주소(예: 8의 배수)에 두는 것입니다. 정렬하지 않은 주소에 포인터나 정수를 두면 어떤 CPU 에서는 느려지고 어떤 CPU 에서는 아예 오류가 납니다.

1. 왜 풀인가 — nginx 의 메모리는 세 가지 수명으로 죽는다

웹 서버가 요청 하나를 처리하는 동안 만드는 데이터는 대부분 요청이 끝나는 순간 함께 필요 없어집니다. 요청 구조체, 헤더 목록, URI 문자열, 모듈마다 요청별로 쓰는 상태, 응답 헤더를 쓸 버퍼… 이것들을 malloc()/free() 로 하나씩 관리하면 수십 개 모듈이 저마다 "누가 언제 free() 하나"를 맞춰야 하고, 한 군데만 빠뜨려도 요청마다 메모리가 샙니다.

nginx 는 질문을 바꿉니다. "이 조각을 언제 버리나" 대신 "이 조각은 무엇과 같이 죽나" 를 고르게 합니다. 고를 수 있는 수명은 크게 셋입니다.

풀 어디에 만드는 곳 크기 없애는 곳
cycle 풀 cycle->pool ngx_init_cycle() NGX_CYCLE_POOL_SIZE = 16KB 워커·마스터가 끝날 때, 마스터에서는 reload 로 새 cycle 을 세운 뒤 옛 cycle 의 풀
연결 풀 c->pool ngx_event_accept() connection_pool_size (기본 64 * sizeof(void *) = 64비트에서 512) ngx_http_close_connection()
요청 풀 r->pool ngx_http_alloc_request() request_pool_size (기본 4096) ngx_http_free_request()

cycle 은 2편에서 본 "설정 한 벌로 돌아가는 nginx 의 상태 전체"입니다. 설정을 읽어 만든 모듈별 설정 구조체, 리스닝 소켓 목록, 열어 둔 로그 파일이 모두 cycle 풀에서 나옵니다. 그래서 nginx -s reload 로 새 cycle 이 서면 옛 cycle 의 풀을 통째로 버리는 것으로 옛 설정이 깨끗이 사라집니다. reload 의 자세한 순서는 4편과 6편에서 봅니다.

설정 파일에서 보던 connection_pool_size·request_pool_size 디렉티브가 바로 아래 두 풀의 크기입니다. 기본값은 ngx_http_core_merge_srv_conf() 에 있습니다.

    ngx_conf_merge_size_value(conf->connection_pool_size,
                              prev->connection_pool_size, 64 * sizeof(void *));
    ngx_conf_merge_size_value(conf->request_pool_size,
                              prev->request_pool_size, 4096);

src/http/ngx_http_core_module.c 3622–3625행

두 디렉티브에는 값을 검사하는 함수 ngx_http_core_pool_size() 가 붙어 있어서, NGX_MIN_POOL_SIZE(64비트에서 112바이트)보다 작거나 16의 배수가 아니면 설정을 거부합니다(5504–5523행).

요청 풀을 만드는 곳은 요청 구조체를 만드는 곳과 같습니다. 순서를 눈여겨볼 만합니다.

    pool = ngx_create_pool(cscf->request_pool_size, c->log);
    if (pool == NULL) {
        return NULL;
    }

    r = ngx_pcalloc(pool, sizeof(ngx_http_request_t));
    if (r == NULL) {
        ngx_destroy_pool(pool);
        return NULL;
    }

    r->pool = pool;

src/http/ngx_http_request.c 584–595행

풀을 먼저 만들고, 요청 구조체 r 자체를 그 풀에서 받습니다. 그래서 요청이 끝날 때 풀 하나를 없애면 r 까지 함께 사라집니다. 없애는 쪽 ngx_http_free_request() 의 마지막 부분이 그것을 주석으로 말합니다.

    /*
     * Setting r->pool to NULL will increase probability to catch double close
     * of request since the request object is allocated from its own pool.
     */

    pool = r->pool;
    r->pool = NULL;

    ngx_destroy_pool(pool);

src/http/ngx_http_request.c 4063–4071행

r->pool 을 먼저 NULL 로 만드는 것은 같은 요청을 두 번 닫는 버그를 잡기 위해서입니다. 함수 앞쪽에서 r->pool == NULL 이면 "http request already closed" 를 찍고 돌아갑니다(3994–3997행). 물론 r 자체가 이미 돌려준 메모리 안에 있으므로 "확률을 높일" 뿐이라고 주석이 솔직하게 적고 있습니다.

풀 셋의 수명을 시간 순서로 그린 그림. ngx_init_cycle 이 cycle 풀을 만든다. 워커가 연결을 accept 하면 연결 풀을 만들고, 요청이 오면 ngx_http_alloc_request 가 요청 풀을 만든다. 응답을 다 보내면 ngx_http_free_request 가 요청 풀을 없애고, keep-alive 다음 요청은 새 요청 풀을 받는다. 연결을 닫으면 ngx_http_close_connection 이 연결 풀을 없애고, 워커가 끝날 때 cycle 풀을 없앤다. 요청 풀은 연결 풀보다 짧게, 연결 풀은 cycle 풀보다 짧게 삽니다. keep-alive 연결 하나에서 요청 풀은 여러 번 생겼다 사라집니다.

헷갈리기 쉬운 곳

2. ngx_pool_t 의 생김새 — 블록 사슬, large 목록, cleanup 목록

풀 구조체입니다. ngx_pool_s 가 이름이고 ngx_pool_t 는 그 typedef 입니다(nginx 는 struct ngx_xxx_s 를 정의하고 ngx_xxx_t 로 부르는 습관이 있습니다).

typedef struct {
    u_char               *last;
    u_char               *end;
    ngx_pool_t           *next;
    ngx_uint_t            failed;
} ngx_pool_data_t;


struct ngx_pool_s {
    ngx_pool_data_t       d;
    size_t                max;
    ngx_pool_t           *current;
    ngx_chain_t          *chain;
    ngx_pool_large_t     *large;
    ngx_pool_cleanup_t   *cleanup;
    ngx_log_t            *log;
};

src/core/ngx_palloc.h 49–65행

두 층으로 나눠 읽습니다.

첫 블록은 ngx_pool_t 전체를 머리로 갖고, 두 번째 블록부터는 앞의 d 부분만 씁니다(3절의 ngx_palloc_block()). 자료형은 둘 다 ngx_pool_t * 로 가리키지만 두 번째 블록에서는 d 바로 뒤부터 할당에 쓰이므로 max·current 자리에는 다른 데이터가 들어 있습니다. 그 필드는 언제나 첫 블록에서만 읽어야 합니다.

large 목록의 고리 ngx_pool_large_t 는 next·alloc 두 필드, cleanup 목록의 고리 ngx_pool_cleanup_t 는 함수 포인터 handler(void (*)(void *data))·data·next 세 필드짜리 작은 구조체입니다(ngx_palloc.h 30–46행).

풀을 만드는 함수는 짧습니다.

ngx_pool_t *
ngx_create_pool(size_t size, ngx_log_t *log)
{
    ngx_pool_t  *p;

    p = ngx_memalign(NGX_POOL_ALIGNMENT, size, log);
    if (p == NULL) {
        return NULL;
    }

    p->d.last = (u_char *) p + sizeof(ngx_pool_t);
    p->d.end = (u_char *) p + size;
    p->d.next = NULL;
    p->d.failed = 0;

    size = size - sizeof(ngx_pool_t);
    p->max = (size < NGX_MAX_ALLOC_FROM_POOL) ? size : NGX_MAX_ALLOC_FROM_POOL;

    p->current = p;
    p->chain = NULL;
    p->large = NULL;
    p->cleanup = NULL;
    p->log = log;

    return p;
}

src/core/ngx_palloc.c 18–43행

  1. ngx_memalign() 으로 size 바이트를 16바이트 정렬해서 받습니다(NGX_POOL_ALIGNMENT 는 16, ngx_palloc.h 24행). 리눅스에서 이 함수는 posix_memalign() 을 부르고 디버그 로그에 posix_memalign: 주소:크기 @정렬 을 남깁니다(src/os/unix/ngx_alloc.c 51–69행).
  2. 받은 메모리의 맨 앞에 풀 구조체 자신을 놓고, last 를 그 바로 뒤로 둡니다. 풀 구조체가 따로 malloc 되지 않고 자기 첫 블록 안에 들어 있는 것입니다.
  3. max 는 "블록에서 머리를 뺀 크기"와 NGX_MAX_ALLOC_FROM_POOL(ngx_pagesize - 1, 보통 4095, ngx_palloc.h 16–20행) 중 작은 쪽입니다.

64비트 리눅스에서 sizeof(ngx_pool_t) 는 80바이트입니다(3절 gdb 예제에서 확인합니다). 그러면 기본 크기별 max 는 이렇게 됩니다.

풀 블록 크기 max 이보다 큰 요청은
연결 풀 512 432 malloc() (large)
요청 풀 4096 4016 malloc() (large)
cycle 풀 16384 4095 malloc() (large)

ngx_pool_t 의 메모리 구조. 첫 블록은 ngx_pool_t 전체를 머리로 갖고, 그 뒤에 이미 나눠 준 곳과 d.last 부터 d.end 까지의 남은 곳이 있다. 첫 블록의 d.next 가 다음 블록을 가리키고, 다음 블록은 ngx_pool_data_t 만 머리로 갖는다. 첫 블록의 large 는 ngx_pool_large_t 목록을 가리키고 그 alloc 이 malloc 으로 따로 받은 큰 메모리를 가리킨다. cleanup 은 ngx_pool_cleanup_t 목록을 가리킨다. 노란색은 메모리, 초록색이 다음 할당이 떼어 갈 남은 곳, 파란색이 뒷정리 함수입니다.

3. ngx_palloc — 할당은 포인터를 미는 것

풀에서 메모리를 받는 함수는 넷입니다. ngx_palloc()(정렬해서), ngx_pnalloc()(정렬하지 않고), ngx_pcalloc()(0으로 채워서), ngx_pmemalign()(원하는 정렬로, 언제나 large). ngx_pnalloc() 은 아래 ngx_palloc() 과 똑같고 ngx_palloc_small() 에 넘기는 셋째 인자만 1 대신 0 입니다(135–145행).

void *
ngx_palloc(ngx_pool_t *pool, size_t size)
{
#if !(NGX_DEBUG_PALLOC)
    if (size <= pool->max) {
        return ngx_palloc_small(pool, size, 1);
    }
#endif

    return ngx_palloc_large(pool, size);
}

src/core/ngx_palloc.c 122–132행

크기가 max 이하면 블록에서(ngx_palloc_small), 넘으면 malloc 으로(ngx_palloc_large) 갑니다. 셋째 인자 1/0 이 정렬 여부입니다. 문자열처럼 한 바이트씩 읽는 데이터는 정렬이 필요 없으므로 ngx_pnalloc() 을 써서 몇 바이트를 아낍니다. 실제로 ngx_pstrdup() 은 ngx_pnalloc() 을 씁니다(src/core/ngx_string.c 74–87행).

#if !(NGX_DEBUG_PALLOC) 는 "풀 디버깅을 켜지 않았을 때만"입니다. 이 매크로를 켜면 모든 할당이 large 로 가서 하나하나 malloc() 되므로 Valgrind 같은 메모리 검사 도구가 조각마다 경계를 볼 수 있습니다. auto/ 아래 configure 스크립트에는 이 이름이 없으므로(검색해 보면 나오지 않습니다) 켜려면 --with-cc-opt=-DNGX_DEBUG_PALLOC=1 처럼 컴파일러 옵션으로 직접 줘야 합니다. 이 글의 빌드는 켜지 않았습니다.

빠른 길: ngx_palloc_small

static ngx_inline void *
ngx_palloc_small(ngx_pool_t *pool, size_t size, ngx_uint_t align)
{
    u_char      *m;
    ngx_pool_t  *p;

    p = pool->current;

    do {
        m = p->d.last;

        if (align) {
            m = ngx_align_ptr(m, NGX_ALIGNMENT);
        }

        if ((size_t) (p->d.end - m) >= size) {
            p->d.last = m + size;

            return m;
        }

        p = p->d.next;

    } while (p);

    return ngx_palloc_block(pool, size);
}

src/core/ngx_palloc.c 148–174행

  1. current 블록부터 봅니다.
  2. 정렬이 필요하면 last 를 NGX_ALIGNMENT(sizeof(uintptr_t), 64비트에서 8, src/core/ngx_config.h 96–102행)의 배수로 올립니다. ngx_align_ptr 는 "더하고 아래 비트를 지우는" 비트 연산 매크로입니다.
  3. 남은 곳(end - m)이 충분하면 last 를 size 만큼 밀고 옛 자리를 돌려줍니다. 끝입니다.
  4. 모자라면 다음 블록에서 다시 봅니다. 끝까지 없으면 새 블록을 만듭니다.

할당의 거의 전부가 3번에서 끝납니다. 비교 한 번, 덧셈 한 번입니다. malloc() 처럼 빈 곳을 찾거나, 나중의 free() 를 위해 조각 크기를 적어 두거나, 스레드끼리 잠금을 잡는 일이 없습니다. 조각을 하나씩 돌려받지 않으니 그럴 필요가 없습니다. 함수에 ngx_inline 을 붙여 함수 호출 비용까지 줄였습니다. 잠금이 없다는 것은 풀 하나를 여러 스레드가 함께 쓰면 안 된다는 뜻이기도 한데, nginx 워커는 이벤트 루프 하나로 돌므로 보통은 문제가 되지 않습니다(7편).

블록이 찼을 때: ngx_palloc_block 과 failed

static void *
ngx_palloc_block(ngx_pool_t *pool, size_t size)
{
    u_char      *m;
    size_t       psize;
    ngx_pool_t  *p, *new;

    psize = (size_t) (pool->d.end - (u_char *) pool);

    m = ngx_memalign(NGX_POOL_ALIGNMENT, psize, pool->log);
    if (m == NULL) {
        return NULL;
    }

    new = (ngx_pool_t *) m;

    new->d.end = m + psize;
    new->d.next = NULL;
    new->d.failed = 0;

    m += sizeof(ngx_pool_data_t);
    m = ngx_align_ptr(m, NGX_ALIGNMENT);
    new->d.last = m + size;

    for (p = pool->current; p->d.next; p = p->d.next) {
        if (p->d.failed++ > 4) {
            pool->current = p->d.next;
        }
    }

    p->d.next = new;

    return m;
}

src/core/ngx_palloc.c 177–210행

  1. 첫 블록과 같은 크기(psize)로 새 블록을 받습니다.
  2. 새 블록의 머리는 ngx_pool_data_t(32바이트)만큼만 쓰고, 바로 뒤에서 이번 할당을 떼어 줍니다.
  3. current 부터 마지막 앞 블록까지 failed 를 하나씩 올립니다. 올리기 전 값이 4보다 크면(이미 5번 실패했으면) current 를 그다음 블록으로 옮깁니다.
  4. 새 블록을 사슬 끝에 붙입니다.

failed 가 하는 일이 이 함수의 요점입니다. 블록에 몇십 바이트가 남아 있으면 작은 할당은 거기서 성공할 수 있으니 버리기 아깝습니다. 그렇다고 거의 찬 블록을 매번 처음부터 훑으면 블록이 늘수록 할당이 느려집니다. 그래서 nginx 는 "새 블록을 만들어야 했던 일이 여섯 번째가 되면 그 블록은 이제 찼다고 치고 건너뛴다" 는 타협을 합니다. 남은 몇십 바이트는 버리는 대신 할당이 언제나 사슬의 뒤쪽 몇 블록만 보게 됩니다.

ngx_palloc 의 흐름. size 가 pool->max 이하이면 ngx_palloc_small 이 current 블록부터 남은 곳을 본다. 충분하면 d.last 를 size 만큼 밀고 옛 d.last 를 돌려준다. 아니면 다음 블록에서 다시 보고, 블록이 더 없으면 ngx_palloc_block 이 같은 크기 블록을 새로 받아 끝에 붙이며 지나온 블록의 failed 를 올리고 이미 failed 가 5 이상인 블록은 current 에서 뺀다. size 가 max 보다 크면 ngx_palloc_large 가 malloc 으로 받아 large 목록에 단다. 초록색 길이 거의 언제나 지나는 길입니다. 노란 상자 둘에서만 malloc 계열 함수가 불립니다.

큰 할당: ngx_palloc_large

    p = ngx_alloc(size, pool->log);
    /* ... */
    for (large = pool->large; large; large = large->next) {
        if (large->alloc == NULL) {
            large->alloc = p;
            return p;
        }

        if (n++ > 3) {
            break;
        }
    }

    large = ngx_palloc_small(pool, sizeof(ngx_pool_large_t), 1);
    /* ... */
    large->alloc = p;
    large->next = pool->large;
    pool->large = large;

    return p;
}

src/core/ngx_palloc.c 220–249행

ngx_alloc() 은 malloc() 을 부르고 디버그 로그에 malloc: 주소:크기 를 남기는 얇은 포장입니다(src/os/unix/ngx_alloc.c 17–31행). 받은 주소는 ngx_pool_large_t 고리에 적어 둡니다. 목록 앞쪽 다섯 칸까지 alloc == NULL 인 빈 칸(4절의 ngx_pfree() 가 비운 칸)이 있으면 그 칸을 다시 쓰고, 없으면 고리 하나를 풀의 작은 할당으로 새로 받아 목록 맨 앞에 붙입니다. 큰 메모리는 malloc 으로, 그것을 기억하는 16바이트짜리 고리는 풀에서 — 두 길이 섞여 있습니다.

예제 1: gdb 로 요청 풀을 들여다보기

이 예제는 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9 로 도커 컨테이너(Debian bookworm, --with-debug, -O0 -g)에서 실행했습니다. gdb 로 다루기 쉽게 -g 'daemon off; master_process off;' 로 프로세스 하나로 띄웠고, 정적 파일 요청 하나를 보내 ngx_http_static_handler() 첫 줄에서 멈춘 뒤 아래 gdb 명령을 실행했습니다. 전체 파일은 예제 폴더 ex/u03-pool-gdb/ 의 nginx.conf·pool.gdb·run.sh 입니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다) — pool.gdb 에서 발췌
# (줄인 곳: set pagination·confirm 두 줄, 구분 줄을 찍는 echo 들, === 1. 의 print 세 줄, 끝의 kill·quit)
# 풀의 블록 사슬을 따라가며 블록마다 쓴 양·남은 양·failed 를 찍는다
define poolwalk
  set $p = $arg0
  while $p
    printf "  block %p  used=%4ld  free=%4ld  failed=%lu\n", $p, (long)($p->d.last - (u_char *) $p), (long)($p->d.end - $p->d.last), $p->d.failed
    set $p = $p->d.next
  end
  printf "  current=%p  large=%p\n", $arg0->current, $arg0->large
end

break ngx_http_static_handler
run

print sizeof(ngx_pool_t)
print sizeof(ngx_http_request_t)
print *r->pool
poolwalk r->pool

# ngx_palloc(r->pool, 3000) 을 여덟 번 직접 부른다
set $i = 0
while $i < 8
  call (void) ngx_palloc(r->pool, 3000)
  set $i = $i + 1
end
poolwalk r->pool

# max 보다 큰 5000 바이트
print r->pool->max
set $big = ngx_palloc(r->pool, 5000)
print $big
print *r->pool->large
=== 2. the request pool ===
$4 = 80
$5 = 1360
$6 = {d = {last = 0x55555577e43c "ubject = \"/CN=openssl-cmp-test\"\nnewkey = insta.priv.pem\nout_trusted = apps/insta.ca.crt # does not include keyUsage digitalSignature\ncertout = insta.cert.pem\n\n[pbm] # Password-based protection for Ins"..., end = 0x55555577e700 "\020\020", next = 0x5555557aa320, failed = 0}, max = 4016, current = 0x55555577d700, chain = 0x0, large = 0x0, cleanup = 0x0, log = 0x555555780c20}
  block 0x55555577d700  used=3388  free= 708  failed=0
  block 0x5555557aa320  used=1152  free=2944  failed=0
  current=0x55555577d700  large=(nil)
=== 3. call ngx_palloc(r->pool, 3000) eight times ===
  block 0x55555577d700  used=3388  free= 708  failed=6
  block 0x5555557aa320  used=1152  free=2944  failed=6
  block 0x5555557ab330  used=3032  free=1064  failed=6
  block 0x5555557ac340  used=3032  free=1064  failed=5
  block 0x5555557ad350  used=3032  free=1064  failed=4
  block 0x5555557ae360  used=3032  free=1064  failed=3
  block 0x5555557af370  used=3032  free=1064  failed=2
  block 0x5555557b0380  used=3032  free=1064  failed=1
  block 0x5555557b1390  used=3032  free=1064  failed=0
  block 0x5555557b23a0  used=3032  free=1064  failed=0
  current=0x5555557ac340  large=(nil)
=== 4. call ngx_palloc(r->pool, 5000) -- larger than max ===
$7 = 4016
$8 = (void *) 0x5555557b33b0
$9 = {next = 0x0, alloc = 0x5555557b33b0}

(주소는 환경에 따라 다릅니다. gdb 가 주소 무작위화(ASLR)를 끄고 띄우므로 같은 컨테이너에서 다시 돌렸을 때는 주소까지 같았습니다. === 1. 부분은 5절에서 봅니다.)

읽을 것이 많습니다.

헷갈리기 쉬운 곳

4. 돌려주기 — destroy, cleanup, 그리고 large 만 되는 ngx_pfree

ngx_destroy_pool

void
ngx_destroy_pool(ngx_pool_t *pool)
{
    ngx_pool_t          *p, *n;
    ngx_pool_large_t    *l;
    ngx_pool_cleanup_t  *c;

    for (c = pool->cleanup; c; c = c->next) {
        if (c->handler) {
            ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, pool->log, 0,
                           "run cleanup: %p", c);
            c->handler(c->data);
        }
    }

#if (NGX_DEBUG)
    /* ... "free: %p" 와 "free: %p, unused: %uz" 를 찍는 디버그 전용 고리 ... */
#endif

    for (l = pool->large; l; l = l->next) {
        if (l->alloc) {
            ngx_free(l->alloc);
        }
    }

    for (p = pool, n = pool->d.next; /* void */; p = n, n = n->d.next) {
        ngx_free(p);

        if (n == NULL) {
            break;
        }
    }
}

src/core/ngx_palloc.c 46–96행

  1. cleanup 을 부릅니다. 목록 앞에서부터인데, 아래에서 보듯 cleanup 은 언제나 목록 앞에 붙으므로 결국 나중에 건 것부터 불립니다.
  2. large 를 모두 free() 합니다.
  3. 블록을 모두 free() 합니다. 첫 블록 안에 풀 구조체가 있으므로 마지막 ngx_free(p) 가 풀 자신까지 돌려줍니다. 그래서 고리를 돌기 전에 n = p->d.next 를 먼저 읽어 둡니다.

ngx_destroy_pool 이 하는 일 세 단계. 첫째, cleanup 목록을 앞에서부터 돌며 handler 가 NULL 이 아닌 고리만 handler(data) 를 부른다. 고리는 언제나 목록 앞에 붙으므로 나중에 건 것부터 불린다. 예제 2의 요청 풀에서는 이때 run cleanup 과 file cleanup: fd:10 이 찍힌다. 둘째, large 목록에서 alloc 이 NULL 이 아닌 것을 ngx_free 한다. 셋째, 블록 사슬을 앞에서부터 ngx_free 하는데, n = p->d.next 를 먼저 읽어 두고, 첫 블록과 함께 ngx_pool_t 자신도 사라진다. 예제 2의 요청 풀에서는 free: …76700, unused: 16 과 free: …BA3320, unused: 2608 이 찍힌다. 풀을 없애는 순서. 회색 칸의 로그는 아래 예제 2의 실제 출력에서 가져왔습니다.

#if (NGX_DEBUG) 블록(61–81행)은 --with-debug 로 빌드했을 때만 들어가는 부분으로, 실제로 돌려주기 전에 large 마다 free: 주소, 블록마다 free: 주소, unused: 남은 바이트 를 로그에 찍습니다. 돌려주는 중에 찍지 않는 까닭은 주석에 있습니다 — "we could allocate the pool->log from this pool", 로그 구조체 자체가 이 풀에 들어 있을 수 있기 때문입니다.

cleanup 을 거는 쪽입니다.

    c = ngx_palloc(p, sizeof(ngx_pool_cleanup_t));
    /* ... */
    c->handler = NULL;
    c->next = p->cleanup;

    p->cleanup = c;

    ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, p->log, 0, "add cleanup: %p", c);

    return c;
}

src/core/ngx_palloc.c 316–339행

고리와 함께 size 바이트의 데이터 칸까지 풀에서 받아 주고(줄인 자리, 321–329행), handler 는 NULL 로 둔 채 돌려줍니다. 함수를 채우는 것은 부른 쪽의 일이고, handler 가 NULL 인 고리는 destroy 때 건너뜁니다. 그래서 "일단 자리를 잡아 두고, 일이 성공했을 때만 handler 를 채운다"는 쓰임이 가능합니다. 정적 파일을 여는 ngx_open_cached_file() 이 딱 그렇게 씁니다.

        cln = ngx_pool_cleanup_add(pool, sizeof(ngx_pool_cleanup_file_t));
        if (cln == NULL) {
            return NGX_ERROR;
        }

        rc = ngx_open_and_stat_file(name, of, pool->log);

        if (rc == NGX_OK && !of->is_dir) {
            cln->handler = ngx_pool_cleanup_file;
            clnf = cln->data;

            clnf->fd = of->fd;
            clnf->name = name->data;
            clnf->log = pool->log;
        }

src/core/ngx_open_file_cache.c 181–195행

(open_file_cache 를 켜지 않았을 때의 길입니다.) 파일 열기에 성공했을 때만 handler 에 ngx_pool_cleanup_file 을 넣습니다. 여기서 pool 은 정적 파일 핸들러가 넘긴 r->pool 이므로, 요청 풀이 없어질 때 파일도 닫힙니다. close() 를 부르는 코드를 요청 처리 어디에도 쓸 필요가 없습니다. ngx_pool_cleanup_file() 은 file cleanup: fd:N 을 로그에 찍고 ngx_close_file() 을 부릅니다(ngx_palloc.c 363–375행).

ngx_pfree — large 만 돌려줄 수 있다

ngx_int_t
ngx_pfree(ngx_pool_t *pool, void *p)
{
    ngx_pool_large_t  *l;

    for (l = pool->large; l; l = l->next) {
        if (p == l->alloc) {
            ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, pool->log, 0,
                           "free: %p", l->alloc);
            ngx_free(l->alloc);
            l->alloc = NULL;

            return NGX_OK;
        }
    }

    return NGX_DECLINED;
}

src/core/ngx_palloc.c 277–294행

APR 에는 없는, 조각 하나를 돌려주는 함수입니다. 다만 large 목록에 있는 것만 됩니다. 블록에서 받은 작은 조각을 넘기면 목록에서 못 찾고 NGX_DECLINED 를 돌려줄 뿐 아무 일도 하지 않습니다. 돌려준 칸은 alloc = NULL 로 비워 두고, 다음 ngx_palloc_large() 가 다시 씁니다(3절).

이 함수가 왜 필요한지는 keep-alive 연결을 보면 압니다. 쉬고 있는 keep-alive 연결이 수천 개라면, 그 연결마다 1KB 헤더 버퍼를 쥐고 있는 것은 낭비입니다. ngx_http_set_keepalive() 는 요청을 마칠 때 이렇게 합니다.

    /*
     * To keep a memory footprint as small as possible for an idle keepalive
     * connection we try to free c->buffer's memory if it was allocated outside
     * the c->pool.  The large header buffers are always allocated outside the
     * c->pool and are freed too.
     */

    b = c->buffer;

    if (ngx_pfree(c->pool, b->start) == NGX_OK) {

src/http/ngx_http_request.c 3463–3472행

"c->pool 밖에서(large 로) 받았다면 돌려준다"는 말이 1절 끝의 "512바이트 연결 풀" 이야기와 이어집니다. 기본 설정에서 1KB 헤더 버퍼는 연결 풀의 max(432)를 넘으므로 언제나 large 이고, 그래서 언제나 돌려줄 수 있습니다. 반대로 connection_pool_size 를 크게 잡아 버퍼가 블록 안에 들어가면 이 ngx_pfree() 는 실패하고 버퍼는 연결이 닫힐 때까지 남습니다.

예제 2: 디버그 로그로 풀이 생기고 사라지는 것 보기

워커 하나, error_log … debug; 로 띄운 nginx 에 curl 로 연결 하나에 요청 두 개(a.html, b.html)를 보내고, 디버그 로그에서 할당·해제 줄만 골랐습니다. 설정과 스크립트는 예제 폴더 ex/u03-pool-debug-log/ 에 있습니다. 실행 환경은 예제 1과 같고, 이번에는 마스터·워커로 평소처럼 띄웠습니다.

curl -s http://127.0.0.1:8080/a.html http://127.0.0.1:8080/b.html
[debug] 11#11: posix_memalign: 0000559582B79BC0:512 @16
[debug] 11#11: *2 accept: 127.0.0.1:41964 fd:3
[debug] 11#11: *2 malloc: 0000559582B7C410:1024
[debug] 11#11: *2 posix_memalign: 0000559582B76700:4096 @16
[debug] 11#11: *2 http request line: "GET /a.html HTTP/1.1"
[debug] 11#11: *2 posix_memalign: 0000559582BA3320:4096 @16
[debug] 11#11: *2 add cleanup: 0000559582B77450
[debug] 11#11: *2 set http keepalive handler
[debug] 11#11: *2 http close request
[debug] 11#11: *2 run cleanup: 0000559582B77450
[debug] 11#11: *2 file cleanup: fd:10
[debug] 11#11: *2 free: 0000559582B76700, unused: 16
[debug] 11#11: *2 free: 0000559582BA3320, unused: 2608
[debug] 11#11: *2 free: 0000559582B7C410
[debug] 11#11: *2 hc free: 0000000000000000
[debug] 11#11: *2 hc busy: 0000000000000000 0
[debug] 11#11: *2 http keepalive handler
[debug] 11#11: *2 malloc: 0000559582B7C410:1024
[debug] 11#11: *2 posix_memalign: 0000559582B76700:4096 @16
[debug] 11#11: *2 http request line: "GET /b.html HTTP/1.1"
...
[debug] 11#11: *2 http keepalive handler
[debug] 11#11: *2 malloc: 0000559582B7C410:1024
[debug] 11#11: *2 close http connection: 3
[debug] 11#11: *2 free: 0000559582B7C410
[debug] 11#11: *2 free: 0000559582B79BC0, unused: 128

(줄 앞의 날짜·시각은 지웠습니다. pid 11, 연결 번호 *2, 주소, fd 는 실행마다 다릅니다. b.html 요청의 줄들은 a.html 과 주소까지 똑같아서 ... 로 줄였습니다.)

위에서부터 소스와 짝을 맞춰 봅니다.

로그 무슨 일 소스
posix_memalign: …:512 @16 연결 풀(512바이트). accept 직후, 연결 번호가 붙기 전 ngx_event_accept.c 159행
malloc: …:1024 헤더 버퍼 1KB. 연결 풀 max 432를 넘어 large ngx_http_request.c 413행
posix_memalign: …:4096 @16 (첫째) 요청 풀 ngx_http_request.c 584행
posix_memalign: …:4096 @16 (둘째) 요청 풀의 두 번째 블록 (ngx_palloc_block) ngx_palloc.c 186행
add cleanup 정적 파일을 열며 파일 닫기 cleanup 을 건다 ngx_palloc.c 336행
run cleanup → file cleanup: fd:10 요청 풀 destroy 의 첫 단계, 파일이 닫힌다 ngx_palloc.c 55–57행, 368–369행
free: …, unused: 16 / unused: 2608 요청 풀의 두 블록. 첫 블록은 16바이트만 남기고 꽉 찼다 ngx_palloc.c 72–79행
free: …C410 (unused 없음) keep-alive 로 넘어가며 헤더 버퍼를 ngx_pfree() ngx_palloc.c 284–285행
http keepalive handler → malloc: …:1024 다음 요청 바이트가 오자 헤더 버퍼를 다시 받는다 ngx_http_request.c 3644행
close http connection: 3 → free: …C410, free: …79BC0, unused: 128 연결 풀 destroy. large(헤더 버퍼) 다음에 블록 ngx_http_request.c 4124–4128행

세 가지를 확인할 수 있습니다.

*2 close http connection 바로 앞의 malloc: …:1024 는 curl 이 연결을 닫을 때 keep-alive 핸들러가 읽을 버퍼를 다시 받았다가 읽은 바이트가 0이라 연결을 닫은 흔적입니다. 워커가 끝날 때(-s quit)는 cycle 풀이 같은 방식으로 정리되는 줄이 이어집니다. cycle 풀은 16KB 블록 다섯 개와 large 일곱 개였습니다(출력은 싣지 않습니다).

헷갈리기 쉬운 곳

5. ngx_str_t — NUL 로 끝나지 않는 문자열

typedef struct {
    size_t      len;
    u_char     *data;
} ngx_str_t;

src/core/ngx_string.h 16–19행

길이와 시작 주소, 그게 전부입니다. C 문자열은 끝에 '\0' 을 두어 길이를 나타내지만, ngx_str_t 는 길이를 따로 들고 다니므로 끝에 '\0' 이 없어도 됩니다. 이것이 왜 중요한지는 클라이언트가 보낸 요청을 생각하면 압니다. 요청 줄 GET /index.html HTTP/1.1 을 받은 nginx 는 URI 를 새 메모리에 복사하지 않고, 헤더 버퍼 안의 / 를 가리키는 data 와 길이 11 만 적습니다. 복사도, 할당도 없습니다. C 문자열이었다면 URI 끝(공백 자리)에 '\0' 을 써 넣거나 복사본을 만들어야 했을 겁니다.

예제 1의 나머지: gdb 로 본 r->uri

예제 1에서 같은 정지점에서 Host: example.test 헤더를 붙인 요청의 ngx_str_t 들도 찍었습니다.

=== 1. ngx_str_t is not NUL-terminated ===
$1 = {len = 11, data = 0x555555783414 "/index.html HTTP/1.1\r\nHost"}
$2 = 11
$3 = {len = 12, data = 0x555555783430 "example.test"}

gdb 는 u_char * 를 C 문자열로 여겨 '\0' 이 나올 때까지 찍습니다. 그래서 r->uri 는 길이가 11인데도 그 뒤의 HTTP/1.1\r\nHost 까지 보입니다. URI 가 헤더 버퍼 안을 그대로 가리키고 있다는 증거입니다. 그런데 왜 Host 에서 멈췄을까요? 원래 그 자리에는 : 가 있어야 합니다.

            h = ngx_list_push(&r->headers_in.headers);
            if (h == NULL) {
                ngx_http_close_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
                break;
            }

            h->hash = r->header_hash;

            h->key.len = r->header_name_end - r->header_name_start;
            h->key.data = r->header_name_start;
            h->key.data[h->key.len] = '\0';

            h->value.len = r->header_end - r->header_start;
            h->value.data = r->header_start;
            h->value.data[h->value.len] = '\0';

src/http/ngx_http_request.c 1512–1526행

헤더를 읽는 ngx_http_process_request_headers() 가 헤더 이름 바로 뒤(: 자리)와 값 바로 뒤(\r 자리)에 '\0' 을 써 넣습니다. 버퍼 안의 바이트를 덮어써서, 헤더 이름과 값은 C 문자열로도 쓸 수 있게 만든 것입니다. 그래서 $3(Host 값)은 example.test 에서 딱 멈췄습니다. 요청 줄의 URI 에는 이런 처리가 없습니다.

헤더 버퍼 c->buffer 안의 바이트 GET, /index.html, HTTP/1.1 CR LF, Host, NUL, 공백, example.test, NUL 이 위에서 아래로 차례로 있다. NUL 두 칸은 원래 콜론과 CR 이던 자리라 빨간색이다. r->uri(len 11)의 data 는 /index.html 을, Host 헤더 key(len 4)의 data 는 Host 를, value(len 12)의 data 는 example.test 를 가리킨다. ngx_str_t 셋이 모두 버퍼 하나를 가리킵니다. 빨간 두 칸은 nginx 가 덮어쓴 바이트입니다.

문자열을 다루는 도구

도구 하는 일 위치
ngx_string("text") 문자열 상수로 { 길이, 주소 } 초기값을 만든다. sizeof(str) - 1 이라 배열 상수에만 쓴다 ngx_string.h 40행
ngx_null_string, ngx_str_set(), ngx_str_null() 빈 값, 대입용 매크로 41–44행
ngx_pstrdup(pool, &str) 풀에 복사본을 만든다. 끝에 '\0' 을 붙이지 않는다 ngx_string.c 74–87행
ngx_sprintf() 의 %V ngx_str_t * 를 길이만큼 찍는다 ngx_string.c 106행(설명), 252–258행

nginx 의 로그 함수는 모두 ngx_sprintf 계열이라, 소스 곳곳에서 "invalid server name or wildcard \"%V\" on %V" 처럼 %V 로 ngx_str_t 를 찍는 것을 보게 됩니다. server_name 이름 목록을 만드는 ngx_http.c 1646행이 그 예입니다.

헷갈리기 쉬운 곳

6. 배열·리스트·큐·해시·레드블랙 트리

nginx 는 C 표준 라이브러리에 없는 자료구조를 직접 만들어 씁니다. 모두 풀에서 메모리를 받는다는 점이 같고, 그래서 따로 "없애는" 함수가 거의 없습니다.

세 자료구조의 모양. ngx_array_t 는 elts, nelts, size, nalloc, pool 필드를 갖고 elts 가 원소가 연속된 덩어리 하나를 가리킨다. 차면 두 배 크기 새 덩어리로 복사하고 옛 덩어리는 풀에 그대로 남는다. ngx_list_t 는 part 가 원소 nalloc 개짜리 덩어리를 가리키고 그 덩어리의 next 가 다음 덩어리를 가리킨다. ngx_queue_t 는 머리와 ngx_connection_t 안의 queue 필드들이 next 로 고리를 이룬다. 회색 상자는 아무도 가리키지 않지만 풀이 없어질 때까지 메모리를 차지하는 옛 덩어리입니다.

ngx_array_t — 늘어나는 배열

typedef struct {
    void        *elts;
    ngx_uint_t   nelts;
    size_t       size;
    ngx_uint_t   nalloc;
    ngx_pool_t  *pool;
} ngx_array_t;

src/core/ngx_array.h 16–22행

elts 는 원소들이 붙어 있는 메모리, nelts 는 지금 원소 수, size 는 원소 하나의 크기, nalloc 은 자리 수입니다. 원소의 자료형을 모르므로 void * 와 size 로 다루고, 쓰는 쪽에서 elts 를 자기 자료형 포인터로 바꿔 읽습니다. 원소를 하나 더하는 함수의 "꽉 찼을 때" 부분입니다.

    if (a->nelts == a->nalloc) {

        /* the array is full */

        size = a->size * a->nalloc;

        p = a->pool;

        if ((u_char *) a->elts + size == p->d.last
            && p->d.last + a->size <= p->d.end)
        {
            /*
             * the array allocation is the last in the pool
             * and there is space for new allocation
             */

            p->d.last += a->size;
            a->nalloc++;

        } else {
            /* allocate a new array */

            new = ngx_palloc(p, 2 * size);
            if (new == NULL) {
                return NULL;
            }

            ngx_memcpy(new, a->elts, size);
            a->elts = new;
            a->nalloc *= 2;
        }
    }

src/core/ngx_array.c 54–85행

풀의 포인터 밀기를 아는 사람만 짤 수 있는 코드입니다. 배열이 풀의 첫 블록에서 마지막으로 받은 것이고 뒤에 자리가 있으면, d.last 를 원소 하나만큼 더 밀어 제자리에서 늘립니다. 아니면 두 배 크기를 새로 받아 복사합니다. 옛 덩어리는 돌려줄 방법이 없으니 풀에 남습니다(위 그림의 회색 상자).

설정에서 보던 것과 잇자면, 2편·9편의 phase 핸들러 목록이 이 배열입니다. 정적 파일 모듈은 설정을 읽은 뒤 h = ngx_array_push(&cmcf->phases[NGX_HTTP_CONTENT_PHASE].handlers); 로 자기 핸들러를 끼웁니다(ngx_http_static_module.c 289행). server_name 의 이름들, listen 주소들도 배열입니다.

ngx_list_t — 원소가 움직이지 않는 리스트

struct ngx_list_part_s {
    void             *elts;
    ngx_uint_t        nelts;
    ngx_list_part_t  *next;
};


typedef struct {
    ngx_list_part_t  *last;
    ngx_list_part_t   part;
    size_t            size;
    ngx_uint_t        nalloc;
    ngx_pool_t       *pool;
} ngx_list_t;

src/core/ngx_list.h 18–31행

배열 덩어리(part)를 사슬로 이은 것입니다. 마지막 part 가 차면 ngx_list_push() 가 같은 크기의 part 를 하나 더 받아 끝에 붙입니다(ngx_list.c 30–63행). 복사하지 않으므로 한 번 넣은 원소의 주소가 바뀌지 않습니다.

이것이 요청 헤더가 배열이 아니라 리스트인 이유입니다. 5절의 코드에서 헤더 한 줄은 ngx_list_push(&r->headers_in.headers) 로 들어갔고(headers 는 ngx_list_t, ngx_http_request.h 187행), r->headers_in.host 같은 필드는 그 리스트 안의 원소를 포인터로 가리킵니다(190행). 배열이었다면 헤더가 늘어 복사가 일어날 때 이 포인터들이 옛 덩어리를 가리키게 됩니다. 응답 헤더 headers_out.headers 도 같은 이유로 리스트이고, 1절 ngx_http_alloc_request() 에서 처음부터 20칸으로 만듭니다(609–615행).

리스트를 도는 방법은 헤더 파일에 주석으로 적혀 있습니다(part 안의 i 가 nelts 에 닿으면 다음 part 로, ngx_list.h 55–77행). 헤더를 도는 모듈 코드에서 이 모양을 그대로 보게 됩니다.

ngx_queue_t — 구조체 안에 박는 양방향 고리

struct ngx_queue_s {
    ngx_queue_t  *prev;
    ngx_queue_t  *next;
};

src/core/ngx_queue.h 18–21행

데이터 필드가 없습니다. 대신 고리에 넣고 싶은 구조체 안에 ngx_queue_t 필드를 하나 둡니다(이런 방식을 흔히 "침투형(intrusive) 리스트"라고 부릅니다). 예를 들어 ngx_connection_t 에는 ngx_queue_t queue; 필드가 있습니다(ngx_connection.h 168행). 고리에서 꺼낸 ngx_queue_t * 로 바깥 구조체를 되찾는 것이 이 매크로입니다.

#define ngx_queue_data(q, type, link)                                         \
    (type *) ((u_char *) q - offsetof(type, link))

src/core/ngx_queue.h 106–107행

offsetof(type, link) 는 구조체 맨 앞에서 그 필드까지의 바이트 수입니다. 필드의 주소에서 그만큼 빼면 구조체의 시작 주소가 됩니다. 연결이 모자랄 때 쉬고 있는 keep-alive 연결을 골라 닫는 ngx_drain_connections() 가 이렇게 씁니다.

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

src/core/ngx_connection.c 1434–1435행

넣고 빼는 일(ngx_queue_insert_head, ngx_queue_remove …)은 모두 포인터 몇 개를 바꾸는 매크로라 할당이 전혀 없습니다. 12편의 upstream keepalive 캐시도 이 큐로 연결을 넣었다 꺼냅니다.

ngx_hash_t — 설정 때 한 번 짓고 읽기만 하는 해시

해시 표는 server_name 으로 가상 서버를 찾을 때, 변수 이름을 찾을 때, map 을 찾을 때 쓰입니다. nginx 의 해시는 보통의 해시 표와 성격이 다릅니다. 설정을 읽을 때 키를 전부 모아 한 번에 짓고, 그 뒤로는 넣지도 빼지도 않습니다. 찾는 함수는 이렇습니다.

void *
ngx_hash_find(ngx_hash_t *hash, ngx_uint_t key, u_char *name, size_t len)
{
    ngx_uint_t       i;
    ngx_hash_elt_t  *elt;

    /* ... */

    elt = hash->buckets[key % hash->size];

    if (elt == NULL) {
        return NULL;
    }

    while (elt->value) {
        if (len != (size_t) elt->len) {
            goto next;
        }

        for (i = 0; i < len; i++) {
            if (name[i] != elt->name[i]) {
                goto next;
            }
        }

        return elt->value;

    next:

        elt = (ngx_hash_elt_t *) ngx_align_ptr(&elt->name[0] + elt->len,
                                               sizeof(void *));
        continue;
    }

    return NULL;
}

src/core/ngx_hash.c 12–49행

한 버킷(bucket, 같은 해시 값끼리 모인 칸)의 원소들은 연결 리스트가 아니라 메모리에 줄지어 있습니다. 원소 하나는 value 포인터, len, 이름 글자들이고(ngx_hash.h 16–20행), 다음 원소는 이름 끝을 8바이트로 정렬한 자리에서 시작합니다. 버킷 끝은 value == NULL 인 칸이 표시합니다. 포인터를 따라 메모리를 뛰어다니지 않으니 CPU 캐시에 잘 맞습니다.

대신 "버킷 하나의 바이트 수"에 상한이 있습니다. 이것이 nginx 를 운영하다 한 번쯤 보는 그 오류입니다. 버킷 크기는 server_names_hash_bucket_size, 기본값은 CPU 캐시 줄 크기(ngx_cacheline_size)입니다(ngx_http_core_module.c 3549–3554행). 해시를 짓는 ngx_hash_init() 은 먼저 이름 하나하나가 버킷 하나에 들어가는지 봅니다.

#define NGX_HASH_ELT_SIZE(name)                                               \
    (sizeof(void *) + ngx_align((name)->key.len + 2, sizeof(void *)))

src/core/ngx_hash.c 248–249행

        if (hinit->bucket_size < NGX_HASH_ELT_SIZE(&names[n]) + sizeof(void *))
        {
            ngx_log_error(NGX_LOG_EMERG, hinit->pool->log, 0,
                          "could not build %s, you should "
                          "increase %s_bucket_size: %i",
                          hinit->name, hinit->name, hinit->bucket_size);
            return NGX_ERROR;
        }

src/core/ngx_hash.c 281–288행

원소 크기는 value 포인터 8바이트 + (이름 길이 + len 2바이트를 8의 배수로 올린 것)이고, 버킷 끝의 NULL 칸 8바이트가 더 있어야 합니다. 버킷이 64바이트라면 8 + align(len + 2, 8) + 8 ≤ 64, 곧 이름이 46글자까지입니다. 계산이 맞는지 돌려 봤습니다.

ngx_hash_find 가 buckets[key % size] 로 버킷 하나를 고른다. 버킷 안에는 원소들이 메모리에 줄지어 있고, 원소 하나는 value 포인터 8바이트, len 2바이트, 이름이며 이름 뒤는 8의 배수로 올린다. 원소들 뒤에 value 가 NULL 인 8바이트 끝 표시가 있다. 버킷 크기는 server_names_hash_bucket_size 로, 이 컨테이너에서는 64바이트다. 이름 46글자는 8 + align(46 + 2, 8) + 8 = 8 + 48 + 8 = 64 로 64 이하라 통과하고(예제 3의 test is successful), 47글자는 8 + align(47 + 2, 8) + 8 = 8 + 56 + 8 = 72 로 64 를 넘어 could not build server_names_hash 오류가 난다(예제 3의 bucket_size: 64). 버킷 하나에 이름 하나가 들어갈 수 있는지를 바이트로 셈한 그림. 64 는 아래 예제 3 출력의 버킷 크기입니다.

예제 3: server_name 46글자와 47글자

server_name 길이만 바꿔 nginx -t 를 돌리는 스크립트입니다(ex/u03-hash-bucket/run.sh). 두 번째 server {} 의 이름을 a 33개 + .example.test(46글자)와 a 34개 + .example.test(47글자)로 바꿔 시험하고, 마지막에 47글자 그대로 server_names_hash_bucket_size 128; 을 더해 봤습니다.

=== server_name of 46 chars ===
nginx: the configuration file /tmp/u03h/nginx.conf syntax is ok
nginx: configuration file /tmp/u03h/nginx.conf test is successful
=== server_name of 47 chars ===
nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 64
nginx: configuration file /tmp/u03h/nginx.conf test failed
=== same 47-char name with server_names_hash_bucket_size 128 ===
nginx: the configuration file /tmp/u03h/nginx.conf syntax is ok
nginx: configuration file /tmp/u03h/nginx.conf test is successful

계산대로 46글자는 통과하고 47글자에서 위 소스의 오류 문장이 그대로 나옵니다. 오류 끝의 64 가 이 컨테이너의 캐시 줄 크기입니다. "server_names_hash_bucket_size 를 늘리라"는 흔한 조언이 왜 통하는지가 이 한 줄에 있습니다. 버킷 수(server_names_hash_max_size, 기본 512)를 넘게 되면 나오는 "could not build optimal …" 경고도 같은 함수에 있습니다(ngx_hash.c 337–344행).

*.example.com 같은 와일드카드 이름은 보통 해시와 별도로 wc_head·wc_tail 해시에 들어가고, 찾을 때는 ngx_hash_find_combined() 가 정확한 이름 → 앞 와일드카드 → 뒤 와일드카드 순서로 봅니다(ngx_hash.c 211–245행). 와일드카드 해시는 이름을 점(.) 단위로 끊어 층층이 짓고, 값 포인터의 아래 두 비트에 "이 값이 데이터인지, 다음 층 해시인지"를 숨겨 둡니다(90–99행의 주석). 요청의 Host 로 가상 서버를 찾는 과정은 8편에서 봅니다.

해시를 짓는 쪽에서는 풀 사용법도 하나 볼 수 있습니다. server_name 해시를 짓는 ngx_http_server_names() 는 짓는 동안만 쓸 임시 풀 ha.temp_pool 을 만들고(ngx_http.c 1611행), 완성된 해시는 cf->pool(cycle 풀)에 남긴 채 임시 풀만 없앱니다(1710행). "결과는 오래 사는 풀에, 작업 중의 찌꺼기는 짧게 사는 풀에"라는 풀 쓰기의 기본형입니다. ngx_init_cycle() 이 설정을 읽는 동안 쓰는 conf.temp_pool 도 같은 생각입니다(ngx_cycle.c 262행, 781행).

ngx_rbtree_t — 타이머가 쓰는 레드블랙 트리

레드블랙 트리는 넣고 빼고 가장 작은 값을 찾는 일이 모두 원소 수의 로그에 비례하는 시간으로 끝나는, 스스로 균형을 잡는 이진 탐색 트리입니다. nginx 의 것도 큐와 같은 침투형입니다. 노드 ngx_rbtree_node_t 는 key·left·right·parent·color·data 를 가진 작은 구조체이고(22–29행), 트리는 이렇습니다.

typedef struct ngx_rbtree_s  ngx_rbtree_t;

typedef void (*ngx_rbtree_insert_pt) (ngx_rbtree_node_t *root,
    ngx_rbtree_node_t *node, ngx_rbtree_node_t *sentinel);

struct ngx_rbtree_s {
    ngx_rbtree_node_t     *root;
    ngx_rbtree_node_t     *sentinel;
    ngx_rbtree_insert_pt   insert;
};

src/core/ngx_rbtree.h 32–41행

트리마다 넣는 자리를 고르는 함수 insert 를 함수 포인터로 받는다는 점이 특징입니다. 균형 잡기는 공통이고 "같은 키를 어떻게 다룰지" 같은 비교만 쓰는 쪽이 정합니다. 가장 많이 쓰는 곳은 이벤트 타이머입니다. ngx_event_t 안에 ngx_rbtree_node_t timer; 가 박혀 있고(ngx_event.h 97행), 만료된 타이머를 처리할 때 ev = ngx_rbtree_data(node, ngx_event_t, timer); 로 바깥 이벤트를 되찾습니다(ngx_event_timer.c 76행). ngx_queue_data 와 똑같은 offsetof 기법입니다. 타이머가 이벤트 루프에서 어떻게 쓰이는지는 7편에서 봅니다.

맛보기: 워커끼리 나누는 메모리 — slab

지금까지의 풀과 자료구조는 모두 워커 한 프로세스 안의 것입니다. limit_req_zone, proxy_cache_path … keys_zone=, upstream { zone … } 처럼 워커끼리 상태를 나눠야 할 때는 공유 메모리를 쓰고, 그 안을 나누는 것이 ngx_slab_pool_t(src/core/ngx_slab.c)입니다. 풀과 반대로 조각 하나를 돌려주는 ngx_slab_free() 가 있고, 여러 프로세스가 동시에 쓰므로 할당 때마다 잠금을 잡습니다 — ngx_slab_alloc() 은 ngx_shmtx_lock() 과 ngx_shmtx_unlock() 사이에서 ngx_slab_alloc_locked() 를 부르는 것이 전부입니다(ngx_slab.c 169–180행).

"잠금 없음, 조각별 해제 없음"인 풀과 "잠금 있음, 조각별 해제 있음"인 slab — 쓰임이 다르니 만듦새도 정반대입니다. slab 의 페이지·크기 등급 관리는 이 글에서 다루지 않습니다.

7. ngx_buf_t 와 ngx_chain_t — 응답을 담는 그릇

마지막으로, 응답 데이터가 실려 나가는 구조체입니다.

struct ngx_buf_s {
    u_char          *pos;
    u_char          *last;
    off_t            file_pos;
    off_t            file_last;

    u_char          *start;         /* start of buffer */
    u_char          *end;           /* end of buffer */
    ngx_buf_tag_t    tag;
    ngx_file_t      *file;
    ngx_buf_t       *shadow;


    /* the buf's content could be changed */
    unsigned         temporary:1;

    /*
     * the buf's content is in a memory cache or in a read only memory
     * and must not be changed
     */
    unsigned         memory:1;

    /* the buf's content is mmap()ed and must not be changed */
    unsigned         mmap:1;

    unsigned         recycled:1;
    unsigned         in_file:1;
    unsigned         flush:1;
    unsigned         sync:1;
    unsigned         last_buf:1;
    unsigned         last_in_chain:1;

    unsigned         last_shadow:1;
    unsigned         temp_file:1;

    /* STUB */ int   num;
};


struct ngx_chain_s {
    ngx_buf_t    *buf;
    ngx_chain_t  *next;
};

src/core/ngx_buf.h 20–62행

버퍼 하나가 메모리일 수도, 파일일 수도 있다는 것이 핵심입니다.

: 1 은 C 의 비트 필드로, 필드 하나가 1비트만 차지합니다. 플래그 열한 개가 unsigned 하나에 들어갑니다. 비트들을 묶어 읽는 매크로도 있습니다 — 예를 들어 ngx_buf_size(b) 는 메모리 버퍼면 last - pos, 아니면 file_last - file_pos 입니다(ngx_buf.h 125–138행).

ngx_chain_t 는 버퍼 하나와 다음 고리, 두 필드뿐인 한 방향 리스트입니다. 정적 파일 핸들러가 응답 본문을 만드는 마지막 부분을 보면 둘이 어떻게 쓰이는지 한눈에 들어옵니다.

    b->file_pos = 0;
    b->file_last = of.size;

    b->in_file = b->file_last ? 1 : 0;
    b->last_buf = (r == r->main) ? 1 : 0;
    b->last_in_chain = 1;
    b->sync = (b->last_buf || b->in_file) ? 0 : 1;

    b->file->fd = of.fd;
    b->file->name = path;
    b->file->log = log;
    b->file->directio = of.is_directio;

    out.buf = b;
    out.next = NULL;

    return ngx_http_output_filter(r, &out);

src/http/modules/ngx_http_static_module.c 261–277행

파일 내용은 한 바이트도 읽지 않았습니다. 버퍼는 ngx_calloc_buf(r->pool) 로 요청 풀에서 받았고(245행), 파일 전체(0 부터 of.size 까지)를 가리키는 파일 버퍼 하나를 고리 하나(out)에 실어 필터 체인으로 넘깁니다. 고리 out 은 풀도 아닌 함수의 지역 변수(스택)입니다. 필터가 이 고리를 붙잡아 둬야 하면 자기 고리로 옮겨 담습니다. 여기서 연 파일 fd 는 4절에서 본 cleanup 이 요청 풀과 함께 닫습니다.

ngx_chain_t 두 개가 next 로 이어져 있다. 첫 고리의 buf 는 temporary 또는 memory 비트가 켜진 메모리 버퍼로, start·pos·last·end 가 메모리 조각을 가리키고 보낼 것은 pos 부터 last 까지다. 둘째 고리의 buf 는 in_file 과 last_buf 가 켜진 파일 버퍼로, file 이 fd 와 name 을 가진 ngx_file_t 를, file_pos·file_last 가 디스크 파일 안의 범위를 가리킨다. 응답 헤더(메모리)와 파일 본문(파일)이 체인 하나에 섞여 실릴 수 있습니다.

다 쓴 고리는 풀의 chain 에

2절에서 지나친 ngx_pool_t 의 chain 필드가 여기서 쓰입니다. 필터는 요청 하나를 보내는 동안 고리를 수없이 만들고 버리는데, 고리 하나(16바이트)마다 풀에서 새로 받으면 블록이 금방 찹니다. 그래서 다 쓴 고리는 풀의 chain 목록에 걸어 두고 다음에 꺼내 씁니다. 고리를 받는 ngx_alloc_chain_link() 는 pool->chain 에 고리가 있으면 맨 앞 것을 떼어 주고, 없을 때만 ngx_palloc() 합니다(src/core/ngx_buf.c 47–65행).

돌려놓는 쪽 ngx_free_chain(pool, cl) 은 고리를 pool->chain 맨 앞에 끼우는 두 줄짜리 매크로입니다(ngx_buf.h 148–150행). 4절의 keep-alive 코드에서 헤더 버퍼 고리를 정리할 때도 이것을 썼습니다(ngx_free_chain(c->pool, ln), ngx_http_request.c 3494행). 같은 생각으로 필터들은 다 쓴 버퍼도 free·busy 목록으로 돌려 씁니다(ngx_chain_update_chains()).

필터 체인이 이 체인을 어떻게 넘겨받아 헤더를 붙이고, gzip 으로 바꾸고, 마지막에 writev·sendfile 로 보내는지는 10편에서 봅니다.

헷갈리기 쉬운 곳

8. httpd 의 APR 풀과 비교하면

httpd 4편에서 본 APR 풀과 나란히 놓으면, 같은 생각을 얼마나 다르게 구현했는지가 보입니다. APR 쪽은 httpd 4편에서 소스로 확인한 내용입니다.

APR 풀 (httpd) ngx_pool_t (nginx)
할당의 빠른 길 지금 노드에서 포인터 밀기 지금 블록(current)에서 포인터 밀기 — 같다
정렬 언제나 8바이트 ngx_palloc 은 NGX_ALIGNMENT(8), ngx_pnalloc 은 정렬 안 함
노드/블록이 찼을 때 allocator 에서 노드를 받는다(목록에 있으면 재활용) ngx_memalign() 으로 같은 크기 블록을 새로 받는다. 재활용 목록이 없다
큰 할당 그만큼 큰 노드를 만든다 max 를 넘으면 malloc() 해서 large 목록에 단다
조각 하나 돌려주기 없다 ngx_pfree() — large 만
풀끼리의 관계 부모·자식 나무. 부모를 없애면 자식이 먼저 관계 없음. 요청 풀·연결 풀을 코드가 따로 없앤다
cleanup 순서 나중에 건 것부터 나중에 건 것부터 — 같다
다 쓴 메모리의 행방 allocator 가 쥐고 있다가 재사용 (MaxMemFree) free() 로 C 라이브러리에 바로 돌려준다
비우기 apr_pool_clear() 를 자주 쓴다 ngx_reset_pool() 이 있으나 거의 안 쓰고 없앴다 다시 만든다

nginx 쪽이 훨씬 단순합니다. ngx_palloc.c 는 430줄이고, 노드 재활용·나무 구조·스레드 대비가 모두 빠져 있습니다. 워커 하나가 스레드 하나로 이벤트 루프를 도는 구조(2편)라서, httpd 처럼 여러 스레드·여러 MPM 을 감당할 필요가 없기 때문으로 보입니다(설계 의도는 소스에 적혀 있지 않으니 추측입니다). 재활용 목록이 없는 대신 블록을 C 라이브러리의 malloc 에 돌려주고, 예제 2에서 본 것처럼 malloc 이 같은 자리를 다시 내주는 것에 기댑니다.

왼쪽은 APR 풀(httpd)이다. 부모 풀이 자식 풀을 두고, 부모를 없애면 자식이 먼저 없어진다. 자식 풀의 다 쓴 노드는 allocator 의 재활용 목록으로 가고, allocator 는 MaxMemFree 까지 쥐고 있다가 다음 노드 요청에 재활용한다. 오른쪽은 ngx_pool_t(nginx)다. cycle 풀, 연결 풀, 요청 풀은 서로 관계가 없고 코드가 따로 없앤다. ngx_destroy_pool 은 블록을 free() 로 C 라이브러리 malloc 에 돌려주고, malloc 이 같은 크기 요청에 같은 자리를 다시 내준다. 예제 2에서 …76700 을 두 번 받은 것이 그것이다. 위 표의 '풀끼리의 관계'와 '다 쓴 메모리의 행방' 두 줄을 그린 것. APR 쪽은 httpd 4편에서 확인한 내용, 주소는 예제 2의 출력입니다.

이 글에서 뺀 것

큰 줄기를 위해 다음은 이름과 위치만 적어 둡니다.

이 글의 풀·자료구조 파일(ngx_palloc.[ch], ngx_array.c, ngx_list.c, ngx_hash.c, ngx_buf.h, ngx_string.h, ngx_queue.h)은 stable 판 1.30.6 과 이 커밋 사이에 바뀐 줄이 없습니다. 1.30.x 를 쓰고 있어도 이 글의 설명은 그대로 맞습니다.

정리

개념 한 줄 요약
풀 (ngx_pool_t) 수명이 같은 메모리를 담는 통. 블록 사슬 + large 목록 + cleanup 목록
세 가지 수명 cycle 풀(16KB, 설정 한 벌), 연결 풀(connection_pool_size, 기본 512), 요청 풀(request_pool_size, 기본 4096)
ngx_palloc() size ≤ max 면 current 블록부터 포인터 밀기, 넘으면 malloc() 해서 large 로
ngx_pnalloc() 정렬하지 않는 ngx_palloc(). 문자열용
failed·current 새 블록이 필요해질 때마다 지나온 블록의 failed++, 이미 5인 블록은 건너뛴다
ngx_destroy_pool() cleanup(나중에 건 것부터) → large free() → 블록 free(). 풀 구조체도 첫 블록과 함께 사라진다
ngx_pfree() large 만 돌려줄 수 있다. keep-alive 연결의 헤더 버퍼를 놓을 때 쓴다
ngx_str_t 길이 + 주소. '\0' 이 없어도 되므로 버퍼 안을 복사 없이 가리킨다. %V 로 찍는다
ngx_array_t 연속 메모리. 차면 두 배로 복사(옛 덩어리는 풀에 남음)
ngx_list_t part 사슬. 원소가 움직이지 않아 요청·응답 헤더에 쓴다
ngx_queue_t·ngx_rbtree_t 구조체 안에 박는 고리·노드. offsetof 로 바깥 구조체를 되찾는다
ngx_hash_t 설정 때 한 번 짓는 읽기 전용 해시. 버킷 크기 초과가 …_bucket_size 오류
ngx_buf_t 메모리(pos~last) 또는 파일(file_pos~file_last)의 한 구간 + 플래그 비트
ngx_chain_t 버퍼를 잇는 한 방향 리스트. 다 쓴 고리는 pool->chain 으로 돌려 쓴다

다음에 볼 것

이 글에서 "cycle 풀"이라고만 하고 넘어간 것을 만드는 곳으로 갑니다. main() 이 명령줄을 읽고, ngx_init_cycle() 이 새 풀을 만들어 그 위에 설정 파일을 읽어 올리고, reload 때 같은 함수가 다시 불려 옛 cycle 옆에 새 cycle 을 세우는 과정이 4편: 기동과 설정 읽기 — main()에서 ngx_init_cycle까지에서 이어집니다. nginx.conf 를 토큰으로 자르는 ngx_conf_read_token() 도 거기서 봅니다.

다른 글