namalzi

← 글 목록

Apache httpd 소스 파헤치기 8: MPM — prefork, worker, event는 연결을 어떻게 나눠 맡나

Apache httpd · 게시

httpd.conf 의 LoadModule mpm_event_module 한 줄이 무엇을 바꾸는지 소스로 따라갑니다. MPM 이 모듈 하나로 갈아 끼워지는 원리(mpm 훅과 ap_mpm_query), 프로세스만 쓰는 prefork, 리스너 스레드와 큐로 일을 나누는 worker, keep-alive 연결을 스레드에서 떼어 epoll 에 맡기는 event, 1초마다 자식을 늘리고 줄이는 부모의 루프와 공유 메모리 스코어보드, 그리고 StartServers·MaxRequestWorkers·ThreadsPerChild 가 코드의 어디서 쓰이는지를 ps·mod_status·gdb 실행 결과로 확인합니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 여덟 번째 글입니다. 5편에서는 main() 이 설정을 읽고 나서 ap_run_mpm() 을 부르면, 그 안에서 MPM 이 자식 프로세스를 띄우고 신호를 기다린다는 데까지 봤습니다. 이번 글은 그 ap_run_mpm() 의 안쪽, 접속을 받아 누구에게 맡길지 정하는 부품을 엽니다.

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

항목 내용
대상 Apache httpd
버전 2.5.1-dev (trunk)
커밋 e4af30fe6d (2026-10-24)
주로 읽는 파일 include/ap_mpm.h, server/mpm_common.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c, server/mpm/event/event.c, server/mpm_fdqueue.c, server/scoreboard.c, include/scoreboard.h, server/listen.c

글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제 환경을 만드는 방법은 1편에 있습니다. MPM 파일들은 하나에 1,600~4,500줄이나 되고 신호 처리·재시작·오류 대응이 대부분을 차지합니다. 이 글은 처음 소스를 여는 분이 따라올 수 있게 "접속이 어디로 흘러가는가"라는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름만 적어 두었습니다.

먼저 알아 둘 말

프로세스와 스레드: 프로세스는 실행 중인 프로그램 하나로, 자기만의 메모리를 가집니다. fork() 로 자기를 복사해 자식 프로세스를 만들 수 있고, 복사된 뒤에는 서로의 메모리를 보지 못합니다. 스레드는 한 프로세스 안에서 동시에 도는 실행 흐름으로, 같은 메모리를 함께 씁니다. 프로세스는 서로 격리되어 하나가 죽어도 다른 것은 멀쩡하지만 무겁고, 스레드는 가볍지만 하나가 메모리를 망가뜨리면 프로세스 전체가 죽습니다. httpd 의 "부모 프로세스 하나 + 자식 여럿" 구조는 5편에서 봤습니다.

MPM(Multi-Processing Module): 듣기 소켓에서 접속을 받아(accept) 어느 프로세스·스레드가 처리할지 정하는 모듈입니다. 받은 접속을 실제로 읽고 응답하는 것은 코어와 다른 모듈이고, MPM 은 "누가 맡을지"와 "일꾼을 몇이나 둘지"만 정합니다.

듣기 소켓(listening socket)과 accept: Listen 80 은 80번 포트에 "접속을 받겠다"고 열어 두는 소켓을 만듭니다. 손님이 접속하면 운영체제가 그 소켓에 줄을 세워 두고, 서버가 accept() 를 부르면 그 손님 전용의 새 소켓(연결 소켓)을 하나 내줍니다. 이후의 대화는 이 새 소켓으로 합니다.

keep-alive: HTTP/1.1 에서는 응답을 보낸 뒤에도 연결을 끊지 않고 같은 연결로 다음 요청을 받을 수 있습니다. 다음 요청이 언제 올지는 모르므로 서버는 KeepAliveTimeout(기본 5초) 동안 기다립니다. 이 "기다리는 동안 누가 연결을 붙잡고 있나"가 이 글의 핵심 질문입니다.

poll / epoll: 소켓 여러 개를 한꺼번에 등록해 두고 "이 가운데 읽을 것이 생긴 소켓을 알려 달라"고 운영체제에 묻는 기능입니다. Linux 에서는 epoll 이고, APR(3편에서 본 운영체제 추상화 층)은 이것을 apr_pollset_* 함수로 감싸 둡니다. 스레드 하나가 소켓 수천 개를 지켜볼 수 있게 해 줍니다.

훅: 코어가 정해 둔 자리에 모듈이 함수를 걸어 두면 코어가 그 자리에서 불러 주는 방식입니다. 훅을 부르는 방식에는 "누가 처리하면 거기서 멈추는" RUN_FIRST 와 "모두 부르는" RUN_ALL 이 있습니다. 자세한 것은 6편에 있습니다.

1. MPM 은 왜 갈아 끼우는 부품인가

운영체제마다, 쓰임마다 정답이 다르다

접속을 나눠 맡기는 방법에는 정답이 하나가 아닙니다. 스레드가 없거나 믿을 수 없는 운영체제도 있었고, 스레드에 안전하지 않은 라이브러리를 쓰는 모듈(예전의 PHP 모듈이 대표적입니다)은 프로세스 하나에 요청 하나가 안전합니다. Windows 는 fork() 가 없어 전혀 다른 방식이 필요합니다. 실제로 server/mpm/ 폴더에는 이 커밋에서 여덟 가지 MPM 이 있습니다 — prefork, worker, event, winnt(Windows), mpmt_os2(OS/2), netware, 그리고 trunk 에만 있는 실험적인 motorz, simple. 2.4.x 브랜치에는 motorz 와 simple 폴더가 없습니다.

그래서 httpd 는 이 결정을 코어에 박아 두지 않고 모듈 하나로 떼어 냈습니다. httpd.conf 의 이 줄이 그것입니다.

LoadModule mpm_event_module modules/mod_mpm_event.so

이 줄을 mod_mpm_prefork.so 로 바꾸면 같은 httpd 실행 파일이 전혀 다른 방식으로 접속을 받습니다. (이 예제 환경은 --enable-mpms-shared=all 로 빌드해서 MPM 이 .so 로 따로 있습니다. 빌드할 때 MPM 하나를 실행 파일에 넣어 버리는 방법도 있습니다.)

코어와 MPM 사이의 약속: mpm 훅 하나

코어가 MPM 에게 바라는 것은 include/ap_mpm.h 의 훅 선언 한 줄로 요약됩니다.

AP_DECLARE_HOOK(int, mpm, (apr_pool_t *pconf, apr_pool_t *plog, server_rec *server_conf))

include/ap_mpm.h 93행

바로 위의 설명 주석은 "MPM 은 재시작이나 종료가 지시될 때까지 돌면서 부모와 자식 프로세스를 다스린다"고 적고 있고(83–92행), 파일 머리의 긴 주석은 접속이 오면 MPM 이 ap_process_connection() 으로 코어를 부른다고 약속합니다(37–81행). 이 훅은 RUN_FIRST 로 구현되어 있어서(server/mpm_common.c 103–105행) 걸린 함수 하나가 일을 맡으면 끝납니다. main() 은 설정을 다 읽은 뒤 이것을 부를 뿐입니다(server/main.c 882행).

각 MPM 은 6편에서 본 보통 모듈과 똑같이 register_hooks 함수에서 자기 함수를 겁니다. prefork 의 경우입니다.

    ap_hook_pre_config(prefork_pre_config, NULL, NULL, APR_HOOK_REALLY_FIRST);
    ap_hook_check_config(prefork_check_config, NULL, NULL, APR_HOOK_MIDDLE);
    ap_hook_mpm(prefork_run, NULL, NULL, APR_HOOK_MIDDLE);
    ap_hook_mpm_query(prefork_query, NULL, NULL, APR_HOOK_MIDDLE);
    ap_hook_mpm_get_name(prefork_get_name, NULL, NULL, APR_HOOK_MIDDLE);

