3. IO 멀티플렉싱: select/poll/epoll/IOCP, Reactor vs Proactor
난이도 상내 답안
모범답안
모범답안 — IO 멀티플렉싱: select/poll/epoll/IOCP, Reactor vs Proactor
난이도: 상
핵심 답변
- thread-per-connection이 무너지는 이유: 연결 1만이면 스레드 1만 → 스택 메모리(스레드당 ~1MB → 수~수십 GB), 컨텍스트 스위칭 폭증(대부분 IO 대기로 노는데도 스케줄러 부하), 락 경합. 확장 불가.
- 해결: 적은 수의 스레드로 많은 소켓 이벤트를 처리하는 IO 멀티플렉싱(select/poll/epoll/IOCP).
- select/poll = O(n)(매번 전체 fd 집합 복사·순회), epoll = O(1)에 가까움(관심 fd를 커널에 등록, 준비된 것만 반환).
- epoll = Reactor("준비됐다"고 알려주면 **앱이 직접 read"), IOCP = Proactor("read까지 커널/OS가 완료한 뒤 결과를 통지"). 핵심 구분은 누가 실제 IO를 수행하는가.
깊이 있는 설명
왜 thread-per-connection이 안 되는가
- 메모리: 스레드 스택 기본 1MB(리눅스) → 1만 연결 = ~10GB. 줄여도 한계.
- 컨텍스트 스위칭: 스레드 수가 코어 수를 한참 넘으면 CPU가 일보다 전환에 시간을 쓴다. 캐시·TLB가 계속 무효화됨.
- 스케줄링 오버헤드: 런큐 관리 비용 O(스레드 수).
- 결정적으로, 게임/네트워크 스레드 대부분은 blocking IO 대기 상태로 놀고 있다. 일하는 스레드는 소수인데 자원만 소모. → "적은 스레드 + 이벤트 기반"이 정답.
select / poll / epoll
- select: fd 집합(비트마스크)을 커널에 넘김. 매 호출마다 전체 집합을 커널로 복사하고, 반환 후 어떤 fd가 준비됐는지 전체를 순회(O(n)).
FD_SETSIZE(보통 1024) 제한. - poll: select와 본질 같음(O(n), 매번 전체 전달·순회). 다만 fd 개수 제한이 없고 배열 구조라 약간 유연.
- epoll: 관심 fd를 커널에 한 번 등록(
epoll_ctl).epoll_wait는 준비된 fd만 돌려준다. 매번 전체를 복사·순회하지 않으므로 연결 수가 많아도 O(준비된 수). 커널 내부는 레드블랙 트리(등록 관리) + ready list로 구현.epoll_create→epoll_ctl(ADD/MOD/DEL)→epoll_wait.
LT vs ET
- 레벨 트리거(LT, 기본): 읽을 데이터가 남아 있는 동안 계속 알림. 한 번에 다 안 읽어도 다음
epoll_wait가 또 알려준다. 구현 쉬움. - 에지 트리거(ET): 상태가 변하는 순간 한 번만 알림. → 알림 받으면
EAGAIN이 날 때까지(=버퍼가 빌 때까지) 다 읽어야 한다. 안 그러면 남은 데이터에 대한 알림을 영영 못 받아 멈춘다. non-blocking 소켓 필수. 시스템콜 횟수가 줄어 성능 ↑, 실수 여지 ↑.
IOCP & Reactor vs Proactor
- Reactor(epoll, select): "소켓이 읽을 준비가 됐다"는 이벤트 통지. 통지를 받은 애플리케이션 스레드가 직접
recv한다. 동기 IO + 이벤트 디멀티플렉싱. - Proactor(IOCP): 애플리케이션이 미리
WSARecv로 읽기 작업을 OS에 의뢰. OS가 데이터를 버퍼에 실제로 다 채운 뒤 "완료됐다"를 completion queue로 통지. 앱은 통지를 받으면 이미 채워진 버퍼를 그냥 쓴다. 비동기 IO. - 한 줄 요약: Reactor는 "준비됐어, 네가 읽어" / Proactor는 "다 읽어놨어, 결과 받아." Windows는 진짜 비동기 IO(IOCP)를 OS가 지원해 Proactor가 자연스럽고, Linux는 전통적으로 Reactor(epoll)가 주류였다(최근 io_uring이 Proactor에 가까운 비동기를 제공).
응용/실무 연결
멀티코어 설계(질문 5):
- 스레드 수 ≈ 코어 수(또는 코어 수 + 약간)로 두고, 각 스레드가 IO 이벤트 + 처리를 담당하는 게 기본. IO 대기는 epoll/IOCP가 흡수하므로 스레드를 많이 둘 필요 없다.
- IOCP: OS가 completion을 워커 스레드 풀에 효율적으로 분배(LIFO로 깨워 캐시 지역성·thundering herd 완화). "IOCP에 코어 수만큼 GetQueuedCompletionStatus 스레드"가 정석.
- epoll 멀티스레드 분배 방식:
- 여러 워커가 하나의 epoll fd를 공유 → 한 이벤트에 여러 스레드가 깨는 thundering herd. 완화책:
EPOLLONESHOT(한 번 통지 후 재등록 필요), Linux 4.5+의EPOLLEXCLUSIVE(하나만 깨움). - 스레드(또는 코어)마다 독립 epoll + accept한 연결을 워커에 분배(SO_REUSEPORT로 커널이 분산, 또는 acceptor가 라운드로빈). 캐시 지역성 좋고 락 경합 적어 흔히 선호.
- 여러 워커가 하나의 epoll fd를 공유 → 한 이벤트에 여러 스레드가 깨는 thundering herd. 완화책:
- 연결은 특정 워커에 고정(sticky) 해 그 연결의 상태(수신 버퍼 등)를 락 없이 다루게 한다.
- 무거운 작업(DB, 디스크)은 IO 스레드에서 분리해 별도 작업 큐/스레드 풀로 넘긴다(IO 스레드가 블로킹되면 안 됨).
게임서버에서는 Linux는 epoll(LT가 안전, ET는 검증된 코드에서), Windows는 IOCP가 사실상 표준이다.
흔한 오답·함정
- "epoll은 항상 select보다 빠르다" → 연결이 적고 대부분 활성이면 select/poll가 더 단순·빠를 수도. epoll의 이점은 연결 多 + 활성 비율 낮음에서 극대화.
- "ET가 LT보다 무조건 좋다" → 잘못 쓰면 데이터를 놓쳐 멈춘다. 대부분 LT로 충분.
- "IOCP는 epoll의 윈도우 버전일 뿐" → 패러다임이 다르다(Proactor vs Reactor). 버퍼 소유·콜백 시점이 다름.
- "스레드를 많이 둘수록 빠르다" → IO 멀티플렉싱의 핵심은 스레드를 줄이는 것.
꼬리질문 대비
- Q. io_uring은 무엇이 다른가? A. Linux의 진짜 비동기 IO. submission/completion 링 버퍼로 시스템콜 횟수까지 줄여 Proactor 스타일을 제공. epoll보다 syscall 오버헤드가 적다.
- Q. EPOLLONESHOT은 왜 쓰나? A. 한 fd의 이벤트가 동시에 여러 스레드에서 처리되는 걸 막기 위해. 처리 후 다시 등록해야 다음 이벤트를 받는다.
- Q. SO_REUSEPORT의 이점은? A. 같은 포트에 여러 소켓이 bind 가능. 커널이 연결을 소켓들에 분산해주므로 워커별 독립 accept로 락 경합·thundering herd를 줄인다.
- Q. C10K 문제란? A. 1만 동시 연결을 한 서버가 감당하는 문제. thread-per-connection의 한계를 드러내며 epoll/IOCP 같은 이벤트 모델을 대중화시킨 계기.