47. 이벤트 루프의 연결당 무제한 드레인 (공정성 붕괴 / 기아)
난이도 상해설 — 이벤트 루프의 연결당 무제한 드레인 (공정성 붕괴 / 기아)
난이도: 상
요약
Tick의 바깥 순회 (A) 안에서, 각 연결을 (B)의 while (TryReceive) 로 수신 큐가 빌 때까지 전부 처리한다. 특정 연결이 폭주(firehose)해 큐가 계속 차 있으면, 그 연결의 while이 끝나지 않아 순회가 다음 연결로 넘어가지 못한다. 결과적으로 한 연결이 단일 스레드 루프를 독점하고, 다른 연결·타이머·하트비트 처리가 굶는다(starvation). 처리 속도보다 유입이 빠르면 사실상 무한 루프처럼 다른 세션이 응답 불능이 된다.
문제점
- 분류태그: 공정성/스케줄링 결함 — 연결당 작업량 상한 부재(head-of-line 독점)
- 증상: 한 클라이언트가 대량 전송하는 동안 다른 유저들의 핑 급증·응답 지연·하트비트 타임아웃으로 인한 오연결 해제. 큐 메모리 급증.
- 재현조건: 어떤 연결의 유입률 ≥ 처리율. 또는 한 tick에 한 연결이 수만 개 메시지를 밀어 넣음.
- 근본원인: 루프가 "이 연결에 데이터가 있는 한 계속"을 종료 조건으로 삼아, 연결 간 **시간 분할(time-slicing)**이 없다. 공정성 정책이 없다.
수정안
연결당 tick 예산(처리 메시지 수 또는 바이트)을 두고, 예산을 소진하면 남은 작업을 다음 tick으로 미룬다(라운드로빈). 아직 데이터가 남은 연결은 "여전히 준비됨"으로 표시해 다음 tick에 이어 처리한다.
public int ReadBudgetPerConn = 64; // 튜닝값
public void Tick()
{
foreach (var conn in _conns)
{
int processed = 0;
while (processed < ReadBudgetPerConn && conn.TryReceive(out var msg))
{
Handler(conn, msg);
processed++;
}
// 큐에 남아 있으면 다음 tick에서 이어서 처리(연결은 계속 ready 상태 유지).
// 필요하면 conn.Inbox.Count>0 인 연결을 별도 ready 집합에 남겨둔다.
}
// 타이머/하트비트 등은 매 tick 반드시 실행된다.
}
핵심: 한 연결이 한 tick에 할 수 있는 일에 상한을 둬 루프가 반드시 다른 연결·타이머로 진행하게 한다. 추가로 연결별 유입 레이트 리밋/백프레셔(수신 윈도우 축소, 큐 상한 초과 시 읽기 일시 중단)를 얹으면 메모리 급증도 막는다.
더 나은 설계
- 가중 공정 큐잉(WFQ)/DRR: 연결마다 quantum을 부여해 바이트 단위로 공정 분배. 대용량 메시지가 소량 메시지를 밀어내지 못하게.
- 백프레셔: 큐 길이가 상한을 넘으면 그 소켓의 읽기 이벤트 등록을 잠시 해제(레벨 트리거 재무장 지연)해 유입을 늦춘다.
- 워커 분리: I/O 루프와 무거운 핸들러를 분리(핸들러는 작업 큐로). 단 순서 보장/세션 어피니티 설계 필요.
- 타이머 우선 보장: tick 예산과 별개로 만료 타이머·하트비트는 매 tick 실행 보장.
면접 포인트
- 단일 스레드 이벤트 루프에서 "한 fd를 완전히 드레인"이 왜 공정성/지연 문제를 만드는가(예: epoll ET에서 EAGAIN까지 드레인 vs 예산 분할의 균형).
- 백프레셔와 공정 스케줄링(DRR/WFQ)의 관계, 큐 상한과 메모리 보호.
- 하트비트/타이머 기아가 어떻게 정상 연결의 오해제(false disconnect)로 이어지는가.
해설 — epoll 루프의 연결당 무제한 드레인 (기아/공정성, C++)
난이도: 상
요약
Tick이 (A)에서 소켓을 순회하며 (B)의 while (TryReceive)(실전에선 while (recv() > 0) 로 EAGAIN까지 드레인)으로 각 연결을 완전히 비울 때까지 처리한다. 폭주 연결의 유입률이 처리율에 근접·초과하면 그 연결의 while이 종료되지 않아 순회가 다음 fd로 진행하지 못한다. 단일 스레드 루프를 한 연결이 독점 → 다른 세션·타이머·하트비트가 굶는다. epoll **엣지 트리거(ET)**에서 "EAGAIN까지 드레인"은 정확성상 필요하지만, 예산 없이 하면 이 독점을 초래한다.
문제점
- 분류태그: 공정성/스케줄링 결함 — 연결당 처리량 상한 부재
- 증상: firehose 연결 동안 타 연결 지연·하트비트 타임아웃·오연결 해제, 수신 큐/메모리 급증.
- 재현조건: 한 fd의 유입률 ≥ 처리율(ET 드레인 루프가 안 끝남), 또는 한 번에 대량 전송.
- 근본원인: fd당 시간 분할이 없음. ET의 "완전 드레인 필요성"과 공정성 사이의 균형 정책 부재.
수정안
fd당 예산(바이트/메시지)을 두고, 예산 소진 시 그 fd를 "아직 읽을 게 남음"으로 표시해 다음 루프에서 이어 처리(ready 목록 재삽입). ET에서는 예산 소진 시에도 재무장을 유지해 이벤트 유실을 막는다.
int read_budget_per_conn = 64;
void Tick() {
for (Connection* conn : conns_) {
int processed = 0;
Message m;
while (processed < read_budget_per_conn && conn->TryReceive(m)) {
handler(*conn, m);
++processed;
}
if (!conn->inbox.empty()) {
// 아직 남음: 다음 tick에 이어서. (ET면 재무장 유지 or ready set에 재등록)
still_ready_.push_back(conn);
}
}
// 타이머/하트비트는 매 tick 보장 실행
}
still_ready_는 실제 구현에서 다음 tick의 처리 대상 집합. 주요 스니펫은 g++ -std=c++17 -fsyntax-only로 검증.
더 나은 설계
- DRR/WFQ로 바이트 단위 공정 분배(대형 메시지가 소형을 밀어내지 못하게).
- 백프레셔: 큐 상한 초과 시 해당 fd의
EPOLLIN등록을 일시 해제해 유입 억제. - I/O 루프와 무거운 핸들러 분리(작업 큐), 단 세션 순서 보장 설계.
면접 포인트
- epoll ET에서 "EAGAIN까지 드레인" 필요성과, 예산 없이 하면 생기는 독점의 상충 — 어떻게 절충하나.
- 백프레셔로 유입을 늦추는 방법(레벨 트리거 재무장 지연, EPOLLIN 해제).
- 타이머/하트비트 기아가 정상 연결 오해제로 이어지는 연쇄.