server/mpm/prefork/prefork.c 1517–1521행

worker 는 worker_run 을(worker.c 2402행), event 는 event_run 을(event.c 4381행) 같은 자리에 겁니다. prefork_run 같은 함수들은 모두 static 입니다. C 에서 static 함수는 그 파일 밖에서 이름으로 부를 수 없다는 뜻이라(2편), 코어는 MPM 의 함수 이름을 하나도 모릅니다. 오직 훅에 걸린 함수 포인터로만 부릅니다. 이것이 "갈아 끼울 수 있다"의 정체입니다.

정확히 하나만 — ap_check_mpm()

MPM 은 하나만 있어야 합니다. 설정을 읽는 ap_read_config() 는 파일을 다 처리한 뒤 이 함수로 확인합니다(server/config.c 2355행).

AP_DECLARE(const char *)ap_check_mpm(void)
{
    static const char *last_mpm_name = NULL;

    if (!_hooks.link_mpm || _hooks.link_mpm->nelts == 0)
        return "No MPM loaded.";
    else if (_hooks.link_mpm->nelts > 1)
        return "More than one MPM loaded.";

    if (last_mpm_name) {
        if (strcmp(last_mpm_name, ap_show_mpm())) {
            return "The MPM cannot be changed during restart.";
        }
    }
    else {
        last_mpm_name = apr_pstrdup(ap_pglobal, ap_show_mpm());
    }

    return NULL;
}

server/mpm_common.c 656–675행

_hooks.link_mpm 은 mpm 훅에 걸린 함수들의 배열이고 nelts 는 그 개수입니다. 0개면 "No MPM loaded.", 2개 이상이면 "More than one MPM loaded." 입니다. static 지역 변수 last_mpm_name 은 함수가 끝나도 값이 남아 있으므로, 처음 읽은 MPM 이름을 기억해 두었다가 재시작(5편)에서 다른 MPM 으로 바뀌면 거절합니다.

MPM 이 끼워지는 방식. httpd.conf 의 LoadModule 로 MPM 모듈이 실리면 register_hooks 에서 mpm 훅과 mpm_query 훅에 함수를 건다. ap_check_mpm 이 mpm 훅이 정확히 하나인지 확인해 0개나 2개 이상이면 설정 오류로 멈추고, 하나면 main 의 ap_run_mpm 이 그 MPM 의 run 함수를 부른다. 다른 모듈은 ap_mpm_query 로 MPM 에게 스코어보드 크기나 비동기 여부를 묻는다. MPM 은 훅 두 개(실행과 질의)로 코어에 붙습니다. 파란색은 절차, 빨간색은 오류, 회색은 MPM 에게 묻는 쪽입니다.

다른 모듈은 MPM 에게 묻는다 — ap_mpm_query()

MPM 이 갈아 끼워지면 다른 모듈은 "지금 MPM 이 스레드를 쓰나? 프로세스는 최대 몇 개인가?"를 알 길이 필요합니다. 그 창구가 ap_mpm_query() 입니다. 질문 번호는 AP_MPMQ_* 상수로 정해져 있고(include/ap_mpm.h 149–186행), 함수 자체는 mpm_query 훅을 부르는 것이 전부입니다.

AP_DECLARE(apr_status_t) ap_mpm_query(int query_code, int *result)
{
    apr_status_t rv;

    if (ap_run_mpm_query(query_code, result, &rv) == DECLINED) {
        rv = APR_EGENERAL;
    }

    return rv;
}

server/mpm_common.c 496–505행

대답은 각 MPM 의 switch 문이 합니다. prefork 는 "스레드를 쓰나?"에 AP_MPMQ_NOT_SUPPORTED, "자식당 스레드 수"에 1 이라고 답하고(prefork.c 316–367행), event 는 "비동기(async) MPM 인가?"(AP_MPMQ_IS_ASYNC)에 1 이라고 답합니다(event.c 697–699행).

이 대답을 실제로 쓰는 곳이 HTTP 를 처리하는 http 모듈입니다. 시작할 때 MPM 이 비동기인지 물어 두고(modules/http/http_core.c 346–348행), 연결을 처리할 때 길을 나눕니다.

static int ap_process_http_connection(conn_rec *c)
{
    if (async_mpm && !c->clogging_input_filters) {
        return ap_process_http_async_connection(c);
    }
    else {
        return ap_process_http_sync_connection(c);
    }
}

modules/http/http_core.c 247–255행

sync 쪽은 한 연결의 요청들을 while 루프로 계속 읽고, async 쪽은 요청 하나만 처리하고 돌아갑니다. 이 두 함수의 속은 9편에서 봅니다. 이 글은 그 차이가 MPM 쪽에서 어떤 모양이 되는지를 4절에서 봅니다.

예제 1: MPM 을 빼거나 둘 넣어 보기, 그리고 httpd -V

이 글의 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev 로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u08-mpm/ 이고 run.sh 하나로 이 글의 예제를 모두 돌립니다. 설정 파일은 MPM 을 싣는 LoadModule 줄만 바꿔 끼우고 나머지(Listen 8080, mod_status, LogLevel debug)는 같게 두었습니다. 아래 명령들은 run.sh 에서 해당 부분만 줄여 옮긴 것입니다.

# 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다)
httpd -t -f /tmp/none.conf     # MPM 을 싣지 않은 설정
httpd -t -f /tmp/two.conf      # mod_mpm_event 와 mod_mpm_worker 를 둘 다 실은 설정
for m in prefork worker event; do
  httpd -V -f /tmp/$m.conf | grep -A2 "Server MPM"
done
=== 1. exactly one MPM ===
  AH00534: httpd: Configuration error: No MPM loaded.
  AH00534: httpd: Configuration error: More than one MPM loaded.

=== 2. same httpd, different MPM: the MPM lines of httpd -V (ap_mpm_query answers) ===
--- prefork
  Server MPM:     prefork
    threaded:     no
      forked:     yes (variable process count)
--- worker
  Server MPM:     worker
    threaded:     yes (fixed thread count)
      forked:     yes (variable process count)
--- event
  Server MPM:     event
    threaded:     yes (fixed thread count)
      forked:     yes (variable process count)

위의 두 줄은 ap_check_mpm() 이 돌려준 문자열 그대로입니다. 아래의 threaded:·forked: 줄은 httpd -V 를 처리하는 show_mpm_settings() 가 ap_mpm_query(AP_MPMQ_IS_THREADED, …) 와 AP_MPMQ_IS_FORKED 로 물어 받은 대답을 글로 바꾼 것입니다(server/main.c 64–100행). 실행 파일은 같은데 대답이 달라집니다.

헷갈리기 쉬운 곳: Listen 은 MPM 의 디렉티브다

