쇄락 문제: 개념, 재현 및 해결 방법

Nginx와 Workerman과 같은 많은 네트워크 서버는 단일 마스터-다중 워커 프로세스 모델을 사용합니다. 마스터 프로세스는 리스닝 소켓을 생성하고 워커 프로세스를 생성하며 관리합니다. 워커 프로세스는 마스터 프로세스가 fork 시스템 호출을 통해 생성되므로 마스터 프로세스의 리스닝 소켓을 상속받아 각 워커 프로세스가 독립적으로 클라이언트 연결을 수신하고 처리할 수 있습니다. 여러 워커 프로세스가 동일한 소켓에서 이벤트를 기다리는 경우, 제목에서 언급된 쇄락 문제가 발생할 수 있습니다.

쇄락 문제란 무엇인가?

쇄락 문제 또는 쇄락 효과는 여러 프로세스가 동일한 이벤트를 기다릴 때 발생합니다. 이벤트가 발생하면 커널은 모든 대기 중인 프로세스를 깨우지만, 실제로 이벤트를 처리할 수 있는 CPU 실행 권한을 가진 프로세스는 하나뿐입니다. 나머지 프로세스들은 무효하게 깨어난 후 다시 블로킹 상태로 전환되어, 다음 이벤트 발생 시 다시 깨어납니다.

예를 들어, 여러 명이 잠을 자면서 배달 음식을 기다리고 있다고 가정해 봅시다. 배달이 도착하면 배달원이 한 번에 모두 깨웁니다. 하지만 음식은 한 명에게만 전달되므로, 다른 사람들은 불만을 토로하며 다시 잠을 청합니다. 다음 배달이 오면 이 과정이 반복됩니다.

여기서 방원들은 프로세스를, 배달원은 운영체제를, 배달 음식은 기다리는 이벤트를 의미합니다.

쇄락 문제가 초래하는 문제점

이벤트 발생 시 모든 프로세스를 깨우기 때문에 운영체제는 여러 프로세스에 대해 빈번한 무효한 스케줄링을 수행합니다. 이로 인해 CPU가 실제 작업을 수행해야 할 프로세스 실행보다 컨텍스트 전환에 더 많은 시간을 소비하게 되어 시스템 성능이 크게 저하됩니다.

쇄락 문제 발생 시점

위에서 설명한 바와 같이, 쇄락 문제는 주로 socket_accept와 socket_select 두 함수 호출에서 발생합니다. 두 시스템 호출의 쇄락 문제를 재현하기 위해 두 가지 예제를 살펴보겠습니다.

socket_accept 함수

PHP의 socket_accept 함수는 accept 시스템 호출의 래퍼입니다. 함수 원형은 다음과 같습니다:

socket_accept(Socket $socket): Socket|false

이 함수는 리스닝 소켓에서 새 연결을 수신하고, 수신 성공 시 클라이언트와 통신하기 위한 새 소켓(연결 소켓)을 반환합니다. 처리할 연결이 없으면 socket_accept 함수는 블로킹되어 새 연결이 나타날 때까지 대기합니다.

// TCP 소켓 생성
$serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 소켓을 지정된 호스트 주소와 포트에 바인딩
socket_bind($serverSocket, "0.0.0.0", 8080);
// 리스닝 소켓으로 설정
socket_listen($serverSocket);

printf("마스터[%d] 실행 중\n", posix_getpid());

for ($i = 0; $i < 5; $i++) {
    $pid = pcntl_fork();
    if ($pid < 0) {
        exit('fork 실패');
    } else if ($pid == 0) {
        // 자식 프로세스
        $pid = posix_getpid();
        printf("워커[%d] 실행 중\n", $pid);

        // 하나의 연결을 처리한 후 다음 연결을 계속 처리할 수 있도록 while true 사용
        while (true) {
            // $serverSocket이 블로킹 IO이므로, 코드가 이 지점에 도달하면 블로킹되어 CPU를 양보합니다.
            // 클라이언트가 연결할 때까지 대기합니다.
            $connSocket = socket_accept($serverSocket);
            if (!$connSocket) {
                printf("워커[%d] 새 연결 수신 실패, 원인: %s\n", $pid, socket_last_error($connSocket));
                continue;
            }

            // 클라이언트 주소 및 포트 가져오기
            socket_getpeername($connSocket, $address, $port);
            printf("워커[%d] 새 연결 수신 성공: %s:%d\n", $pid, $address, $port);
            // 클라이언트 연결 닫기
            socket_close($connSocket);
        }
    }
    // 부모 프로세스
}

