에듀테크 프로젝트 대시보드
I/O 멀티플렉싱 (select·poll·epoll)
채팅 서버에 접속자가 1만 명이다. 연결마다 스레드를 하나씩 띄우면 메모리가 터진다. 한 스레드가 수천 개의 소켓을 동시에 지켜보는 방법 — 그게 I/O 멀티플렉싱이다.
select/poll 은
매번 전체 목록을 커널에 넘겨 O(n) 으로 훑고, epoll 은 관심 목록을 커널에 등록해 두고
준비된 것만 O(1) 로 돌려받는다. 이벤트 루프가 도는 엔진이 바로 이것이다.
소켓에서 read() 를 부르면, 받을 데이터가 없을 때 그 스레드는 잠들어 버린다(블로킹).
연결 하나당 스레드 하나를 쓰면 대부분의 스레드가 "데이터 올 때까지 대기" 상태로 놀면서,
스택 메모리(스레드당 보통 1~8MB)와 문맥 전환 비용만 잡아먹는다. 1999년 Dan Kegel이 이걸
C10K 문제(동시 접속 1만 개)라 이름 붙였다.
| 전략 | 동작 | 한계 |
|---|---|---|
| 블로킹 + 스레드풀 | 연결마다 스레드가 read() 에서 대기 | 스레드 수 = 연결 수, 메모리·전환 폭발 |
| 논블로킹 + 바쁜 폴링 | O_NONBLOCK 소켓을 무한 루프로 계속 확인 | 준비 안 됐어도 CPU 100% 회전 |
| I/O 멀티플렉싱 | 커널에 "이 소켓들 중 준비된 것 알려줘" 한 번에 요청 → 준비될 때까지 한 번만 잠듦 | select/poll은 목록 스캔이 O(n) |
아래는 64개의 클라이언트 연결이다. 매 틱마다 임의의 연결 몇 개에 데이터가 도착해 초록(ready) 이 된다. 서버가 이걸 어떻게 찾아 처리하는지 방식별로 비교해 보자.
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와 같다.
epoll은 세 개의 시스템 콜로 나뉜다.
| 시스템 콜 | 역할 |
|---|---|
| epoll_create | 커널 안에 관심 목록(interest list) 자료구조를 만든다 (레드-블랙 트리) |
| epoll_ctl | fd 를 목록에 ADD / MOD / DEL. 한 번만 등록하면 계속 유지됨 |
| epoll_wait | 준비된 fd 들만 담긴 ready 리스트를 즉시 반환. 커널이 콜백으로 미리 채워 둠 |
핵심 차이: 소켓에 데이터가 도착하면 커널의 인터럽트 핸들러가 그 fd 를 곧바로 ready 큐에 넣는다.
epoll_wait 는 큐를 꺼내 주기만 하면 되므로, 감시 대상이 1만 개든 10만 개든
비용은 "준비된 개수"에만 비례한다. 목록을 매번 복사하지도 않는다.
kqueue, 윈도우의 IOCP 도 같은 아이디어다.
Nginx, Redis, Node.js(libuv), Netty 같은 고성능 서버는 전부 이 계열 위에서 돈다.
epoll 은 두 가지 통지 모드가 있다. 버퍼에 데이터가 있는 동안 계속 알릴지(레벨), 새로 도착한 순간에만 알릴지(엣지)의 차이다.
레벨: 버퍼에 1바이트라도 남아 있으면 다음 epoll_wait 가 또 깨운다 — 조금씩 읽어도 됨.
엣지: "도착" 이벤트에서 딱 한 번만 깨운다 — 그 순간 recv 가 EAGAIN 낼 때까지
전부 비우지 않으면 남은 데이터가 영영 묻힌다. 대신 깨우는 횟수가 적어 더 빠르다.
| select | poll | epoll | |
|---|---|---|---|
| fd 개수 상한 | FD_SETSIZE (~1024) | 없음 | 없음 |
| 매 호출 커널 복사 | 전체 마스크 | 전체 배열 | 없음(등록만) |
| 준비된 fd 찾기 | O(n) 스캔 | O(n) 스캔 | O(1) ready 큐 |
| 이식성 | 거의 모든 OS | POSIX | 리눅스 전용 |
| 적합한 상황 | fd 적고 이식성 필요 | fd 중간 | 연결 수만 개+, 대부분 유휴 |
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()