Listen 80 은 코어의 설정처럼 보이지만, 소스에서 이 디렉티브를 등록하는 것은 MPM 입니다. include/ap_listen.h 에 Listen, ListenBacklog, SendBufferSize 등을 묶은 LISTEN_COMMANDS 매크로가 있고(176–190행), 각 MPM 의 디렉티브 표가 이것으로 시작합니다(예: prefork.c 1584행, event.c 4510행). 듣기 소켓을 여는 ap_setup_listeners() 도 각 MPM 의 open_logs 훅에서 부릅니다(prefork.c 1315행). 그러면 예제 1의 none.conf 처럼 MPM 을 싣지 않은 설정에서는 Listen 8080 이 "모르는 디렉티브" 오류를 낼 것 같지만, 실제로 httpd -t 는 "No MPM loaded." 로 멈췄습니다. ap_check_mpm() 은 설정 파일을 트리로 다 읽어 LoadModule 을 처리한 직후, 나머지 디렉티브를 실행하기 전에 불리기 때문입니다(server/config.c 2348–2362행). 설정이 두 단계로 처리되는 이야기는 7편에 있습니다.

2. prefork — 프로세스 하나에 연결 하나

prefork 는 가장 오래되고 가장 단순한 방식입니다. 이름 그대로 접속이 오기 전에(pre) 미리 fork 해 둔 자식 프로세스들이, 각자 스레드 하나로 접속을 하나씩 받아 처리합니다.

prefork 의 구조. root 로 도는 부모 프로세스가 daemon 사용자로 도는 자식 프로세스 여럿을 fork 한다. 각 자식은 스레드가 하나다. Listen 이 둘 이상이면 accept 뮤텍스를 잡은 자식 하나만 accept 하고, Linux 에서 Listen 이 하나면 뮤텍스 없이 accept 한다. 받은 연결은 ap_process_connection 으로 처리하며 그동안 자식 전체가 그 연결에 매여 있다. 처리가 끝나면 다시 accept 로 돌아간다. 듣기 소켓은 부모가 열어 두고 자식이 물려받는다. 빨간 상자가 prefork 의 약점입니다. 연결 하나가 자식 프로세스 하나를 통째로 차지합니다.

자식의 하루: child_main() 의 루프

자식 프로세스는 make_child() 에서 fork() 로 태어나(prefork.c 745행) 곧바로 child_main() 에 들어가 평생 이 루프를 돕니다. (/* ... */ 는 이 글에서 줄인 곳입니다.)

        apr_pool_clear(ptrans);

        if ((ap_max_requests_per_child > 0
             && requests_this_child++ >= ap_max_requests_per_child)) {
            clean_child_exit(0);
        }

        (void) ap_update_child_status(sbh, SERVER_READY, (request_rec *) NULL);

        /*
         * Wait for an acceptable connection to arrive.
         */

        /* Lock around "accept", if necessary */
        SAFE_ACCEPT(accept_mutex_on());
        /* ... Listen 이 여럿이면 poll 로 읽을 수 있는 듣기 소켓을 고른다 ... */
        status = lr->accept_func(&csd, lr, ptrans);

        SAFE_ACCEPT(accept_mutex_off());      /* unlock after "accept" */
        /* ... 오류 처리 ... */
        current_conn = ap_run_create_connection(ptrans, ap_server_conf, csd, my_child_num, sbh, bucket_alloc);
        if (current_conn) {
#if APR_HAS_THREADS
            current_conn->current_thread = thd;
#endif
            ap_process_connection(current_conn, csd);
            ap_lingering_close(current_conn);
        }

server/mpm/prefork/prefork.c 580–695행

  1. 풀을 비웁니다. ptrans 는 연결 하나 동안 쓰는 메모리 풀이고, 접속을 받기 전마다 비워 다시 씁니다(4편).
  2. 은퇴할 때인지 봅니다. MaxConnectionsPerChild 를 넘게 연결을 처리했으면 스스로 끝냅니다. 이 디렉티브는 MPM 이 아니라 코어에 등록되어 있고(server/core.c 4994행) 공용 변수 ap_max_requests_per_child 에 들어갑니다(server/mpm_common.c 411행).
  3. 스코어보드에 "놀고 있음"(SERVER_READY)을 적습니다. 스코어보드는 6절에서 봅니다.
  4. accept 합니다. lr->accept_func 는 듣기 소켓 구조체 ap_listen_rec 에 들어 있는 함수 포인터입니다(include/ap_listen.h 40행, 55–87행). Unix 에서는 ap_unixd_accept() 가 들어가고(prefork.c 559행), 그 안에서 APR 의 apr_socket_accept() 를 부릅니다(os/unix/unixd.c 303행). 함수 포인터가 무엇인지는 2편에 있습니다.
  5. 연결을 처리합니다. ap_process_connection() 은 이 연결의 요청을 읽고 응답하는 코어의 입구입니다(9편). 이 함수가 돌아올 때까지 — keep-alive 로 다음 요청을 기다리는 시간까지 포함해서 — 이 자식 프로세스는 다른 일을 할 수 없습니다.

accept 뮤텍스와 SAFE_ACCEPT

여러 자식이 같은 듣기 소켓에서 동시에 accept() 를 부르면 어떻게 될까요? Listen 이 하나이고 운영체제가 잘 처리해 주면 문제가 없지만, Listen 이 여럿이면 자식들은 먼저 poll 로 "어느 소켓에 손님이 왔나"를 본 뒤 accept 해야 하고, 그 사이에 다른 자식이 손님을 채 가면 엉뚱한 소켓에서 기다리게 될 수 있습니다. 그래서 prefork 는 프로세스 사이의 잠금(accept 뮤텍스)을 잡은 자식 하나만 accept 하게 합니다. 이 잠금을 할지 말지는 매크로 하나가 정합니다.

#ifdef SINGLE_LISTEN_UNSERIALIZED_ACCEPT
#define SAFE_ACCEPT(stmt) (ap_listeners->next ? (stmt) : APR_SUCCESS)
#else
#define SAFE_ACCEPT(stmt) (stmt)
#endif

server/mpm/prefork/prefork.c 310–314행

SINGLE_LISTEN_UNSERIALIZED_ACCEPT 는 "듣기 소켓이 하나면 잠그지 않아도 안전한 운영체제"라는 표시이고, configure 가 Linux(2.2 이후) 등에서 켭니다(configure.in 348–356행). 켜져 있으면 SAFE_ACCEPT(x) 는 듣기 소켓 목록에 다음 것(ap_listeners->next)이 있을 때만 x 를 실행합니다. 뮤텍스를 만드는 코드도 SAFE_ACCEPT 로 감싸여 있어서(prefork.c 1031–1035행) Listen 이 하나면 뮤텍스가 아예 없습니다. 매크로가 C 에서 어떻게 펼쳐지는지는 2편에 있습니다.

예제 2: prefork 를 기본값으로 띄우기

=== 3. start with defaults, count processes and threads ===
--- prefork
  PID / PPID / threads(NLWP) / user
  27 1 1 root
  28 27 1 daemon
  30 27 1 daemon
  31 27 1 daemon
  32 27 1 daemon
  33 27 1 daemon
  httpd processes: 6, threads in total: 6
  scoreboard, first 80 slots: W____...........................................................................
  scoreboard, all 256 slots = '_' 4  'W' 1  '.' 251
  BusyWorkers: 1
  IdleWorkers: 4
  log: prefork.c(1087): AH00165: Accept mutex: none (default: pthread)

=== 4. prefork accept mutex: one Listen vs two ===
  two Listens: prefork.c(1087): AH00165: Accept mutex: pthread (default: pthread)

PID 는 실행마다 다릅니다. ps -eo pid,ppid,nlwp,user 로 본 결과, root 인 부모 하나(PPID 1) 아래에 daemon 사용자의 자식이 다섯이고 모두 스레드가 하나(NLWP 1)입니다. 다섯은 StartServers 의 기본값입니다(server/mpm/prefork/mpm_default.h 33행). 스코어보드 줄은 mod_status 의 /server-status?auto 에서 가져왔고 6절에서 읽습니다. log: 줄은 prefork_run() 이 LogLevel debug 에서 남기는 로그입니다(prefork.c 1087–1092행). Listen 8080 하나일 때는 뮤텍스가 none, Listen 8081 을 더하자 pthread 방식의 뮤텍스가 생겼습니다. 앞에서 읽은 SAFE_ACCEPT 그대로입니다.