// 부모 프로세스는 자식 프로세스가 종료될 때까지 대기하고 리소스를 회수합니다
while (true) {
    // 대기 중인 신호에 대한 신호 처리기 호출
    \pcntl_signal_dispatch();
    // 현재 프로세스의 실행을 일시 중지하고, 자식 프로세스가 종료되거나 신호가 전달될 때까지 대기
    $pid = \pcntl_wait($status, WUNTRACED);
    // 대기 중인 신호에 대한 신호 처리기 다시 호출
    \pcntl_signal_dispatch();

    if ($pid > 0) {
        printf("워커[%d] 종료\n", $pid);
    }
}

위 코드는 먼저 리스닝 소켓 $serverSocket을 생성한 후 pcntl_fork 함수를 통해 5개의 자식 프로세스를 생성합니다. pcntl_fork 함수 호출 후, 자식 프로세스 생성이 성공하면 해당 함수는 두 가지 반환 값을 가집니다. 부모 프로세스에서는 자식 프로세스의 PID를 반환하고, 자식 프로세스에서는 0을 반환합니다. 생성 실패 시 -1을 반환합니다.

  • 부모 프로세스: pcntl_wait 함수를 호출하여 자식 프로세스가 종료될 때까지 블로킹하고, 프로세스 리소스를 회수합니다
  • 자식 프로세스: socket_accept 함수를 호출하고 블로킹하여 새 연결을 처리할 준비를 합니다.

위 코드를 accept.php로 저장하고 CLI에서 php accept.php를 실행하여 서버 프로그램을 시작하면, 1개의 마스터 프로세스와 5개의 워커 프로세스가 모두 실행 상태에 있음을 확인할 수 있습니다:

pstree -acp pid를 실행하여 프로세스 트리를 확인해 보세요:

프로세스 트리 구조는 서비스 시작 로그와 일치합니다.

이제 telnet 0.0.0.0 8080 명령을 실행하여 서버 프로그램에 연결하면 accept.php 출력:

어떻게 된 일인가? 처음에 설명한 것과 다르게, 실제로는 하나의 프로세스만 깨어나서 새 연결을 처리했습니다!

걱정 마세요. 이는 예상된 결과입니다. Linux 2.6 이후 버전에서는 accept의 쇄락 문제가 이미 수정되었기 때문입니다. 이 단계를 시연하는 주된 목적은 이후 내용을 위한 배경을 제공하기 위함입니다.

socket_select 함수

socket_accept 함수와 마찬가지로, socket_select 함수도 select 시스템 호출의 래퍼입니다. select는 가장 초기의 다중화 구현 방식 중 하나로, 후에 나타난 poll, epoll에 비해 성능이 훨씬 떨어집니다. 그럼에도 불구하고 여기서 select를 사용하는 이유는 무엇일까요?

첫째, select 시스템 호출을 지원하는 운영체제가 많기 때문입니다. Windows와 MacOS도 select 시스템 호출을 지원합니다. 둘째, 현재까지의 Linux 커널 버전 4.4.0에서도 select의 쇄락 문제가 해결되지 않았기 때문입니다.

socket_select는 소켓 배열을 받아 이벤트가 발생할 때까지 블로킹합니다. 함수 원형은 다음과 같습니다:

socket_select(
    array|null &$read,
    array|null &$write,
    array|null &$except,
    int|null $seconds,
    int $microseconds = 0
): int|false
  • $read는 읽기 이벤트를 모니터링할 소켓 배열을 나타냅니다.
  • $write는 쓰기 이벤트를 모니터링할 소켓 배열을 나타냅니다.
  • $except는 예외 이벤트를 모니터링할 소켓 배열을 나타냅니다.
  • $seconds와 $microseconds는 select 블로킹 타임아웃 시간을 조합하여 나타냅니다. $seconds가 0이면 대기 없이 즉시 반환하며, null로 설정하면 이벤트가 발생할 때까지 계속 블로킹합니다.

함수 타임아웃 전에 이벤트가 발생하면, 반환 값은 이벤트가 발생한 소켓 수입니다. 함수 타임아웃 시 반환 값은 0이며, 오류 발생 시 false를 반환합니다.

