한양과학기술고등학교

에듀테크 프로젝트 대시보드

I/O 멀티플렉싱 (select·poll·epoll)

I/O 멀티플렉싱 — select · poll · epoll

채팅 서버에 접속자가 1만 명이다. 연결마다 스레드를 하나씩 띄우면 메모리가 터진다. 한 스레드가 수천 개의 소켓을 동시에 지켜보는 방법 — 그게 I/O 멀티플렉싱이다.

한 줄 요약 — "누가 읽을 준비가 됐는지 나 대신 커널이 알려줘." select/poll 은 매번 전체 목록을 커널에 넘겨 O(n) 으로 훑고, epoll 은 관심 목록을 커널에 등록해 두고 준비된 것만 O(1) 로 돌려받는다. 이벤트 루프가 도는 엔진이 바로 이것이다.

1. 왜 스레드-per-연결이 안 되나 (C10K 문제)

소켓에서 read() 를 부르면, 받을 데이터가 없을 때 그 스레드는 잠들어 버린다(블로킹). 연결 하나당 스레드 하나를 쓰면 대부분의 스레드가 "데이터 올 때까지 대기" 상태로 놀면서, 스택 메모리(스레드당 보통 1~8MB)와 문맥 전환 비용만 잡아먹는다. 1999년 Dan Kegel이 이걸 C10K 문제(동시 접속 1만 개)라 이름 붙였다.

해결의 방향

전략동작한계
블로킹 + 스레드풀연결마다 스레드가 read() 에서 대기스레드 수 = 연결 수, 메모리·전환 폭발
논블로킹 + 바쁜 폴링O_NONBLOCK 소켓을 무한 루프로 계속 확인준비 안 됐어도 CPU 100% 회전
I/O 멀티플렉싱커널에 "이 소켓들 중 준비된 것 알려줘" 한 번에 요청 → 준비될 때까지 한 번만 잠듦select/poll은 목록 스캔이 O(n)

2. 인터랙티브 — 세 가지 방식으로 같은 부하 처리하기

아래는 64개의 클라이언트 연결이다. 매 틱마다 임의의 연결 몇 개에 데이터가 도착해 초록(ready) 이 된다. 서버가 이걸 어떻게 찾아 처리하는지 방식별로 비교해 보자.

틱 0 처리한 연결 0 이번 틱 커널이 훑은 fd 수 0 누적 스캔 비용 0

3. select — 가장 오래된 방식

select(nfds, &readset, ...) 는 비트마스크에 감시할 fd를 1로 켜서 커널에 넘긴다. 커널은 0번부터 nfds-1번까지 전부 확인해 준비된 fd만 남긴 마스크를 돌려준다. 루프를 돌 때마다 (1) 마스크를 매번 새로 채워야 하고, (2) 커널이 전체를 스캔하며, (3) FD_SETSIZE(보통 1024) 를 넘는 fd는 못 다룬다.

while True:
    rlist = list_of_all_client_sockets       # 매 루프 목록을 다시 만든다
    ready, _, _ = select.select(rlist, [], [])  # 커널이 전부 스캔 → O(n)
    for sock in ready:                       # 준비된 것만 처리
        data = sock.recv(4096)

poll() 은 비트마스크 대신 struct pollfd 배열을 써서 1024 제한을 없앴지만, 여전히 매 호출마다 전체 배열을 커널에 복사하고 O(n) 으로 훑는다. 본질은 select와 같다.

4. epoll — 관심 목록을 커널이 기억한다 (리눅스)

epoll은 세 개의 시스템 콜로 나뉜다.

시스템 콜역할
epoll_create커널 안에 관심 목록(interest list) 자료구조를 만든다 (레드-블랙 트리)
epoll_ctlfd 를 목록에 ADD / MOD / DEL. 한 번만 등록하면 계속 유지됨
epoll_wait준비된 fd 들만 담긴 ready 리스트를 즉시 반환. 커널이 콜백으로 미리 채워 둠

핵심 차이: 소켓에 데이터가 도착하면 커널의 인터럽트 핸들러가 그 fd 를 곧바로 ready 큐에 넣는다. epoll_wait 는 큐를 꺼내 주기만 하면 되므로, 감시 대상이 1만 개든 10만 개든 비용은 "준비된 개수"에만 비례한다. 목록을 매번 복사하지도 않는다.

BSD/macOS 의 kqueue, 윈도우의 IOCP 도 같은 아이디어다. Nginx, Redis, Node.js(libuv), Netty 같은 고성능 서버는 전부 이 계열 위에서 돈다.

5. 레벨 트리거 vs 엣지 트리거

epoll 은 두 가지 통지 모드가 있다. 버퍼에 데이터가 있는 동안 계속 알릴지(레벨), 새로 도착한 순간에만 알릴지(엣지)의 차이다.

소켓 버퍼:
버퍼에 남은 바이트 0 epoll_wait 가 이 fd 를 깨울까? 아니오

레벨: 버퍼에 1바이트라도 남아 있으면 다음 epoll_wait 가 또 깨운다 — 조금씩 읽어도 됨.
엣지: "도착" 이벤트에서 딱 한 번만 깨운다 — 그 순간 recv 가 EAGAIN 낼 때까지 전부 비우지 않으면 남은 데이터가 영영 묻힌다. 대신 깨우는 횟수가 적어 더 빠르다.

6. 한눈에 비교

selectpollepoll
fd 개수 상한FD_SETSIZE (~1024)없음없음
매 호출 커널 복사전체 마스크전체 배열없음(등록만)
준비된 fd 찾기O(n) 스캔O(n) 스캔O(1) ready 큐
이식성거의 모든 OSPOSIX리눅스 전용
적합한 상황fd 적고 이식성 필요fd 중간연결 수만 개+, 대부분 유휴

7. 파이썬으로 직접 — selectors 모듈

표준 라이브러리 selectors 는 OS 에 맞춰 epoll/kqueue/select 중 가장 좋은 것을 자동 선택한다. 아래는 한 스레드로 도는 에코 서버의 뼈대다.

import selectors, socket

sel = selectors.DefaultSelector()          # 리눅스면 EpollSelector

srv = socket.socket()
srv.bind(("0.0.0.0", 8080)); srv.listen()
srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ, data="accept")

while True:
    for key, _ in sel.select():          # 준비된 fd 만 돌려받음
        if key.data == "accept":
            conn, _ = srv.accept()
            conn.setblocking(False)
            sel.register(conn, selectors.EVENT_READ, data="echo")
        else:
            data = key.fileobj.recv(4096)
            if data:
                key.fileobj.send(data)
            else:
                sel.unregister(key.fileobj); key.fileobj.close()
정리 — 멀티플렉싱은 "동시에 여러 I/O 를 진행" 하는 게 아니라, "여러 소켓을 한 번의 대기 로 지켜보다가 준비된 것만 골라 처리" 하는 기법이다. select→poll→epoll 은 같은 목표를 점점 싸게 달성해 온 역사다. 관련: 이벤트 루프, 프로세스 vs 스레드, WebSocket, TCP 3-way Handshake.