3. worker — 리스너 스레드 하나와 작업 스레드 여럿

prefork 는 동시에 처리할 연결 수만큼 프로세스가 필요합니다. 프로세스는 메모리를 따로 쓰므로 연결이 수백 개면 무겁습니다. worker 는 자식 프로세스 하나 안에 스레드를 여럿 두어 이 문제를 풉니다. 자식마다 ThreadsPerChild(기본 25, server/mpm/worker/mpm_default.h 51행)개의 작업 스레드와, accept 만 하는 리스너 스레드 하나가 있습니다.

worker 의 구조. 작업 스레드 25개가 놀고 있다고 fd_queue_info_t 에 알린다. 리스너 스레드는 놀고 있는 스레드가 생길 때까지 기다린 뒤 accept 하고, 새 소켓과 풀을 fd_queue_t 에 넣는다. 작업 스레드 하나가 큐에서 소켓을 꺼내 process_socket 과 ap_process_connection 으로 처리한다. keep-alive 로 다음 요청을 기다리는 동안에도 그 스레드가 연결을 붙잡고 있고, 연결이 끝나야 다시 놀고 있는 상태로 돌아간다. 노란 상자 둘이 스레드 사이에 공유하는 자료 구조입니다. ①~④ 순서로 소켓이 리스너에서 작업 스레드로 건너갑니다.

리스너: 놀고 있는 일꾼부터 확보하고 accept