socket_select 함수의 예제 프로그램은 위 socket_accept 함수와 비슷하지만, 리스닝 소켓을 비블로킹으로 설정하고 socket_accept 함수 전에 socket_select를 호출하여 이벤트를 기다리는 차이가 있습니다.

// TCP 소켓 생성
$serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 소켓을 지정된 호스트 주소와 포트에 바인딩
socket_bind($serverSocket, "0.0.0.0", 8080);
// 리스닝 소켓으로 설정
socket_listen($serverSocket);
// 비블로킹으로 설정
socket_set_nonblock($serverSocket);

printf("마스터[%d] 실행 중\n", posix_getpid());

for ($i = 0; $i < 5; $i++) {
    $pid = pcntl_fork();
    if ($pid < 0) {
        exit('fork 실패');
    } else if ($pid == 0) {
        // 자식 프로세스
        $pid = posix_getpid();
        printf("워커[%d] 실행 중\n", $pid);

        // 하나의 연결을 처리한 후 다음 연결을 계속 처리할 수 있도록 while true 사용
        while (true) {
            // 리스닝 소켓을 읽기 이벤트 소켓 배열에 추가합니다.
            // 이는 리스닝 소켓에서 읽기 이벤트를 기다리겠다는 의미입니다.
            // 리스닝 소켓에서 읽기 이벤트가 발생하면 클라이언트가 연결되었음을 의미합니다.
            $reads = [$serverSocket];
            // 쓰기 이벤트와 예외 이벤트는 신경 쓰지 않으므로 빈 배열로 설정합니다.
            $writes = $excepts = [];
            // 타임아웃 시간을 NULL로 설정하여 이벤트가 발생할 때까지 계속 블로킹하도록 합니다.
            $num = socket_select($reads, $writes, $excepts, NULL);

            printf("워커[%d] 깨어남, num: %d\n", $pid, $num);

            $connSocket = socket_accept($serverSocket);
            if (!$connSocket) {
                printf("워커[%d] 새 연결 수신 실패\n", $pid);
                continue;
            }

            // 클라이언트 주소 및 포트 가져오기
            socket_getpeername($connSocket, $address, $port);
            printf("워커[%d] 새 연결 수신 성공: %s:%d\n", $pid, $address, $port);
            // 클라이언트 연결 닫기
            socket_close($connSocket);
        }
    }
    // 부모 프로세스
}

// 부모 프로세스는 자식 프로세스가 종료될 때까지 대기하고 리소스를 회수합니다
while (true) {
    // 대기 중인 신호에 대한 신호 처리기 호출
    \pcntl_signal_dispatch();
    // 현재 프로세스의 실행을 일시 중지하고, 자식 프로세스가 종료되거나 신호가 전달될 때까지 대기
    $pid = \pcntl_wait($status, WUNTRACED);
    // 대기 중인 신호에 대한 신호 처리기 다시 호출
    \pcntl_signal_dispatch();

    if ($pid > 0) {
        printf("워커[%d] 종료\n", $pid);
    }
}

위 코드를 select.php로 저장하고 실행하여 서비스를 시작한 후 telnet 127.0.0.1 8080으로 연결하면 5개의 자식 프로세스가 모두 wakeup을 출력하지만, 하나의 프로세스만 accept에 성공한 것을 확인할 수 있습니다.

쇄락 문제 해결 방법

쇄락 문제는 주로 시스템 호출에서 발생하지만, 커널 시스템 업데이트는 그리 빈번하지 않으며 모든 운영체제가 이 문제를 해결한다는 보장도 없습니다. 따라서 해결 방법은 사용자 프로그램 수준과 커널 프로그램 수준으로 나눌 수 있습니다. 사용자 프로그램 수준에서는 락을 추가하여 문제를 해결하고, 커널 프로그램 수준에서는 커널 프로그램이 이 문제를 일劳永逸하게 해결할 수 있는 메커니즘을 제공합니다.

사용자 프로그램: 락 추가

위에서 알 수 있듯이, 쇄락 문제는 여러 프로세스가 동일한 소켓에서 이벤트를 모니터링하는 경우에 발생합니다. 따라서 리스닝 소켓을 처리하는 프로세스를 하나만 두면 됩니다.

Nginx는 여러 프로세스가 동시에 accept를 호출하지 않도록 자체적으로 구현된 accept 락 메커니즘을 채택했습니다. Nginx 다중 프로세스 락은 기본적으로 CPU 스피닝 락을 통해 구현되며, 운영체제가 지원하지 않으면 파일 락을 사용합니다.

