썬더링 허드
하나를 기다리며 잠들어 있던 대기자 여럿이 사건 하나에 한꺼번에 깨어나는 일입니다. 일을 가져가는 것은 그중 하나뿐입니다. 나머지는 헛걸음하고 다시 잠듭니다.
상세
깨어나는 수와 실제로 할 일의 수가 어긋나는 자리입니다. 대기자는 여럿입니다. 도착한 사건은 하나입니다.
epoll_ctl(2) 은 이 어긋남이 기본 동작이라고 적습니다. 같은 대상 파일에 여러 epoll 파일 디스크립터가 붙어 있습니다. 여기서 깨우기 사건이 일어나면, EPOLLEXCLUSIVE 를 주지 않은 기본 상태에서는 모든 epoll 파일 디스크립터가 사건을 받습니다. 깨우는 수를 줄이는 쪽이 나중에 붙은 선택지입니다. EPOLLEXCLUSIVE 는 리눅스 4.5 부터 있습니다.
깨어난 다음에 무슨 일이 벌어지는지는 Apache HTTP Server 성능 튜닝 문서가 프로세스 쪽에서 적어 둡니다. 요청 사이에 놀고 있는 자식 프로세스들은 select(2) 에서 함께 블록됩니다. 어느 소켓에든 요청 하나가 나타나면 블록돼 있던 자식들이 전부 깨어나 select(2) 에서 돌아옵니다. 문서는 깨어나는 자식의 수가 운영체제와 타이밍 문제에 따라 달라진다고 덧붙입니다. 깨어난 자식들은 모두 루프 아래로 내려가 연결을 accept(2) 하려 듭니다. 연결이 여전히 하나뿐이라면 성공하는 것은 하나입니다. 나머지는 accept(2) 에서 블록됩니다.
헛걸음의 값은 두 자리에서 나타납니다. futex(2) 의 FUTEX_CMP_REQUEUE 설명은 이렇게 적습니다. 깨어난 대기자 전원이 또 다른 futex 를 잡아야 하는 상황이라면 그들을 다 깨우는 것은 무의미합니다. 하나를 뺀 전원이 곧바로 잠금 A 에 다시 블록되기 때문입니다. nginx 문서는 워커 프로세스 쪽에서 같은 것을 적습니다. 새 연결의 양이 적으면 워커 프로세스 일부가 시스템 자원을 그냥 낭비할 수 있다는 것입니다.
이름은 문서마다 갈립니다. epoll(7) · epoll_ctl(2) · futex(2) 는 이 현상을 thundering herd 라고 적습니다. Apache 문서는 같은 그림을 starvation 문제라고 부릅니다. 그리고 PR#467 에 처음 기록됐다고 적습니다. socket(7) 은 이름을 붙이지 않습니다. 같은 소켓에서 accept(2) 하려고 다투는 여러 스레드라는 구성으로만 적습니다.
발생 조건
sequenceDiagram
participant 사건
participant 커널
participant 대기자1
participant 대기자2
participant 대기자3
Note over 대기자1,대기자3: 같은 하나를 기다리며 잠들어 있다
사건->>커널: 하나 도착
커널->>대기자1: 깨움
커널->>대기자2: 깨움
커널->>대기자3: 깨움
대기자1->>커널: 일을 가져감
대기자2->>커널: 헛걸음. 다시 잠듦
대기자3->>커널: 헛걸음. 다시 잠듦
다섯 가지가 함께 서야 합니다.
첫째, 대기자가 둘 이상이어야 합니다. 그리고 그들이 동시에 잠들어 있어야 합니다. epoll(7) 은 여러 스레드가 epoll_wait(2) 에서 블록된 상황으로 적습니다. 프로세스도 같은 자리입니다. fork(2) 를 건너 epoll 파일 디스크립터를 물려받은 자식들이면 성립합니다. Apache 문서는 요청 사이에 놀고 있는 자식 여럿이 select(2) 에서 블록된다고 적습니다.
둘째, 그 대기자들이 같은 하나를 기다려야 합니다. epoll(7) 은 같은 epoll 파일 디스크립터라고 못 박습니다. socket(7) 은 같은 소켓에서 accept(2) 하려고 다투는 여러 스레드라고 적습니다. futex(2) 는 futex 로 구현한 대기 큐 B 하나를 여러 대기 스레드가 기다리는 시나리오로 적습니다.
셋째, 사건이 하나 와야 합니다. Apache 문서의 문장이 이 조건을 그대로 적습니다. 어느 소켓에든 요청 하나가 나타나면 블록돼 있던 자식들이 전부 깨어납니다. nginx 문서도 사건이 드문 쪽에 조건을 답니다. 새 연결의 양이 적으면 워커 프로세스 일부가 시스템 자원을 그냥 낭비할 수 있다는 것입니다.
넷째, 그 일을 가져갈 수 있는 대기자가 하나뿐이어야 합니다. Apache 문서는 연결이 여전히 하나뿐이라면 하나만 성공한다고 적습니다. futex(2) 의 시나리오도 같습니다. 하나를 뺀 전원이 잠금 A 에 곧바로 다시 블록됩니다.
다섯째, 깨우기가 대기자 전원에게 가야 합니다. epoll_ctl(2) 은 이것이 기본 동작이라고 적습니다. EPOLLEXCLUSIVE 를 주지 않은 상태에서는 같은 대상 파일에 붙은 모든 epoll 파일 디스크립터가 사건을 받습니다. 앞의 네 가지가 다 서 있어도 깨우기가 한 대기자에게만 가면 헛걸음할 대기자가 남지 않습니다.
안 터지는 선
조건 하나를 빼면 그림이 달라집니다.
기다리는 대상이 각자 다르면 둘째 조건이 무너집니다. socket(7) 의 SO_REUSEPORT 가 그 자리입니다. 리눅스 3.9 부터 여러 소켓을 같은 소켓 주소에 바인드할 수 있습니다. TCP(Transmission Control Protocol, 전송 제어 프로토콜) 소켓이면 스레드마다 별도의 리스너 소켓을 두는 방식으로 accept(2) 부하 분산을 개선할 수 있다고 적습니다. 문서는 이것을 전통적 방식과 견줍니다. 그 전통적 방식 중 하나가 같은 소켓에서 accept(2) 하려고 다투는 여러 스레드입니다.
깨우는 수가 제한되면 다섯째 조건이 무너집니다. epoll(7) 은 관심 목록의 파일 디스크립터가 에지 트리거 알림으로 표시돼 있으면 스레드 하나만 epoll_wait(2) 에서 깨어난다고 적습니다. 문서는 이것이 어떤 시나리오에서 thundering herd 깨우기를 피하는 데 쓸모 있는 최적화라고 적습니다. futex(2) 의 재큐잉도 같은 모양입니다. 대기자 하나만 깨웁니다. 나머지 대기자는 잠금 A 의 대기 큐로 옮겨집니다. 깨어난 대기자가 A 를 풀면 다음 대기자가 진행합니다.
대기자가 하나뿐이면 첫째 조건이 무너집니다. 깨울 대상과 할 일이 하나씩이라 수가 어긋날 자리가 없습니다.
사건이 대기자 수만큼 오면 셋째 조건이 무너집니다. 깨어난 대기자마다 가져갈 것이 있어 헛걸음이 남지 않습니다.
예시
nginx 의 accept_mutex
accept_mutex off;
accept_mutex 의 현재 기본값입니다. events 컨텍스트에 씁니다. 켜면 워커 프로세스들이 차례로 새 연결을 accept 합니다. 끄면 모든 워커 프로세스가 새 연결을 통지받습니다. 그리고 새 연결의 양이 적으면 워커 프로세스 일부가 시스템 자원을 그냥 낭비할 수 있습니다. 기본값이 off 이므로, 워커 여럿이 같은 리스닝 소켓에 붙어 있는 기본 구성이 곧 이 표제어의 구성입니다. 1.11.3 이전에는 기본값이 on 이었습니다. nginx 문서는 EPOLLEXCLUSIVE 플래그를 지원하는 시스템이나 reuseport 를 쓸 때는 accept_mutex 를 켤 필요가 없다고 적습니다.
epoll_ctl(2) 의 EPOLLEXCLUSIVE
플래그를 안 준 상태가 이 표제어의 자리입니다. epoll_ctl(2) 은 같은 대상 파일에 붙은 모든 epoll 파일 디스크립터가 사건을 받는 것이 그 기본이라고 적습니다. 플래그를 주면 그중 하나 이상이 epoll_wait(2) 로 사건을 받습니다. 문서는 이것이 어떤 시나리오에서 thundering herd 문제를 피하는 데 쓸모 있다고 적습니다.
값이 몇 개 더 붙어 있습니다. EPOLLEXCLUSIVE 는 EPOLL_CTL_ADD 연산에서만 쓸 수 있습니다. EPOLL_CTL_MOD 로 쓰려 하면 실패합니다. 이 플래그를 준 뒤 같은 epfd, fd 짝에 EPOLL_CTL_MOD 를 걸어도 실패합니다. 대상 파일 디스크립터를 epoll 인스턴스로 지정해도 실패합니다. 이 경우들의 오류는 전부 EINVAL 입니다. 함께 지정할 수 있는 값은 EPOLLIN · EPOLLOUT · EPOLLWAKEUP · EPOLLET 입니다.
같은 파일 디스크립터가 여러 epoll 인스턴스에 들어 있고 그중 일부만 이 플래그를 줬다면, 사건은 플래그를 안 준 모든 인스턴스에 전달됩니다. 그리고 플래그를 준 인스턴스 중 적어도 하나에 전달됩니다.
Apache httpd 의 select 루프
rc = select (last_socket+1, &accept_fds, NULL, NULL, NULL);
if (rc < 1) continue;
new_connection = -1;
for (i = first_socket; i <= last_socket; ++i) {
if (FD_ISSET (i, &accept_fds)) {
new_connection = accept (i, NULL, NULL);
if (new_connection != -1) break;
}
}
Apache HTTP Server 성능 튜닝 문서가 교육용으로 실어 둔 소박한 구현입니다. 자식 프로세스 여럿이 이 루프를 동시에 돕니다. 문서는 이 구현에 심각한 starvation 문제가 있다고 적습니다. 요청 하나가 어느 소켓에 나타나면 블록돼 있던 자식들이 전부 깨어나 select(2) 에서 돌아옵니다. 그들은 모두 루프 아래로 내려가 연결을 accept 하려 듭니다. 성공하는 것은 하나뿐입니다. 나머지는 accept(2) 에서 블록됩니다. 문서는 이 때문에 그 자식들이 그 소켓 하나의 요청만 처리하도록 묶인다고 적습니다. 그리고 그들을 전부 깨울 만큼 새 요청이 그 소켓에 들어올 때까지 거기 갇힌다고 적습니다. 이 문제가 PR#467 에 처음 기록됐다고 적습니다. 아파치가 고른 해법은 안쪽 루프 진입을 직렬화하는 것입니다. Mutex 지시어의 mpm-accept 뮤텍스가 그 자리입니다.
경계
캐시 스탬피드도 이건가. 아닙니다.
이 표제어의 조건은 깨우기로만 이루어집니다. epoll(7) · epoll_ctl(2) · futex(2) 의 서술 어디에도 캐시 항목 · 만료 · 미스가 나오지 않습니다. 같은 하나를 기다리며 잠든 대기자와 그들을 깨우는 사건 하나만 있으면 조건이 다 섭니다. 캐시 스탬피드는 세 가지를 더 요구합니다. 캐시 항목과 그 항목의 만료가 있어야 합니다. 미스를 본 요청이 저마다 원본으로 가야 합니다. 이름이 겹치는 것이지 같은 자리가 아닙니다.
EPOLLEXCLUSIVE 를 주면 이 표제어가 사라지나. 그렇게까지는 아닙니다. epoll_ctl(2) 은 플래그를 준 상태에서 epoll 파일 디스크립터 하나 이상이 사건을 받는다고 적습니다. 정확히 하나라고 적지 않습니다. 문서가 이 플래그의 쓸모에 다는 조건도 「어떤 시나리오에서」입니다. epoll(7) 의 에지 트리거 서술과 futex(2) 의 재큐잉 서술도 같은 조건을 답니다.
관련 항목
이 현상이 일어나는 커널 API
epoll · epoll_wait · epoll_ctl · select · accept · SO_REUSEPORT · futex · 소켓
이 조건을 뒤집는 손잡이
EPOLLEXCLUSIVE · EPOLL_CTL_ADD · EPOLL_CTL_MOD · EINVAL(Invalid Argument, 잘못된 인자) · accept_mutex · FUTEX_CMP_REQUEUE · FUTEX_REQUEUE
깨우기 방식을 가르는 대립 개념
에지 트리거 · 레벨 트리거 · 알림
이 현상이 놓이는 시스템 계층
커널 · Linux · 문맥 교환 · 운영체제 · TCP
같은 자원을 두고 겨루는 동시성 개념
락 · 뮤텍스 · 세마포어 · 조건 변수 · 커넥션 풀
이것이 속하는 상위 분류
헷갈리는 이웃
캐시 스탬피드 · 재시도 폭풍 · 데드락 · 기아 상태