리스너 스레드의 루프에서 줄기만 뽑으면 이렇습니다.

        if (!have_idle_worker) {
            rv = ap_queue_info_wait_for_idler(worker_queue_info, NULL);
            /* ... */
            have_idle_worker = 1;
        }
        /* ... accept 뮤텍스, Listen 이 여럿이면 poll ... */
            rv = lr->accept_func(&csd, lr, ptrans);
        /* ... */
            if (csd != NULL) {
                rv = ap_queue_push_socket(worker_queue, csd, NULL, ptrans);

server/mpm/worker/worker.c 585–699행

순서가 중요합니다. 리스너는 놀고 있는 작업 스레드가 있다는 것을 확인한 다음에야 accept 합니다(586행). 일꾼이 모두 바쁘면 accept 하지 않고 기다리므로, 새 손님은 운영체제의 대기 줄(backlog)에 남아 있습니다. 받아 놓고 아무도 처리하지 못하는 일은 생기지 않지만, 대신 손님은 그만큼 기다립니다. 4절의 예제에서 이것이 눈에 보입니다.

작업 스레드: 큐에서 꺼내 처리

        rv = ap_queue_pop_socket(worker_queue, &csd, &ptrans);
        /* ... */
        process_socket(thd, ptrans, csd, process_slot, thread_slot, bucket_alloc);

server/mpm/worker/worker.c 796–829행

process_socket() 은 prefork 와 똑같이 ap_run_create_connection() 과 ap_process_connection() 을 부릅니다(482–499행). 연결 하나를 맡는 단위가 프로세스에서 스레드로 바뀌었을 뿐, 연결이 끝날 때까지 그 일꾼이 매여 있다는 점은 prefork 와 같습니다.

둘 사이의 다리: server/mpm_fdqueue.c

리스너와 작업 스레드는 server/mpm_fdqueue.c 의 두 자료 구조로 이어집니다. worker 와 event 가 함께 씁니다.

넣는 쪽은 짧습니다.

apr_status_t ap_queue_push_socket(fd_queue_t *queue,
                                  apr_socket_t *sd, void *sd_baton,
                                  apr_pool_t *p)
{
    fd_queue_elem_t *elem;
    apr_status_t rv;

    if ((rv = apr_thread_mutex_lock(queue->one_big_mutex)) != APR_SUCCESS) {
        return rv;
    }

    AP_DEBUG_ASSERT(!queue->terminated);
    AP_DEBUG_ASSERT(!ap_queue_full(queue));

    elem = &queue->data[queue->in++];
    if (queue->in >= queue->bounds)
        queue->in -= queue->bounds;
    elem->sd = sd;
    elem->sd_baton = sd_baton;
    elem->p = p;
    queue->nelts++;

    apr_thread_cond_signal(queue->not_empty);

    return apr_thread_mutex_unlock(queue->one_big_mutex);
}

server/mpm_fdqueue.c 382–407행

in 자리에 소켓과 풀을 적고, bounds(크기)에 닿으면 0으로 돌립니다. 그리고 not_empty 조건 변수로 기다리던 작업 스레드 하나를 깨웁니다. 꺼내는 ap_queue_pop_something() 은 큐가 비어 있으면 그 조건 변수에서 잠듭니다(432–490행). 위 함수의 설명 주석에 "ap_queue_info_wait_for_idler 가 먼저 불려 놀고 있는 스레드를 확보해 두었다"는 전제가 적혀 있는데(376–381행), 이 전제 덕분에 큐가 넘치는지(ap_queue_full)는 디버그 빌드에서 확인만 하고 넘어갑니다.

헷갈리기 쉬운 곳: 스레드가 25개가 아니라 27개

예제를 돌려 보면 worker 의 자식 하나에 스레드가 27개입니다.

--- worker
  PID / PPID / threads(NLWP) / user
  64 1 1 root
  66 64 27 daemon
  67 64 27 daemon
  69 64 27 daemon
  httpd processes: 4, threads in total: 82

작업 스레드 25개 + 리스너 1개 + 프로세스가 처음부터 가진 메인 스레드 1개입니다. 메인 스레드는 child_main() 에서 부모의 신호(5절의 "죽음의 파이프")를 기다리는 일을 합니다(worker.c 1298–1300행). 스레드들을 만드는 start_threads 라는 스레드가 하나 더 있지만, 다 만들고 나면 스스로 끝납니다(worker.c 1058행). 자식 셋은 worker 의 StartServers 기본값 3입니다(mpm_default.h 33행). event 도 같은 모양이라, gdb 로 확인한 결과는 4절에 싣습니다.

4. event — keep-alive 연결을 스레드에서 떼어 내기

worker 의 남은 문제

worker 에서 keep-alive 연결은 다음 요청이 올 때까지 최대 KeepAliveTimeout 동안 작업 스레드를 붙잡습니다. 그 스레드는 실제로는 아무것도 하지 않고 소켓 하나를 바라보고 있을 뿐입니다. 브라우저는 페이지를 받은 뒤에도 연결을 열어 두는 일이 흔하므로, 스레드 대부분이 "놀면서 바쁜" 상태가 됩니다. event.c 머리의 주석이 바로 이 문제를 적고 있습니다 — 전통적으로 Apache 는 클라이언트의 데이터를 기다리는 동안 프로세스나 스레드 하나를 통째로 붙잡아 두었고, 이 MPM 은 듣기 소켓과 keep-alive 상태인 모든 소켓을 맡는 전용 스레드를 두어 이것을 푼다(server/mpm/event/event.c 17–39행).

작업 스레드는 요청 하나만 처리하고 상태를 남긴다

event 는 worker 와 같은 큐(fd_queue_t)와 작업 스레드 구조를 씁니다. 다른 점은 작업 스레드가 부르는 process_socket() 이 ap_process_connection() 대신 ap_run_process_connection() 을 직접 부르고, 돌아온 뒤 연결의 상태를 보고 다음 일을 정한다는 것입니다. 1절에서 본 대로 event 는 "비동기 MPM 이다"라고 답하므로 http 모듈은 async 쪽 함수를 써서, 요청 하나를 처리한 뒤 c->cs->state 에 상태를 적어 두고 돌아옵니다(9편).

상태 목록은 include/httpd.h 에 있습니다(1328–1348행).

상태 뜻 (소스 주석) event 가 하는 일
CONN_STATE_PROCESSING process_connection 훅이 처리 중 작업 스레드가 훅을 부른다
CONN_STATE_WRITE_COMPLETION keep-alive 로 가기 전에 MPM 이 남은 출력을 내보냄 다 못 보냈으면 쓰기 가능을 기다리는 큐(wc_q)로
CONN_STATE_KEEPALIVE MPM 안에서 keep-alive 로 대기 (KeepAliveTimeout) keep-alive 큐(ka_q)로, 스레드는 풀려난다
CONN_STATE_ASYNC_WAITIO 소켓이 읽기/쓰기 가능해질 때까지 MPM 이 대기 (Timeout) io_q 로
CONN_STATE_LINGER 등 출력을 마저 내보내고 천천히 닫음 닫기 대기 큐(linger_q, short_linger_q)로
CONN_STATE_SUSPENDED 모듈이 연결을 잠시 맡아 둠 MPM 은 손을 뗀다

keep-alive 로 넘어가는 부분입니다.

    if (cs->pub.state == CONN_STATE_KEEPALIVE) {
        ap_update_child_status(cs->sbh, SERVER_BUSY_KEEPALIVE, NULL);
        /* ... 주석 ... */
        cs->queue_timestamp = apr_time_now();
        notify_suspend(cs);

        /* Add work to pollset. */
        cs->pub.sense = CONN_SENSE_DEFAULT;
        update_reqevents_from_sense(cs, CONN_SENSE_WANT_READ);
        apr_thread_mutex_lock(timeout_mutex);
        TO_QUEUE_APPEND(cs->sc->ka_q, cs);
        rv = apr_pollset_add(event_pollset, &cs->pfd);
        /* ... 실패 처리 ... */
        return;
    }

server/mpm/event/event.c 1305–1339행

연결을 keep-alive 큐 뒤에 붙이고(TO_QUEUE_APPEND), 소켓을 pollset 에 넣고, return 합니다. 함수가 돌아가면 작업 스레드는 다시 큐에서 다음 일을 꺼내러 갑니다. 이 연결은 이제 어느 스레드의 것도 아닙니다. 큐에 시간 순서대로 붙이기만 하면 되는 이유는 큐마다 기다리는 시간이 하나로 정해져 있기 때문입니다 — 위에서 줄인 주석과 큐 선언부의 주석(273–286행)이 그렇게 설명합니다.

리스너 스레드: 듣기 소켓과 기다리는 연결을 함께 poll

event 의 리스너는 worker 의 리스너보다 훨씬 많은 일을 합니다. 듣기 소켓과, 위에서 맡겨진 연결 소켓들이 모두 같은 pollset 에 들어 있습니다(듣기 소켓은 2763–2772행에서 PT_ACCEPT 로 등록). 리스너는 apr_pollset_poll() 하나로 둘 다 기다리고(2112행), 깨어나면 무엇이 준비됐는지 봅니다. 연결 소켓(PT_CSD)이면 그 연결이 어떤 상태로 기다리고 있었는지에 따라 큐에서 뺍니다.

                switch (cs->pub.state) {
                case CONN_STATE_WRITE_COMPLETION:
                    remove_from_q = cs->sc->wc_q;
                    blocking = 1;
                    break;

                case CONN_STATE_ASYNC_WAITIO:
                    cs->pub.state = CONN_STATE_PROCESSING;
                    remove_from_q = cs->sc->io_q;
                    blocking = 1;
                    break;

                case CONN_STATE_KEEPALIVE:
                    cs->pub.state = CONN_STATE_PROCESSING;
                    remove_from_q = cs->sc->ka_q;
                    break;

                case CONN_STATE_LINGER_NORMAL:
                    remove_from_q = linger_q;
                    break;

                case CONN_STATE_LINGER_SHORT:
                    remove_from_q = short_linger_q;
                    break;

server/mpm/event/event.c 2149–2172행

keep-alive 연결에 다음 요청이 도착하면 상태를 PROCESSING 으로 바꾸고 큐와 pollset 에서 뺀 뒤, 놀고 있는 작업 스레드에게 넘깁니다(push2worker(), 2208–2215행). 받는 작업 스레드는 처음 그 연결을 맡았던 스레드가 아니어도 됩니다. ap_mpm.h 의 머리 주석이 "앞으로는 ap_process_connection 이 연결을 다 끝내기 전에 MPM 으로 돌아오고, 같은 스레드에서 다시 불린다는 보장이 없다"고 예고한 것이 바로 이 모습입니다(49–53행). 듣기 소켓(PT_ACCEPT)이면 accept 해서 같은 방법으로 넘깁니다(2218–2297행).

시간이 다 된 연결도 리스너가 정리합니다. 큐 맨 앞의 만료 시각이 지나면 keep-alive 큐에서 시간이 다 된 연결을 닫고, 쓰기 대기·닫기 대기 큐도 차례로 처리합니다(2352–2399행).

event 의 구조. 리스너 스레드가 epoll 기반 event_pollset 을 poll 한다. pollset 에는 듣기 소켓과 기다리는 연결 소켓이 함께 있다. 듣기 소켓이 읽을 수 있으면 accept 해서 작업 스레드에게 넘기고, 기다리던 연결에 데이터가 오면 큐와 pollset 에서 빼서 작업 스레드에게 넘긴다. 작업 스레드는 process_connection 훅을 부른 뒤 남은 상태를 보고, KEEPALIVE·WRITE_COMPLETION·ASYNC_WAITIO 면 해당 타임아웃 큐와 pollset 에 넣고 스레드를 돌려주며, LINGER 면 닫기 대기 큐로 보낸다. 두 경우 모두 다시 pollset 으로 돌아간다. 노란 상자(pollset)가 "기다리는 연결"을 맡습니다. 초록 상자에서 작업 스레드가 풀려나는 것이 worker 와의 차이입니다.

예제 3: 작업 스레드 4개에 keep-alive 연결 6개

차이를 눈으로 보려고 작업 스레드를 4개로 줄였습니다(StartServers 1, ServerLimit 1, ThreadsPerChild 4, MaxRequestWorkers 4). 그리고 bash 의 /dev/tcp 로 연결 6개를 열어 각각 요청을 하나씩 보내고 닫지 않고 둡니다. 응답을 받은 뒤 다음 요청을 보내지 않는 브라우저 흉내입니다. 1초 뒤 curl 로 새 요청을 하나 보내 걸린 시간을 잽니다. (아래는 run.sh 의 해당 부분을 줄여 옮긴 것입니다.)

# 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다)
for i in 3 4 5 6 7 8; do
  eval "exec $i<>/dev/tcp/127.0.0.1/8080"            # 연결을 열고
  printf 'GET /index.html HTTP/1.1\r\nHost: localhost\r\n\r\n' >&$i   # 요청 하나, 닫지 않는다
done
sleep 1
curl -s -o /dev/null -w "  time for a new request: %{time_total}s (HTTP %{http_code})\n" http://127.0.0.1:8080/index.html
=== 5. six idle keep-alive connections against 4 worker threads ===
--- worker (KeepAliveTimeout 5)
  time for a new request: 5.669387s (HTTP 200)
  BusyWorkers: 4
  IdleWorkers: 0
  Scoreboard: CKWK
--- event (KeepAliveTimeout 5)
  time for a new request: 0.001105s (HTTP 200)
  BusyWorkers: 1
  IdleWorkers: 3
  ConnsTotal: 0
  ConnsAsyncKeepAlive: 0
  Scoreboard: W___
  log: event.c(2015): connections: 6 (waitio:0 write-completion:0keep-alive:6 lingering:0 suspended:0 clogged:0), workers: 0/4 shutdown
--- worker (KeepAliveTimeout 2)
  time for a new request: 2.766733s (HTTP 200)
  BusyWorkers: 4
  IdleWorkers: 0
  Scoreboard: WKKC

시간과 스코어보드의 글자 순서는 실행마다 조금씩 다릅니다.

헷갈리기 쉬운 곳

예제 4: event 자식의 스레드 27개는 무엇을 하나

컨테이너 안에서는 실행 중인 프로세스에 gdb 를 붙이는 권한(ptrace)이 없어서, gdb 아래에서 httpd -X(자식을 만들지 않고 한 프로세스로 도는 디버그 모드)로 event 를 띄우고 3초 뒤 멈춰 모든 스레드의 호출 스택을 찍었습니다. 처음에는 실행 중인 자식에 gdb -p 로 붙으려다 "Could not attach to process" 로 막혀서 이렇게 바꿨습니다.

# 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다)
(sleep 3; kill -TRAP $(pgrep -x httpd)) &
timeout 30 gdb -batch -ex "handle SIGPIPE nostop noprint" -ex run -ex "thread apply all bt" -ex kill \
    --args /opt/httpd/bin/httpd -X -f /tmp/event.conf > /tmp/gdb.txt 2>&1 || true
=== 6. what are the 27 threads of one event child doing (gdb, -X single-process mode) ===
  27 threads; functions seen in their call stacks:
        1  in child_main
        1  in listener_thread
       25  in worker_thread

child_main() 의 메인 스레드 하나, listener_thread() 하나, worker_thread() 25개입니다. 기본 설정의 event 자식이 ps 에서 스레드 27개로 보인 것(6절 예제 5의 출력)과 맞습니다. 스레드를 만드는 start_threads() 는 작업 스레드를 만든 뒤 리스너를 만들고 끝납니다(2855–2891행).

5. 부모 프로세스: 1초마다 자식을 세고 늘리고 줄인다

세 MPM 모두 부모 프로세스는 요청을 처리하지 않습니다. 부모가 하는 일은 자식을 돌보는 것뿐이고, 그 루프는 세 MPM 이 거의 같은 모양입니다. prefork 를 보면 prefork_run() 의 끝부분이 이 루프입니다(prefork.c 1104–1196행).

  1. ap_wait_or_timeout() — 끝난 자식이 있는지 기다리지 않고 확인하고(APR_NOWAIT), 없으면 1초 잡니다(server/mpm_common.c 268행, 280행).
  2. 자식이 끝났으면 그 자식의 스코어보드 칸을 SERVER_DEAD 로 적고, 이번 바퀴의 유지 보수는 건너뜁니다.
  3. 1초가 그냥 지났으면 perform_idle_server_maintenance() 를 부릅니다(1195행).

worker 와 event 도 server_main_loop() 에서 같은 일을 합니다(event 는 3496–3607행).

부모의 유지 보수 루프. ap_wait_or_timeout 이 죽은 자식이 없으면 1초 잔다. 죽은 자식이 있으면 그 스코어보드 칸을 SERVER_DEAD 로 적고 유지 보수를 건너뛴다. 1초가 지났으면 perform_idle_server_maintenance 가 스코어보드에서 놀고 있는 일꾼을 세어, MaxSpare 보다 많으면 죽음의 파이프로 자식 하나에게 그만두라고 알리고, MinSpare 보다 적으면 make_child 로 fork 한다. 한 번에 만드는 수는 1, 2, 4 로 두 배씩 늘어 최대 32개다. 둘 다 아니면 그대로 두고 spawn rate 를 1 로 되돌린다. 어느 경우든 다시 처음으로 돌아간다. 부모는 1초마다 스코어보드를 보고 판단합니다. 자식에게 직접 묻지 않습니다.

perform_idle_server_maintenance()

prefork 판을 보면, 먼저 스코어보드의 칸을 훑어 상태가 SERVER_READY 이하인 자식(놀고 있거나 막 시작하는 중)을 셉니다(prefork.c 863행). 그다음 판단입니다.

    if (idle_count > ap_daemons_max_free) {
        static int bucket_kill_child_record = -1;
        /* kill off one child... we use the pod because that'll cause it to
         * shut down gracefully, in case it happened to pick up a request
         * while we were counting
         */
        bucket_kill_child_record = (bucket_kill_child_record + 1) % retained->mpm->num_buckets;
        ap_mpm_pod_signal(retained->buckets[bucket_kill_child_record].pod);
        retained->idle_spawn_rate = 1;
    }
    else if (idle_count < ap_daemons_min_free) {
        /* ... 빈 칸이 없으면 "server reached MaxRequestWorkers setting" 을 한 번 기록 ... */
        else {
            /* ... */
            for (i = 0; i < free_length; ++i) {
                make_child(ap_server_conf, free_slots[i]);
            }
            /* the next time around we want to spawn twice as many if this
             * wasn't good enough, but not if we've just done a graceful
             */
            if (retained->hold_off_on_exponential_spawning) {
                --retained->hold_off_on_exponential_spawning;
            }
            else if (retained->idle_spawn_rate < MAX_SPAWN_RATE) {
                retained->idle_spawn_rate *= 2;
                if (retained->idle_spawn_rate > MAX_SPAWN_RATE) {
                    retained->idle_spawn_rate = MAX_SPAWN_RATE;
                }
            }
        }
    }

server/mpm/prefork/prefork.c 872–919행

worker 와 event 도 같은 틀이지만 세는 단위가 자식이 아니라 스레드이고(MinSpareThreads/MaxSpareThreads), 자식 하나를 줄일 때 스레드 ThreadsPerChild 개가 한꺼번에 사라집니다(worker.c 1565–1569행, event.c 3357–3394행). event 판은 그만두는 중인 자식이 이미 있고 스코어보드에 여유가 없으면 줄이기를 미루는 조건이 더 붙어 있습니다(3374–3376행).

헷갈리기 쉬운 곳: StartServers 는 시작할 때만 쓰인다

StartServers 는 기동할 때 startup_children() 에 넘겨지는 수일 뿐입니다(prefork.c 1070행)(graceful 재시작 때는 같은 수가 죽은 자식을 하나씩 바로 채우는 데 쓰입니다, 1065–1068행·1151–1158행). 그 뒤로는 1초마다 Min/MaxSpare 로 맞추므로, StartServers 50 으로 띄워도 손님이 없으면 자식은 1초에 하나씩 줄어 MaxSpareServers 까지 내려갑니다. 위 코드가 한 바퀴에 하나만 줄이기 때문입니다.

6. 스코어보드 — 부모와 자식이 함께 보는 공유 메모리

부모는 어떻게 자식들이 놀고 있는지 알까요? 프로세스는 서로의 메모리를 볼 수 없으므로(먼저 알아 둘 말), 모두가 볼 수 있는 공유 메모리에 상태판을 하나 두고 각자 자기 칸에 상태를 적습니다. 이것이 스코어보드입니다.

스코어보드의 구조. 부모가 fork 하기 전에 apr_shm_create 로 만든 공유 메모리 한 덩어리 안에, global_score 하나, 자식마다 process_score 하나씩 ServerLimit 개, 스레드마다 worker_score 하나씩 ServerLimit 곱하기 ThreadLimit 개가 들어 있다. 자식과 스레드는 ap_update_child_status 로 자기 칸만 쓰고, 부모의 유지 보수 루프와 mod_status 가 그것을 읽는다. 노란 상자 하나가 공유 메모리 전체입니다. 쓰는 쪽은 자기 칸만 쓰므로 잠금이 필요 없습니다.

세 가지 구조체

include/scoreboard.h 에 세 층이 있습니다.

status 값은 SERVER_DEAD(0)부터 SERVER_IDLE_KILL(10)까지입니다(56–67행). mod_status 는 이것을 한 글자씩으로 바꿔 보여 줍니다(modules/generators/mod_status.c 1006–1017행).

값 상수 mod_status 글자
0 SERVER_DEAD .
1 SERVER_STARTING S
2 SERVER_READY _
3 SERVER_BUSY_READ R
4 SERVER_BUSY_WRITE W
5 SERVER_BUSY_KEEPALIVE K
8 SERVER_CLOSING C
9 SERVER_GRACEFUL G

(6 L, 7 D, 10 I 는 표에서 뺐습니다.) 5절의 유지 보수 코드가 "SERVER_READY 이하"를 놀고 있다고 센 것은 STARTING(1)과 READY(2)를 함께 세려는 것이고, 주석은 이것이 상수의 순서에 기대고 있다고 적어 둡니다(prefork.c 857–862행).

크기는 ServerLimit × ThreadLimit, 만드는 것은 부모

AP_DECLARE(int) ap_calc_scoreboard_size(void)
{
    ap_mpm_query(AP_MPMQ_HARD_LIMIT_THREADS, &thread_limit);
    ap_mpm_query(AP_MPMQ_HARD_LIMIT_DAEMONS, &server_limit);

    scoreboard_size  = SIZE_OF_global_score;
    scoreboard_size += SIZE_OF_process_score * server_limit;
    scoreboard_size += SIZE_OF_worker_score * server_limit * thread_limit;

    return scoreboard_size;
}

server/scoreboard.c 138–148행

여기서도 ap_mpm_query() 가 쓰입니다. 코어의 스코어보드 코드는 MPM 이 무엇인지 모르고, 한계값만 물어 크기를 정합니다. prefork 는 스레드 한계로 HARD_THREAD_LIMIT(1)을 답하므로(prefork.c 86–88행) worker_score 가 자식당 하나입니다. 이것이 ServerLimit·ThreadLimit 를 재시작으로 바꿀 수 없는 이유이기도 합니다. 공유 메모리의 크기가 처음에 정해지기 때문입니다(각 MPM 의 check_config 가 처음 값으로 되돌리고 경고합니다, 예: event.c 4190–4198행).

만드는 쪽은 ap_create_scoreboard() 이고, 코어가 이것을 pre_mpm 훅에 걸어 둡니다(server/core.c 6178행). MPM 은 자식을 fork 하기 전, graceful 이 아닌 시작·재시작에서 이 훅을 부릅니다(prefork.c 998–1002행). ScoreBoardFile 을 지정하지 않으면 이름 없는(anonymous) 공유 메모리를 apr_shm_create() 로 만듭니다(server/scoreboard.c 232행). fork 로 태어난 자식은 이 메모리를 그대로 물려받아 같은 곳을 봅니다.

잠금 없이 쓰는 이유

여러 프로세스가 한 메모리에 쓰는데 잠금이 없습니다. 소스 주석이 이유를 적고 있습니다 — 상태를 갱신하는 함수는 칸 하나만 고치고, 한 칸에는 한 번에 한 쪽만 쓴다(그 칸의 주인인 자식, 또는 자식이 죽었음을 적는 부모)(server/scoreboard.c 344–353행). 부모와 mod_status 는 읽기만 하므로 가끔 약간 낡은 값을 볼 수는 있지만, 1초마다 다시 보는 유지 보수에는 그것으로 충분합니다.

예제 5: 스코어보드 읽기

2절 예제와 같은 실행에서 mod_status 의 ?auto 출력을 봤습니다.

--- prefork
  scoreboard, first 80 slots: W____...........................................................................
  scoreboard, all 256 slots = '_' 4  'W' 1  '.' 251
--- worker
  scoreboard, first 80 slots: _________________________W_________________________________________________.....
  scoreboard, all 400 slots = '_' 74  'W' 1  '.' 325
  BusyWorkers: 1
  IdleWorkers: 74
--- event
  PID / PPID / threads(NLWP) / user
  180 1 1 root
  182 180 27 daemon
  183 180 27 daemon
  185 180 27 daemon
  httpd processes: 4, threads in total: 82
  scoreboard, first 80 slots: __________________________________________________W________________________.....
  scoreboard, all 400 slots = '_' 74  'W' 1  '.' 325

7. 디렉티브는 코드의 어디로 가나

숫자를 받는 MPM 디렉티브는 모두 7편에서 본 AP_INIT_TAKE1 로 등록되고, 처리 함수는 대개 atoi() 로 숫자를 읽어(AsyncRequestWorkerFactor 만 소수를 받으려고 strtod()) 파일 안의 static 변수에 넣을 뿐입니다(예: event 의 표, event.c 4509–4532행). 값들이 서로 맞는지는 check_config 훅에서 한꺼번에 맞춥니다.

디렉티브 들어가는 변수 쓰이는 곳
StartServers ap_daemons_to_start 기동할 때 startup_children() 에 한 번 (prefork.c 1070행)
MinSpareServers / MaxSpareServers (prefork) ap_daemons_min_free / ap_daemons_max_free 유지 보수의 판단 (872행, 882행)
MinSpareThreads / MaxSpareThreads (worker·event) min_spare_threads / max_spare_threads 유지 보수의 판단 (event.c 3357행, 3394행)
MaxRequestWorkers (prefork) ap_daemons_limit 자식 수의 상한 그 자체 (prefork.c 1568행)
MaxRequestWorkers (worker·event) max_workers ÷ ThreadsPerChild 로 자식 수 상한을 계산 (worker.c 2311행, event.c 4288행)
ThreadsPerChild threads_per_child 만들 작업 스레드 수, 큐의 크기 (event.c 2677행, 2807행)
ServerLimit / ThreadLimit server_limit / thread_limit ap_mpm_query 를 거쳐 스코어보드 크기 (6절)
AsyncRequestWorkerFactor (event) worker_factor 새 접속을 멈추는 기준 (connections_above_limit())
MaxConnectionsPerChild (코어) ap_max_requests_per_child 자식·스레드가 은퇴할 때 (2절)
Listen 등 (LISTEN_COMMANDS) ap_listeners 목록 부모가 소켓을 열고 자식이 물려받음 (8절)

헷갈리기 쉬운 곳: 같은 MaxRequestWorkers, 다른 뜻

prefork 의 디렉티브 설명은 "Maximum number of children alive at the same time"(prefork.c 1593–1594행), event 의 설명은 "Maximum number of threads alive at the same time"(event.c 4521–4522행)입니다. 이름은 같지만 prefork 에서는 프로세스 수, worker·event 에서는 스레드 수입니다. 그래서 worker·event 에서 MaxRequestWorkers 가 ThreadsPerChild 의 배수가 아니면 check_config 가 배수로 내려 맞추고 경고합니다(worker.c 2313–2330행). 또 event 에서는 이 값이 "동시에 처리할 수 있는 연결 수"가 아닙니다. 4절 예제처럼 keep-alive 연결은 스레드를 차지하지 않으므로, 열려 있는 연결은 이보다 많을 수 있습니다.

같은 MaxRequestWorkers 가 MPM 마다 다른 변수로 가는 모습. prefork 에서는 ap_daemons_limit 에 들어가 자식 프로세스 수의 상한 그 자체가 된다(Maximum number of children alive at the same time). worker 와 event 에서는 max_workers 에 들어가고, ap_daemons_limit(event 에서는 active_daemons_limit) = max_workers / threads_per_child 로 자식 수의 상한을 계산한다. 나머지가 있으면 ThreadsPerChild 의 배수로 내려 맞추고 not an integer multiple 경고를 남긴다. event 에서는 keep-alive 연결이 스레드를 차지하지 않으므로 열린 연결은 이 값보다 많을 수 있다 이름은 같아도 prefork 는 프로세스 수, worker·event 는 스레드 수입니다. 계산은 worker.c 2311–2330행과 event.c 4288–4306행에서 확인했습니다.

8. 듣기 소켓은 부모가 열고, 재시작을 넘어 살아남는다

Listen 디렉티브를 처리하는 ap_set_listener() 는 ap_listen_rec 를 목록 ap_listeners 에 더하기만 하고(server/listen.c 1074행), 실제로 소켓을 여는 것은 MPM 의 open_logs 훅이 부르는 ap_setup_listeners() 입니다(1절). 이 훅은 부모에서 fork 전에 돌므로, 자식들은 열린 소켓을 그대로 물려받아 같은 소켓에서 accept 합니다. 80번처럼 1024 미만의 포트는 root 만 열 수 있는데, 자식이 daemon 사용자로 내려간 뒤에도(5편) 80번에서 받을 수 있는 이유가 이것입니다.

재시작할 때도 소켓을 닫았다 다시 열지 않습니다.

  1. 각 MPM 의 pre_config 가 부르는 ap_listen_pre_config() 가 지금의 목록을 old_listeners 로 옮겨 둡니다(server/listen.c 986–988행).
  2. 설정을 다시 읽으며 Listen 을 만날 때마다, 주소와 포트가 같은 옛 항목이 있으면 그 항목(열린 소켓 포함)을 새 목록으로 그대로 옮깁니다(find_listeners() 424–456행, 472–478행).
  3. 소켓을 여는 단계에서 새 설정에 없는 옛 소켓만 닫습니다(699–701행).

듣기 소켓의 일생. 기동할 때 Listen 을 만날 때마다 ap_set_listener() 는 ap_listen_rec 를 ap_listeners 목록에 더하기만 한다. 부모의 open_logs 훅이 부르는 ap_setup_listeners() 가 fork 전에 소켓을 열고(root 라 1024 미만 포트도 열린다), 자식들은 daemon 으로 내려간 뒤에도 열린 소켓을 물려받아 같은 소켓에서 accept 한다. 재시작할 때는 ① ap_listen_pre_config() 가 지금 목록을 old_listeners 로 옮기고, ② 설정을 다시 읽으며 Listen 마다 find_listeners() 가 주소와 포트가 같은 옛 항목을 열린 소켓째 새 목록으로 옮기고, ③ 소켓을 여는 단계에서 새 설정에 없는 옛 소켓만 닫는다. 그래서 같은 주소의 듣기 소켓은 닫히지 않고, 그사이 온 손님은 운영체제의 대기 줄에서 새 자식을 기다린다 위 세 단계를 기동 때의 흐름에 이어 붙였습니다. 노란 칸이 재시작 때만 하는 일입니다.

그래서 재시작 중에도 같은 주소의 듣기 소켓은 닫히지 않고, 그사이 들어온 손님은 운영체제의 대기 줄에서 새 자식을 기다립니다.

헷갈리기 쉬운 곳: 리스너 "버킷"

소스에서 my_bucket, num_buckets 라는 이름이 자주 보입니다. ListenCoresBucketsRatio 를 설정하고 운영체제가 SO_REUSEPORT 를 지원하면, 듣기 소켓을 CPU 코어 수에 비례해 여러 벌 복제하고 자식들을 그 묶음(버킷)에 나눠 배정합니다(server/listen.c 823–870행). 기본값은 0이라(994행) 버킷은 하나이고, 이 글의 예제도 모두 버킷 하나로 돌았습니다. 코드에서 % num_buckets 같은 계산을 만나면 "기본 설정에서는 1로 나누는 것"이라고 읽으면 됩니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
MPM 접속을 받아 어느 프로세스·스레드가 처리할지 정하는 모듈. LoadModule 로 정확히 하나를 싣는다
mpm 훅 코어와 MPM 의 가장 큰 약속. main() 이 ap_run_mpm() 으로 부르면 MPM 이 부모 루프를 돈다
ap_mpm_query() 다른 모듈이 MPM 의 성질과 한계를 묻는 창구. 스코어보드 크기, http 모듈의 sync/async 선택이 이것으로 정해진다
prefork 스레드 하나짜리 자식이 연결 하나씩. 듣기 소켓이 여럿이면 accept 뮤텍스로 줄을 세운다
worker 자식마다 리스너 1 + 작업 스레드 N. 놀고 있는 스레드를 먼저 확보하고 accept 해 fd_queue_t 로 넘긴다
event worker 구조에 더해, keep-alive·쓰기 대기·닫기 대기 연결을 리스너의 pollset 에 맡겨 작업 스레드를 풀어 준다
연결 상태 cs->state 작업 스레드가 요청 하나를 처리하고 남기는 다음 할 일. event 는 이것으로 큐를 고른다
부모의 유지 보수 1초마다 스코어보드를 세어 Min/MaxSpare 에 맞춘다. 늘릴 때는 두 배씩, 줄일 때는 하나씩
POD 부모가 "누구든 하나 그만두라"고 알리는 파이프. 자식은 일을 마친 뒤 읽는다
스코어보드 부모가 fork 전에 만드는 공유 메모리. 각자 자기 칸만 써서 잠금이 없다. mod_status 가 이것을 보여 준다
듣기 소켓 부모가 열고 자식이 물려받는다. 재시작해도 같은 주소의 소켓은 닫지 않고 넘긴다

다음에 볼 것

MPM 이 소켓 하나를 일꾼에게 넘기는 데까지 봤습니다. 일꾼이 그 소켓으로 ap_process_connection() 을 부른 뒤, 연결이 conn_rec 가 되고 요청 줄과 헤더를 읽어 request_rec 가 되는 과정은 9편: 연결과 요청 읽기 — 소켓 하나가 request_rec가 되기까지에서 이어집니다. 4절에서 event 가 상태만 보고 넘긴 sync/async 두 함수의 속도 그 글에 있습니다.

Spring 연재와 견주어 보면, worker 의 "연결 하나 = 스레드 하나"는 서블릿 컨테이너의 스레드 하나가 요청 하나를 맡아 DispatcherServlet 을 끝까지 따라가는 모습(Spring 21편)과 닮았고, event 가 기다리는 연결을 스레드에서 떼어 소수의 스레드가 poll 로 지켜보게 한 것은 WebFlux 가 "본문이 다 도착할 때까지 스레드를 붙잡고 기다리지 않으려는" 설계(Spring 24편)와 같은 문제를 겨냥합니다. 다만 event 는 요청을 처리하는 동안에는 여전히 작업 스레드 하나를 쓰고, 요청과 요청 사이에 기다리는 시간만 스레드에서 떼어 낸다는 점이 다릅니다.

다른 글