LabHub
배우기 러닝패스 코스

연결 하나가 느려지자 나머지가 전부 멈췄다 · 큰 손님 옆의 작은 손님: 준비 큐와 공정성 · 이론

큰 손님 옆의 작은 손님: 준비 큐와 공정성 이해하기

LabHub 에서 이어서 보기

한 줄 요약

준비된 연결을 찾는 일과 그 연결에 CPU를 얼마나 줄지는 다른 문제입니다. 유한한 작업 예산과 중복 없는 준비 큐로 작은 요청이 긴 요청 뒤에서 굶지 않게 만듭니다.

왜 이게 필요했나

앞에서는 한 번 recv하고 다른 연결로 돌아왔습니다. 이번에는 데이터가 있는 동안 계속 읽는 최적화를 상상해 봅시다. 손님 A는 8KiB 원고를 주고 손님 B는 한 글자만 줍니다. A를 완전히 비운 다음 B를 보면 총처리량은 좋아 보여도 B의 첫 응답은 늦습니다. A가 읽는 속도보다 빠르게 계속 보내면 루프는 B나 종료 요청까지 돌아오지 못할 수도 있습니다. 논블로킹은 각 호출이 기다리지 않는다는 뜻이지, 호출을 무한 반복하는 코드가 공정하다는 뜻이 아닙니다.

이 문제는 accept에도 있습니다. 새 연결을 EAGAIN까지 받는 루프는 평소에는 금방 끝나지만, 접속이 계속 들어오는 조건에서는 기존 연결의 일을 미룹니다. 읽기 바이트 수, 연결 시작 수, 준비 작업 처리 수에 각각 예산을 두는 이유입니다. 예산은 연결의 총허용량과 다릅니다. 이번 턴에 1KiB만 읽겠다는 것과 요청을 최대 1KiB만 받겠다는 것은 서로 다른 계약입니다.

어떻게 동작하나

[epoll(7)](https://man7.org/linux/man-pages/man7/epoll.7.html)는 준비 목록과 라운드로빈으로 기아를 피하는 설계를 설명합니다. 레벨 트리거는 조건이 남아 있으면 다음 대기에서도 준비 상태가 보입니다. 엣지 트리거는 이미 받은 알림만 믿고 일부 데이터 뒤에 잠들면 남은 데이터를 놓칠 수 있습니다. ET에서 예산을 다 써서 멈춘 작업은 애플리케이션이 기억해야 합니다. EAGAIN을 관찰했을 때 비로소 지금 할 수 있는 일을 소진했다고 판단합니다.

이번 비교 실험은 Python의 저수준 epoll에 EPOLLET를 직접 지정합니다. 후속 연결 진단기는 DefaultSelector를 사용하며 ET 읽기 서버를 구현하는 것은 아닙니다. 두 코드를 구분하세요. 실험은 준비 상태를 잊는 오류를 보여 주고, 진단기는 여러 완료 이벤트를 예산만큼 나눠 처리하는 데 같은 큐 원칙을 적용합니다.

python3 /opt/fixtures/reactor/fairness_probe.py

실험은 외부 통신 없이 socketpair 두 쌍을 만들고 A에 8192바이트, B에 1바이트를 먼저 넣습니다. 커널 반환 순서를 가정하지 않도록 프로그램이 의도적으로 A부터 처리합니다. 각 차례에는 최대 1024바이트를 읽습니다. 추가 송신이나 EOF 없이 다음 결과를 비교합니다.

| 전략 | A에서 소비 | B의 차례 | 다음 커널 이벤트 |
| --- | --- | --- | --- |
| 각 연결 한 번만 처리하고 준비 사실을 버림 | 1024바이트 | 두 번째 | 0개 |
| 할 일이 남은 연결을 큐 뒤로 돌림 | 8192바이트 | 두 번째 | 0개 |

첫 전략은 B에게 차례를 줬지만 A의 나머지 7168바이트를 남겼습니다. 다음 이벤트 0개를 입력 0바이트로 해석하면 안 됩니다. 두 번째는 B를 빨리 처리하면서 A의 일을 이어 갑니다. 이것은 두 연결의 통제된 결과이며 모든 트래픽의 지연 상한이나 성능 향상률을 증명하지 않습니다.

큐가 새로운 메모리 누수가 되지 않게

같은 연결이 select마다 반복 등장하더라도 큐에는 한 번만 넣습니다. deque는 순서를, set은 포함 여부를 담당합니다. 꺼낼 때 set에서도 제거해야 나중에 다시 넣을 수 있습니다. 연결 종료 때는 큐에서도 지워야 합니다. 파일 서술자 숫자만 키로 쓰면 닫힌 FD의 숫자가 새 연결에 재사용될 때 이전 작업과 혼동할 수 있습니다. 실습은 Dial 객체를 키로 사용하며, 개념 시험은 같은 FD에 서로 다른 세대 번호를 붙여 구분합니다.

작은 예산이 항상 좋은 것은 아니다

예산을 1로 줄이면 연결 간 전환은 잦아지지만 큐 관리와 시스템 호출 비용도 늘어납니다. 반대로 예산을 크게 주면 한 차례의 작업량이 커져 제어 요청이 뒤로 밀릴 수 있습니다. 같은 총바이트를 가진 요청만 돌리지 말고 긴 요청과 짧은 요청의 비율을 바꿔 보세요. 처리량, 짧은 요청의 대기, 제어 요청을 관찰하기까지 걸린 턴 수를 따로 기록하면 무엇을 얻고 잃었는지 설명할 수 있습니다. 이번 실험의 두 번째 차례라는 값은 이 입력 배치의 증거이며 임의의 요청 수에서도 두 번째가 된다는 보장은 아닙니다.

큐가 남았는데 select에서 1초씩 자면 이미 발견한 일을 스스로 미룹니다. 다음 대기는 0으로 하되 이번 턴의 처리 예산은 유지합니다. 반대로 큐도 새 작업도 없는데 계속 0으로 호출하면 바쁜 대기가 됩니다. 대기 시간은 '항상 짧게'가 아니라 아직 할 수 있는 일과 가장 가까운 마감에서 계산합니다.

현장에서 만나는 모습

다운로드 서버의 총전송량만 보면 짧은 제어 요청이 밀리는 현상이 숨을 수 있습니다. 긴 연결과 짧은 연결을 섞고, 짧은 연결이 몇 번째 차례에 처리되는지와 최장 대기를 함께 관찰하세요. 작업 하나가 오래 계산하는 구조라면 I/O 예산만으로 부족합니다. CPU 작업 분리나 계산량 제한은 별도로 설계해야 합니다.

다음 확인에서 할 것

다음 퀴즈에서는 준비 큐의 반례를 먼저 확인합니다. 이후 종합 실습의 ReadyQueue에서 중복 제거·FIFO·종료 작업 삭제를 구현합니다. 연결 시작 예산과 완료 처리 예산을 따로 검증합니다. 큐만 만들어 놓고 한 번에 전부 소비하는 반복문을 쓰면 이 모듈의 목적을 달성하지 못합니다.