Nginx 이벤트 처리 진입 함수는 ngx_process_events_and_timers()이며, 다음은 단순화된 락 과정입니다:

// accept 락을 사용할지 여부,
// 사용하면 쇄락을 방지하기 위해 락을 획득해야 합니다. 기본적으로 비활성화되어 있습니다.
if (ngx_use_accept_mutex) {
    if (ngx_accept_disabled > 0) {
        // ngx_accept_disabled 값은 알고리즘을 통해 계산됩니다.
        // 값이 0보다 크면 이 프로세스의 부하가 너무 높아 새 연결을 수신하지 않음을 의미합니다.
        ngx_accept_disabled--;
    } else {
        // accept 락 획득 시도, 오류 발생 시 바로 반환
        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;
            }
        }
    }
}

ngx_trylock_accept_mutex 함수에서 락을 획득하면, Nginx는 리스닝 소켓의 읽기 이벤트를 이벤트 루프에 추가합니다. 이 프로세스에 새 연결이 들어오면 accept할 수 있습니다.

커널 프로그램: 근본적인 해결

고성능 Nginx에서 accept 락은 기본적으로 비활성화되어 있습니다. 만약 accept 락을 활성화하면, 여러 워커 프로세스가 병렬로 실행되는 경우 accept 함수 호출이 직렬화되어 효율이 떨어집니다. 따라서 가장 좋은 방법은 커널 프로그램이 쇄락 문제를 해결하고, 문제의 근본 원인에서 해결하는 것입니다.

Linux 커널 3.9 이상 버전에서는 새로운 소켓 매개변수 SO_REUSEPORT를 제공합니다. 이 매개변수는 여러 프로세스가 동일한 소켓에 바인딩할 수 있도록 허용하며, 새 연결을 수신할 때 커널은 하나의 프로세스만 깨워 처리하며, 커널에서도 부하 분산을 수행하여 특정 프로세스의 부하가 너무 높아지는 것을 방지합니다.

epoll 다중화 메커니즘의 경우, Linux 커널 4.5+에서는 EPOLLEXCLUSIVE 플래그가 추가되었습니다. 이 플래그는 epoll_wait 함수에서 블로킹된 하나의 프로세스만 이벤트로 인해 깨어나도록 보장하여 쇄락 문제를 방지합니다.

Nginx의 ngx_event_process_init 함수에서 Nginx가 SO_REUSEPORT와 EPOLLEXCLUSIVE를 어떻게 사용하는지 확인할 수 있습니다.

// Nginx는 포트 재사용을 지원합니다
#if (NGX_HAVE_REUSEPORT)
    // listen 80 reuseport 구성 시, 다중 프로세스가 동일한 포트를 공유할 수 있으며,
    // 이 경우 리스닝 소켓을 직접 이벤트 루프에 추가하고 읽기 이벤트를 모니터링할 수 있습니다.
    if (ls[i].reuseport) {
        if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
            return NGX_ERROR;
        }

        continue;
    }
#endif

    // accept_mutex 락을 활성화한 후,
    // 각 워커 프로세스는 직접 리스닝 소켓을 처리할 수 없으며,
    // 워커 프로세스가 락을 획득한 후에만 자신의 이벤트 루프에 리스닝 소켓을 추가할 수 있습니다.
    if (ngx_use_accept_mutex) {
        continue;
    }

// Nginx는 EPOLLEXCLUSIVE 플래그를 지원합니다
#if (NGX_HAVE_EPOLLEXCLUSIVE)
    // nginx가 epoll 다중화 메커니즘을 사용하고 워커 프로세스가 1개 이상인 경우,
    // 리스닝 소켓을 자신의 이벤트 루프에 추가하고 EPOLLEXCLUSIVE 플래그를 설정합니다.
    if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
        && ccf->worker_processes > 1)
    {
        if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
            == NGX_ERROR)
        {
            return NGX_ERROR;
        }

        continue;
    }
#endif

    // accept_mutex 락이 비활성화되고, reuseport 포트 재사용이 시작되지 않으며, EPOLLEXCLUSIVE 플래그를 지원하지 않는 경우,
    // 이후 리스닝 소켓에서 이벤트가 발생하면 쇄락 문제가 발생합니다.
    if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
        return NGX_ERROR;
    }

태그: nginx PHP linux socket 쇄락 문제

6월 8일 19:08에 게시됨